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:
Sasan
2026-07-31 23:28:39 -04:00
committed by GitHub
parent 4376387791
commit 6938f7dddb
31 changed files with 2520 additions and 117 deletions

View File

@@ -38,7 +38,7 @@ on:
default: false
type: boolean
run_windows_ci:
description: "Run the focused Windows-native CI test shard after probing"
description: "Run the focused Windows CI shard and native Scheduled Task proof"
required: false
default: false
type: boolean
@@ -281,6 +281,165 @@ jobs:
export PATH="$NODE_BIN:$PATH"
pnpm test:windows:ci
- name: Preflight native Scheduled Task session
if: ${{ inputs.run_windows_ci }}
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
$sessionId = (Get-Process -Id $PID).SessionId
Write-Host "identity=$($identity.Name)"
Write-Host "session_id=$sessionId"
Write-Host "user_interactive=$([Environment]::UserInteractive)"
Write-Host "administrator=$isAdmin"
query user 2>&1 | Write-Host
if (-not [Environment]::UserInteractive) {
throw "Native Scheduled Task proof requires an interactive Windows runner session."
}
- name: Run native Scheduled Task lifecycle proof
id: native_schtasks
if: ${{ inputs.run_windows_ci }}
timeout-minutes: 5
shell: bash
env:
CI_WINDOWS_SCHTASKS_PROOF_PATH: ${{ github.workspace }}\.artifacts\windows-schtasks\proof.json
CI_WINDOWS_SCHTASKS_ROOT: ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}
CI_WINDOWS_SCHTASKS_TEST_ID: ${{ github.run_id }}-${{ github.run_attempt }}
EXPECTED_HEAD: ${{ inputs.target_ref }}
run: |
set -euo pipefail
export PATH="$NODE_BIN:$PATH"
if [[ ! "$EXPECTED_HEAD" =~ ^[0-9a-f]{40}$ ]]; then
echo "Native Scheduled Task proof requires target_ref to be an exact 40-character commit SHA." >&2
exit 1
fi
CI_WINDOWS_SCHTASKS_HEAD="$(git rev-parse HEAD)"
if [[ "$CI_WINDOWS_SCHTASKS_HEAD" != "$EXPECTED_HEAD" ]]; then
echo "Checked out $CI_WINDOWS_SCHTASKS_HEAD, expected frozen target $EXPECTED_HEAD." >&2
exit 1
fi
export CI_WINDOWS_SCHTASKS_HEAD
mkdir -p .artifacts/windows-schtasks
pnpm test:windows:schtasks:integration
- name: Clean native Scheduled Task residue
id: native_cleanup
if: ${{ always() && inputs.run_windows_ci }}
shell: pwsh
env:
TEST_ID: ${{ github.run_id }}-${{ github.run_attempt }}
TEST_ROOT: ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}
run: |
$ErrorActionPreference = "Continue"
$cleanupErrors = @()
$profile = "schtasks-int-$env:TEST_ID"
$taskName = "OpenClaw Gateway ($profile)"
$stateDir = Join-Path $env:USERPROFILE ".openclaw-$profile"
New-Item -ItemType Directory -Force -Path $env:TEST_ROOT | Out-Null
schtasks.exe /End /TN $taskName 2>$null
Start-Sleep -Milliseconds 200
$activePidPath = Join-Path $env:TEST_ROOT "active-pid.txt"
if (Test-Path -LiteralPath $activePidPath) {
try {
$probePid = 0
$activePid = (Get-Content -LiteralPath $activePidPath -Raw).Trim()
if (-not [int]::TryParse($activePid, [ref]$probePid) -or $probePid -le 1) {
throw "Invalid Scheduled Task active process id: $activePid"
}
$processQueryError = @()
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $probePid" -ErrorAction SilentlyContinue -ErrorVariable processQueryError
if ($processQueryError.Count -gt 0) {
throw "Could not inspect Scheduled Task probe process $probePid."
}
if ($process) {
$probePath = Join-Path $env:TEST_ROOT "probe.cjs"
$eventsPath = Join-Path $env:TEST_ROOT "runs.txt"
if (
$process.CommandLine -like "*$probePath*" -and
$process.CommandLine -like "*$eventsPath*"
) {
taskkill.exe /F /T /PID $probePid 2>$null
$deadline = [DateTime]::UtcNow.AddSeconds(30)
do {
Start-Sleep -Milliseconds 200
$processQueryError = @()
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $probePid" -ErrorAction SilentlyContinue -ErrorVariable processQueryError
if ($processQueryError.Count -gt 0) {
throw "Could not verify Scheduled Task probe process $probePid exited."
}
} while ($process -and [DateTime]::UtcNow -lt $deadline)
if ($process) {
throw "Scheduled Task probe process $probePid survived cleanup."
}
} else {
throw "Refusing to kill reused or unverifiable process id $probePid."
}
}
} catch {
$cleanupErrors += $_.Exception.Message
}
}
schtasks.exe /Delete /F /TN $taskName 2>$null
$deleteExit = $LASTEXITCODE
try {
$service = New-Object -ComObject "Schedule.Service"
$service.Connect()
$null = $service.GetFolder("\").GetTask($taskName)
$taskExists = $true
} catch {
$exception = $_.Exception
while ($null -ne $exception.InnerException) {
$exception = $exception.InnerException
}
if ($exception.HResult -eq -2147024894 -or $exception.HResult -eq -2147024893) {
$taskExists = $false
} else {
$cleanupErrors += "Could not verify Scheduled Task cleanup for $taskName (HRESULT $($exception.HResult))."
$taskExists = $null
}
}
if ($taskExists -eq $true) {
$cleanupErrors += "Scheduled Task cleanup left $taskName registered (delete exit $deleteExit)."
}
@(
"task_name=$taskName"
"delete_exit=$deleteExit"
"task_exists=$taskExists"
"proof_outcome=${{ steps.native_schtasks.outcome }}"
"cleanup_errors=$($cleanupErrors -join ' ')"
) | Set-Content -LiteralPath (Join-Path $env:TEST_ROOT "cleanup-summary.txt")
if ($cleanupErrors.Count -gt 0) {
throw ($cleanupErrors -join " ")
}
exit 0
- name: Upload native Scheduled Task proof
id: native_proof_upload
if: ${{ always() && inputs.run_windows_ci }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: windows-schtasks-proof-${{ github.run_id }}-${{ github.run_attempt }}
path: |
.artifacts/windows-schtasks/proof.json
${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}\failure-diagnostics.json
${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}\cleanup-summary.txt
if-no-files-found: warn
retention-days: 7
- name: Remove retained native Scheduled Task evidence
if: ${{ always() && inputs.run_windows_ci && steps.native_cleanup.outcome == 'success' && steps.native_proof_upload.outcome == 'success' }}
shell: pwsh
env:
TEST_ID: ${{ github.run_id }}-${{ github.run_attempt }}
TEST_ROOT: ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}
run: |
$profile = "schtasks-int-$env:TEST_ID"
Remove-Item -LiteralPath (Join-Path $env:USERPROFILE ".openclaw-$profile") -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $env:TEST_ROOT -Recurse -Force -ErrorAction SilentlyContinue
- name: Keep runner alive for SSH inspection
if: ${{ always() && !cancelled() }}
env:

View File

@@ -129,6 +129,16 @@ openclaw gateway restart --wait 30s
Inline `--password` can be exposed in local process listings. Prefer `--password-file`, env, or a SecretRef-backed `gateway.auth.password`.
</Warning>
### Install identity
Service management (`install`, `start`, `stop`, `restart`, `uninstall`, Doctor service repair, and self-update service handling) belongs to the install that owns the host service. That is the canonical `.openclaw` directory under the OS account home, or the `.openclaw-<profile>` directory a named profile projects there. Named profiles use distinct native service identities.
`OPENCLAW_HOME`, or an `OPENCLAW_STATE_DIR` or `OPENCLAW_CONFIG_PATH` that points elsewhere, is treated as isolated state and skipped. A relocated or copied state tree cannot adopt and rewrite the account's host service.
On macOS and Windows, native service-managed profile names must be lowercase. Runtime-only profiles may still use uppercase, but case-distinct names such as `Main` and `main` share paths on normal case-insensitive filesystems and cannot safely own separate native services. On macOS, the lowercase names `gateway` and `node` are also unavailable for native service management because their historical LaunchAgent labels collide with the default Gateway and node-host services.
Named profiles must also use the native service identity derived from `OPENCLAW_PROFILE`. Unset `OPENCLAW_LAUNCHD_LABEL`, `OPENCLAW_SYSTEMD_UNIT`, or `OPENCLAW_WINDOWS_TASK_NAME` before service management; custom identities remain available for the default profile or runtime-only/external-supervisor setups.
### External supervisors
Set `OPENCLAW_SUPERVISOR_MODE=external` only when another process manager owns the Gateway lifecycle. In this mode:

View File

@@ -1641,6 +1641,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Run the Gateway
- H3: Options
- H2: Restart the Gateway
- H3: Install identity
- H3: External supervisors
- H3: Gateway profiling
- H2: Query a running Gateway

View File

@@ -250,6 +250,8 @@ unavailable instead of triggering a network request.
When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.homedir()`) for internal OpenClaw path defaults. This includes the default state directory, config path, agent directories, credentials, installer onboarding workspace, and the default dev checkout used by `openclaw update --channel dev`.
`OPENCLAW_HOME` does not grant ownership of the OS account's native Gateway service. Gateway service-management commands treat a relocated home as isolated state; use the OS account home and a named profile when a separate native service identity is required.
**Precedence:** `OPENCLAW_HOME` > `$HOME` > `USERPROFILE` > Termux `PREFIX` home fallback on Android > `os.homedir()`
**Example** (macOS LaunchDaemon):

View File

@@ -1894,6 +1894,7 @@
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
"test:watch": "node scripts/test-projects.mjs --watch",
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:schtasks:integration": "node scripts/run-with-env.mjs CI_WINDOWS_SCHTASKS_INTEGRATION=1 OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1 -- node scripts/run-vitest.mjs src/daemon/schtasks.integration.e2e.test.ts",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
"ts-topology": "node --import tsx scripts/ts-topology.ts",

View File

@@ -104,6 +104,7 @@ vi.mock("../../config/mutate.js", () => ({
vi.mock("../../config/paths.js", () => ({
isDefaultInstallIdentity: isDefaultInstallIdentityMock,
resolveNativeServiceProfileConflict: () => null,
resolveGatewayPort: resolveGatewayPortMock,
resolveIsNixMode: resolveIsNixModeMock,
}));

View File

@@ -254,6 +254,55 @@ describe("runServiceRestart token drift", () => {
);
});
it("runs the service mutation guard before restarting a loaded service", async () => {
const beforeServiceMutation = vi.fn();
await runServiceRestart({
...createServiceRunArgs(),
beforeServiceMutation,
});
expect(beforeServiceMutation).toHaveBeenCalledTimes(1);
expect(beforeServiceMutation.mock.invocationCallOrder[0]).toBeLessThan(
service.restart.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
});
it("aborts loaded-service mutation when the service guard rejects", async () => {
const repairLoadedService = vi.fn();
await expect(
runServiceRestart({
...createServiceRunArgs(),
beforeServiceMutation: () => {
throw new Error("service mutation denied");
},
repairLoadedService,
}),
).rejects.toThrow("service mutation denied");
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(repairLoadedService).not.toHaveBeenCalled();
expect(service.restart).not.toHaveBeenCalled();
});
it("does not run the service mutation guard before not-loaded recovery", async () => {
service.isLoaded.mockResolvedValue(false);
const beforeServiceMutation = vi.fn();
await runServiceRestart({
...createServiceRunArgs(),
beforeServiceMutation,
onNotLoaded: async () => ({
result: "restarted",
message: "Gateway restart signal sent to unmanaged process on port 18789: 4200.",
}),
});
expect(beforeServiceMutation).not.toHaveBeenCalled();
expect(service.restart).not.toHaveBeenCalled();
});
it("repairs managed port drift before restarting", async () => {
service.readRuntime.mockResolvedValue({ status: "running", pid: 1234 });
service.readCommand.mockResolvedValue({

View File

@@ -459,6 +459,7 @@ export async function runServiceRestart(params: {
opts?: DaemonLifecycleOptions;
checkTokenDrift?: boolean;
expectedPort?: number;
beforeServiceMutation?: () => void;
repairLoadedService?: (
ctx: ServiceStartRepairContext,
) => Promise<ServiceRecoveryResult<"restarted"> | null>;
@@ -533,6 +534,12 @@ export async function runServiceRestart(params: {
}
}
// Loaded services cross the native mutation boundary here. Not-loaded recovery
// may still target a separately verified unmanaged listener.
if (loaded) {
params.beforeServiceMutation?.();
}
if (!loaded) {
try {
handledRecovery = (await params.onNotLoaded?.({ json, stdout, warn, fail })) ?? null;

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

View File

@@ -1,6 +1,11 @@
// Daemon lifecycle tests cover CLI service lifecycle orchestration and cleanup.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../../test-utils/env.js";
import {
expectRestartError,
requireMockCallArg,
type RestartParams,
} from "./lifecycle.test-helpers.js";
type RestartHealthSnapshot = {
healthy: boolean;
@@ -11,30 +16,13 @@ type RestartHealthSnapshot = {
elapsedMs?: number;
};
type RestartPostCheckContext = {
json: boolean;
stdout: NodeJS.WritableStream;
warnings: string[];
fail: (message: string, hints?: string[]) => void;
};
type RestartParams = {
opts?: { json?: boolean };
repairLoadedService?: (ctx: {
json: boolean;
stdout: NodeJS.WritableStream;
state: unknown;
issues: unknown[];
}) => Promise<unknown>;
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<void>;
};
const service = {
readCommand: vi.fn(),
readRuntime: vi.fn(),
restart: vi.fn(),
stop: vi.fn(),
};
const isDefaultInstallIdentity = vi.hoisted(() => vi.fn(() => true));
const runServiceStart = vi.fn();
const runServiceRestart = vi.fn();
@@ -99,29 +87,6 @@ const createGatewayLifecycleMutationAudit = vi.fn(
}),
);
function requireMockCallArg(
mockFn: { mock: { calls: unknown[][] } },
label: string,
index = 0,
): Record<string, unknown> {
const arg = mockFn.mock.calls[index]?.[0] as Record<string, unknown> | undefined;
if (!arg) {
throw new Error(`expected ${label} call #${index + 1}`);
}
return arg;
}
async function expectRestartError(
promise: Promise<unknown>,
): Promise<Error & { hints?: string[] }> {
try {
await promise;
} catch (error) {
return error as Error & { hints?: string[] };
}
throw new Error("expected restart to fail");
}
vi.mock("../../config/config.js", () => ({
getRuntimeConfig: () => loadConfig(),
loadConfig: () => loadConfig(),
@@ -129,7 +94,10 @@ vi.mock("../../config/config.js", () => ({
resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env),
}));
vi.mock("../../config/paths.js", () => ({ isDefaultInstallIdentity: () => true }));
vi.mock("../../config/paths.js", () => ({
isDefaultInstallIdentity: () => isDefaultInstallIdentity(),
resolveNativeServiceProfileConflict: () => null,
}));
vi.mock("../../infra/gateway-processes.js", () => ({
findVerifiedGatewayListenerPidsOnPortSync,
@@ -276,13 +244,12 @@ describe("runDaemonRestart health checks", () => {
]);
delete process.env.OPENCLAW_CONTAINER_HINT;
service.readCommand.mockReset();
service.readRuntime.mockReset();
service.readRuntime.mockResolvedValue({ status: "stopped" });
service.restart.mockReset();
service.readRuntime.mockReset().mockResolvedValue({ status: "stopped" });
service.restart.mockReset().mockResolvedValue({ outcome: "completed" });
service.stop.mockReset();
runServiceStart.mockReset();
runServiceStart.mockReset().mockResolvedValue(undefined);
runServiceRestart.mockReset();
runServiceStop.mockReset();
runServiceStop.mockReset().mockResolvedValue(undefined);
waitForGatewayHealthyListener.mockReset();
waitForGatewayHealthyRestart.mockReset();
terminateStaleGatewayPids.mockReset();
@@ -290,43 +257,36 @@ describe("runDaemonRestart health checks", () => {
renderRestartDiagnostics.mockReset();
resolveGatewayPort.mockReset();
findVerifiedGatewayListenerPidsOnPortSync.mockReset();
signalVerifiedGatewayPidSync.mockReset();
writeGatewayRestartIntentSync.mockReset();
signalVerifiedGatewayPidSync.mockReset().mockImplementation(() => {});
writeGatewayRestartIntentSync.mockReset().mockReturnValue(true);
clearGatewayRestartIntentSync.mockReset();
formatGatewayPidList.mockReset();
formatGatewayPidList.mockReset().mockImplementation((pids) => pids.join(", "));
probeGateway.mockReset();
callGatewayCli.mockReset();
isRestartEnabled.mockReset();
loadConfig.mockReset();
readActiveGatewayLockPort.mockReset();
readActiveGatewayLockPort.mockReset().mockResolvedValue(undefined);
readActiveGatewayLockIdentity.mockReset();
recoverInstalledLaunchAgent.mockReset();
recoverInstalledLaunchAgent.mockReset().mockResolvedValue(null);
repairLoadedGatewayServiceForStart.mockReset();
isTerminalInteractive.mockReset();
isTerminalInteractive.mockReturnValue(true);
isTerminalInteractive.mockReset().mockReturnValue(true);
appendGatewayLifecycleAudit.mockClear();
createGatewayLifecycleMutationAudit.mockClear();
isDefaultInstallIdentity.mockReset().mockReturnValue(true);
service.readCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "--port", "18789"],
environment: {},
});
service.restart.mockResolvedValue({ outcome: "completed" });
runServiceStart.mockResolvedValue(undefined);
recoverInstalledLaunchAgent.mockResolvedValue(null);
readActiveGatewayLockPort.mockResolvedValue(undefined);
readActiveGatewayLockIdentity.mockResolvedValue({
pid: 4200,
ownerId: "gateway-owner-old",
createdAt: "2026-07-16T12:00:00.000Z",
port: 18_789,
});
findInstalledSystemdGatewayScope.mockReset();
findInstalledSystemdGatewayScope.mockResolvedValue(null);
restartSystemdService.mockReset();
restartSystemdService.mockResolvedValue({ outcome: "completed" });
stopSystemdService.mockReset();
stopSystemdService.mockResolvedValue(undefined);
findInstalledSystemdGatewayScope.mockReset().mockResolvedValue(null);
restartSystemdService.mockReset().mockResolvedValue({ outcome: "completed" });
stopSystemdService.mockReset().mockResolvedValue(undefined);
runServiceRestart.mockImplementation(async (params: RestartParams) => {
const fail = (message: string, hints?: string[]) => {
@@ -342,7 +302,6 @@ describe("runDaemonRestart health checks", () => {
});
return true;
});
runServiceStop.mockResolvedValue(undefined);
waitForGatewayHealthyListener.mockResolvedValue({
healthy: true,
portUsage: { port: 18789, status: "busy", listeners: [], hints: [] },
@@ -383,9 +342,6 @@ describe("runDaemonRestart health checks", () => {
},
});
isRestartEnabled.mockReturnValue(true);
signalVerifiedGatewayPidSync.mockImplementation(() => {});
writeGatewayRestartIntentSync.mockReturnValue(true);
formatGatewayPidList.mockImplementation((pids) => pids.join(", "));
});
afterEach(() => {
@@ -417,6 +373,16 @@ describe("runDaemonRestart health checks", () => {
expect(requireMockCallArg(runServiceRestart, "runServiceRestart").expectedPort).toBeUndefined();
});
it("guards loaded service restart at the native mutation boundary", async () => {
await runDaemonRestart({ json: true });
const restartParams = requireMockCallArg(runServiceRestart, "runServiceRestart");
isDefaultInstallIdentity.mockReturnValue(false);
expect(() => (restartParams.beforeServiceMutation as () => void)()).toThrow(
/non-default state dir/,
);
});
it("uses the installed service environment for managed restart health", async () => {
process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-caller-state";
process.env.OPENCLAW_SYSTEMD_UNIT = "openclaw-gateway-maintenance.service";
@@ -840,12 +806,15 @@ describe("runDaemonRestart health checks", () => {
});
it("signals a single unmanaged gateway process on restart", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
isDefaultInstallIdentity.mockReturnValue(false);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
mockUnmanagedRestart({ runPostRestartCheck: true });
await runDaemonRestart({ json: true });
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789);
expect(findInstalledSystemdGatewayScope).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1");
expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith({
action: "restart",
@@ -860,6 +829,17 @@ describe("runDaemonRestart health checks", () => {
expect(service.restart).not.toHaveBeenCalled();
});
it("rejects denied Darwin recovery when no unmanaged listener exists", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
isDefaultInstallIdentity.mockReturnValue(false);
mockUnmanagedRestart();
await expect(runDaemonRestart({ json: true })).rejects.toThrow(/non-default state dir/);
expect(recoverInstalledLaunchAgent).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
});
it("uses targeted RPC for an unmanaged Windows gateway restart", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
@@ -1025,6 +1005,7 @@ describe("runDaemonRestart health checks", () => {
});
it("fails unmanaged restart when multiple gateway listeners are present", async () => {
isDefaultInstallIdentity.mockReturnValue(false);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200, 4300]);
mockUnmanagedRestart();

View File

@@ -28,6 +28,7 @@ import {
assertGatewayServiceMutationAllowed,
formatExternalSupervisorActionRequired,
isGatewayExternallySupervised,
resolveGatewayServiceMutationError,
} from "../../infra/gateway-supervision.js";
import {
clearGatewayRestartIntentSync,
@@ -383,16 +384,13 @@ async function signalGatewayRestart(
};
}
async function restartGatewayWithoutServiceManager(
port: number,
restartIntent?: GatewayRestartIntent,
) {
const managed = await handleSystemScopeSystemdGateway("restart");
async function restartUnmanaged(port: number, intent?: GatewayRestartIntent, allowSystem = true) {
const managed = allowSystem ? await handleSystemScopeSystemdGateway("restart") : null;
if (managed) {
return managed;
}
return await signalGatewayRestart(port, {
restartIntent,
restartIntent: intent,
enforceRestartConfig: true,
processLabel: "unmanaged",
auditSource: "cli",
@@ -402,7 +400,7 @@ async function restartGatewayWithoutServiceManager(
type GatewaySignalRestartResult = NonNullable<Awaited<ReturnType<typeof signalGatewayRestart>>>;
function isGatewaySignalRestartResult(
result: Awaited<ReturnType<typeof restartGatewayWithoutServiceManager>>,
result: Awaited<ReturnType<typeof restartUnmanaged>>,
): result is GatewaySignalRestartResult {
return result !== null && "pid" in result && typeof result.pid === "number";
}
@@ -595,6 +593,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
},
checkTokenDrift: true,
expectedPort: configuredPort,
beforeServiceMutation: () => assertGatewayServiceMutationAllowed("restart the gateway"),
repairLoadedService: async ({ json, stdout, warn, state, issues }) => {
const result = await repairLoadedGatewayServiceForStart({
action: "restart",
@@ -612,7 +611,8 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
return result;
},
onNotLoaded: async () => {
if (process.platform === "darwin") {
const mutationError = resolveGatewayServiceMutationError("restart the gateway");
if (process.platform === "darwin" && !mutationError) {
const recovered = await recoverInstalledLaunchAgent({ result: "restarted" });
if (recovered) {
appendGatewayLifecycleAudit({
@@ -623,7 +623,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
return recovered;
}
}
const handled = await restartGatewayWithoutServiceManager(unmanagedPort, restartIntent);
const handled = await restartUnmanaged(unmanagedPort, restartIntent, !mutationError);
if (handled) {
restartedWithoutServiceManager = true;
if (isGatewaySignalRestartResult(handled) && handled.previousLockIdentity) {
@@ -635,6 +635,9 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
}
return handled;
}
if (mutationError) {
throw mutationError;
}
return null;
},
postRestartCheck: async ({ warnings, fail, stdout, warn }) => {

View File

@@ -358,6 +358,41 @@ describe("applyCliProfileEnv", () => {
expect(env.OPENCLAW_CONFIG_PATH).toBe("/srv/openclaw/custom.json");
});
it.each(["openclaw-gateway-main", "openclaw-gateway-main.service"])(
"drops inherited canonical service identities when switching profiles (%s)",
(systemdUnit) => {
const env: Record<string, string | undefined> = {
OPENCLAW_PROFILE: "main",
OPENCLAW_STATE_DIR: "/home/peter/.openclaw-main",
OPENCLAW_CONFIG_PATH: "/home/peter/.openclaw-main/openclaw.json",
OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.main",
OPENCLAW_SYSTEMD_UNIT: systemdUnit,
OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (main)",
};
applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" });
expect(env.OPENCLAW_LAUNCHD_LABEL).toBeUndefined();
expect(env.OPENCLAW_SYSTEMD_UNIT).toBeUndefined();
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBeUndefined();
},
);
it("preserves explicit custom service identities when switching profiles", () => {
const env: Record<string, string | undefined> = {
OPENCLAW_PROFILE: "main",
OPENCLAW_LAUNCHD_LABEL: "com.example.gateway",
OPENCLAW_SYSTEMD_UNIT: "custom-gateway.service",
OPENCLAW_WINDOWS_TASK_NAME: "Custom Gateway",
};
applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" });
expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("com.example.gateway");
expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("custom-gateway.service");
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("Custom Gateway");
});
it.each([
{ inheritedProfile: "Main", selectedProfile: "main" },
{ inheritedProfile: "main", selectedProfile: "Main" },

View File

@@ -5,6 +5,11 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import {
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "../daemon/constants.js";
import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js";
import { resolveCliArgvInvocation } from "./argv-invocation.js";
import { isValidProfileName } from "./profile-utils.js";
@@ -129,6 +134,24 @@ export function applyCliProfileEnv(params: {
env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json");
}
if (switchesInheritedProfile) {
const inheritedSystemdServiceName = resolveGatewaySystemdServiceName(inheritedProfile);
const inheritedServiceIdentities = {
OPENCLAW_LAUNCHD_LABEL: [resolveGatewayLaunchAgentLabel(inheritedProfile)],
OPENCLAW_SYSTEMD_UNIT: [
inheritedSystemdServiceName,
`${inheritedSystemdServiceName}.service`,
],
OPENCLAW_WINDOWS_TASK_NAME: [resolveGatewayWindowsTaskName(inheritedProfile)],
};
for (const [key, inheritedValues] of Object.entries(inheritedServiceIdentities)) {
const activeValue = normalizeOptionalString(env[key]);
if (activeValue && inheritedValues.includes(activeValue)) {
delete env[key];
}
}
}
if (profile === "dev" && !env.OPENCLAW_GATEWAY_PORT?.trim()) {
env.OPENCLAW_GATEWAY_PORT = "19001";
}

View File

@@ -44,6 +44,11 @@ const resolveGlobalManager = vi.fn();
const serviceLoaded = vi.fn();
const serviceStop = vi.fn();
const serviceRestart = vi.fn();
const isDefaultInstallIdentity = vi.hoisted(() =>
vi.fn<(env?: NodeJS.ProcessEnv, homedir?: () => string, platform?: NodeJS.Platform) => boolean>(
() => true,
),
);
const suspendScheduledTaskAutoStartForUpdate = vi.fn();
const resumeScheduledTaskAutoStartAfterUpdate = vi.fn();
const prepareRestartScript = vi.fn();
@@ -322,7 +327,12 @@ vi.mock("../config/backup-rotation.js", () => ({
}));
vi.mock("../daemon/service.js", () => ({
readGatewayServiceState: async () => {
readGatewayServiceState: async (
_service: unknown,
args?: {
validateEnvBeforeStatusRead?: (env: NodeJS.ProcessEnv) => void;
},
) => {
const command = await serviceReadCommand();
const env = {
...process.env,
@@ -330,6 +340,7 @@ vi.mock("../daemon/service.js", () => ({
? (command.environment as NodeJS.ProcessEnv | undefined)
: undefined),
};
args?.validateEnvBeforeStatusRead?.(env);
const [loaded, runtime] = await Promise.all([
serviceLoaded({ env }).catch(() => false),
serviceReadRuntime(env).catch(() => undefined),
@@ -365,6 +376,15 @@ vi.mock("../daemon/schtasks.js", () => ({
resumeScheduledTaskAutoStartAfterUpdate(...args),
}));
vi.mock("../config/paths.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/paths.js")>()),
isDefaultInstallIdentity: (
env?: NodeJS.ProcessEnv,
homedir?: () => string,
platform?: NodeJS.Platform,
) => isDefaultInstallIdentity(env, homedir, platform),
}));
vi.mock("../infra/ports.js", () => ({
inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args),
classifyPortListener: (...args: unknown[]) => classifyPortListener(...args),
@@ -1364,6 +1384,8 @@ describe("update-cli", () => {
resolveGlobalManager.mockResolvedValue("npm");
serviceStop.mockResolvedValue(undefined);
serviceRestart.mockResolvedValue({ outcome: "completed" });
isDefaultInstallIdentity.mockReset();
isDefaultInstallIdentity.mockReturnValue(true);
suspendScheduledTaskAutoStartForUpdate.mockResolvedValue(false);
resumeScheduledTaskAutoStartAfterUpdate.mockResolvedValue(false);
serviceLoaded.mockResolvedValue(false);
@@ -4206,6 +4228,99 @@ describe("update-cli", () => {
processOffSpy.mockRestore();
});
it("does not inspect or mutate a Windows host service from an isolated install", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const tempDir = await createTrackedTempDir("openclaw-update-isolated-service-");
const { nodeModules } = await setupInstalledPackageRoot(tempDir);
mockRunningManagedGateway();
mockFileBackedPathExists();
mockNpmGlobalRoot(nodeModules);
isDefaultInstallIdentity.mockReturnValue(false);
await withEnvAsync({ OPENCLAW_HOME: path.join(tempDir, "relocated-home") }, async () => {
await updateCommand({ yes: true });
});
platformSpy.mockRestore();
expect(isDefaultInstallIdentity).toHaveBeenCalled();
expect(serviceReadCommand).not.toHaveBeenCalled();
expect(suspendScheduledTaskAutoStartForUpdate).not.toHaveBeenCalled();
expect(serviceStop).not.toHaveBeenCalled();
expect(prepareRestartScript).not.toHaveBeenCalled();
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
expect(packageInstallCommandCall()).toBeDefined();
});
it.each([
{
platform: "darwin" as const,
envKey: "OPENCLAW_LAUNCHD_LABEL",
value: "ai.openclaw.gateway",
},
{
platform: "linux" as const,
envKey: "OPENCLAW_SYSTEMD_UNIT",
value: "openclaw-gateway.service",
},
{
platform: "win32" as const,
envKey: "OPENCLAW_WINDOWS_TASK_NAME",
value: "OpenClaw Gateway",
},
])(
"does not reuse a conflicting $envKey selector from the managed service on $platform",
async ({ platform, envKey, value }) => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform);
const tempDir = await createTrackedTempDir(`openclaw-update-${platform}-selector-`);
const home = path.join(tempDir, "home");
const stateDir = path.join(home, ".openclaw-work");
const { nodeModules } = await setupInstalledPackageRoot(tempDir);
serviceReadCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
environment: {
OPENCLAW_PROFILE: "work",
[envKey]: value,
},
});
serviceLoaded.mockResolvedValue(true);
serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" });
mockFileBackedPathExists();
mockNpmGlobalRoot(nodeModules);
try {
await withEnvAsync(
{
HOME: home,
USERPROFILE: undefined,
OPENCLAW_HOME: undefined,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
[envKey]: undefined,
},
async () => {
await updateCommand({ yes: true });
},
);
} finally {
platformSpy.mockRestore();
}
expect(isDefaultInstallIdentity).toHaveBeenCalled();
expect(serviceReadRuntime).not.toHaveBeenCalled();
expect(suspendScheduledTaskAutoStartForUpdate).not.toHaveBeenCalled();
expect(serviceStop).not.toHaveBeenCalled();
expect(serviceRestart).not.toHaveBeenCalled();
expect(prepareRestartScript).not.toHaveBeenCalled();
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
expect(packageInstallCommandCall()).toBeUndefined();
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(getErrorOutput()).toContain(envKey);
},
);
it("restores Windows Scheduled Task autostart when service stop fails", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
mockPackageInstallStatus(createCaseDir("openclaw-update-stop-failure"));

View File

@@ -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) {

View File

@@ -29,6 +29,7 @@ import {
import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js";
import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js";
import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js";
import { assertGatewayServiceMutationAllowed } from "../../infra/gateway-supervision.js";
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
@@ -111,6 +112,8 @@ export type PreManagedServiceStop = {
inspected: boolean;
runtimeInspected: boolean;
running: boolean;
serviceMutationAllowed?: boolean;
serviceMutationSkipMessage?: string;
serviceMatchesMutationRoot?: boolean;
blockMessage?: string;
serviceEnv?: NodeJS.ProcessEnv;
@@ -128,6 +131,41 @@ export type UpdateCommandRecoveryState = {
windowsTaskAutoStartRecovery?: WindowsTaskAutoStartRecovery;
};
export class GatewayServiceUpdateOwnershipError extends Error {
constructor(message: string, cause: unknown) {
super(message, { cause });
this.name = "GatewayServiceUpdateOwnershipError";
}
}
export function resolveGatewayServiceManagementBlockMessageForUpdate(
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
try {
assertGatewayServiceManagementAllowedForUpdate(env);
return undefined;
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
}
export function assertGatewayServiceManagementAllowedForUpdate(
env: NodeJS.ProcessEnv = process.env,
): void {
try {
assertGatewayServiceMutationAllowed("manage the gateway service during update", env);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new GatewayServiceUpdateOwnershipError(message, err);
}
}
export function isGatewayServiceManagementAllowedForUpdate(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return resolveGatewayServiceManagementBlockMessageForUpdate(env) === undefined;
}
export class UpdateCommandAbort extends Error {
constructor() {
super("openclaw-update-abort");
@@ -334,12 +372,38 @@ export async function maybeStopManagedServiceBeforeMutableUpdate(params: {
shouldRestart: boolean;
jsonMode: boolean;
}): Promise<PreManagedServiceStop> {
const serviceMutationSkipMessage = resolveGatewayServiceManagementBlockMessageForUpdate(
process.env,
);
if (serviceMutationSkipMessage) {
return {
stopped: false,
inspected: false,
runtimeInspected: false,
running: false,
serviceMutationAllowed: false,
serviceMutationSkipMessage,
};
}
let service: ReturnType<typeof resolveGatewayService>;
let serviceState: Awaited<ReturnType<typeof readGatewayServiceState>>;
try {
service = resolveGatewayService();
serviceState = await readGatewayServiceState(service, { env: process.env });
} catch {
serviceState = await readGatewayServiceState(service, {
env: process.env,
validateEnvBeforeStatusRead: assertGatewayServiceManagementAllowedForUpdate,
});
} catch (err) {
if (err instanceof GatewayServiceUpdateOwnershipError) {
return {
stopped: false,
inspected: false,
runtimeInspected: false,
running: false,
serviceMutationAllowed: false,
blockMessage: err.message,
};
}
return { stopped: false, inspected: false, runtimeInspected: false, running: false };
}
@@ -949,6 +1013,9 @@ function resolveManagedServiceNodeRunner(
* when the package root is the same.
*/
export async function resolveManagedServiceNodeRunnerOverride(): Promise<string | undefined> {
if (!isGatewayServiceManagementAllowedForUpdate(process.env)) {
return undefined;
}
const command = await resolveGatewayService()
.readCommand(process.env)
.catch(() => null);
@@ -970,6 +1037,9 @@ export async function resolveManagedServiceNodeRunnerOverride(): Promise<string
export async function resolveManagedServicePackageUpdateRoot(params: {
root: string;
}): Promise<ManagedServiceRootRedirect | null> {
if (!isGatewayServiceManagementAllowedForUpdate(process.env)) {
return null;
}
const command = await resolveGatewayService()
.readCommand(process.env)
.catch(() => null);
@@ -1004,9 +1074,11 @@ export async function gatewayServiceCommandUsesRoot(params: {
}
const command =
params.command === undefined
? await resolveGatewayService()
.readCommand(params.env ?? process.env)
.catch(() => null)
? isGatewayServiceManagementAllowedForUpdate(params.env ?? process.env)
? await resolveGatewayService()
.readCommand(params.env ?? process.env)
.catch(() => null)
: null
: params.command;
const layout = await summarizeGatewayServiceLayout(command);
const serviceRoot = layout?.packageRoot;
@@ -1037,8 +1109,22 @@ export async function maybeRestartService(params: {
nodeRunner?: string;
skipLegacyServiceRestart?: boolean;
requireRunningServiceAfterRestart?: boolean;
serviceMutationSkipMessage?: string;
timeoutMs: number;
}): Promise<boolean> {
if (
params.shouldRestart &&
(!isGatewayServiceManagementAllowedForUpdate(process.env) ||
!isGatewayServiceManagementAllowedForUpdate(params.serviceEnv ?? process.env))
) {
const message =
resolveGatewayServiceManagementBlockMessageForUpdate(process.env) ??
resolveGatewayServiceManagementBlockMessageForUpdate(params.serviceEnv ?? process.env);
if (message) {
defaultRuntime.error(message);
}
return false;
}
const verifyRestartedGateway = async (
expectedGatewayVersion: string | undefined,
opts: { requireRunningService?: boolean } = {},
@@ -1325,6 +1411,18 @@ export async function maybeRestartService(params: {
return true;
}
if (params.serviceMutationSkipMessage) {
if (params.opts.json) {
defaultRuntime.error(params.serviceMutationSkipMessage);
} else {
defaultRuntime.log("");
defaultRuntime.log(
theme.warn(`Gateway: restart skipped: ${params.serviceMutationSkipMessage}`),
);
}
return true;
}
if (!params.opts.json) {
defaultRuntime.log("");
defaultRuntime.log(theme.muted("Gateway: restart skipped (--no-restart)."));

View File

@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vitest";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import type { GatewayService } from "../../daemon/service.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { defaultRuntime } from "../../runtime.js";
import {
updatePluginsAfterCoreUpdate,
type PostCorePluginUpdateResult,
@@ -17,6 +18,7 @@ import {
resolvePostInstallDoctorEnv,
resolvePostUpdateServiceStateReadEnv,
resolveUpdatedGatewayRestartPort,
maybeRestartService,
shouldPrepareUpdatedInstallRestart,
} from "./update-command-service.js";
import { testing as updateCommandServiceTesting } from "./update-command-service.test-support.js";
@@ -176,6 +178,31 @@ describe("resolveUpdatedGatewayRestartPort", () => {
});
});
describe("maybeRestartService", () => {
it("reports service ownership skips to JSON callers", async () => {
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
await expect(
maybeRestartService({
shouldRestart: false,
result: {
status: "ok",
mode: "npm",
steps: [],
durationMs: 0,
},
opts: { json: true },
refreshServiceEnv: false,
gatewayPort: 18789,
serviceMutationSkipMessage: "service management skipped: ownership conflict",
timeoutMs: 1_000,
}),
).resolves.toBe(true);
expect(errorSpy).toHaveBeenCalledWith("service management skipped: ownership conflict");
});
});
describe("resolvePostUpdateServiceStateReadEnv", () => {
it("keeps package restart preparation anchored to the pre-update service env", () => {
const processEnv = {

View File

@@ -12,6 +12,7 @@ import {
isNixMode,
normalizeStateDirEnv,
pinRuntimePaths,
resolveNativeServiceProfileConflict,
resolveDefaultConfigCandidates,
resolveConfigPathCandidate,
resolveConfigPath,
@@ -57,6 +58,21 @@ describe("default install identity", () => {
).toBe(true);
});
it("preserves implicit legacy config discovery for the default profile", async () => {
await withTempDir({ prefix: "openclaw-default-install-legacy-config-" }, async (home) => {
const stateDir = path.join(home, ".openclaw");
const legacyStateDir = path.join(home, ".clawdbot");
const legacyConfigPath = path.join(legacyStateDir, "clawdbot.json");
await fs.mkdir(stateDir, { recursive: true });
await fs.mkdir(legacyStateDir, { recursive: true });
await fs.writeFile(legacyConfigPath, "{}");
const env = { HOME: home };
expect(resolveConfigPathCandidate(env, () => home)).toBe(legacyConfigPath);
expect(isDefaultInstallIdentity(env, () => home)).toBe(true);
});
});
it("rejects non-default state or config paths", () => {
const home = "/home/test";
@@ -73,11 +89,208 @@ describe("default install identity", () => {
it("rejects process home overrides that relocate the implicit install", () => {
const accountHome = "/home/test";
const stateDir = path.join(accountHome, ".openclaw");
expect(isDefaultInstallIdentity({ HOME: "/tmp/copied-home" }, () => accountHome)).toBe(false);
expect(isDefaultInstallIdentity({ OPENCLAW_HOME: "/tmp/copied-home" }, () => accountHome)).toBe(
false,
);
expect(
isDefaultInstallIdentity(
{
HOME: "/tmp/copied-home",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
},
() => accountHome,
),
).toBe(false);
expect(
isDefaultInstallIdentity(
{
USERPROFILE: "/tmp/copied-home",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
},
() => accountHome,
),
).toBe(false);
});
it("rejects installs relocated through OPENCLAW_HOME", () => {
const accountHome = "/home/test";
const installHome = "/srv/openclaw";
const stateDir = path.join(installHome, ".openclaw");
expect(isDefaultInstallIdentity({ OPENCLAW_HOME: installHome }, () => accountHome)).toBe(false);
expect(
isDefaultInstallIdentity(
{
OPENCLAW_HOME: installHome,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
},
() => accountHome,
),
).toBe(false);
expect(
isDefaultInstallIdentity(
{
OPENCLAW_HOME: installHome,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: path.join(installHome, ".openclaw-work"),
OPENCLAW_CONFIG_PATH: path.join(installHome, ".openclaw-work", "openclaw.json"),
},
() => accountHome,
),
).toBe(false);
});
it("accepts the canonical paths a named profile projects", async () => {
await withTempDir({ prefix: "openclaw-profile-install-" }, async (home) => {
const defaultStateDir = path.join(home, ".openclaw");
const profileStateDir = path.join(home, ".openclaw-work");
await fs.mkdir(defaultStateDir, { recursive: true });
await fs.writeFile(path.join(defaultStateDir, "openclaw.json"), "{}");
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: profileStateDir,
OPENCLAW_CONFIG_PATH: path.join(profileStateDir, "openclaw.json"),
},
() => home,
),
).toBe(true);
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: profileStateDir,
},
() => home,
),
).toBe(false);
await fs.mkdir(profileStateDir, { recursive: true });
await fs.writeFile(path.join(profileStateDir, "openclaw.json"), "{}");
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: profileStateDir,
},
() => home,
),
).toBe(true);
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: path.join(home, ".openclaw-other"),
},
() => home,
),
).toBe(false);
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: "default",
OPENCLAW_STATE_DIR: defaultStateDir,
},
() => home,
),
).toBe(true);
});
});
it.each([
{
platform: "darwin" as const,
envKey: "OPENCLAW_LAUNCHD_LABEL",
value: "ai.openclaw.gateway",
},
{
platform: "linux" as const,
envKey: "OPENCLAW_SYSTEMD_UNIT",
value: "openclaw-gateway.service",
},
{
platform: "win32" as const,
envKey: "OPENCLAW_WINDOWS_TASK_NAME",
value: "OpenClaw Gateway",
},
])("rejects a named profile overriding $envKey on $platform", ({ platform, envKey, value }) => {
const home = "/home/test";
const stateDir = path.join(home, ".openclaw-work");
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
[envKey]: value,
},
() => home,
platform,
),
).toBe(false);
});
it.each(["../escape", "work/../../escape", "work\\..\\escape", "."])(
"rejects invalid profile %j even when its derived paths match",
(profile) => {
const home = "/home/test";
const profileStateDir = path.join(home, `.openclaw-${profile}`);
expect(
isDefaultInstallIdentity(
{
HOME: home,
OPENCLAW_PROFILE: profile,
OPENCLAW_STATE_DIR: profileStateDir,
OPENCLAW_CONFIG_PATH: path.join(profileStateDir, "openclaw.json"),
},
() => home,
),
).toBe(false);
},
);
it.each(["gateway", "node"])(
"rejects macOS profile %j because its LaunchAgent label is reserved",
(profile) => {
expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "darwin")).toBe(
profile,
);
expect(
resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "linux"),
).toBeNull();
},
);
it.each(["Main", "MAIN", "Work"])(
"rejects mixed-case native service profile %j on case-insensitive platforms",
(profile) => {
expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "darwin")).toBe(
profile,
);
expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "win32")).toBe(
profile,
);
expect(
resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: profile }, "linux"),
).toBeNull();
},
);
it("keeps lowercase native service profiles byte-compatible", () => {
expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: "main" }, "darwin")).toBeNull();
expect(resolveNativeServiceProfileConflict({ OPENCLAW_PROFILE: "main" }, "win32")).toBeNull();
});
});

View File

@@ -2,6 +2,8 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isValidProfileName } from "../cli/profile-utils.js";
import { resolveGatewayNativeServiceIdentityConflict } from "../daemon/constants.js";
import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js";
import { parseTcpPort } from "../infra/tcp-port.js";
import { isFastTestRuntimeEnv } from "../infra/test-runtime-env.js";
@@ -124,32 +126,88 @@ export function isDefaultStateDir(
);
}
/** Canonical state directory name for the selected profile, mirroring root `--profile`. */
function profileStateDirName(env: NodeJS.ProcessEnv): string | null {
const profile = env.OPENCLAW_PROFILE?.trim();
if (!profile || profile.toLowerCase() === "default") {
return NEW_STATE_DIRNAME;
}
if (!isValidProfileName(profile)) {
return null;
}
return `${NEW_STATE_DIRNAME}-${profile}`;
}
export function resolveNativeServiceProfileConflict(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): string | null {
if (platform !== "darwin" && platform !== "win32") {
return null;
}
const profile = env.OPENCLAW_PROFILE?.trim();
if (!profile || profile.toLowerCase() === "default") {
return null;
}
// Normal macOS and Windows filesystems fold case, so case-distinct profile
// names can share state and native-service paths even though the CLI keeps
// them distinct. Leave the runtime profile valid, but deny service mutation.
if (profile !== profile.toLowerCase()) {
return profile;
}
if (platform !== "darwin") {
return null;
}
// These names map to the shipped default Gateway and node-host LaunchAgent
// labels, so authorizing them would let one profile control another service.
return profile === "gateway" || profile === "node" ? profile : null;
}
/** Whether host service management belongs to the active default install identity. */
export function isDefaultInstallIdentity(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = resolveSystemAccountHomeDir,
platform: NodeJS.Platform = process.platform,
): boolean {
const accountHome = resolveRequiredHomeDir({}, homedir);
const accountHomedir = () => accountHome;
// Profiles have distinct host-service names; relocated homes do not. Keep
// OPENCLAW_HOME isolated so an alternate state tree cannot adopt that service.
if (env.OPENCLAW_HOME?.trim()) {
return false;
}
if (
normalizePathForComparison(resolveStateDir(env, envHomedir(env))) !==
normalizePathForComparison(newStateDir(accountHomedir))
normalizePathForComparison(resolveRequiredHomeDir(env, homedir)) !==
normalizePathForComparison(accountHome)
) {
return false;
}
if (!env.OPENCLAW_CONFIG_PATH?.trim()) {
if (
resolveNativeServiceProfileConflict(env, platform) ||
resolveGatewayNativeServiceIdentityConflict(env, platform)
) {
return false;
}
const stateDirName = profileStateDirName(env);
// Environment profiles can bypass root CLI parsing. Reject them before path
// construction so separators or dot segments cannot authorize a host service.
if (!stateDirName) {
return false;
}
const canonicalStateDir = path.join(accountHome, stateDirName);
if (
normalizePathForComparison(resolveStateDir(env, envHomedir(env))) !==
normalizePathForComparison(canonicalStateDir)
) {
return false;
}
// Default installs historically allow implicit legacy config discovery.
// Named profiles must resolve their own config so they cannot inherit the default profile.
if (stateDirName === NEW_STATE_DIRNAME && !env.OPENCLAW_CONFIG_PATH?.trim()) {
return true;
}
const defaultConfigEnv = {
...env,
HOME: accountHome,
OPENCLAW_HOME: undefined,
OPENCLAW_STATE_DIR: undefined,
OPENCLAW_CONFIG_PATH: undefined,
};
return (
normalizePathForComparison(resolveConfigPathCandidate(env, envHomedir(env))) ===
normalizePathForComparison(resolveConfigPathCandidate(defaultConfigEnv, accountHomedir))
normalizePathForComparison(path.join(canonicalStateDir, CONFIG_FILENAME))
);
}

View File

@@ -4,6 +4,7 @@ import {
GATEWAY_LAUNCH_AGENT_LABEL,
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
resolveGatewayLaunchAgentLabel,
resolveGatewayNativeServiceIdentityConflict,
resolveGatewayProfileSuffix,
resolveGatewayServiceDescription,
resolveGatewaySystemdServiceName,
@@ -47,6 +48,48 @@ describe("resolveGatewayWindowsTaskName", () => {
});
});
describe("resolveGatewayNativeServiceIdentityConflict", () => {
it.each([
{
platform: "darwin" as const,
envKey: "OPENCLAW_LAUNCHD_LABEL",
value: "ai.openclaw.gateway",
},
{
platform: "linux" as const,
envKey: "OPENCLAW_SYSTEMD_UNIT",
value: "openclaw-gateway.service",
},
{
platform: "win32" as const,
envKey: "OPENCLAW_WINDOWS_TASK_NAME",
value: "OpenClaw Gateway",
},
])("rejects $envKey overrides for named profiles on $platform", ({ platform, envKey, value }) => {
expect(
resolveGatewayNativeServiceIdentityConflict(
{ OPENCLAW_PROFILE: "work", [envKey]: value },
platform,
),
).toMatchObject({ envKey });
});
it("accepts canonical named-profile identities and default-profile overrides", () => {
expect(
resolveGatewayNativeServiceIdentityConflict(
{ OPENCLAW_PROFILE: "work", OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway-work" },
"linux",
),
).toBeNull();
expect(
resolveGatewayNativeServiceIdentityConflict(
{ OPENCLAW_SYSTEMD_UNIT: "custom-gateway.service" },
"linux",
),
).toBeNull();
});
});
describe("resolveGatewayProfileSuffix", () => {
it("returns empty string when no profile is set", () => {
expect(resolveGatewayProfileSuffix()).toBe("");

View File

@@ -59,6 +59,42 @@ export function resolveGatewayWindowsTaskName(profile?: string): string {
return `OpenClaw Gateway (${normalized})`;
}
type GatewayNativeServiceIdentityConflict = {
envKey: "OPENCLAW_LAUNCHD_LABEL" | "OPENCLAW_SYSTEMD_UNIT" | "OPENCLAW_WINDOWS_TASK_NAME";
expected: string;
};
export function resolveGatewayNativeServiceIdentityConflict(
env: Record<string, string | undefined>,
platform: NodeJS.Platform = process.platform,
): GatewayNativeServiceIdentityConflict | null {
const profile = normalizeGatewayProfile(env.OPENCLAW_PROFILE);
if (!profile) {
return null;
}
if (platform === "darwin") {
const envKey = "OPENCLAW_LAUNCHD_LABEL";
const actual = env[envKey]?.trim();
const expected = resolveGatewayLaunchAgentLabel(profile);
return actual && actual !== expected ? { envKey, expected } : null;
}
if (platform === "linux") {
const envKey = "OPENCLAW_SYSTEMD_UNIT";
const actual = env[envKey]?.trim();
const normalizedActual = actual?.endsWith(".service") ? actual : actual && `${actual}.service`;
const expected = `${resolveGatewaySystemdServiceName(profile)}.service`;
return normalizedActual && normalizedActual !== expected ? { envKey, expected } : null;
}
if (platform === "win32") {
const envKey = "OPENCLAW_WINDOWS_TASK_NAME";
const actual = env[envKey]?.trim();
const expected = resolveGatewayWindowsTaskName(profile);
return actual && actual !== expected ? { envKey, expected } : null;
}
return null;
}
function formatGatewayServiceDescription(params?: { profile?: string; version?: string }): string {
const profile = normalizeGatewayProfile(params?.profile);
const version = params?.version?.trim();

View File

@@ -6,6 +6,7 @@ import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { withTimeout } from "../utils/with-timeout.js";
import {
installLaunchAgent,
@@ -13,6 +14,7 @@ import {
repairLaunchAgentBootstrap,
restartLaunchAgent,
resolveLaunchAgentPlistPath,
startLaunchAgent,
stopLaunchAgent,
uninstallLaunchAgent,
} from "./launchd.js";
@@ -192,6 +194,81 @@ describeLaunchdIntegration("launchd integration", () => {
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
}, 60_000);
it("manages a named profile through the guarded host-service lifecycle", async () => {
const testId = randomUUID().slice(0, 8);
const profile = `launchd-int-${testId}`;
const accountHome = os.userInfo().homedir;
const stateDir = path.join(accountHome, `.openclaw-${profile}`);
const profileEnv: GatewayServiceEnv = {
HOME: accountHome,
OPENCLAW_HOME: undefined,
OPENCLAW_PROFILE: profile,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
OPENCLAW_LAUNCHD_LABEL: undefined,
OPENCLAW_SUPERVISOR_MODE: undefined,
};
await withEnvAsync(profileEnv, async () => {
const service = resolveGatewayService();
try {
await service.install({
env: profileEnv,
stdout,
programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"],
});
const installed = await waitForRunningRuntime({ env: profileEnv });
await service.stop({ env: profileEnv, stdout });
await waitForNotRunningRuntime({ env: profileEnv });
const startResult = await startGatewayService(service, { env: profileEnv, stdout });
expect(startResult.outcome).toBe("started");
const started = await waitForRunningRuntime({
env: profileEnv,
pidNot: installed.pid,
});
await service.restart({ env: profileEnv, stdout });
await expectRuntimePidReplaced({ env: profileEnv, previousPid: started.pid });
} finally {
try {
await service.uninstall({ env: profileEnv, stdout });
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
}
});
}, 60_000);
it("refuses a relocated OPENCLAW_HOME before launchd mutation", async () => {
const testId = randomUUID().slice(0, 8);
const relocatedHome = await fs.mkdtemp(
path.join(os.tmpdir(), `openclaw-relocated-home-${testId}-`),
);
const relocatedEnv: GatewayServiceEnv = {
HOME: os.userInfo().homedir,
OPENCLAW_HOME: relocatedHome,
OPENCLAW_PROFILE: `launchd-int-${testId}`,
};
try {
await withEnvAsync(relocatedEnv, async () => {
const service = resolveGatewayService();
await expect(
service.install({
env: relocatedEnv,
stdout,
programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"],
}),
).rejects.toThrow("service management skipped: non-default state dir or config path");
await expect(fs.access(resolveLaunchAgentPlistPath(relocatedEnv))).rejects.toThrow();
});
} finally {
await fs.rm(relocatedHome, { recursive: true, force: true });
}
});
it("keeps LaunchAgent supervision after a raw SIGTERM", async () => {
const launchEnv = launchEnvOrThrow(env);
await initializeLaunchdRuntime(launchEnv, stdout);
@@ -208,9 +285,7 @@ describeLaunchdIntegration("launchd integration", () => {
const before = await waitForRunningRuntime({ env: launchEnv });
await stopLaunchAgent({ env: launchEnv, stdout });
await waitForNotRunningRuntime({ env: launchEnv });
const service = resolveGatewayService();
const startResult = await startGatewayService(service, { env: launchEnv, stdout });
expect(startResult.outcome).toBe("started");
await startLaunchAgent({ env: launchEnv, stdout });
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
}, 60_000);

File diff suppressed because it is too large Load Diff

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

View File

@@ -23,8 +23,16 @@ const sleepMock = vi.hoisted(() =>
timeState.now += ms;
}),
);
type SpawnSyncResult = {
pid: number;
output: (string | null)[];
stdout: string;
stderr: string;
status: number;
signal: null;
};
const spawnSync = vi.hoisted(() =>
vi.fn(() => ({
vi.fn<(command: string, args?: readonly string[]) => SpawnSyncResult>(() => ({
pid: 0,
output: [null, "-2147024891", ""],
stdout: "-2147024891",
@@ -52,12 +60,14 @@ vi.mock("../utils.js", async () => {
});
const {
resolveTaskScriptPath,
restartScheduledTask,
resumeScheduledTaskAutoStartAfterUpdate,
startScheduledTask,
stopScheduledTask,
suspendScheduledTaskAutoStartForUpdate,
} = await import("./schtasks.js");
const { resolveScheduledTaskOwnedGatewayPids } = await import("./schtasks-process.js");
const GATEWAY_PORT = 18789;
const SUCCESS_RESPONSE = { code: 0, stdout: "", stderr: "" } as const;
const INSTALLED_GATEWAY_COMMAND_LINE =
@@ -465,6 +475,93 @@ describe("Scheduled Task stop/restart cleanup", () => {
});
});
it("does not adopt a portless arbitrary task action", async () => {
await withPreparedGatewayTask(async ({ env }) => {
delete env.OPENCLAW_GATEWAY_PORT;
const scriptPath = resolveTaskScriptPath(env);
await fs.writeFile(
scriptPath,
'@echo off\r\n"C:\\Program Files\\nodejs\\node.exe" "C:\\probe.cjs"\r\n',
"utf8",
);
await expect(resolveScheduledTaskOwnedGatewayPids(env)).resolves.toEqual([]);
expect(inspectPortUsage).not.toHaveBeenCalled();
});
});
it("adopts exact persisted Windows argv and escalates through taskkill tree cleanup", async () => {
await withPreparedGatewayTask(async ({ env, stdout }) => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
pushSuccessfulSchtasksResponses(3);
inspectPortUsage.mockResolvedValue(freePortUsage());
let forced = false;
spawnSync.mockImplementation((command, args) => {
const executable = command.toLowerCase();
if (executable.endsWith("taskkill.exe")) {
const argv = Array.isArray(args) ? args.map(String) : [];
if (argv.includes("/F")) {
forced = true;
return {
pid: 0,
output: [null, "", ""],
stdout: "",
stderr: "",
status: 0,
signal: null,
};
}
return {
pid: 0,
output: [null, "", ""],
stdout: "",
stderr: "",
status: 1,
signal: null,
};
}
const processes = [
{
ProcessId: 3131,
CommandLine:
'"C:\\Program Files\\nodejs\\node.exe" "C:\\other-openclaw.cjs" gateway --port 18789',
},
...(!forced
? [
{
ProcessId: 4242,
CommandLine: INSTALLED_GATEWAY_COMMAND_LINE,
},
]
: []),
{ ProcessId: 9999, CommandLine: "powershell.exe" },
];
const output = JSON.stringify(processes);
return {
pid: 0,
output: [null, output, ""],
stdout: output,
stderr: "",
status: 0,
signal: null,
};
});
await stopScheduledTask({ env, stdout });
const taskkillCalls = spawnSync.mock.calls
.filter(([command]) => command.toLowerCase().endsWith("taskkill.exe"))
.map(([, args]) => args);
expect(taskkillCalls).toEqual([
["/T", "/PID", "4242"],
["/F", "/T", "/PID", "4242"],
]);
expect(taskkillCalls.flat()).not.toContain("3131");
expect(killProcessTree).not.toHaveBeenCalled();
});
});
it("starts a registered task and ignores audit observer failures", async () => {
await withPreparedGatewayTask(async ({ env }) => {
schtasksResponses.push(

View File

@@ -185,6 +185,31 @@ describe("readGatewayServiceState", () => {
{ timeoutMs: undefined },
);
});
it("validates merged service env before native status probes", async () => {
const isLoaded = vi.fn(async () => true);
const readRuntime = vi.fn(async () => ({ status: "running" as const }));
const service = createService({
isLoaded,
readCommand: vi.fn(async () => ({
programArguments: ["openclaw", "gateway", "run"],
environment: { OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway.service" },
})),
readRuntime,
});
await expect(
readGatewayServiceState(service, {
env: {},
validateEnvBeforeStatusRead: (env) => {
throw new Error(`refused ${env.OPENCLAW_SYSTEMD_UNIT}`);
},
}),
).rejects.toThrow("refused openclaw-gateway.service");
expect(isLoaded).not.toHaveBeenCalled();
expect(readRuntime).not.toHaveBeenCalled();
});
});
describe("startGatewayService", () => {

View File

@@ -90,6 +90,10 @@ export type GatewayService = {
) => Promise<GatewayServiceRuntime>;
};
type ReadGatewayServiceStateArgs = GatewayServiceEnvArgs & {
validateEnvBeforeStatusRead?: (env: GatewayServiceEnv) => void;
};
const TEMP_PROGRAM_ROOTS = [os.tmpdir(), "/tmp", "/private/tmp", "/var/tmp"].map((entry) =>
path.resolve(entry),
);
@@ -179,11 +183,14 @@ export function formatGatewayServiceStartRepairIssues(
export async function readGatewayServiceState(
service: GatewayService,
args: GatewayServiceEnvArgs = {},
args: ReadGatewayServiceStateArgs = {},
): Promise<GatewayServiceState> {
const baseEnv = args.env ?? (process.env as GatewayServiceEnv);
const command = await service.readCommand(baseEnv).catch(() => null);
const env = mergeGatewayServiceEnv(baseEnv, command);
// Callers that may mutate the selected service can reject persisted selector
// drift before isLoaded/readRuntime invoke the native service manager.
args.validateEnvBeforeStatusRead?.(env);
// Propagate the status read deadline so a wedged service manager fails soft
// instead of hanging both probes. readCommand parses local files and needs no
// bound; isLoaded/readRuntime can spawn service-manager subprocesses.

View File

@@ -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",

View File

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

View File

@@ -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",

View File

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