diff --git a/.github/workflows/windows-testbox-probe.yml b/.github/workflows/windows-testbox-probe.yml index 9729a9ba67b0..de0f45829d1f 100644 --- a/.github/workflows/windows-testbox-probe.yml +++ b/.github/workflows/windows-testbox-probe.yml @@ -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: diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index aca004248e4d..5d126b57dc7a 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -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`. +### 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-` 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: diff --git a/docs/docs_map.md b/docs/docs_map.md index fdc67ce436dd..ddc4cf504413 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/help/environment.md b/docs/help/environment.md index 0a10c7025502..bc0e68f2ae5b 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -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): diff --git a/package.json b/package.json index fc5c45b19d30..a2f0b7236bd8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 53242f7d580d..b78b5813c78f 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -104,6 +104,7 @@ vi.mock("../../config/mutate.js", () => ({ vi.mock("../../config/paths.js", () => ({ isDefaultInstallIdentity: isDefaultInstallIdentityMock, + resolveNativeServiceProfileConflict: () => null, resolveGatewayPort: resolveGatewayPortMock, resolveIsNixMode: resolveIsNixModeMock, })); diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 861d6079b373..d4ed026ddd9e 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -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({ diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 2d8cb9e81679..f684edef5feb 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -459,6 +459,7 @@ export async function runServiceRestart(params: { opts?: DaemonLifecycleOptions; checkTokenDrift?: boolean; expectedPort?: number; + beforeServiceMutation?: () => void; repairLoadedService?: ( ctx: ServiceStartRepairContext, ) => Promise | 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; diff --git a/src/cli/daemon-cli/lifecycle.test-helpers.ts b/src/cli/daemon-cli/lifecycle.test-helpers.ts new file mode 100644 index 000000000000..1c50747221fa --- /dev/null +++ b/src/cli/daemon-cli/lifecycle.test-helpers.ts @@ -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; + postRestartCheck?: (ctx: RestartPostCheckContext) => Promise; +}; + +export function requireMockCallArg( + mockFn: { mock: { calls: unknown[][] } }, + label: string, + index = 0, +): Record { + const arg = mockFn.mock.calls[index]?.[0] as Record | undefined; + if (!arg) { + throw new Error(`expected ${label} call #${index + 1}`); + } + return arg; +} + +export async function expectRestartError( + promise: Promise, +): Promise { + try { + await promise; + } catch (error) { + return error as Error & { hints?: string[] }; + } + throw new Error("expected restart to fail"); +} diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 130e54db053a..719f265d1408 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -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; - postRestartCheck?: (ctx: RestartPostCheckContext) => Promise; -}; - 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 { - const arg = mockFn.mock.calls[index]?.[0] as Record | undefined; - if (!arg) { - throw new Error(`expected ${label} call #${index + 1}`); - } - return arg; -} - -async function expectRestartError( - promise: Promise, -): Promise { - 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(); diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 52fce8413a8c..cd067ffea18a 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -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>>; function isGatewaySignalRestartResult( - result: Awaited>, + result: Awaited>, ): 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 }) => { diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 862904295e86..1e2d82e5354c 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -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 = { + 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 = { + 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" }, diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 309613c7cc3a..0d6e42fdcab5 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -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"; } diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 29fbfa3d93bb..1ca2851c9ff1 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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()), + 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")); diff --git a/src/cli/update-cli/update-command-post-update.ts b/src/cli/update-cli/update-command-post-update.ts index 946920efb0ca..e6b517c211a1 100644 --- a/src/cli/update-cli/update-command-post-update.ts +++ b/src/cli/update-cli/update-command-post-update.ts @@ -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) { diff --git a/src/cli/update-cli/update-command-service.ts b/src/cli/update-cli/update-command-service.ts index e4d6495a0b7f..640b9127cac8 100644 --- a/src/cli/update-cli/update-command-service.ts +++ b/src/cli/update-cli/update-command-service.ts @@ -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 { + const serviceMutationSkipMessage = resolveGatewayServiceManagementBlockMessageForUpdate( + process.env, + ); + if (serviceMutationSkipMessage) { + return { + stopped: false, + inspected: false, + runtimeInspected: false, + running: false, + serviceMutationAllowed: false, + serviceMutationSkipMessage, + }; + } let service: ReturnType; let serviceState: Awaited>; 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 { + if (!isGatewayServiceManagementAllowedForUpdate(process.env)) { + return undefined; + } const command = await resolveGatewayService() .readCommand(process.env) .catch(() => null); @@ -970,6 +1037,9 @@ export async function resolveManagedServiceNodeRunnerOverride(): Promise { + 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 { + 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).")); diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index a31191252910..5ce9b8951da7 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -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 = { diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index 0fecb67b9e5d..1de67155fb40 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -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(); }); }); diff --git a/src/config/paths.ts b/src/config/paths.ts index aecd004d6f66..59be6d7011e1 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -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)) ); } diff --git a/src/daemon/constants.test.ts b/src/daemon/constants.test.ts index 6c3a4d829e70..573b0ec07f67 100644 --- a/src/daemon/constants.test.ts +++ b/src/daemon/constants.test.ts @@ -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(""); diff --git a/src/daemon/constants.ts b/src/daemon/constants.ts index 7523de9c7442..d21380063478 100644 --- a/src/daemon/constants.ts +++ b/src/daemon/constants.ts @@ -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, + 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(); diff --git a/src/daemon/launchd.integration.e2e.test.ts b/src/daemon/launchd.integration.e2e.test.ts index f5cd6a90961e..e0b24b99eacb 100644 --- a/src/daemon/launchd.integration.e2e.test.ts +++ b/src/daemon/launchd.integration.e2e.test.ts @@ -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); diff --git a/src/daemon/schtasks.integration.e2e.test.ts b/src/daemon/schtasks.integration.e2e.test.ts new file mode 100644 index 000000000000..7d193c508f89 --- /dev/null +++ b/src/daemon/schtasks.integration.e2e.test.ts @@ -0,0 +1,1052 @@ +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import { createServer, type AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { getWindowsPowerShellExePath } from "../infra/windows-install-roots.js"; +import { withEnvAsync } from "../test-utils/env.js"; +import { resolveGatewayWindowsTaskName } from "./constants.js"; +import { execSchtasks } from "./schtasks-exec.js"; +import { resolveStartupEntryPaths, resolveTaskLauncherScriptPath } from "./schtasks-layout.js"; +import { readWindowsProcessSnapshot } from "./schtasks-process.js"; +import { probeScheduledTaskExists } from "./schtasks-runtime.js"; +import { type ProbeRunEvent, waitForExactProbeRun } from "./schtasks.integration.test-helpers.js"; +import { resolveTaskScriptPath } from "./schtasks.js"; +import type { GatewayServiceRuntime } from "./service-runtime.js"; +import type { GatewayServiceEnv } from "./service-types.js"; +import { resolveGatewayService } from "./service.js"; + +const WAIT_INTERVAL_MS = 200; +const WAIT_TIMEOUT_MS = 30_000; +const DIAGNOSTIC_TEXT_LIMIT = 16_384; +const DIAGNOSTIC_PROCESS_LIMIT = 32; +const TASK_LOGON_INTERACTIVE_TOKEN = 3; +const TASK_RUNLEVEL_LEAST_PRIVILEGE = 0; + +type ScheduledTaskPrincipal = { + lastRunTime: string; + lastTaskResult: number; + logonType: number; + runLevel: number; + taskState: number; +}; + +type WindowsProcessDiagnostic = { + CommandLine?: string | null; + ParentProcessId?: number; + ProcessId?: number; +}; + +type FailureDiagnosticSnapshot = { + capturedAt: string; + principal: ScheduledTaskPrincipal | null; + principalError: string | null; + processCapture: { + error: string | null; + ok: boolean; + processes: WindowsProcessDiagnostic[]; + truncated: boolean; + }; + taskXml: string | null; + verboseQuery: { + code: number; + stdout: string | null; + stderr: string | null; + }; +}; + +type TaskDefinitionSnapshot = { exists: false; taskXml: null } | { exists: true; taskXml: string }; + +async function sleep(delayMs = WAIT_INTERVAL_MS): Promise { + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); +} + +async function waitForRuntimeStatus( + readRuntime: () => Promise, + expected: "running" | "stopped", + expectedPid?: number, +): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + let lastStatus = "unknown"; + let lastDetail = ""; + let lastPid: number | undefined; + while (Date.now() < deadline) { + const runtime = await readRuntime(); + lastStatus = runtime.status ?? "unknown"; + lastDetail = runtime.detail ?? ""; + lastPid = runtime.pid; + if (runtime.status === expected && (expectedPid === undefined || runtime.pid === expectedPid)) { + return; + } + await sleep(); + } + throw new Error( + `Timed out waiting for Scheduled Task status=${expected}${ + expectedPid === undefined ? "" : ` pid=${expectedPid}` + }; observed ${lastStatus}${lastPid === undefined ? "" : ` pid=${lastPid}`}: ${lastDetail}`, + ); +} + +async function waitForProcessExit(pid: number): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) { + return; + } + await sleep(); + } + throw new Error(`Timed out waiting for Scheduled Task process ${pid} to exit`); +} + +async function reserveLoopbackPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Could not reserve a loopback port for the Scheduled Task probe"); + } + const port = (address as AddressInfo).port; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +async function canBindLoopbackPort(port: number): Promise { + const server = createServer(); + return new Promise((resolve) => { + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); +} + +async function waitForLoopbackPortRelease(port: number): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await canBindLoopbackPort(port)) { + return; + } + await sleep(); + } + throw new Error(`Timed out waiting for Scheduled Task loopback port ${port} to be reusable`); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} + +async function readTaskXml(taskName: string): Promise { + const result = await execSchtasks(["/Query", "/TN", taskName, "/XML"]); + return result.code === 0 + ? result.stdout.replace(/^\uFEFF/u, "").replaceAll(String.fromCharCode(0), "") + : null; +} + +function readTaskPrincipal(taskName: string): ScheduledTaskPrincipal { + const encodedTaskName = Buffer.from(taskName, "utf8").toString("base64"); + const script = [ + "$ErrorActionPreference='Stop'", + `$taskName=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedTaskName}'))`, + "$service=New-Object -ComObject 'Schedule.Service'", + "$service.Connect()", + "$task=$service.GetFolder('\\').GetTask($taskName)", + "$principal=$task.Definition.Principal", + "$result=@{logonType=[int]$principal.LogonType;runLevel=[int]$principal.RunLevel;taskState=[int]$task.State;lastTaskResult=[int64]$task.LastTaskResult;lastRunTime=$task.LastRunTime.ToUniversalTime().ToString('o')}", + "[Console]::Out.Write(($result | ConvertTo-Json -Compress))", + ].join("; "); + const result = spawnSync( + getWindowsPowerShellExePath(), + [ + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ], + { encoding: "utf8", timeout: 5_000, windowsHide: true }, + ); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `Could not inspect Scheduled Task principal for ${taskName}: ${ + result.stderr.trim() || `PowerShell exited ${result.status ?? "without status"}` + }`, + ); + } + const parsed = JSON.parse(result.stdout.trim()) as Partial; + if ( + typeof parsed.logonType !== "number" || + !Number.isInteger(parsed.logonType) || + typeof parsed.runLevel !== "number" || + !Number.isInteger(parsed.runLevel) || + typeof parsed.taskState !== "number" || + !Number.isInteger(parsed.taskState) || + typeof parsed.lastTaskResult !== "number" || + !Number.isInteger(parsed.lastTaskResult) || + typeof parsed.lastRunTime !== "string" + ) { + throw new Error(`Scheduled Task principal returned invalid data for ${taskName}`); + } + return { + lastRunTime: parsed.lastRunTime, + lastTaskResult: parsed.lastTaskResult, + logonType: parsed.logonType, + runLevel: parsed.runLevel, + taskState: parsed.taskState, + }; +} + +function readRelatedProcessDiagnostics(needles: string[]): { + error: string | null; + ok: boolean; + processes: WindowsProcessDiagnostic[]; + truncated: boolean; +} { + const script = [ + "$ErrorActionPreference='Stop'", + "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress", + ].join("; "); + const result = spawnSync( + getWindowsPowerShellExePath(), + [ + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + Buffer.from(script, "utf16le").toString("base64"), + ], + { encoding: "utf8", maxBuffer: 1024 * 1024, timeout: 5_000, windowsHide: true }, + ); + if (result.error) { + return { error: result.error.message, ok: false, processes: [], truncated: false }; + } + if (result.status !== 0) { + return { + error: result.stderr.trim() || `PowerShell exited ${result.status ?? "without status"}`, + ok: false, + processes: [], + truncated: false, + }; + } + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout.trim() || "[]"); + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + ok: false, + processes: [], + truncated: false, + }; + } + const entries = (Array.isArray(parsed) ? parsed : [parsed]).filter( + (entry): entry is WindowsProcessDiagnostic => typeof entry === "object" && entry !== null, + ); + const normalizedNeedles = needles.map((needle) => needle.replaceAll("/", "\\").toLowerCase()); + const matching = entries.filter((entry) => { + const commandLine = (entry.CommandLine ?? "").replaceAll("/", "\\").toLowerCase(); + return normalizedNeedles.some((needle) => commandLine.includes(needle)); + }); + const parentPids = new Set( + matching + .map((entry) => entry.ParentProcessId) + .filter((pid): pid is number => typeof pid === "number"), + ); + const processes = entries.filter( + (entry) => + matching.includes(entry) || + (typeof entry.ProcessId === "number" && parentPids.has(entry.ProcessId)), + ); + return { + error: null, + ok: true, + processes: processes.slice(0, DIAGNOSTIC_PROCESS_LIMIT), + truncated: processes.length > DIAGNOSTIC_PROCESS_LIMIT, + }; +} + +function sanitizeDiagnosticText( + value: string | null | undefined, + replacements: Array<[string, string]>, +): string | null { + if (value === null || value === undefined) { + return null; + } + const variantPlaceholders = new Map(); + for (const [privateValue, placeholder] of replacements) { + if (privateValue) { + for (const variant of new Set([ + privateValue, + privateValue.replaceAll("/", "\\"), + privateValue.replaceAll("\\", "/"), + ])) { + variantPlaceholders.set(variant.toLowerCase(), placeholder); + } + } + } + const pattern = Array.from(variantPlaceholders.keys()) + .toSorted((left, right) => right.length - left.length) + .map((variant) => variant.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")) + .join("|"); + const sanitized = pattern + ? value.replace(new RegExp(pattern, "giu"), (match) => { + return variantPlaceholders.get(match.toLowerCase()) ?? match; + }) + : value; + return sanitized.length <= DIAGNOSTIC_TEXT_LIMIT + ? sanitized + : `${sanitized.slice(0, DIAGNOSTIC_TEXT_LIMIT)}\n[truncated]`; +} + +function sanitizeTaskXml( + value: string | null, + replacements: Array<[string, string]>, +): string | null { + const identityRedacted = + value?.replace( + /<(UserId|Author)>([\s\S]*?)<\/\1>/giu, + (_match, tag: string) => `<${tag}>`, + ) ?? null; + if (identityRedacted === null) { + return null; + } + return identityRedacted + .split(/(<[^>]+>)/gu) + .map((segment) => + segment.startsWith("<") ? segment : (sanitizeDiagnosticText(segment, replacements) ?? ""), + ) + .join(""); +} + +function sanitizeVerboseQuery(value: string, replacements: Array<[string, string]>): string | null { + return ( + sanitizeDiagnosticText(value, replacements)?.replace( + /^(\s*(?:HostName|Run As User)\s*:\s*).*$/gimu, + "$1", + ) ?? null + ); +} + +function resolveDiagnosticReplacements(params: { + rootDir: string; + stateDir: string; +}): Array<[string, string]> { + const username = os.userInfo().username; + const domain = process.env.USERDOMAIN?.trim(); + return [ + [os.userInfo().homedir, ""], + [params.rootDir, ""], + [params.stateDir, ""], + [domain && username ? `${domain}\\${username}` : "", ""], + [process.env.COMPUTERNAME?.trim() ?? "", ""], + [os.hostname(), ""], + ]; +} + +function assertInteractiveLeastPrivilegeTask(params: { + principal: ScheduledTaskPrincipal; + taskXml: string; +}): void { + expect(params.taskXml).toContain("InteractiveToken"); + expect(params.principal.logonType).toBe(TASK_LOGON_INTERACTIVE_TOKEN); + expect(params.principal.runLevel).toBe(TASK_RUNLEVEL_LEAST_PRIVILEGE); + const exportedRunLevel = params.taskXml.match(/([^<]+)<\/RunLevel>/u)?.[1]; + // Task Scheduler may omit the default LeastPrivilege node when exporting XML. + // If present, it must agree with the effective COM principal checked above. + expect(exportedRunLevel === undefined || exportedRunLevel === "LeastPrivilege").toBe(true); +} + +async function waitForSuccessfulScheduledTaskRun( + taskName: string, +): Promise { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + let lastPrincipal: ScheduledTaskPrincipal | null = null; + let lastError: unknown; + while (Date.now() < deadline) { + try { + lastPrincipal = readTaskPrincipal(taskName); + if ( + lastPrincipal.lastTaskResult === 0 && + !Number.isNaN(Date.parse(lastPrincipal.lastRunTime)) && + Date.parse(lastPrincipal.lastRunTime) > 0 + ) { + return lastPrincipal; + } + } catch (error) { + lastError = error; + } + await sleep(); + } + throw new Error( + `Timed out waiting for Scheduled Task ${taskName} to record a successful run; ${ + lastPrincipal + ? `observed state=${lastPrincipal.taskState} result=${lastPrincipal.lastTaskResult}` + : `last inspection failed: ${lastError instanceof Error ? lastError.message : String(lastError)}` + }`, + ); +} + +async function readTaskDefinitionSnapshot(taskName: string): Promise { + const exists = probeScheduledTaskExists(taskName); + if (exists === null) { + throw new Error(`Could not determine whether Scheduled Task ${taskName} exists`); + } + if (!exists) { + return { exists: false, taskXml: null }; + } + const taskXml = await readTaskXml(taskName); + if (!taskXml) { + throw new Error(`Could not export Scheduled Task XML for ${taskName}`); + } + return { exists: true, taskXml }; +} + +async function clearActivePid(activePidPath: string, pid: number): Promise { + const activePid = Number.parseInt(await fs.readFile(activePidPath, "utf8").catch(() => ""), 10); + if (activePid === pid) { + await fs.rm(activePidPath, { force: true }); + } +} + +async function forceKillActiveProcess(params: { + activePidPath: string; + eventsPath: string; + probePath: string; +}): Promise { + await sleep(); + let activePidText: string; + try { + activePidText = await fs.readFile(params.activePidPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + const activePid = Number.parseInt(activePidText.trim(), 10); + if (!Number.isSafeInteger(activePid) || activePid <= 1) { + throw new Error(`Invalid Scheduled Task active process id: ${activePidText.trim() || "empty"}`); + } + if (!isProcessAlive(activePid)) { + await fs.rm(params.activePidPath, { force: true }); + return; + } + const normalizedProbePath = params.probePath.replaceAll("/", "\\").toLowerCase(); + const normalizedEventsPath = params.eventsPath.replaceAll("/", "\\").toLowerCase(); + const snapshot = readWindowsProcessSnapshot(); + if (!snapshot) { + throw new Error("Could not verify Scheduled Task probe ownership during cleanup"); + } + const activeProcess = snapshot.find((entry) => entry.ProcessId === activePid); + const commandLine = (activeProcess?.CommandLine ?? "").replaceAll("/", "\\").toLowerCase(); + if (!commandLine.includes(normalizedProbePath) || !commandLine.includes(normalizedEventsPath)) { + throw new Error( + `Refused to kill reused or unverifiable Scheduled Task process id ${activePid}`, + ); + } + try { + process.kill(activePid, "SIGKILL"); + } catch {} + await waitForProcessExit(activePid); + await fs.rm(params.activePidPath, { force: true }); +} + +async function readFailureDiagnosticSnapshot(params: { + eventsPath: string; + probePath: string; + replacements: Array<[string, string]>; + scriptPath: string; + taskName: string; +}): Promise { + const verboseQuery = await execSchtasks(["/Query", "/TN", params.taskName, "/V", "/FO", "LIST"]); + const taskXml = await readTaskXml(params.taskName); + let principal: ScheduledTaskPrincipal | null = null; + let principalError: string | null = null; + try { + principal = readTaskPrincipal(params.taskName); + } catch (error) { + principalError = error instanceof Error ? error.message : String(error); + } + const processCapture = readRelatedProcessDiagnostics([ + params.scriptPath, + params.probePath, + params.eventsPath, + ]); + for (const process of processCapture.processes) { + process.CommandLine = sanitizeDiagnosticText(process.CommandLine, params.replacements); + } + return { + capturedAt: new Date().toISOString(), + principal, + principalError: sanitizeDiagnosticText(principalError, params.replacements), + processCapture: { + ...processCapture, + error: sanitizeDiagnosticText(processCapture.error, params.replacements), + }, + taskXml: sanitizeTaskXml(taskXml, params.replacements), + verboseQuery: { + code: verboseQuery.code, + stdout: sanitizeVerboseQuery(verboseQuery.stdout, params.replacements), + stderr: sanitizeDiagnosticText(verboseQuery.stderr, params.replacements), + }, + }; +} + +async function writeFailureDiagnostics(params: { + cleanupEnd: { stdout: string; stderr: string; code: number } | null; + postEnd: FailureDiagnosticSnapshot | null; + postEndError: string | null; + preCleanup: FailureDiagnosticSnapshot | null; + preCleanupError: string | null; + replacements: Array<[string, string]>; + rootDir: string; + serviceOutput: string; +}): Promise { + await fs.writeFile( + path.join(params.rootDir, "failure-diagnostics.json"), + `${JSON.stringify( + { + preCleanup: params.preCleanup, + preCleanupError: sanitizeDiagnosticText(params.preCleanupError, params.replacements), + cleanupEnd: params.cleanupEnd + ? { + code: params.cleanupEnd.code, + stdout: sanitizeDiagnosticText(params.cleanupEnd.stdout, params.replacements), + stderr: sanitizeDiagnosticText(params.cleanupEnd.stderr, params.replacements), + } + : null, + postEnd: params.postEnd, + postEndError: sanitizeDiagnosticText(params.postEndError, params.replacements), + serviceOutput: sanitizeDiagnosticText(params.serviceOutput, params.replacements), + }, + null, + 2, + )}\n`, + "utf8", + ); +} + +async function cleanupNativeTask(params: { + activePidPath: string; + eventsPath: string; + preserveEvidence: boolean; + probePath: string; + rootDir: string; + scriptPath: string; + serviceOutput: string; + stateDir: string; + taskName: string; +}): Promise { + const cleanupErrors: unknown[] = []; + const replacements = resolveDiagnosticReplacements({ + rootDir: params.rootDir, + stateDir: params.stateDir, + }); + const snapshotParams = { + eventsPath: params.eventsPath, + probePath: params.probePath, + replacements, + scriptPath: params.scriptPath, + taskName: params.taskName, + }; + let preCleanup: FailureDiagnosticSnapshot | null = null; + let preCleanupError: string | null = null; + if (params.preserveEvidence) { + try { + preCleanup = await readFailureDiagnosticSnapshot(snapshotParams); + } catch (error) { + preCleanupError = error instanceof Error ? error.message : String(error); + } + } + const endResult = await execSchtasks(["/End", "/TN", params.taskName]).catch((error: unknown) => { + cleanupErrors.push(error); + return null; + }); + if (params.preserveEvidence) { + let postEnd: FailureDiagnosticSnapshot | null = null; + let postEndError: string | null = null; + try { + postEnd = await readFailureDiagnosticSnapshot(snapshotParams); + } catch (error) { + postEndError = error instanceof Error ? error.message : String(error); + } + try { + await writeFailureDiagnostics({ + cleanupEnd: endResult, + postEnd, + postEndError, + preCleanup, + preCleanupError, + replacements, + rootDir: params.rootDir, + serviceOutput: params.serviceOutput, + }); + } catch (error) { + cleanupErrors.push(error); + } + } + try { + await forceKillActiveProcess({ + activePidPath: params.activePidPath, + eventsPath: params.eventsPath, + probePath: params.probePath, + }); + } catch (error) { + cleanupErrors.push(error); + } + const deletion = await execSchtasks(["/Delete", "/F", "/TN", params.taskName]).catch( + (error: unknown) => { + cleanupErrors.push(error); + return null; + }, + ); + const taskExists = probeScheduledTaskExists(params.taskName); + if (taskExists === null) { + cleanupErrors.push(new Error(`Could not verify Scheduled Task cleanup for ${params.taskName}`)); + } else if (taskExists) { + const detail = deletion ? (deletion.stderr || deletion.stdout).trim() : ""; + cleanupErrors.push( + new Error( + `Scheduled Task cleanup left ${params.taskName} registered${detail ? `: ${detail}` : ""}`, + ), + ); + } + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, "Native Scheduled Task process or task cleanup failed"); + } + if (params.preserveEvidence) { + return; + } + for (const cleanupPath of [params.stateDir, params.rootDir]) { + try { + await fs.rm(cleanupPath, { recursive: true, force: true }); + } catch (error) { + cleanupErrors.push(error); + } + } + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, "Native Scheduled Task path cleanup failed"); + } +} + +function expectProbeProcessAlive(pid: number): void { + expect(isProcessAlive(pid), `Expected Scheduled Task probe process ${pid} to remain alive`).toBe( + true, + ); +} + +function expectScheduledTaskProbeOrigin(params: { + eventsPath: string; + probePath: string; + run: ProbeRunEvent; + scriptPath: string; +}): void { + expect(params.run.ppid).not.toBe(process.pid); + const capture = readRelatedProcessDiagnostics([ + params.eventsPath, + params.probePath, + params.scriptPath, + ]); + expect(capture.ok).toBe(true); + expect(capture.truncated).toBe(false); + const processEntry = capture.processes.find((entry) => entry.ProcessId === params.run.pid); + expect(processEntry?.ParentProcessId).toBe(params.run.ppid); + const parentEntry = capture.processes.find((entry) => entry.ProcessId === params.run.ppid); + const normalizeCommandLine = (value: string | null | undefined) => + (value ?? "").replaceAll("/", "\\").toLowerCase(); + const processCommandLine = normalizeCommandLine(processEntry?.CommandLine); + expect(processCommandLine.includes(normalizeCommandLine(params.probePath))).toBe(true); + expect(processCommandLine.includes(normalizeCommandLine(params.eventsPath))).toBe(true); + expect( + normalizeCommandLine(parentEntry?.CommandLine).includes( + normalizeCommandLine(params.scriptPath), + ), + ).toBe(true); +} + +function resolveTestId(): string { + const configured = process.env.CI_WINDOWS_SCHTASKS_TEST_ID?.trim(); + if (!configured) { + return randomUUID().slice(0, 8); + } + if (!/^[a-z0-9-]{1,48}$/u.test(configured)) { + throw new Error("CI_WINDOWS_SCHTASKS_TEST_ID must use lowercase letters, digits, or -"); + } + return configured; +} + +async function createIntegrationRoot( + configuredRoot: string | undefined, + id: string, +): Promise { + if (!configuredRoot) { + return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-schtasks-int-${id}-`)); + } + const rootDir = path.resolve(configuredRoot); + try { + // Cleanup may only remove a directory this exact run created. + await fs.mkdir(rootDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`CI_WINDOWS_SCHTASKS_ROOT must not already exist: ${rootDir}`, { + cause: error, + }); + } + throw error; + } + return rootDir; +} + +describe("schtasks Windows integration principal assertion", () => { + it("accepts omitted default run level when COM reports least privilege", () => { + expect(() => + assertInteractiveLeastPrivilegeTask({ + taskXml: "InteractiveToken", + principal: { + lastRunTime: "2026-07-31T00:00:00.0000000Z", + lastTaskResult: 0, + logonType: TASK_LOGON_INTERACTIVE_TOKEN, + runLevel: TASK_RUNLEVEL_LEAST_PRIVILEGE, + taskState: 3, + }, + }), + ).not.toThrow(); + }); + + it("rejects an elevated effective run level", () => { + expect(() => + assertInteractiveLeastPrivilegeTask({ + taskXml: "InteractiveTokenLeastPrivilege", + principal: { + lastRunTime: "2026-07-31T00:00:00.0000000Z", + lastTaskResult: 0, + logonType: TASK_LOGON_INTERACTIVE_TOKEN, + runLevel: 1, + taskState: 3, + }, + }), + ).toThrow(); + }); + + it("refuses to reuse or delete an existing configured root", async () => { + const existingRoot = path.join(os.tmpdir(), `openclaw-schtasks-existing-${randomUUID()}`); + await fs.mkdir(existingRoot); + try { + await expect(createIntegrationRoot(existingRoot, "existing")).rejects.toThrow( + "CI_WINDOWS_SCHTASKS_ROOT must not already exist", + ); + await expect(fs.access(existingRoot)).resolves.toBeUndefined(); + } finally { + await fs.rm(existingRoot, { recursive: true, force: true }); + } + }); + + it("redacts task identities without rewriting placeholders", () => { + expect( + sanitizeDiagnosticText("openclaw user on host-user", [ + ["openclaw", ""], + ["user", ""], + ]), + ).toBe(" on host-"); + expect( + sanitizeTaskXml("privateS-1-5-21", [ + ["user", ""], + ]), + ).toBe(""); + expect( + sanitizeTaskXml("privateS-1-5-21", [ + ["openclaw", ""], + ]), + ).toBe(""); + }); +}); + +const nativeIntegrationEnabled = + process.platform === "win32" && process.env.CI_WINDOWS_SCHTASKS_INTEGRATION === "1"; + +describe.runIf(nativeIntegrationEnabled)("schtasks Windows integration", () => { + it("isolates and completes the native Scheduled Task lifecycle", async () => { + const id = resolveTestId(); + const configuredRoot = process.env.CI_WINDOWS_SCHTASKS_ROOT?.trim(); + const rootDir = await createIntegrationRoot(configuredRoot, id); + const accountHome = os.userInfo().homedir; + const profile = `schtasks-int-${id}`; + const stateDir = path.join(accountHome, `.openclaw-${profile}`); + const activePidPath = path.join(rootDir, "active-pid.txt"); + const eventsPath = path.join(rootDir, "runs.txt"); + const probePath = path.join(rootDir, "probe.cjs"); + const gatewayPort = await reserveLoopbackPort(); + const taskName = resolveGatewayWindowsTaskName(profile); + const stdout = new PassThrough(); + let serviceOutput = ""; + stdout.setEncoding("utf8"); + stdout.on("data", (chunk: string) => { + if (serviceOutput.length < DIAGNOSTIC_TEXT_LIMIT) { + serviceOutput = `${serviceOutput}${chunk}`.slice(0, DIAGNOSTIC_TEXT_LIMIT); + } + }); + const env: GatewayServiceEnv = { + ...process.env, + APPDATA: path.join(rootDir, "appdata"), + HOME: accountHome, + USERPROFILE: accountHome, + OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"), + OPENCLAW_GATEWAY_PORT: String(gatewayPort), + OPENCLAW_HOME: undefined, + OPENCLAW_PROFILE: profile, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TASK_SCRIPT: undefined, + OPENCLAW_TASK_SCRIPT_NAME: undefined, + OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER: "1", + OPENCLAW_WINDOWS_TASK_NAME: undefined, + }; + const defaultTaskBefore = await readTaskDefinitionSnapshot("OpenClaw Gateway"); + const scriptPath = resolveTaskScriptPath(env); + const launcherPath = resolveTaskLauncherScriptPath(env, scriptPath); + + await fs.writeFile( + probePath, + [ + 'const fs = require("node:fs");', + 'const net = require("node:net");', + "const eventsPath = process.argv[5];", + "const activePidPath = process.argv[6];", + "const appendEvent = (phase) => fs.appendFileSync(eventsPath, `${JSON.stringify({ phase, pid: process.pid, ppid: process.ppid })}\\n`);", + 'const portIndex = process.argv.indexOf("--port");', + "const port = Number.parseInt(process.argv[portIndex + 1] ?? '', 10);", + "if (!Number.isInteger(port) || port < 1) throw new Error('Missing gateway --port');", + "const activePidTempPath = `${activePidPath}.${process.pid}.tmp`;", + "const server = net.createServer((socket) => socket.end());", + 'appendEvent("started");', + "server.listen({ host: '127.0.0.1', port, exclusive: true }, () => {", + " fs.writeFileSync(activePidTempPath, String(process.pid));", + " fs.renameSync(activePidTempPath, activePidPath);", + ' appendEvent("listening");', + "});", + "server.on('error', (error) => { console.error(error); process.exit(1); });", + "setInterval(() => {}, 1000).unref();", + "", + ].join("\n"), + "utf8", + ); + + let testFailed = false; + let testError: unknown; + let lifecyclePids: number[] = []; + let installedPrincipal: ScheduledTaskPrincipal | null = null; + const programArguments = [ + process.execPath, + probePath, + "gateway", + "--port", + String(gatewayPort), + eventsPath, + activePidPath, + ]; + try { + await withEnvAsync(env, async () => { + const service = resolveGatewayService(); + const readRuntime = () => service.readRuntime(env); + + expect((await execSchtasks(["/Query", "/TN", taskName])).code).not.toBe(0); + expect(path.relative(stateDir, scriptPath)).not.toMatch(/^\.\.(?:[\\/]|$)/u); + expect(await canBindLoopbackPort(gatewayPort)).toBe(true); + + await service.install({ + env, + stdout, + programArguments, + workingDirectory: rootDir, + environment: { OPENCLAW_GATEWAY_PORT: String(gatewayPort) }, + description: `OpenClaw CI Scheduled Task integration ${id}`, + }); + + expect((await execSchtasks(["/Query", "/TN", taskName])).code).toBe(0); + const taskXml = await readTaskXml(taskName); + if (!taskXml) { + throw new Error(`Could not export Scheduled Task XML for ${taskName}`); + } + expect(taskXml).toContain(""); + expect(taskXml.replaceAll("/", "\\").toLowerCase()).toContain( + launcherPath.replaceAll("/", "\\").toLowerCase(), + ); + installedPrincipal = await waitForSuccessfulScheduledTaskRun(taskName); + assertInteractiveLeastPrivilegeTask({ + taskXml, + principal: installedPrincipal, + }); + for (const startupEntryPath of resolveStartupEntryPaths(env)) { + await expect(fs.access(startupEntryPath)).rejects.toThrow(); + } + const command = await service.readCommand(env); + expect(command?.programArguments).toEqual(programArguments); + expect(command?.environment?.OPENCLAW_GATEWAY_PORT).toBe(String(gatewayPort)); + const installedRun = await waitForExactProbeRun(eventsPath, 1); + const installedPid = installedRun.pid; + expectProbeProcessAlive(installedPid); + expectScheduledTaskProbeOrigin({ + eventsPath, + probePath, + run: installedRun, + scriptPath, + }); + expect(await canBindLoopbackPort(gatewayPort)).toBe(false); + await waitForRuntimeStatus(readRuntime, "running", installedPid); + + const stopMutations: string[] = []; + await service.stop({ + env, + stdout, + onMutation: (mutation) => stopMutations.push(mutation.mode), + }); + expect(stopMutations).toEqual(["schtasks-stop"]); + await waitForProcessExit(installedPid); + await clearActivePid(activePidPath, installedPid); + await waitForLoopbackPortRelease(gatewayPort); + await waitForRuntimeStatus(readRuntime, "stopped"); + expect((await execSchtasks(["/Query", "/TN", taskName])).code).toBe(0); + + const startMutations: string[] = []; + await service.start({ + env, + stdout, + onMutation: (mutation) => startMutations.push(mutation.mode), + }); + expect(startMutations).toEqual(["schtasks-start"]); + const startedRun = await waitForExactProbeRun(eventsPath, 2); + const startedPid = startedRun.pid; + expect(startedPid).not.toBe(installedPid); + expectProbeProcessAlive(startedPid); + expectScheduledTaskProbeOrigin({ + eventsPath, + probePath, + run: startedRun, + scriptPath, + }); + expect(await canBindLoopbackPort(gatewayPort)).toBe(false); + await waitForRuntimeStatus(readRuntime, "running", startedPid); + + const restartMutations: string[] = []; + const restartResult = await service.restart({ + env, + stdout, + onMutation: (mutation) => restartMutations.push(mutation.mode), + }); + expect(restartResult).toEqual({ outcome: "completed" }); + expect(restartMutations).toEqual(["schtasks-end", "schtasks-restart"]); + const restartedRun = await waitForExactProbeRun(eventsPath, 3); + const restartedPid = restartedRun.pid; + lifecyclePids = [installedPid, startedPid, restartedPid]; + expect(restartedPid).not.toBe(startedPid); + expectProbeProcessAlive(restartedPid); + expectScheduledTaskProbeOrigin({ + eventsPath, + probePath, + run: restartedRun, + scriptPath, + }); + await waitForProcessExit(startedPid); + await clearActivePid(activePidPath, startedPid); + expect(await canBindLoopbackPort(gatewayPort)).toBe(false); + await waitForRuntimeStatus(readRuntime, "running", restartedPid); + + await service.stop({ env, stdout }); + await waitForProcessExit(restartedPid); + await clearActivePid(activePidPath, restartedPid); + await waitForLoopbackPortRelease(gatewayPort); + await waitForRuntimeStatus(readRuntime, "stopped"); + + await service.uninstall({ env, stdout }); + expect((await execSchtasks(["/Query", "/TN", taskName])).code).not.toBe(0); + await expect(fs.access(scriptPath)).rejects.toThrow(); + await expect(fs.access(launcherPath)).rejects.toThrow(); + expect(await canBindLoopbackPort(gatewayPort)).toBe(true); + expect(await readTaskDefinitionSnapshot("OpenClaw Gateway")).toEqual(defaultTaskBefore); + + const proofPath = process.env.CI_WINDOWS_SCHTASKS_PROOF_PATH?.trim(); + if (proofPath) { + const proofHead = process.env.CI_WINDOWS_SCHTASKS_HEAD?.trim(); + if (!proofHead || !/^[0-9a-f]{40}$/u.test(proofHead)) { + throw new Error( + "CI_WINDOWS_SCHTASKS_HEAD must identify the exact 40-character checkout SHA", + ); + } + await fs.mkdir(path.dirname(proofPath), { recursive: true }); + await fs.writeFile( + proofPath, + `${JSON.stringify( + { + result: "pass", + head: proofHead, + profile, + taskName, + lifecycle: ["install", "stop", "start", "restart", "stop", "uninstall"], + pids: lifecyclePids, + gatewayPort, + portReleaseRebind: true, + startupFallback: false, + defaultTaskUnchanged: true, + taskXml: { + interactiveToken: true, + leastPrivilege: true, + logonType: installedPrincipal?.logonType, + runLevel: installedPrincipal?.runLevel, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + } + }); + } catch (error) { + testFailed = true; + testError = error; + } + + let cleanupFailed = false; + let cleanupError: unknown; + try { + await cleanupNativeTask({ + activePidPath, + eventsPath, + preserveEvidence: testFailed, + probePath, + rootDir, + scriptPath, + serviceOutput, + stateDir, + taskName, + }); + } catch (error) { + cleanupFailed = true; + cleanupError = error; + } + if (cleanupFailed) { + throw new AggregateError( + testFailed ? [testError, cleanupError] : [cleanupError], + "Native Scheduled Task cleanup failed", + ); + } + if (testFailed) { + throw testError; + } + }, 180_000); +}); diff --git a/src/daemon/schtasks.integration.test-helpers.ts b/src/daemon/schtasks.integration.test-helpers.ts new file mode 100644 index 000000000000..dd0b2d8bc06b --- /dev/null +++ b/src/daemon/schtasks.integration.test-helpers.ts @@ -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 { + 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; + 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 { + 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; +} diff --git a/src/daemon/schtasks.stop.test.ts b/src/daemon/schtasks.stop.test.ts index 80ca1db84bcd..1ce4bb2be1a9 100644 --- a/src/daemon/schtasks.stop.test.ts +++ b/src/daemon/schtasks.stop.test.ts @@ -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( diff --git a/src/daemon/service.test.ts b/src/daemon/service.test.ts index 151eafb2f08d..ae9c36bdbb56 100644 --- a/src/daemon/service.test.ts +++ b/src/daemon/service.test.ts @@ -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", () => { diff --git a/src/daemon/service.ts b/src/daemon/service.ts index ddc32500068c..4ea791652ee2 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -90,6 +90,10 @@ export type GatewayService = { ) => Promise; }; +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 { 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. diff --git a/src/infra/gateway-supervision.test.ts b/src/infra/gateway-supervision.test.ts index ac7311e3f403..6442dda66ba4 100644 --- a/src/infra/gateway-supervision.test.ts +++ b/src/infra/gateway-supervision.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; import { assertGatewayServiceMutationAllowed, formatExternalSupervisorUpdateRequired, @@ -45,10 +47,99 @@ describe("gateway supervision", () => { ...override, }), ).toThrow( - `${NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON}. Rerun with HOME set to the OS account home and without OPENCLAW_HOME, OPENCLAW_STATE_DIR, or OPENCLAW_CONFIG_PATH overrides to restart the gateway.`, + `${NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON}. Rerun with HOME set to the OS account home, without OPENCLAW_HOME, and with OPENCLAW_STATE_DIR and OPENCLAW_CONFIG_PATH either unset or pointing at the canonical paths for that account home and profile to restart the gateway.`, ); }); + it("allows native service mutation for a named profile's canonical state dir", () => { + const accountHome = os.userInfo().homedir; + + expect(() => + assertGatewayServiceMutationAllowed("restart the gateway", { + HOME: accountHome, + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: path.join(accountHome, ".openclaw-work"), + OPENCLAW_CONFIG_PATH: path.join(accountHome, ".openclaw-work", "openclaw.json"), + }), + ).not.toThrow(); + }); + + it.each([ + { + platform: "darwin" as const, + platformName: "macOS", + envKey: "OPENCLAW_LAUNCHD_LABEL", + value: "ai.openclaw.gateway", + }, + { + platform: "linux" as const, + platformName: "Linux", + envKey: "OPENCLAW_SYSTEMD_UNIT", + value: "openclaw-gateway.service", + }, + { + platform: "win32" as const, + platformName: "Windows", + envKey: "OPENCLAW_WINDOWS_TASK_NAME", + value: "OpenClaw Gateway", + }, + ])( + "rejects named-profile $envKey overrides on $platformName", + ({ platform, platformName, envKey, value }) => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform); + const accountHome = os.userInfo().homedir; + try { + expect(() => + assertGatewayServiceMutationAllowed("restart the gateway", { + HOME: accountHome, + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: path.join(accountHome, ".openclaw-work"), + OPENCLAW_CONFIG_PATH: path.join(accountHome, ".openclaw-work", "openclaw.json"), + [envKey]: value, + }), + ).toThrow( + `named profiles cannot override ${envKey} for ${platformName} service management`, + ); + } finally { + platformSpy.mockRestore(); + } + }, + ); + + it("rejects macOS profile names that collide with reserved LaunchAgent identities", () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + try { + expect(() => + assertGatewayServiceMutationAllowed("restart the gateway", { + OPENCLAW_PROFILE: "gateway", + }), + ).toThrow('macOS profile "gateway" conflicts with a reserved LaunchAgent identity'); + } finally { + platformSpy.mockRestore(); + } + }); + + it.each([ + { platform: "darwin" as const, platformName: "macOS" }, + { platform: "win32" as const, platformName: "Windows" }, + ])( + "rejects case-distinct native service identities on $platformName", + ({ platform, platformName }) => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform); + try { + expect(() => + assertGatewayServiceMutationAllowed("restart the gateway", { + OPENCLAW_PROFILE: "Main", + }), + ).toThrow( + `${platformName} profile "Main" is not lowercase-safe for case-insensitive state and native-service paths`, + ); + } finally { + platformSpy.mockRestore(); + } + }, + ); + it("explains why self-update must be delegated", () => { expect(formatExternalSupervisorUpdateRequired()).toContain( "stop the gateway, update and finalize the runtime, then restart it safely", diff --git a/src/infra/gateway-supervision.ts b/src/infra/gateway-supervision.ts index 455146e0d172..8f2e864e3cfc 100644 --- a/src/infra/gateway-supervision.ts +++ b/src/infra/gateway-supervision.ts @@ -1,5 +1,6 @@ // Defines gateway lifecycle ownership shared by service, restart, and update paths. -import { isDefaultInstallIdentity } from "../config/paths.js"; +import { isDefaultInstallIdentity, resolveNativeServiceProfileConflict } from "../config/paths.js"; +import { resolveGatewayNativeServiceIdentityConflict } from "../daemon/constants.js"; const GATEWAY_SUPERVISOR_MODE_ENV = "OPENCLAW_SUPERVISOR_MODE"; export const EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON = "external-supervisor-update-required"; @@ -39,9 +40,41 @@ export function assertGatewayServiceMutationAllowed( if (isGatewayExternallySupervised(env)) { throw new Error(formatExternalSupervisorActionRequired(action)); } + const conflictingProfile = resolveNativeServiceProfileConflict(env); + if (conflictingProfile) { + if (conflictingProfile !== conflictingProfile.toLowerCase()) { + const platformName = process.platform === "win32" ? "Windows" : "macOS"; + throw new Error( + `service management skipped: ${platformName} profile "${conflictingProfile}" is not lowercase-safe for case-insensitive state and native-service paths. Use a lowercase profile name to ${action}, or keep this profile runtime-only without a native service.`, + ); + } + throw new Error( + `service management skipped: macOS profile "${conflictingProfile}" conflicts with a reserved LaunchAgent identity. Choose a different profile name to ${action}.`, + ); + } + const serviceIdentityConflict = resolveGatewayNativeServiceIdentityConflict(env); + if (serviceIdentityConflict) { + const platformName = + process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux"; + throw new Error( + `service management skipped: named profiles cannot override ${serviceIdentityConflict.envKey} for ${platformName} service management. Unset ${serviceIdentityConflict.envKey} so OpenClaw derives the native service identity from OPENCLAW_PROFILE to ${action}, or keep this profile runtime-only without a native service.`, + ); + } if (!isDefaultInstallIdentity(env)) { throw new Error( - `${NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON}. Rerun with HOME set to the OS account home and without OPENCLAW_HOME, OPENCLAW_STATE_DIR, or OPENCLAW_CONFIG_PATH overrides to ${action}.`, + `${NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON}. Rerun with HOME set to the OS account home, without OPENCLAW_HOME, and with OPENCLAW_STATE_DIR and OPENCLAW_CONFIG_PATH either unset or pointing at the canonical paths for that account home and profile to ${action}.`, ); } } + +export function resolveGatewayServiceMutationError( + action: string, + env: NodeJS.ProcessEnv = process.env, +): Error | null { + try { + assertGatewayServiceMutationAllowed(action, env); + return null; + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } +} diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index 6bad21627a3c..bacf8607f177 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -209,6 +209,18 @@ describe("package scripts", () => { ); }); + it("keeps the native Scheduled Task lifecycle proof opt-in", () => { + const scripts = readPackageJson().scripts; + + expect(scripts["test:windows:ci"]).not.toContain("schtasks.integration.e2e.test.ts"); + expect(scripts["test:windows:schtasks:integration"]).toContain( + "CI_WINDOWS_SCHTASKS_INTEGRATION=1", + ); + expect(scripts["test:windows:schtasks:integration"]).toContain( + "src/daemon/schtasks.integration.e2e.test.ts", + ); + }); + it("runs shared test-state cleanup coverage in Windows CI", () => { expect(readPackageJson().scripts["test:windows:ci"]).toContain( "src/test-utils/openclaw-test-state.test.ts", diff --git a/test/scripts/check-workflows.test.ts b/test/scripts/check-workflows.test.ts index 1251acde81cf..c5e137a7275a 100644 --- a/test/scripts/check-workflows.test.ts +++ b/test/scripts/check-workflows.test.ts @@ -243,7 +243,7 @@ describe("check-workflows", () => { expect(workflow).toContain("run_windows_ci:"); expect(workflow).toContain( - 'description: "Run the focused Windows-native CI test shard after probing"', + 'description: "Run the focused Windows CI shard and native Scheduled Task proof"', ); expect(workflow).toContain("default: false"); expect(workflow).toContain("if: ${{ inputs.run_windows_ci }}"); @@ -251,6 +251,20 @@ describe("check-workflows", () => { expect(workflow).toContain("uses: ./.github/actions/setup-pnpm-store-cache"); expect(workflow).toContain("pnpm install --frozen-lockfile --prefer-offline"); expect(workflow).toContain("pnpm test:windows:ci"); + expect(workflow).toContain("pnpm test:windows:schtasks:integration"); + expect(workflow).toContain('CI_WINDOWS_SCHTASKS_HEAD="$(git rev-parse HEAD)"'); + expect(workflow).toContain('if [[ "$CI_WINDOWS_SCHTASKS_HEAD" != "$EXPECTED_HEAD" ]]; then'); + expect(workflow).toContain('$activePidPath = Join-Path $env:TEST_ROOT "active-pid.txt"'); + expect(workflow).toContain('$process.CommandLine -like "*$probePath*"'); + expect(workflow).toContain('$process.CommandLine -like "*$eventsPath*"'); + expect(workflow).toContain("schtasks.exe /Delete /F /TN $taskName"); + expect(workflow).toContain('$service = New-Object -ComObject "Schedule.Service"'); + expect(workflow).toContain("failure-diagnostics.json"); + expect(workflow).toContain("cleanup-summary.txt"); + expect(workflow).not.toContain("task-before-cleanup.xml"); + expect(workflow).not.toContain("Copy-Item -LiteralPath $stateDir"); + expect(workflow).toContain(" exit 0"); + expect(workflow).toContain(".artifacts/windows-schtasks/"); expect(workflow).toContain("if: ${{ always() && !cancelled() }}"); expect(workflow).toContain("if: ${{ always() && !cancelled() && inputs.require_wsl2 }}"); });