mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 02:51:39 +00:00
fix: allow gateway service commands for named profiles (#116314)
* fix: gateway service commands refuse a named profile or relocated OPENCLAW_HOME - Resolve the default install identity against the canonical state directory for the active OpenClaw home and profile instead of the unprofiled OS account default. - `--profile <name>` / `--dev` project `.openclaw-<profile>` state and config paths, so every named profile was classified as isolated state and refused `install`, `start`, `stop`, `restart`, `uninstall`, Doctor service repair, and self-update service handling. - `OPENCLAW_HOME` relocates all OpenClaw path defaults and is documented for running as a dedicated service user; a relocated home is now an install identity. `HOME` alone still is not. - An `OPENCLAW_STATE_DIR` or `OPENCLAW_CONFIG_PATH` pointing outside those canonical paths is still treated as isolated state. - Recovery guidance in the refusal message now names the paths that must match. Verified: focused vitest shards for the changed suites plus the daemon, CLI, and doctor suites that consume the identity check; tsgo core and core-test lanes; oxlint; docs format, MDX, link, and map checks. * fix(gateway): keep relocated homes isolated * fix(config): validate service profile identity * fix(daemon): enforce named-profile service ownership * fix(update): reject drifted service selectors before probes * test(windows): prove scheduled task lifecycle * test(windows): harden scheduled task proof cleanup * test(windows): bind lifecycle proof to checkout * test(windows): normalize cleanup exit status * test(windows): verify effective task privilege * test(windows): protect scheduled task proof roots * test(windows): prove listener-owned task lifecycle * test(windows): fix scheduled task proof contracts * test(windows): remove redundant mock coercions * test(windows): measure fallback before task probes * test(windows): prove scheduled task process origin * fix(gateway): preserve unmanaged restart fallback * test(gateway): cover denied restart ownership * test(gateway): keep restart helper types private * test(gateway): classify lifecycle helpers as test code --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
161
.github/workflows/windows-testbox-probe.yml
vendored
161
.github/workflows/windows-testbox-probe.yml
vendored
@@ -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:
|
||||
|
||||
@@ -129,6 +129,16 @@ openclaw gateway restart --wait 30s
|
||||
Inline `--password` can be exposed in local process listings. Prefer `--password-file`, env, or a SecretRef-backed `gateway.auth.password`.
|
||||
</Warning>
|
||||
|
||||
### Install identity
|
||||
|
||||
Service management (`install`, `start`, `stop`, `restart`, `uninstall`, Doctor service repair, and self-update service handling) belongs to the install that owns the host service. That is the canonical `.openclaw` directory under the OS account home, or the `.openclaw-<profile>` directory a named profile projects there. Named profiles use distinct native service identities.
|
||||
|
||||
`OPENCLAW_HOME`, or an `OPENCLAW_STATE_DIR` or `OPENCLAW_CONFIG_PATH` that points elsewhere, is treated as isolated state and skipped. A relocated or copied state tree cannot adopt and rewrite the account's host service.
|
||||
|
||||
On macOS and Windows, native service-managed profile names must be lowercase. Runtime-only profiles may still use uppercase, but case-distinct names such as `Main` and `main` share paths on normal case-insensitive filesystems and cannot safely own separate native services. On macOS, the lowercase names `gateway` and `node` are also unavailable for native service management because their historical LaunchAgent labels collide with the default Gateway and node-host services.
|
||||
|
||||
Named profiles must also use the native service identity derived from `OPENCLAW_PROFILE`. Unset `OPENCLAW_LAUNCHD_LABEL`, `OPENCLAW_SYSTEMD_UNIT`, or `OPENCLAW_WINDOWS_TASK_NAME` before service management; custom identities remain available for the default profile or runtime-only/external-supervisor setups.
|
||||
|
||||
### External supervisors
|
||||
|
||||
Set `OPENCLAW_SUPERVISOR_MODE=external` only when another process manager owns the Gateway lifecycle. In this mode:
|
||||
|
||||
@@ -1641,6 +1641,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Run the Gateway
|
||||
- H3: Options
|
||||
- H2: Restart the Gateway
|
||||
- H3: Install identity
|
||||
- H3: External supervisors
|
||||
- H3: Gateway profiling
|
||||
- H2: Query a running Gateway
|
||||
|
||||
@@ -250,6 +250,8 @@ unavailable instead of triggering a network request.
|
||||
|
||||
When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.homedir()`) for internal OpenClaw path defaults. This includes the default state directory, config path, agent directories, credentials, installer onboarding workspace, and the default dev checkout used by `openclaw update --channel dev`.
|
||||
|
||||
`OPENCLAW_HOME` does not grant ownership of the OS account's native Gateway service. Gateway service-management commands treat a relocated home as isolated state; use the OS account home and a named profile when a separate native service identity is required.
|
||||
|
||||
**Precedence:** `OPENCLAW_HOME` > `$HOME` > `USERPROFILE` > Termux `PREFIX` home fallback on Android > `os.homedir()`
|
||||
|
||||
**Example** (macOS LaunchDaemon):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -104,6 +104,7 @@ vi.mock("../../config/mutate.js", () => ({
|
||||
|
||||
vi.mock("../../config/paths.js", () => ({
|
||||
isDefaultInstallIdentity: isDefaultInstallIdentityMock,
|
||||
resolveNativeServiceProfileConflict: () => null,
|
||||
resolveGatewayPort: resolveGatewayPortMock,
|
||||
resolveIsNixMode: resolveIsNixModeMock,
|
||||
}));
|
||||
|
||||
@@ -254,6 +254,55 @@ describe("runServiceRestart token drift", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the service mutation guard before restarting a loaded service", async () => {
|
||||
const beforeServiceMutation = vi.fn();
|
||||
|
||||
await runServiceRestart({
|
||||
...createServiceRunArgs(),
|
||||
beforeServiceMutation,
|
||||
});
|
||||
|
||||
expect(beforeServiceMutation).toHaveBeenCalledTimes(1);
|
||||
expect(beforeServiceMutation.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
service.restart.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
});
|
||||
|
||||
it("aborts loaded-service mutation when the service guard rejects", async () => {
|
||||
const repairLoadedService = vi.fn();
|
||||
|
||||
await expect(
|
||||
runServiceRestart({
|
||||
...createServiceRunArgs(),
|
||||
beforeServiceMutation: () => {
|
||||
throw new Error("service mutation denied");
|
||||
},
|
||||
repairLoadedService,
|
||||
}),
|
||||
).rejects.toThrow("service mutation denied");
|
||||
|
||||
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
|
||||
expect(repairLoadedService).not.toHaveBeenCalled();
|
||||
expect(service.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not run the service mutation guard before not-loaded recovery", async () => {
|
||||
service.isLoaded.mockResolvedValue(false);
|
||||
const beforeServiceMutation = vi.fn();
|
||||
|
||||
await runServiceRestart({
|
||||
...createServiceRunArgs(),
|
||||
beforeServiceMutation,
|
||||
onNotLoaded: async () => ({
|
||||
result: "restarted",
|
||||
message: "Gateway restart signal sent to unmanaged process on port 18789: 4200.",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(beforeServiceMutation).not.toHaveBeenCalled();
|
||||
expect(service.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("repairs managed port drift before restarting", async () => {
|
||||
service.readRuntime.mockResolvedValue({ status: "running", pid: 1234 });
|
||||
service.readCommand.mockResolvedValue({
|
||||
|
||||
@@ -459,6 +459,7 @@ export async function runServiceRestart(params: {
|
||||
opts?: DaemonLifecycleOptions;
|
||||
checkTokenDrift?: boolean;
|
||||
expectedPort?: number;
|
||||
beforeServiceMutation?: () => void;
|
||||
repairLoadedService?: (
|
||||
ctx: ServiceStartRepairContext,
|
||||
) => Promise<ServiceRecoveryResult<"restarted"> | null>;
|
||||
@@ -533,6 +534,12 @@ export async function runServiceRestart(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// Loaded services cross the native mutation boundary here. Not-loaded recovery
|
||||
// may still target a separately verified unmanaged listener.
|
||||
if (loaded) {
|
||||
params.beforeServiceMutation?.();
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
try {
|
||||
handledRecovery = (await params.onNotLoaded?.({ json, stdout, warn, fail })) ?? null;
|
||||
|
||||
41
src/cli/daemon-cli/lifecycle.test-helpers.ts
Normal file
41
src/cli/daemon-cli/lifecycle.test-helpers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
type RestartPostCheckContext = {
|
||||
json: boolean;
|
||||
stdout: NodeJS.WritableStream;
|
||||
warnings: string[];
|
||||
fail: (message: string, hints?: string[]) => void;
|
||||
};
|
||||
|
||||
export type RestartParams = {
|
||||
opts?: { json?: boolean };
|
||||
beforeServiceMutation?: () => void;
|
||||
repairLoadedService?: (ctx: {
|
||||
json: boolean;
|
||||
stdout: NodeJS.WritableStream;
|
||||
state: unknown;
|
||||
issues: unknown[];
|
||||
}) => Promise<unknown>;
|
||||
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<void>;
|
||||
};
|
||||
|
||||
export function requireMockCallArg(
|
||||
mockFn: { mock: { calls: unknown[][] } },
|
||||
label: string,
|
||||
index = 0,
|
||||
): Record<string, unknown> {
|
||||
const arg = mockFn.mock.calls[index]?.[0] as Record<string, unknown> | undefined;
|
||||
if (!arg) {
|
||||
throw new Error(`expected ${label} call #${index + 1}`);
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
|
||||
export async function expectRestartError(
|
||||
promise: Promise<unknown>,
|
||||
): Promise<Error & { hints?: string[] }> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return error as Error & { hints?: string[] };
|
||||
}
|
||||
throw new Error("expected restart to fail");
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
// Daemon lifecycle tests cover CLI service lifecycle orchestration and cleanup.
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../../test-utils/env.js";
|
||||
import {
|
||||
expectRestartError,
|
||||
requireMockCallArg,
|
||||
type RestartParams,
|
||||
} from "./lifecycle.test-helpers.js";
|
||||
|
||||
type RestartHealthSnapshot = {
|
||||
healthy: boolean;
|
||||
@@ -11,30 +16,13 @@ type RestartHealthSnapshot = {
|
||||
elapsedMs?: number;
|
||||
};
|
||||
|
||||
type RestartPostCheckContext = {
|
||||
json: boolean;
|
||||
stdout: NodeJS.WritableStream;
|
||||
warnings: string[];
|
||||
fail: (message: string, hints?: string[]) => void;
|
||||
};
|
||||
|
||||
type RestartParams = {
|
||||
opts?: { json?: boolean };
|
||||
repairLoadedService?: (ctx: {
|
||||
json: boolean;
|
||||
stdout: NodeJS.WritableStream;
|
||||
state: unknown;
|
||||
issues: unknown[];
|
||||
}) => Promise<unknown>;
|
||||
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<void>;
|
||||
};
|
||||
|
||||
const service = {
|
||||
readCommand: vi.fn(),
|
||||
readRuntime: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const isDefaultInstallIdentity = vi.hoisted(() => vi.fn(() => true));
|
||||
|
||||
const runServiceStart = vi.fn();
|
||||
const runServiceRestart = vi.fn();
|
||||
@@ -99,29 +87,6 @@ const createGatewayLifecycleMutationAudit = vi.fn(
|
||||
}),
|
||||
);
|
||||
|
||||
function requireMockCallArg(
|
||||
mockFn: { mock: { calls: unknown[][] } },
|
||||
label: string,
|
||||
index = 0,
|
||||
): Record<string, unknown> {
|
||||
const arg = mockFn.mock.calls[index]?.[0] as Record<string, unknown> | undefined;
|
||||
if (!arg) {
|
||||
throw new Error(`expected ${label} call #${index + 1}`);
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
|
||||
async function expectRestartError(
|
||||
promise: Promise<unknown>,
|
||||
): Promise<Error & { hints?: string[] }> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return error as Error & { hints?: string[] };
|
||||
}
|
||||
throw new Error("expected restart to fail");
|
||||
}
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
getRuntimeConfig: () => loadConfig(),
|
||||
loadConfig: () => loadConfig(),
|
||||
@@ -129,7 +94,10 @@ vi.mock("../../config/config.js", () => ({
|
||||
resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/paths.js", () => ({ isDefaultInstallIdentity: () => true }));
|
||||
vi.mock("../../config/paths.js", () => ({
|
||||
isDefaultInstallIdentity: () => isDefaultInstallIdentity(),
|
||||
resolveNativeServiceProfileConflict: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/gateway-processes.js", () => ({
|
||||
findVerifiedGatewayListenerPidsOnPortSync,
|
||||
@@ -276,13 +244,12 @@ describe("runDaemonRestart health checks", () => {
|
||||
]);
|
||||
delete process.env.OPENCLAW_CONTAINER_HINT;
|
||||
service.readCommand.mockReset();
|
||||
service.readRuntime.mockReset();
|
||||
service.readRuntime.mockResolvedValue({ status: "stopped" });
|
||||
service.restart.mockReset();
|
||||
service.readRuntime.mockReset().mockResolvedValue({ status: "stopped" });
|
||||
service.restart.mockReset().mockResolvedValue({ outcome: "completed" });
|
||||
service.stop.mockReset();
|
||||
runServiceStart.mockReset();
|
||||
runServiceStart.mockReset().mockResolvedValue(undefined);
|
||||
runServiceRestart.mockReset();
|
||||
runServiceStop.mockReset();
|
||||
runServiceStop.mockReset().mockResolvedValue(undefined);
|
||||
waitForGatewayHealthyListener.mockReset();
|
||||
waitForGatewayHealthyRestart.mockReset();
|
||||
terminateStaleGatewayPids.mockReset();
|
||||
@@ -290,43 +257,36 @@ describe("runDaemonRestart health checks", () => {
|
||||
renderRestartDiagnostics.mockReset();
|
||||
resolveGatewayPort.mockReset();
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReset();
|
||||
signalVerifiedGatewayPidSync.mockReset();
|
||||
writeGatewayRestartIntentSync.mockReset();
|
||||
signalVerifiedGatewayPidSync.mockReset().mockImplementation(() => {});
|
||||
writeGatewayRestartIntentSync.mockReset().mockReturnValue(true);
|
||||
clearGatewayRestartIntentSync.mockReset();
|
||||
formatGatewayPidList.mockReset();
|
||||
formatGatewayPidList.mockReset().mockImplementation((pids) => pids.join(", "));
|
||||
probeGateway.mockReset();
|
||||
callGatewayCli.mockReset();
|
||||
isRestartEnabled.mockReset();
|
||||
loadConfig.mockReset();
|
||||
readActiveGatewayLockPort.mockReset();
|
||||
readActiveGatewayLockPort.mockReset().mockResolvedValue(undefined);
|
||||
readActiveGatewayLockIdentity.mockReset();
|
||||
recoverInstalledLaunchAgent.mockReset();
|
||||
recoverInstalledLaunchAgent.mockReset().mockResolvedValue(null);
|
||||
repairLoadedGatewayServiceForStart.mockReset();
|
||||
isTerminalInteractive.mockReset();
|
||||
isTerminalInteractive.mockReturnValue(true);
|
||||
isTerminalInteractive.mockReset().mockReturnValue(true);
|
||||
appendGatewayLifecycleAudit.mockClear();
|
||||
createGatewayLifecycleMutationAudit.mockClear();
|
||||
isDefaultInstallIdentity.mockReset().mockReturnValue(true);
|
||||
|
||||
service.readCommand.mockResolvedValue({
|
||||
programArguments: ["openclaw", "gateway", "--port", "18789"],
|
||||
environment: {},
|
||||
});
|
||||
service.restart.mockResolvedValue({ outcome: "completed" });
|
||||
runServiceStart.mockResolvedValue(undefined);
|
||||
recoverInstalledLaunchAgent.mockResolvedValue(null);
|
||||
readActiveGatewayLockPort.mockResolvedValue(undefined);
|
||||
readActiveGatewayLockIdentity.mockResolvedValue({
|
||||
pid: 4200,
|
||||
ownerId: "gateway-owner-old",
|
||||
createdAt: "2026-07-16T12:00:00.000Z",
|
||||
port: 18_789,
|
||||
});
|
||||
findInstalledSystemdGatewayScope.mockReset();
|
||||
findInstalledSystemdGatewayScope.mockResolvedValue(null);
|
||||
restartSystemdService.mockReset();
|
||||
restartSystemdService.mockResolvedValue({ outcome: "completed" });
|
||||
stopSystemdService.mockReset();
|
||||
stopSystemdService.mockResolvedValue(undefined);
|
||||
findInstalledSystemdGatewayScope.mockReset().mockResolvedValue(null);
|
||||
restartSystemdService.mockReset().mockResolvedValue({ outcome: "completed" });
|
||||
stopSystemdService.mockReset().mockResolvedValue(undefined);
|
||||
|
||||
runServiceRestart.mockImplementation(async (params: RestartParams) => {
|
||||
const fail = (message: string, hints?: string[]) => {
|
||||
@@ -342,7 +302,6 @@ describe("runDaemonRestart health checks", () => {
|
||||
});
|
||||
return true;
|
||||
});
|
||||
runServiceStop.mockResolvedValue(undefined);
|
||||
waitForGatewayHealthyListener.mockResolvedValue({
|
||||
healthy: true,
|
||||
portUsage: { port: 18789, status: "busy", listeners: [], hints: [] },
|
||||
@@ -383,9 +342,6 @@ describe("runDaemonRestart health checks", () => {
|
||||
},
|
||||
});
|
||||
isRestartEnabled.mockReturnValue(true);
|
||||
signalVerifiedGatewayPidSync.mockImplementation(() => {});
|
||||
writeGatewayRestartIntentSync.mockReturnValue(true);
|
||||
formatGatewayPidList.mockImplementation((pids) => pids.join(", "));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -417,6 +373,16 @@ describe("runDaemonRestart health checks", () => {
|
||||
expect(requireMockCallArg(runServiceRestart, "runServiceRestart").expectedPort).toBeUndefined();
|
||||
});
|
||||
|
||||
it("guards loaded service restart at the native mutation boundary", async () => {
|
||||
await runDaemonRestart({ json: true });
|
||||
|
||||
const restartParams = requireMockCallArg(runServiceRestart, "runServiceRestart");
|
||||
isDefaultInstallIdentity.mockReturnValue(false);
|
||||
expect(() => (restartParams.beforeServiceMutation as () => void)()).toThrow(
|
||||
/non-default state dir/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the installed service environment for managed restart health", async () => {
|
||||
process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-caller-state";
|
||||
process.env.OPENCLAW_SYSTEMD_UNIT = "openclaw-gateway-maintenance.service";
|
||||
@@ -840,12 +806,15 @@ describe("runDaemonRestart health checks", () => {
|
||||
});
|
||||
|
||||
it("signals a single unmanaged gateway process on restart", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||
isDefaultInstallIdentity.mockReturnValue(false);
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||
mockUnmanagedRestart({ runPostRestartCheck: true });
|
||||
|
||||
await runDaemonRestart({ json: true });
|
||||
|
||||
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789);
|
||||
expect(findInstalledSystemdGatewayScope).not.toHaveBeenCalled();
|
||||
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1");
|
||||
expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith({
|
||||
action: "restart",
|
||||
@@ -860,6 +829,17 @@ describe("runDaemonRestart health checks", () => {
|
||||
expect(service.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects denied Darwin recovery when no unmanaged listener exists", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
isDefaultInstallIdentity.mockReturnValue(false);
|
||||
mockUnmanagedRestart();
|
||||
|
||||
await expect(runDaemonRestart({ json: true })).rejects.toThrow(/non-default state dir/);
|
||||
|
||||
expect(recoverInstalledLaunchAgent).not.toHaveBeenCalled();
|
||||
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses targeted RPC for an unmanaged Windows gateway restart", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||
@@ -1025,6 +1005,7 @@ describe("runDaemonRestart health checks", () => {
|
||||
});
|
||||
|
||||
it("fails unmanaged restart when multiple gateway listeners are present", async () => {
|
||||
isDefaultInstallIdentity.mockReturnValue(false);
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200, 4300]);
|
||||
mockUnmanagedRestart();
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
assertGatewayServiceMutationAllowed,
|
||||
formatExternalSupervisorActionRequired,
|
||||
isGatewayExternallySupervised,
|
||||
resolveGatewayServiceMutationError,
|
||||
} from "../../infra/gateway-supervision.js";
|
||||
import {
|
||||
clearGatewayRestartIntentSync,
|
||||
@@ -383,16 +384,13 @@ async function signalGatewayRestart(
|
||||
};
|
||||
}
|
||||
|
||||
async function restartGatewayWithoutServiceManager(
|
||||
port: number,
|
||||
restartIntent?: GatewayRestartIntent,
|
||||
) {
|
||||
const managed = await handleSystemScopeSystemdGateway("restart");
|
||||
async function restartUnmanaged(port: number, intent?: GatewayRestartIntent, allowSystem = true) {
|
||||
const managed = allowSystem ? await handleSystemScopeSystemdGateway("restart") : null;
|
||||
if (managed) {
|
||||
return managed;
|
||||
}
|
||||
return await signalGatewayRestart(port, {
|
||||
restartIntent,
|
||||
restartIntent: intent,
|
||||
enforceRestartConfig: true,
|
||||
processLabel: "unmanaged",
|
||||
auditSource: "cli",
|
||||
@@ -402,7 +400,7 @@ async function restartGatewayWithoutServiceManager(
|
||||
type GatewaySignalRestartResult = NonNullable<Awaited<ReturnType<typeof signalGatewayRestart>>>;
|
||||
|
||||
function isGatewaySignalRestartResult(
|
||||
result: Awaited<ReturnType<typeof restartGatewayWithoutServiceManager>>,
|
||||
result: Awaited<ReturnType<typeof restartUnmanaged>>,
|
||||
): result is GatewaySignalRestartResult {
|
||||
return result !== null && "pid" in result && typeof result.pid === "number";
|
||||
}
|
||||
@@ -595,6 +593,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
|
||||
},
|
||||
checkTokenDrift: true,
|
||||
expectedPort: configuredPort,
|
||||
beforeServiceMutation: () => assertGatewayServiceMutationAllowed("restart the gateway"),
|
||||
repairLoadedService: async ({ json, stdout, warn, state, issues }) => {
|
||||
const result = await repairLoadedGatewayServiceForStart({
|
||||
action: "restart",
|
||||
@@ -612,7 +611,8 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
|
||||
return result;
|
||||
},
|
||||
onNotLoaded: async () => {
|
||||
if (process.platform === "darwin") {
|
||||
const mutationError = resolveGatewayServiceMutationError("restart the gateway");
|
||||
if (process.platform === "darwin" && !mutationError) {
|
||||
const recovered = await recoverInstalledLaunchAgent({ result: "restarted" });
|
||||
if (recovered) {
|
||||
appendGatewayLifecycleAudit({
|
||||
@@ -623,7 +623,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
|
||||
return recovered;
|
||||
}
|
||||
}
|
||||
const handled = await restartGatewayWithoutServiceManager(unmanagedPort, restartIntent);
|
||||
const handled = await restartUnmanaged(unmanagedPort, restartIntent, !mutationError);
|
||||
if (handled) {
|
||||
restartedWithoutServiceManager = true;
|
||||
if (isGatewaySignalRestartResult(handled) && handled.previousLockIdentity) {
|
||||
@@ -635,6 +635,9 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
if (mutationError) {
|
||||
throw mutationError;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
postRestartCheck: async ({ warnings, fail, stdout, warn }) => {
|
||||
|
||||
@@ -358,6 +358,41 @@ describe("applyCliProfileEnv", () => {
|
||||
expect(env.OPENCLAW_CONFIG_PATH).toBe("/srv/openclaw/custom.json");
|
||||
});
|
||||
|
||||
it.each(["openclaw-gateway-main", "openclaw-gateway-main.service"])(
|
||||
"drops inherited canonical service identities when switching profiles (%s)",
|
||||
(systemdUnit) => {
|
||||
const env: Record<string, string | undefined> = {
|
||||
OPENCLAW_PROFILE: "main",
|
||||
OPENCLAW_STATE_DIR: "/home/peter/.openclaw-main",
|
||||
OPENCLAW_CONFIG_PATH: "/home/peter/.openclaw-main/openclaw.json",
|
||||
OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.main",
|
||||
OPENCLAW_SYSTEMD_UNIT: systemdUnit,
|
||||
OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (main)",
|
||||
};
|
||||
|
||||
applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" });
|
||||
|
||||
expect(env.OPENCLAW_LAUNCHD_LABEL).toBeUndefined();
|
||||
expect(env.OPENCLAW_SYSTEMD_UNIT).toBeUndefined();
|
||||
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves explicit custom service identities when switching profiles", () => {
|
||||
const env: Record<string, string | undefined> = {
|
||||
OPENCLAW_PROFILE: "main",
|
||||
OPENCLAW_LAUNCHD_LABEL: "com.example.gateway",
|
||||
OPENCLAW_SYSTEMD_UNIT: "custom-gateway.service",
|
||||
OPENCLAW_WINDOWS_TASK_NAME: "Custom Gateway",
|
||||
};
|
||||
|
||||
applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" });
|
||||
|
||||
expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("com.example.gateway");
|
||||
expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("custom-gateway.service");
|
||||
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("Custom Gateway");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ inheritedProfile: "Main", selectedProfile: "main" },
|
||||
{ inheritedProfile: "main", selectedProfile: "Main" },
|
||||
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
resolveGatewayLaunchAgentLabel,
|
||||
resolveGatewaySystemdServiceName,
|
||||
resolveGatewayWindowsTaskName,
|
||||
} from "../daemon/constants.js";
|
||||
import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import { resolveCliArgvInvocation } from "./argv-invocation.js";
|
||||
import { isValidProfileName } from "./profile-utils.js";
|
||||
@@ -129,6 +134,24 @@ export function applyCliProfileEnv(params: {
|
||||
env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json");
|
||||
}
|
||||
|
||||
if (switchesInheritedProfile) {
|
||||
const inheritedSystemdServiceName = resolveGatewaySystemdServiceName(inheritedProfile);
|
||||
const inheritedServiceIdentities = {
|
||||
OPENCLAW_LAUNCHD_LABEL: [resolveGatewayLaunchAgentLabel(inheritedProfile)],
|
||||
OPENCLAW_SYSTEMD_UNIT: [
|
||||
inheritedSystemdServiceName,
|
||||
`${inheritedSystemdServiceName}.service`,
|
||||
],
|
||||
OPENCLAW_WINDOWS_TASK_NAME: [resolveGatewayWindowsTaskName(inheritedProfile)],
|
||||
};
|
||||
for (const [key, inheritedValues] of Object.entries(inheritedServiceIdentities)) {
|
||||
const activeValue = normalizeOptionalString(env[key]);
|
||||
if (activeValue && inheritedValues.includes(activeValue)) {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (profile === "dev" && !env.OPENCLAW_GATEWAY_PORT?.trim()) {
|
||||
env.OPENCLAW_GATEWAY_PORT = "19001";
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ const resolveGlobalManager = vi.fn();
|
||||
const serviceLoaded = vi.fn();
|
||||
const serviceStop = vi.fn();
|
||||
const serviceRestart = vi.fn();
|
||||
const isDefaultInstallIdentity = vi.hoisted(() =>
|
||||
vi.fn<(env?: NodeJS.ProcessEnv, homedir?: () => string, platform?: NodeJS.Platform) => boolean>(
|
||||
() => true,
|
||||
),
|
||||
);
|
||||
const suspendScheduledTaskAutoStartForUpdate = vi.fn();
|
||||
const resumeScheduledTaskAutoStartAfterUpdate = vi.fn();
|
||||
const prepareRestartScript = vi.fn();
|
||||
@@ -322,7 +327,12 @@ vi.mock("../config/backup-rotation.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/service.js", () => ({
|
||||
readGatewayServiceState: async () => {
|
||||
readGatewayServiceState: async (
|
||||
_service: unknown,
|
||||
args?: {
|
||||
validateEnvBeforeStatusRead?: (env: NodeJS.ProcessEnv) => void;
|
||||
},
|
||||
) => {
|
||||
const command = await serviceReadCommand();
|
||||
const env = {
|
||||
...process.env,
|
||||
@@ -330,6 +340,7 @@ vi.mock("../daemon/service.js", () => ({
|
||||
? (command.environment as NodeJS.ProcessEnv | undefined)
|
||||
: undefined),
|
||||
};
|
||||
args?.validateEnvBeforeStatusRead?.(env);
|
||||
const [loaded, runtime] = await Promise.all([
|
||||
serviceLoaded({ env }).catch(() => false),
|
||||
serviceReadRuntime(env).catch(() => undefined),
|
||||
@@ -365,6 +376,15 @@ vi.mock("../daemon/schtasks.js", () => ({
|
||||
resumeScheduledTaskAutoStartAfterUpdate(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../config/paths.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../config/paths.js")>()),
|
||||
isDefaultInstallIdentity: (
|
||||
env?: NodeJS.ProcessEnv,
|
||||
homedir?: () => string,
|
||||
platform?: NodeJS.Platform,
|
||||
) => isDefaultInstallIdentity(env, homedir, platform),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/ports.js", () => ({
|
||||
inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args),
|
||||
classifyPortListener: (...args: unknown[]) => classifyPortListener(...args),
|
||||
@@ -1364,6 +1384,8 @@ describe("update-cli", () => {
|
||||
resolveGlobalManager.mockResolvedValue("npm");
|
||||
serviceStop.mockResolvedValue(undefined);
|
||||
serviceRestart.mockResolvedValue({ outcome: "completed" });
|
||||
isDefaultInstallIdentity.mockReset();
|
||||
isDefaultInstallIdentity.mockReturnValue(true);
|
||||
suspendScheduledTaskAutoStartForUpdate.mockResolvedValue(false);
|
||||
resumeScheduledTaskAutoStartAfterUpdate.mockResolvedValue(false);
|
||||
serviceLoaded.mockResolvedValue(false);
|
||||
@@ -4206,6 +4228,99 @@ describe("update-cli", () => {
|
||||
processOffSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not inspect or mutate a Windows host service from an isolated install", async () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
const tempDir = await createTrackedTempDir("openclaw-update-isolated-service-");
|
||||
const { nodeModules } = await setupInstalledPackageRoot(tempDir);
|
||||
mockRunningManagedGateway();
|
||||
mockFileBackedPathExists();
|
||||
mockNpmGlobalRoot(nodeModules);
|
||||
isDefaultInstallIdentity.mockReturnValue(false);
|
||||
|
||||
await withEnvAsync({ OPENCLAW_HOME: path.join(tempDir, "relocated-home") }, async () => {
|
||||
await updateCommand({ yes: true });
|
||||
});
|
||||
platformSpy.mockRestore();
|
||||
|
||||
expect(isDefaultInstallIdentity).toHaveBeenCalled();
|
||||
expect(serviceReadCommand).not.toHaveBeenCalled();
|
||||
expect(suspendScheduledTaskAutoStartForUpdate).not.toHaveBeenCalled();
|
||||
expect(serviceStop).not.toHaveBeenCalled();
|
||||
expect(prepareRestartScript).not.toHaveBeenCalled();
|
||||
expect(runRestartScript).not.toHaveBeenCalled();
|
||||
expect(runDaemonRestart).not.toHaveBeenCalled();
|
||||
expect(packageInstallCommandCall()).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
platform: "darwin" as const,
|
||||
envKey: "OPENCLAW_LAUNCHD_LABEL",
|
||||
value: "ai.openclaw.gateway",
|
||||
},
|
||||
{
|
||||
platform: "linux" as const,
|
||||
envKey: "OPENCLAW_SYSTEMD_UNIT",
|
||||
value: "openclaw-gateway.service",
|
||||
},
|
||||
{
|
||||
platform: "win32" as const,
|
||||
envKey: "OPENCLAW_WINDOWS_TASK_NAME",
|
||||
value: "OpenClaw Gateway",
|
||||
},
|
||||
])(
|
||||
"does not reuse a conflicting $envKey selector from the managed service on $platform",
|
||||
async ({ platform, envKey, value }) => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform);
|
||||
const tempDir = await createTrackedTempDir(`openclaw-update-${platform}-selector-`);
|
||||
const home = path.join(tempDir, "home");
|
||||
const stateDir = path.join(home, ".openclaw-work");
|
||||
const { nodeModules } = await setupInstalledPackageRoot(tempDir);
|
||||
serviceReadCommand.mockResolvedValue({
|
||||
programArguments: ["openclaw", "gateway", "run"],
|
||||
environment: {
|
||||
OPENCLAW_PROFILE: "work",
|
||||
[envKey]: value,
|
||||
},
|
||||
});
|
||||
serviceLoaded.mockResolvedValue(true);
|
||||
serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" });
|
||||
mockFileBackedPathExists();
|
||||
mockNpmGlobalRoot(nodeModules);
|
||||
|
||||
try {
|
||||
await withEnvAsync(
|
||||
{
|
||||
HOME: home,
|
||||
USERPROFILE: undefined,
|
||||
OPENCLAW_HOME: undefined,
|
||||
OPENCLAW_PROFILE: "work",
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
|
||||
[envKey]: undefined,
|
||||
},
|
||||
async () => {
|
||||
await updateCommand({ yes: true });
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
platformSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(isDefaultInstallIdentity).toHaveBeenCalled();
|
||||
expect(serviceReadRuntime).not.toHaveBeenCalled();
|
||||
expect(suspendScheduledTaskAutoStartForUpdate).not.toHaveBeenCalled();
|
||||
expect(serviceStop).not.toHaveBeenCalled();
|
||||
expect(serviceRestart).not.toHaveBeenCalled();
|
||||
expect(prepareRestartScript).not.toHaveBeenCalled();
|
||||
expect(runRestartScript).not.toHaveBeenCalled();
|
||||
expect(runDaemonRestart).not.toHaveBeenCalled();
|
||||
expect(packageInstallCommandCall()).toBeUndefined();
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
expect(getErrorOutput()).toContain(envKey);
|
||||
},
|
||||
);
|
||||
|
||||
it("restores Windows Scheduled Task autostart when service stop fails", async () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
mockPackageInstallStatus(createCaseDir("openclaw-update-stop-failure"));
|
||||
|
||||
@@ -38,9 +38,13 @@ import {
|
||||
} from "./update-command-post-core.js";
|
||||
import { POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON } from "./update-command-post-plugin-validation.js";
|
||||
import {
|
||||
assertGatewayServiceManagementAllowedForUpdate,
|
||||
GatewayServiceUpdateOwnershipError,
|
||||
gatewayServiceCommandUsesRoot,
|
||||
isGatewayServiceManagementAllowedForUpdate,
|
||||
maybeRestartService,
|
||||
maybeRestartServiceAfterFailedMutableUpdate,
|
||||
resolveGatewayServiceManagementBlockMessageForUpdate,
|
||||
resolvePostUpdateServiceStateReadEnv,
|
||||
resolveUpdatedGatewayRestartPort,
|
||||
restoreWindowsTaskAutoStartOrExit,
|
||||
@@ -340,18 +344,30 @@ export async function finishUpdate(params: {
|
||||
let refreshGatewayServiceEnv = false;
|
||||
let gatewayServiceEnv: NodeJS.ProcessEnv | undefined;
|
||||
let skipLegacyServiceRestart = false;
|
||||
const serviceStateReadEnv = resolvePostUpdateServiceStateReadEnv({
|
||||
updateMode: resultWithPostUpdate.mode,
|
||||
processEnv: process.env,
|
||||
preManagedServiceEnv: params.preManagedServiceStop?.serviceEnv,
|
||||
});
|
||||
const serviceMutationAllowed =
|
||||
params.preManagedServiceStop?.serviceMutationAllowed !== false &&
|
||||
isGatewayServiceManagementAllowedForUpdate(process.env) &&
|
||||
isGatewayServiceManagementAllowedForUpdate(serviceStateReadEnv);
|
||||
const serviceMutationSkipMessage =
|
||||
params.shouldRestart && !serviceMutationAllowed
|
||||
? (params.preManagedServiceStop?.serviceMutationSkipMessage ??
|
||||
resolveGatewayServiceManagementBlockMessageForUpdate(process.env) ??
|
||||
resolveGatewayServiceManagementBlockMessageForUpdate(serviceStateReadEnv))
|
||||
: undefined;
|
||||
let gatewayPort = resolveUpdatedGatewayRestartPort({
|
||||
config: restartConfigSnapshot.valid ? restartConfigSnapshot.config : undefined,
|
||||
processEnv: process.env,
|
||||
});
|
||||
if (params.shouldRestart) {
|
||||
if (params.shouldRestart && serviceMutationAllowed) {
|
||||
try {
|
||||
const serviceState = await readGatewayServiceState(resolveGatewayService(), {
|
||||
env: resolvePostUpdateServiceStateReadEnv({
|
||||
updateMode: resultWithPostUpdate.mode,
|
||||
processEnv: process.env,
|
||||
preManagedServiceEnv: params.preManagedServiceStop?.serviceEnv,
|
||||
}),
|
||||
env: serviceStateReadEnv,
|
||||
validateEnvBeforeStatusRead: assertGatewayServiceManagementAllowedForUpdate,
|
||||
});
|
||||
const serviceMatchesUpdateRoot =
|
||||
(await gatewayServiceCommandUsesRoot({
|
||||
@@ -399,7 +415,12 @@ export async function finishUpdate(params: {
|
||||
// ownership authorizes rewriting the service definition.
|
||||
refreshGatewayServiceEnv = serviceOwnershipConfirmed;
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayServiceUpdateOwnershipError) {
|
||||
defaultRuntime.error(err.message);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
// Ignore errors during pre-check; fallback to standard restart
|
||||
}
|
||||
}
|
||||
@@ -420,7 +441,7 @@ export async function finishUpdate(params: {
|
||||
return;
|
||||
}
|
||||
const restartOk = await maybeRestartService({
|
||||
shouldRestart: params.shouldRestart,
|
||||
shouldRestart: params.shouldRestart && serviceMutationAllowed,
|
||||
result: resultWithPostUpdate,
|
||||
opts: params.opts,
|
||||
refreshServiceEnv: refreshGatewayServiceEnv,
|
||||
@@ -432,6 +453,7 @@ export async function finishUpdate(params: {
|
||||
skipLegacyServiceRestart,
|
||||
requireRunningServiceAfterRestart:
|
||||
resultWithPostUpdate.mode === "git" && params.preManagedServiceStop?.stopped === true,
|
||||
serviceMutationSkipMessage,
|
||||
timeoutMs: params.updateStepTimeoutMs,
|
||||
});
|
||||
if (!restartOk) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js";
|
||||
import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js";
|
||||
import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js";
|
||||
import { assertGatewayServiceMutationAllowed } from "../../infra/gateway-supervision.js";
|
||||
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
|
||||
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js";
|
||||
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
|
||||
@@ -111,6 +112,8 @@ export type PreManagedServiceStop = {
|
||||
inspected: boolean;
|
||||
runtimeInspected: boolean;
|
||||
running: boolean;
|
||||
serviceMutationAllowed?: boolean;
|
||||
serviceMutationSkipMessage?: string;
|
||||
serviceMatchesMutationRoot?: boolean;
|
||||
blockMessage?: string;
|
||||
serviceEnv?: NodeJS.ProcessEnv;
|
||||
@@ -128,6 +131,41 @@ export type UpdateCommandRecoveryState = {
|
||||
windowsTaskAutoStartRecovery?: WindowsTaskAutoStartRecovery;
|
||||
};
|
||||
|
||||
export class GatewayServiceUpdateOwnershipError extends Error {
|
||||
constructor(message: string, cause: unknown) {
|
||||
super(message, { cause });
|
||||
this.name = "GatewayServiceUpdateOwnershipError";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGatewayServiceManagementBlockMessageForUpdate(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
try {
|
||||
assertGatewayServiceManagementAllowedForUpdate(env);
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertGatewayServiceManagementAllowedForUpdate(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
try {
|
||||
assertGatewayServiceMutationAllowed("manage the gateway service during update", env);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new GatewayServiceUpdateOwnershipError(message, err);
|
||||
}
|
||||
}
|
||||
|
||||
export function isGatewayServiceManagementAllowedForUpdate(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return resolveGatewayServiceManagementBlockMessageForUpdate(env) === undefined;
|
||||
}
|
||||
|
||||
export class UpdateCommandAbort extends Error {
|
||||
constructor() {
|
||||
super("openclaw-update-abort");
|
||||
@@ -334,12 +372,38 @@ export async function maybeStopManagedServiceBeforeMutableUpdate(params: {
|
||||
shouldRestart: boolean;
|
||||
jsonMode: boolean;
|
||||
}): Promise<PreManagedServiceStop> {
|
||||
const serviceMutationSkipMessage = resolveGatewayServiceManagementBlockMessageForUpdate(
|
||||
process.env,
|
||||
);
|
||||
if (serviceMutationSkipMessage) {
|
||||
return {
|
||||
stopped: false,
|
||||
inspected: false,
|
||||
runtimeInspected: false,
|
||||
running: false,
|
||||
serviceMutationAllowed: false,
|
||||
serviceMutationSkipMessage,
|
||||
};
|
||||
}
|
||||
let service: ReturnType<typeof resolveGatewayService>;
|
||||
let serviceState: Awaited<ReturnType<typeof readGatewayServiceState>>;
|
||||
try {
|
||||
service = resolveGatewayService();
|
||||
serviceState = await readGatewayServiceState(service, { env: process.env });
|
||||
} catch {
|
||||
serviceState = await readGatewayServiceState(service, {
|
||||
env: process.env,
|
||||
validateEnvBeforeStatusRead: assertGatewayServiceManagementAllowedForUpdate,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayServiceUpdateOwnershipError) {
|
||||
return {
|
||||
stopped: false,
|
||||
inspected: false,
|
||||
runtimeInspected: false,
|
||||
running: false,
|
||||
serviceMutationAllowed: false,
|
||||
blockMessage: err.message,
|
||||
};
|
||||
}
|
||||
return { stopped: false, inspected: false, runtimeInspected: false, running: false };
|
||||
}
|
||||
|
||||
@@ -949,6 +1013,9 @@ function resolveManagedServiceNodeRunner(
|
||||
* when the package root is the same.
|
||||
*/
|
||||
export async function resolveManagedServiceNodeRunnerOverride(): Promise<string | undefined> {
|
||||
if (!isGatewayServiceManagementAllowedForUpdate(process.env)) {
|
||||
return undefined;
|
||||
}
|
||||
const command = await resolveGatewayService()
|
||||
.readCommand(process.env)
|
||||
.catch(() => null);
|
||||
@@ -970,6 +1037,9 @@ export async function resolveManagedServiceNodeRunnerOverride(): Promise<string
|
||||
export async function resolveManagedServicePackageUpdateRoot(params: {
|
||||
root: string;
|
||||
}): Promise<ManagedServiceRootRedirect | null> {
|
||||
if (!isGatewayServiceManagementAllowedForUpdate(process.env)) {
|
||||
return null;
|
||||
}
|
||||
const command = await resolveGatewayService()
|
||||
.readCommand(process.env)
|
||||
.catch(() => null);
|
||||
@@ -1004,9 +1074,11 @@ export async function gatewayServiceCommandUsesRoot(params: {
|
||||
}
|
||||
const command =
|
||||
params.command === undefined
|
||||
? await resolveGatewayService()
|
||||
.readCommand(params.env ?? process.env)
|
||||
.catch(() => null)
|
||||
? isGatewayServiceManagementAllowedForUpdate(params.env ?? process.env)
|
||||
? await resolveGatewayService()
|
||||
.readCommand(params.env ?? process.env)
|
||||
.catch(() => null)
|
||||
: null
|
||||
: params.command;
|
||||
const layout = await summarizeGatewayServiceLayout(command);
|
||||
const serviceRoot = layout?.packageRoot;
|
||||
@@ -1037,8 +1109,22 @@ export async function maybeRestartService(params: {
|
||||
nodeRunner?: string;
|
||||
skipLegacyServiceRestart?: boolean;
|
||||
requireRunningServiceAfterRestart?: boolean;
|
||||
serviceMutationSkipMessage?: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
params.shouldRestart &&
|
||||
(!isGatewayServiceManagementAllowedForUpdate(process.env) ||
|
||||
!isGatewayServiceManagementAllowedForUpdate(params.serviceEnv ?? process.env))
|
||||
) {
|
||||
const message =
|
||||
resolveGatewayServiceManagementBlockMessageForUpdate(process.env) ??
|
||||
resolveGatewayServiceManagementBlockMessageForUpdate(params.serviceEnv ?? process.env);
|
||||
if (message) {
|
||||
defaultRuntime.error(message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const verifyRestartedGateway = async (
|
||||
expectedGatewayVersion: string | undefined,
|
||||
opts: { requireRunningService?: boolean } = {},
|
||||
@@ -1325,6 +1411,18 @@ export async function maybeRestartService(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (params.serviceMutationSkipMessage) {
|
||||
if (params.opts.json) {
|
||||
defaultRuntime.error(params.serviceMutationSkipMessage);
|
||||
} else {
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(
|
||||
theme.warn(`Gateway: restart skipped: ${params.serviceMutationSkipMessage}`),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!params.opts.json) {
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(theme.muted("Gateway: restart skipped (--no-restart)."));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
|
||||
import type { GatewayService } from "../../daemon/service.js";
|
||||
import type { UpdateRunResult } from "../../infra/update-runner.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
updatePluginsAfterCoreUpdate,
|
||||
type PostCorePluginUpdateResult,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
resolvePostInstallDoctorEnv,
|
||||
resolvePostUpdateServiceStateReadEnv,
|
||||
resolveUpdatedGatewayRestartPort,
|
||||
maybeRestartService,
|
||||
shouldPrepareUpdatedInstallRestart,
|
||||
} from "./update-command-service.js";
|
||||
import { testing as updateCommandServiceTesting } from "./update-command-service.test-support.js";
|
||||
@@ -176,6 +178,31 @@ describe("resolveUpdatedGatewayRestartPort", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeRestartService", () => {
|
||||
it("reports service ownership skips to JSON callers", async () => {
|
||||
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
|
||||
|
||||
await expect(
|
||||
maybeRestartService({
|
||||
shouldRestart: false,
|
||||
result: {
|
||||
status: "ok",
|
||||
mode: "npm",
|
||||
steps: [],
|
||||
durationMs: 0,
|
||||
},
|
||||
opts: { json: true },
|
||||
refreshServiceEnv: false,
|
||||
gatewayPort: 18789,
|
||||
serviceMutationSkipMessage: "service management skipped: ownership conflict",
|
||||
timeoutMs: 1_000,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith("service management skipped: ownership conflict");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePostUpdateServiceStateReadEnv", () => {
|
||||
it("keeps package restart preparation anchored to the pre-update service env", () => {
|
||||
const processEnv = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
GATEWAY_LAUNCH_AGENT_LABEL,
|
||||
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
|
||||
resolveGatewayLaunchAgentLabel,
|
||||
resolveGatewayNativeServiceIdentityConflict,
|
||||
resolveGatewayProfileSuffix,
|
||||
resolveGatewayServiceDescription,
|
||||
resolveGatewaySystemdServiceName,
|
||||
@@ -47,6 +48,48 @@ describe("resolveGatewayWindowsTaskName", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGatewayNativeServiceIdentityConflict", () => {
|
||||
it.each([
|
||||
{
|
||||
platform: "darwin" as const,
|
||||
envKey: "OPENCLAW_LAUNCHD_LABEL",
|
||||
value: "ai.openclaw.gateway",
|
||||
},
|
||||
{
|
||||
platform: "linux" as const,
|
||||
envKey: "OPENCLAW_SYSTEMD_UNIT",
|
||||
value: "openclaw-gateway.service",
|
||||
},
|
||||
{
|
||||
platform: "win32" as const,
|
||||
envKey: "OPENCLAW_WINDOWS_TASK_NAME",
|
||||
value: "OpenClaw Gateway",
|
||||
},
|
||||
])("rejects $envKey overrides for named profiles on $platform", ({ platform, envKey, value }) => {
|
||||
expect(
|
||||
resolveGatewayNativeServiceIdentityConflict(
|
||||
{ OPENCLAW_PROFILE: "work", [envKey]: value },
|
||||
platform,
|
||||
),
|
||||
).toMatchObject({ envKey });
|
||||
});
|
||||
|
||||
it("accepts canonical named-profile identities and default-profile overrides", () => {
|
||||
expect(
|
||||
resolveGatewayNativeServiceIdentityConflict(
|
||||
{ OPENCLAW_PROFILE: "work", OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway-work" },
|
||||
"linux",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveGatewayNativeServiceIdentityConflict(
|
||||
{ OPENCLAW_SYSTEMD_UNIT: "custom-gateway.service" },
|
||||
"linux",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGatewayProfileSuffix", () => {
|
||||
it("returns empty string when no profile is set", () => {
|
||||
expect(resolveGatewayProfileSuffix()).toBe("");
|
||||
|
||||
@@ -59,6 +59,42 @@ export function resolveGatewayWindowsTaskName(profile?: string): string {
|
||||
return `OpenClaw Gateway (${normalized})`;
|
||||
}
|
||||
|
||||
type GatewayNativeServiceIdentityConflict = {
|
||||
envKey: "OPENCLAW_LAUNCHD_LABEL" | "OPENCLAW_SYSTEMD_UNIT" | "OPENCLAW_WINDOWS_TASK_NAME";
|
||||
expected: string;
|
||||
};
|
||||
|
||||
export function resolveGatewayNativeServiceIdentityConflict(
|
||||
env: Record<string, string | undefined>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): GatewayNativeServiceIdentityConflict | null {
|
||||
const profile = normalizeGatewayProfile(env.OPENCLAW_PROFILE);
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
const envKey = "OPENCLAW_LAUNCHD_LABEL";
|
||||
const actual = env[envKey]?.trim();
|
||||
const expected = resolveGatewayLaunchAgentLabel(profile);
|
||||
return actual && actual !== expected ? { envKey, expected } : null;
|
||||
}
|
||||
if (platform === "linux") {
|
||||
const envKey = "OPENCLAW_SYSTEMD_UNIT";
|
||||
const actual = env[envKey]?.trim();
|
||||
const normalizedActual = actual?.endsWith(".service") ? actual : actual && `${actual}.service`;
|
||||
const expected = `${resolveGatewaySystemdServiceName(profile)}.service`;
|
||||
return normalizedActual && normalizedActual !== expected ? { envKey, expected } : null;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
const envKey = "OPENCLAW_WINDOWS_TASK_NAME";
|
||||
const actual = env[envKey]?.trim();
|
||||
const expected = resolveGatewayWindowsTaskName(profile);
|
||||
return actual && actual !== expected ? { envKey, expected } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatGatewayServiceDescription(params?: { profile?: string; version?: string }): string {
|
||||
const profile = normalizeGatewayProfile(params?.profile);
|
||||
const version = params?.version?.trim();
|
||||
|
||||
@@ -6,6 +6,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { withTimeout } from "../utils/with-timeout.js";
|
||||
import {
|
||||
installLaunchAgent,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
repairLaunchAgentBootstrap,
|
||||
restartLaunchAgent,
|
||||
resolveLaunchAgentPlistPath,
|
||||
startLaunchAgent,
|
||||
stopLaunchAgent,
|
||||
uninstallLaunchAgent,
|
||||
} from "./launchd.js";
|
||||
@@ -192,6 +194,81 @@ describeLaunchdIntegration("launchd integration", () => {
|
||||
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
|
||||
}, 60_000);
|
||||
|
||||
it("manages a named profile through the guarded host-service lifecycle", async () => {
|
||||
const testId = randomUUID().slice(0, 8);
|
||||
const profile = `launchd-int-${testId}`;
|
||||
const accountHome = os.userInfo().homedir;
|
||||
const stateDir = path.join(accountHome, `.openclaw-${profile}`);
|
||||
const profileEnv: GatewayServiceEnv = {
|
||||
HOME: accountHome,
|
||||
OPENCLAW_HOME: undefined,
|
||||
OPENCLAW_PROFILE: profile,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
|
||||
OPENCLAW_LAUNCHD_LABEL: undefined,
|
||||
OPENCLAW_SUPERVISOR_MODE: undefined,
|
||||
};
|
||||
|
||||
await withEnvAsync(profileEnv, async () => {
|
||||
const service = resolveGatewayService();
|
||||
try {
|
||||
await service.install({
|
||||
env: profileEnv,
|
||||
stdout,
|
||||
programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"],
|
||||
});
|
||||
const installed = await waitForRunningRuntime({ env: profileEnv });
|
||||
|
||||
await service.stop({ env: profileEnv, stdout });
|
||||
await waitForNotRunningRuntime({ env: profileEnv });
|
||||
|
||||
const startResult = await startGatewayService(service, { env: profileEnv, stdout });
|
||||
expect(startResult.outcome).toBe("started");
|
||||
const started = await waitForRunningRuntime({
|
||||
env: profileEnv,
|
||||
pidNot: installed.pid,
|
||||
});
|
||||
|
||||
await service.restart({ env: profileEnv, stdout });
|
||||
await expectRuntimePidReplaced({ env: profileEnv, previousPid: started.pid });
|
||||
} finally {
|
||||
try {
|
||||
await service.uninstall({ env: profileEnv, stdout });
|
||||
} finally {
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("refuses a relocated OPENCLAW_HOME before launchd mutation", async () => {
|
||||
const testId = randomUUID().slice(0, 8);
|
||||
const relocatedHome = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), `openclaw-relocated-home-${testId}-`),
|
||||
);
|
||||
const relocatedEnv: GatewayServiceEnv = {
|
||||
HOME: os.userInfo().homedir,
|
||||
OPENCLAW_HOME: relocatedHome,
|
||||
OPENCLAW_PROFILE: `launchd-int-${testId}`,
|
||||
};
|
||||
|
||||
try {
|
||||
await withEnvAsync(relocatedEnv, async () => {
|
||||
const service = resolveGatewayService();
|
||||
await expect(
|
||||
service.install({
|
||||
env: relocatedEnv,
|
||||
stdout,
|
||||
programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"],
|
||||
}),
|
||||
).rejects.toThrow("service management skipped: non-default state dir or config path");
|
||||
await expect(fs.access(resolveLaunchAgentPlistPath(relocatedEnv))).rejects.toThrow();
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(relocatedHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps LaunchAgent supervision after a raw SIGTERM", async () => {
|
||||
const launchEnv = launchEnvOrThrow(env);
|
||||
await initializeLaunchdRuntime(launchEnv, stdout);
|
||||
@@ -208,9 +285,7 @@ describeLaunchdIntegration("launchd integration", () => {
|
||||
const before = await waitForRunningRuntime({ env: launchEnv });
|
||||
await stopLaunchAgent({ env: launchEnv, stdout });
|
||||
await waitForNotRunningRuntime({ env: launchEnv });
|
||||
const service = resolveGatewayService();
|
||||
const startResult = await startGatewayService(service, { env: launchEnv, stdout });
|
||||
expect(startResult.outcome).toBe("started");
|
||||
await startLaunchAgent({ env: launchEnv, stdout });
|
||||
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
|
||||
}, 60_000);
|
||||
|
||||
|
||||
1052
src/daemon/schtasks.integration.e2e.test.ts
Normal file
1052
src/daemon/schtasks.integration.e2e.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
72
src/daemon/schtasks.integration.test-helpers.ts
Normal file
72
src/daemon/schtasks.integration.test-helpers.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const POLL_INTERVAL_MS = 200;
|
||||
const RUN_EVENT_SETTLE_MS = 2_000;
|
||||
const WAIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type ProbeRunEvent = {
|
||||
phase: "listening" | "started";
|
||||
pid: number;
|
||||
ppid: number;
|
||||
};
|
||||
|
||||
async function readRunEvents(eventsPath: string): Promise<ProbeRunEvent[]> {
|
||||
const content = await fs.readFile(eventsPath, "utf8").catch(() => "");
|
||||
return content
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parsed = JSON.parse(line) as Partial<ProbeRunEvent>;
|
||||
if (
|
||||
(parsed.phase !== "started" && parsed.phase !== "listening") ||
|
||||
!Number.isSafeInteger(parsed.pid) ||
|
||||
(parsed.pid ?? 0) <= 1 ||
|
||||
!Number.isSafeInteger(parsed.ppid) ||
|
||||
(parsed.ppid ?? 0) <= 1
|
||||
) {
|
||||
throw new Error("Scheduled Task probe recorded an invalid run event");
|
||||
}
|
||||
return parsed as ProbeRunEvent;
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForExactProbeRun(
|
||||
eventsPath: string,
|
||||
expectedCount: number,
|
||||
): Promise<ProbeRunEvent> {
|
||||
const deadline = Date.now() + WAIT_TIMEOUT_MS;
|
||||
let events: ProbeRunEvent[] = [];
|
||||
while (Date.now() < deadline) {
|
||||
events = await readRunEvents(eventsPath);
|
||||
if (events.filter((event) => event.phase === "listening").length >= expectedCount) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, RUN_EVENT_SETTLE_MS);
|
||||
});
|
||||
events = await readRunEvents(eventsPath);
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
const started = events.filter((event) => event.phase === "started");
|
||||
const listening = events.filter((event) => event.phase === "listening");
|
||||
if (started.length !== expectedCount || listening.length !== expectedCount) {
|
||||
throw new Error(
|
||||
`Expected exactly ${expectedCount} Scheduled Task probe runs; observed ${started.length} starts and ${listening.length} listeners`,
|
||||
);
|
||||
}
|
||||
const startedEvent = started[expectedCount - 1];
|
||||
const listeningEvent = listening[expectedCount - 1];
|
||||
if (!startedEvent || !listeningEvent) {
|
||||
throw new Error(`Scheduled Task run ${expectedCount} did not record complete process events`);
|
||||
}
|
||||
if (startedEvent.pid !== listeningEvent.pid || startedEvent.ppid !== listeningEvent.ppid) {
|
||||
throw new Error(
|
||||
`Scheduled Task run ${expectedCount} changed process identity before listening`,
|
||||
);
|
||||
}
|
||||
return startedEvent;
|
||||
}
|
||||
@@ -23,8 +23,16 @@ const sleepMock = vi.hoisted(() =>
|
||||
timeState.now += ms;
|
||||
}),
|
||||
);
|
||||
type SpawnSyncResult = {
|
||||
pid: number;
|
||||
output: (string | null)[];
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
status: number;
|
||||
signal: null;
|
||||
};
|
||||
const spawnSync = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
vi.fn<(command: string, args?: readonly string[]) => SpawnSyncResult>(() => ({
|
||||
pid: 0,
|
||||
output: [null, "-2147024891", ""],
|
||||
stdout: "-2147024891",
|
||||
@@ -52,12 +60,14 @@ vi.mock("../utils.js", async () => {
|
||||
});
|
||||
|
||||
const {
|
||||
resolveTaskScriptPath,
|
||||
restartScheduledTask,
|
||||
resumeScheduledTaskAutoStartAfterUpdate,
|
||||
startScheduledTask,
|
||||
stopScheduledTask,
|
||||
suspendScheduledTaskAutoStartForUpdate,
|
||||
} = await import("./schtasks.js");
|
||||
const { resolveScheduledTaskOwnedGatewayPids } = await import("./schtasks-process.js");
|
||||
const GATEWAY_PORT = 18789;
|
||||
const SUCCESS_RESPONSE = { code: 0, stdout: "", stderr: "" } as const;
|
||||
const INSTALLED_GATEWAY_COMMAND_LINE =
|
||||
@@ -465,6 +475,93 @@ describe("Scheduled Task stop/restart cleanup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not adopt a portless arbitrary task action", async () => {
|
||||
await withPreparedGatewayTask(async ({ env }) => {
|
||||
delete env.OPENCLAW_GATEWAY_PORT;
|
||||
const scriptPath = resolveTaskScriptPath(env);
|
||||
await fs.writeFile(
|
||||
scriptPath,
|
||||
'@echo off\r\n"C:\\Program Files\\nodejs\\node.exe" "C:\\probe.cjs"\r\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(resolveScheduledTaskOwnedGatewayPids(env)).resolves.toEqual([]);
|
||||
|
||||
expect(inspectPortUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("adopts exact persisted Windows argv and escalates through taskkill tree cleanup", async () => {
|
||||
await withPreparedGatewayTask(async ({ env, stdout }) => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
pushSuccessfulSchtasksResponses(3);
|
||||
inspectPortUsage.mockResolvedValue(freePortUsage());
|
||||
let forced = false;
|
||||
spawnSync.mockImplementation((command, args) => {
|
||||
const executable = command.toLowerCase();
|
||||
if (executable.endsWith("taskkill.exe")) {
|
||||
const argv = Array.isArray(args) ? args.map(String) : [];
|
||||
if (argv.includes("/F")) {
|
||||
forced = true;
|
||||
return {
|
||||
pid: 0,
|
||||
output: [null, "", ""],
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
status: 0,
|
||||
signal: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
pid: 0,
|
||||
output: [null, "", ""],
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
status: 1,
|
||||
signal: null,
|
||||
};
|
||||
}
|
||||
const processes = [
|
||||
{
|
||||
ProcessId: 3131,
|
||||
CommandLine:
|
||||
'"C:\\Program Files\\nodejs\\node.exe" "C:\\other-openclaw.cjs" gateway --port 18789',
|
||||
},
|
||||
...(!forced
|
||||
? [
|
||||
{
|
||||
ProcessId: 4242,
|
||||
CommandLine: INSTALLED_GATEWAY_COMMAND_LINE,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ ProcessId: 9999, CommandLine: "powershell.exe" },
|
||||
];
|
||||
const output = JSON.stringify(processes);
|
||||
return {
|
||||
pid: 0,
|
||||
output: [null, output, ""],
|
||||
stdout: output,
|
||||
stderr: "",
|
||||
status: 0,
|
||||
signal: null,
|
||||
};
|
||||
});
|
||||
|
||||
await stopScheduledTask({ env, stdout });
|
||||
|
||||
const taskkillCalls = spawnSync.mock.calls
|
||||
.filter(([command]) => command.toLowerCase().endsWith("taskkill.exe"))
|
||||
.map(([, args]) => args);
|
||||
expect(taskkillCalls).toEqual([
|
||||
["/T", "/PID", "4242"],
|
||||
["/F", "/T", "/PID", "4242"],
|
||||
]);
|
||||
expect(taskkillCalls.flat()).not.toContain("3131");
|
||||
expect(killProcessTree).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a registered task and ignores audit observer failures", async () => {
|
||||
await withPreparedGatewayTask(async ({ env }) => {
|
||||
schtasksResponses.push(
|
||||
|
||||
@@ -185,6 +185,31 @@ describe("readGatewayServiceState", () => {
|
||||
{ timeoutMs: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("validates merged service env before native status probes", async () => {
|
||||
const isLoaded = vi.fn(async () => true);
|
||||
const readRuntime = vi.fn(async () => ({ status: "running" as const }));
|
||||
const service = createService({
|
||||
isLoaded,
|
||||
readCommand: vi.fn(async () => ({
|
||||
programArguments: ["openclaw", "gateway", "run"],
|
||||
environment: { OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway.service" },
|
||||
})),
|
||||
readRuntime,
|
||||
});
|
||||
|
||||
await expect(
|
||||
readGatewayServiceState(service, {
|
||||
env: {},
|
||||
validateEnvBeforeStatusRead: (env) => {
|
||||
throw new Error(`refused ${env.OPENCLAW_SYSTEMD_UNIT}`);
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("refused openclaw-gateway.service");
|
||||
|
||||
expect(isLoaded).not.toHaveBeenCalled();
|
||||
expect(readRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("startGatewayService", () => {
|
||||
|
||||
@@ -90,6 +90,10 @@ export type GatewayService = {
|
||||
) => Promise<GatewayServiceRuntime>;
|
||||
};
|
||||
|
||||
type ReadGatewayServiceStateArgs = GatewayServiceEnvArgs & {
|
||||
validateEnvBeforeStatusRead?: (env: GatewayServiceEnv) => void;
|
||||
};
|
||||
|
||||
const TEMP_PROGRAM_ROOTS = [os.tmpdir(), "/tmp", "/private/tmp", "/var/tmp"].map((entry) =>
|
||||
path.resolve(entry),
|
||||
);
|
||||
@@ -179,11 +183,14 @@ export function formatGatewayServiceStartRepairIssues(
|
||||
|
||||
export async function readGatewayServiceState(
|
||||
service: GatewayService,
|
||||
args: GatewayServiceEnvArgs = {},
|
||||
args: ReadGatewayServiceStateArgs = {},
|
||||
): Promise<GatewayServiceState> {
|
||||
const baseEnv = args.env ?? (process.env as GatewayServiceEnv);
|
||||
const command = await service.readCommand(baseEnv).catch(() => null);
|
||||
const env = mergeGatewayServiceEnv(baseEnv, command);
|
||||
// Callers that may mutate the selected service can reject persisted selector
|
||||
// drift before isLoaded/readRuntime invoke the native service manager.
|
||||
args.validateEnvBeforeStatusRead?.(env);
|
||||
// Propagate the status read deadline so a wedged service manager fails soft
|
||||
// instead of hanging both probes. readCommand parses local files and needs no
|
||||
// bound; isLoaded/readRuntime can spawn service-manager subprocesses.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }}");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user