From 383f8947c1daa9deacec4dc9cf312ffd254ecbf6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 11:09:56 -0400 Subject: [PATCH] fix: doctor skips host services for isolated state (#115922) * fix(doctor): isolate host service management * fix(doctor): clarify service isolation recovery * test(doctor): isolate service identity fixtures * test(daemon): keep lifecycle fixtures lint-clean * test(daemon): isolate install identity fixtures --- src/cli/daemon-cli.coverage.test.ts | 5 ++ .../daemon-cli/install.integration.test.ts | 6 ++ src/cli/daemon-cli/install.test.ts | 17 ++++ src/cli/daemon-cli/install.ts | 13 ++- src/cli/daemon-cli/lifecycle.test.ts | 5 +- src/cli/daemon-cli/start-repair.test.ts | 6 ++ src/cli/daemon-cli/start-repair.ts | 2 + .../doctor-gateway-daemon-flow.test.ts | 42 ++++++++++ src/commands/doctor-gateway-daemon-flow.ts | 6 ++ src/commands/doctor-gateway-services.test.ts | 18 ++++ src/commands/doctor-gateway-services.ts | 14 +++- src/commands/doctor-update.test.ts | 7 ++ src/commands/doctor-update.ts | 3 +- src/config/paths.test.ts | 40 +++++++++ src/config/paths.ts | 41 +++++++++- src/daemon/service.test.ts | 5 ++ src/flows/doctor-core-checks.ts | 4 + ...tor-health-contribution-runners.gateway.ts | 13 +++ src/flows/doctor-health-contributions.test.ts | 6 ++ ...rver-startup-copied-auth-secretref.test.ts | 82 +++++++++++++++++++ src/infra/gateway-supervision.test.ts | 15 ++++ src/infra/gateway-supervision.ts | 9 ++ src/secrets/resolve-errors.ts | 5 +- src/secrets/resolve.ts | 2 +- .../runtime-auth-store-inline-refs.test.ts | 43 ++++++++++ src/secrets/runtime-degraded-state.ts | 2 + src/secrets/runtime-owner-assignments.ts | 53 +++++++++++- 27 files changed, 442 insertions(+), 22 deletions(-) create mode 100644 src/gateway/server-startup-copied-auth-secretref.test.ts diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 4ce23867cb69..843869988380 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -87,6 +87,11 @@ const mocks = await vi.hoisted(async () => { const { runtimeLogs } = mocks; +vi.mock("../config/paths.js", async () => { + const actual = await vi.importActual("../config/paths.js"); + return { ...actual, isDefaultInstallIdentity: () => true }; +}); + vi.mock("./daemon-cli/probe.js", () => ({ probeGatewayStatus: (opts: unknown) => probeGatewayStatus(opts), })); diff --git a/src/cli/daemon-cli/install.integration.test.ts b/src/cli/daemon-cli/install.integration.test.ts index 0f2dbca3402b..0db59d615190 100644 --- a/src/cli/daemon-cli/install.integration.test.ts +++ b/src/cli/daemon-cli/install.integration.test.ts @@ -22,6 +22,12 @@ const serviceMock = vi.hoisted(() => ({ readRuntime: vi.fn(async () => ({ status: "stopped" as const })), })); +vi.mock("../../config/paths.js", async () => { + const actual = + await vi.importActual("../../config/paths.js"); + return { ...actual, isDefaultInstallIdentity: () => true }; +}); + vi.mock("../../daemon/service.js", () => ({ resolveGatewayService: () => serviceMock, })); diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 7955726846e0..53242f7d580d 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -13,6 +13,7 @@ const resolveNodeStartupTlsEnvironmentMock = vi.hoisted(() => vi.fn()); const loadConfigMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789)); +const isDefaultInstallIdentityMock = vi.hoisted(() => vi.fn(() => true)); const replaceConfigFileMock = vi.hoisted(() => vi.fn()); const resolveIsNixModeMock = vi.hoisted(() => vi.fn(() => false)); const resolveSecretInputRefMock = vi.hoisted(() => @@ -102,6 +103,7 @@ vi.mock("../../config/mutate.js", () => ({ })); vi.mock("../../config/paths.js", () => ({ + isDefaultInstallIdentity: isDefaultInstallIdentityMock, resolveGatewayPort: resolveGatewayPortMock, resolveIsNixMode: resolveIsNixModeMock, })); @@ -276,6 +278,7 @@ describe("runDaemonInstall", () => { resolveNodeStartupTlsEnvironmentMock.mockReset(); readConfigFileSnapshotMock.mockReset(); resolveGatewayPortMock.mockClear(); + isDefaultInstallIdentityMock.mockReturnValue(true); replaceConfigFileMock.mockReset(); resolveIsNixModeMock.mockReset(); resolveSecretInputRefMock.mockReset(); @@ -362,6 +365,20 @@ describe("runDaemonInstall", () => { expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); }); + it("blocks non-default install identities before inspecting host services", async () => { + isDefaultInstallIdentityMock.mockReturnValue(false); + + await runDaemonInstall({ json: true }); + + expect(actionState.failed[0]?.message).toContain( + "service management skipped: non-default state dir or config path", + ); + expect(readConfigFileSnapshotMock).not.toHaveBeenCalled(); + expect(service.isLoaded).not.toHaveBeenCalled(); + expect(service.readCommand).not.toHaveBeenCalled(); + expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); + }); + it("validates token SecretRef but does not serialize resolved token into service env", async () => { mockResolvedGatewayTokenSecretRef(); diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index a938d4bc6868..8e00ff243508 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -25,10 +25,7 @@ import { isLoopbackHost, resolveGatewayBindHost, } from "../../gateway/net.js"; -import { - formatExternalSupervisorActionRequired, - isGatewayExternallySupervised, -} from "../../infra/gateway-supervision.js"; +import { assertGatewayServiceMutationAllowed } from "../../infra/gateway-supervision.js"; import { isDangerousHostEnvOverrideVarName, isDangerousHostEnvVarName, @@ -149,10 +146,10 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { if (failIfNixDaemonInstallMode(fail)) { return; } - if (isGatewayExternallySupervised()) { - fail( - `Gateway install blocked: ${formatExternalSupervisorActionRequired("install or rewrite the gateway service")}`, - ); + try { + assertGatewayServiceMutationAllowed("install or rewrite the gateway service"); + } catch (error) { + fail(`Gateway install blocked: ${String(error)}`); return; } diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 9721207c2b15..130e54db053a 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -129,9 +129,10 @@ vi.mock("../../config/config.js", () => ({ resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env), })); +vi.mock("../../config/paths.js", () => ({ isDefaultInstallIdentity: () => true })); + vi.mock("../../infra/gateway-processes.js", () => ({ - findVerifiedGatewayListenerPidsOnPortSync: (port: number) => - findVerifiedGatewayListenerPidsOnPortSync(port), + findVerifiedGatewayListenerPidsOnPortSync, signalVerifiedGatewayPidSync: (pid: number, signal: "SIGTERM" | "SIGUSR1") => signalVerifiedGatewayPidSync(pid, signal), formatGatewayPidList: (pids: number[]) => formatGatewayPidList(pids), diff --git a/src/cli/daemon-cli/start-repair.test.ts b/src/cli/daemon-cli/start-repair.test.ts index 4de705edd82b..76458129cd95 100644 --- a/src/cli/daemon-cli/start-repair.test.ts +++ b/src/cli/daemon-cli/start-repair.test.ts @@ -34,6 +34,7 @@ const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789)); const resolveOpenClawWrapperPathMock = vi.hoisted(() => vi.fn()); const formatGatewayServiceStartRepairIssuesMock = vi.hoisted(() => vi.fn()); const defaultRuntimeLogMock = vi.hoisted(() => vi.fn()); +const assertGatewayServiceMutationAllowedMock = vi.hoisted(() => vi.fn()); vi.mock("../../commands/daemon-install-helpers.js", () => ({ buildGatewayInstallPlan: buildGatewayInstallPlanMock, @@ -64,6 +65,10 @@ vi.mock("../../daemon/service.js", () => ({ formatGatewayServiceStartRepairIssues: formatGatewayServiceStartRepairIssuesMock, })); +vi.mock("../../infra/gateway-supervision.js", () => ({ + assertGatewayServiceMutationAllowed: assertGatewayServiceMutationAllowedMock, +})); + vi.mock("../../runtime.js", () => ({ defaultRuntime: { log: defaultRuntimeLogMock }, })); @@ -87,6 +92,7 @@ describe("repairLoadedGatewayServiceForStart", () => { resolveOpenClawWrapperPathMock.mockReset(); formatGatewayServiceStartRepairIssuesMock.mockReset(); defaultRuntimeLogMock.mockClear(); + assertGatewayServiceMutationAllowedMock.mockReset(); resolveGatewayInstallTokenMock.mockResolvedValue({ tokenRefConfigured: false, diff --git a/src/cli/daemon-cli/start-repair.ts b/src/cli/daemon-cli/start-repair.ts index da181c436adc..59194b83b5ea 100644 --- a/src/cli/daemon-cli/start-repair.ts +++ b/src/cli/daemon-cli/start-repair.ts @@ -12,6 +12,7 @@ import type { GatewayServiceState, } from "../../daemon/service.js"; import { formatGatewayServiceStartRepairIssues } from "../../daemon/service.js"; +import { assertGatewayServiceMutationAllowed } from "../../infra/gateway-supervision.js"; import { parseTcpPort, parseTcpPortFromArgs } from "../../infra/tcp-port.js"; import { defaultRuntime } from "../../runtime.js"; import { mergeInstallInvocationEnv } from "./install.js"; @@ -48,6 +49,7 @@ export async function repairLoadedGatewayServiceForStart( warnings?: string[]; loaded: boolean; }> { + assertGatewayServiceMutationAllowed("repair the gateway service"); const { snapshot: configSnapshot, writeOptions: configWriteOptions } = await readConfigFileSnapshotForWrite(); const cfg = configSnapshot.valid ? configSnapshot.sourceConfig : configSnapshot.config; diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index 551fc7f23839..1f9a005c5f46 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -35,6 +35,7 @@ const findSystemGatewayServices = vi.hoisted(() => ); const buildGatewayRuntimeHints = vi.hoisted(() => vi.fn((): string[] => [])); const formatGatewayRuntimeSummary = vi.hoisted(() => vi.fn((): string | null => null)); +const isDefaultInstallIdentity = vi.hoisted(() => vi.fn(() => true)); vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); @@ -44,6 +45,11 @@ vi.mock("../config/config.js", async () => { }; }); +vi.mock("../config/paths.js", async () => { + const actual = await vi.importActual("../config/paths.js"); + return { ...actual, isDefaultInstallIdentity }; +}); + vi.mock("../daemon/constants.js", () => ({ resolveGatewayLaunchAgentLabel: vi.fn(() => "ai.openclaw.gateway"), resolveNodeLaunchAgentLabel: vi.fn(() => "ai.openclaw.node"), @@ -161,6 +167,7 @@ describe("maybeRepairGatewayDaemon", () => { service.readRuntime.mockResolvedValue({ status: "running" }); service.readCommand.mockResolvedValue(null); service.restart.mockResolvedValue({ outcome: "completed" }); + isDefaultInstallIdentity.mockReturnValue(true); readGatewayRestartHandoffSync.mockReturnValue(null); findSystemGatewayServices.mockResolvedValue([]); inspectPortUsage.mockResolvedValue({ @@ -287,6 +294,41 @@ describe("maybeRepairGatewayDaemon", () => { await runScheduledGatewayRepairAndExpectVerificationSkipped("Restart gateway service now?"); }); + it("skips every service-manager seam for a non-default install identity", async () => { + await withEnvAsync( + { + OPENCLAW_STATE_DIR: "/tmp/openclaw-copied-state", + OPENCLAW_CONFIG_PATH: "/tmp/openclaw-copied-state/openclaw.json", + }, + async () => { + isDefaultInstallIdentity.mockReturnValue(false); + await runNonInteractiveRepair(); + }, + ); + + expect(service.isLoaded).not.toHaveBeenCalled(); + expect(service.readRuntime).not.toHaveBeenCalled(); + expect(service.readCommand).not.toHaveBeenCalled(); + expect(service.install).not.toHaveBeenCalled(); + expect(service.restart).not.toHaveBeenCalled(); + expect(launchd.repairLaunchAgentBootstrap).not.toHaveBeenCalled(); + expect(findSystemGatewayServices).not.toHaveBeenCalled(); + expect(note).toHaveBeenCalledWith( + "service management skipped: non-default state dir or config path", + "Gateway", + ); + }); + + it("still inspects the managed service for the default install identity", async () => { + await withEnvAsync( + { OPENCLAW_STATE_DIR: undefined, OPENCLAW_CONFIG_PATH: undefined }, + runNonInteractiveRepair, + ); + + expect(service.isLoaded).toHaveBeenCalledTimes(1); + expect(service.readRuntime).toHaveBeenCalledTimes(1); + }); + it("reports recent restart handoffs during deep doctor", async () => { vi.useFakeTimers(); vi.setSystemTime(40_000); diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index 5dc30542f654..341d65c02ebd 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -2,6 +2,7 @@ import { note } from "../../packages/terminal-core/src/note.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveGatewayPort } from "../config/config.js"; +import { isDefaultInstallIdentity } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveGatewayLaunchAgentLabel, @@ -18,6 +19,7 @@ import type { GatewayServiceRuntime } from "../daemon/service-runtime.js"; import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js"; import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; +import { NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON } from "../infra/gateway-supervision.js"; import { formatPortDiagnostics, inspectPortConnections, @@ -193,6 +195,10 @@ export async function maybeRepairGatewayDaemon(params: { healthOk: boolean; healthSkipped?: boolean; }) { + if (!isDefaultInstallIdentity(process.env)) { + note(NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON, "Gateway"); + return; + } if (params.healthOk) { await maybeReportEstablishedGatewayClients({ cfg: params.cfg, diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 10e7d3b7e114..d6a2f740d357 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => ({ resolveGatewayAuthTokenForService: vi.fn(), resolveGatewayPort: vi.fn(() => 18789), resolveIsNixMode: vi.fn(() => false), + isDefaultInstallIdentity: vi.fn(() => true), findExtraGatewayServices: vi.fn().mockResolvedValue([]), renderGatewayServiceCleanupHints: vi.fn().mockReturnValue([]), needsNodeRuntimeMigration: vi.fn(() => false), @@ -53,6 +54,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../config/paths.js", () => ({ + isDefaultInstallIdentity: mocks.isDefaultInstallIdentity, resolveGatewayPort: mocks.resolveGatewayPort, resolveIsNixMode: mocks.resolveIsNixMode, })); @@ -421,6 +423,7 @@ describe("maybeRepairGatewayServiceConfig", () => { vi.clearAllMocks(); fsMocks.realpath.mockImplementation(async (value: string) => value); mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.isDefaultInstallIdentity.mockReturnValue(true); mocks.readRuntime.mockResolvedValue({ status: "unknown" }); mocks.readWindowsStartupFallbackRuntimeForUpdate.mockResolvedValue(null); mocks.needsNodeRuntimeMigration.mockReturnValue(false); @@ -486,6 +489,21 @@ describe("maybeRepairGatewayServiceConfig", () => { expectNoteContaining("adaptive default", "Gateway heap"); }); + it("skips service audit and rewrite for a non-default install identity", async () => { + mocks.isDefaultInstallIdentity.mockReturnValue(false); + + await runRepair({ gateway: {} }); + + expect(mocks.readCommand).not.toHaveBeenCalled(); + expect(mocks.auditGatewayServiceConfig).not.toHaveBeenCalled(); + expect(mocks.stage).not.toHaveBeenCalled(); + expect(mocks.install).not.toHaveBeenCalled(); + expectNoteContaining( + "service management skipped: non-default state dir or config path", + "Gateway", + ); + }); + it("treats gateway.auth.token as source of truth for service token repairs", async () => { setupGatewayTokenRepairScenario(); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 840884f40351..94aff5f4a0ef 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -8,7 +8,7 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; import { replaceConfigFile, type OpenClawConfig } from "../config/config.js"; -import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; +import { isDefaultInstallIdentity, resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import { formatGatewayHeapLimitReport, inspectGatewayHeapLimit } from "../daemon/gateway-heap.js"; import { @@ -37,6 +37,7 @@ import { } from "../daemon/systemd.js"; import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { isTruthyEnvValue } from "../infra/env.js"; +import { NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON } from "../infra/gateway-supervision.js"; import { readWindowsProcessArgsSync } from "../infra/windows-port-pids.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -357,6 +358,9 @@ async function filterInactiveExtraGatewayServices( export async function detectExtraGatewayServiceIssues( options: Pick = {}, ): Promise { + if (!isDefaultInstallIdentity(process.env)) { + return []; + } const detectedExtraServices = await findExtraGatewayServices(process.env, { deep: options.deep, }); @@ -538,6 +542,10 @@ export async function maybeRepairGatewayServiceConfig( prompter: DoctorPrompter, options: GatewayServiceConfigRepairOptions = {}, ): Promise { + if (!isDefaultInstallIdentity(process.env)) { + note(NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON, "Gateway"); + return cfg; + } if (resolveIsNixMode(process.env)) { note("Nix mode detected; skip service updates.", "Gateway"); return cfg; @@ -934,6 +942,10 @@ export async function maybeScanExtraGatewayServices( runtime: RuntimeEnv, prompter: DoctorPrompter, ) { + if (!isDefaultInstallIdentity(process.env)) { + note(NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON, "Gateway"); + return; + } const extraServices = await detectExtraGatewayServiceIssues(options); if (extraServices.length === 0) { return; diff --git a/src/commands/doctor-update.test.ts b/src/commands/doctor-update.test.ts index a779a902ee22..978848272c35 100644 --- a/src/commands/doctor-update.test.ts +++ b/src/commands/doctor-update.test.ts @@ -11,6 +11,7 @@ const originalServiceRepairPolicy = process.env.OPENCLAW_SERVICE_REPAIR_POLICY; const mocks = vi.hoisted(() => ({ createUpdateProgress: vi.fn(), + isDefaultInstallIdentity: vi.fn(() => true), note: vi.fn(), readGatewayServiceState: vi.fn(), restartGatewayService: vi.fn(), @@ -24,6 +25,11 @@ vi.mock("../cli/update-cli/progress.js", () => ({ createUpdateProgress: mocks.createUpdateProgress, })); +vi.mock("../config/paths.js", async () => { + const actual = await vi.importActual("../config/paths.js"); + return { ...actual, isDefaultInstallIdentity: mocks.isDefaultInstallIdentity }; +}); + vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWithTimeout, })); @@ -67,6 +73,7 @@ async function runOffer(params?: { beforeEach(async () => { mocks.createUpdateProgress.mockReset(); mocks.createUpdateProgress.mockReturnValue({ progress: {}, stop: vi.fn() }); + mocks.isDefaultInstallIdentity.mockReturnValue(true); mocks.note.mockReset(); mocks.readGatewayServiceState.mockReset(); mocks.restartGatewayService.mockReset(); diff --git a/src/commands/doctor-update.ts b/src/commands/doctor-update.ts index c1c153233bde..d5673ff4ea68 100644 --- a/src/commands/doctor-update.ts +++ b/src/commands/doctor-update.ts @@ -5,6 +5,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st import { note } from "../../packages/terminal-core/src/note.js"; import { formatCliCommand } from "../cli/command-format.js"; import { createUpdateProgress } from "../cli/update-cli/progress.js"; +import { isDefaultInstallIdentity } from "../config/paths.js"; import { summarizeGatewayServiceLayout } from "../daemon/service-layout.js"; import { readGatewayServiceState, resolveGatewayService } from "../daemon/service.js"; import { isTruthyEnvValue } from "../infra/env.js"; @@ -61,7 +62,7 @@ const NO_GATEWAY_SERVICE_UPDATE: GatewayServiceUpdatePolicy = { async function inspectGatewayServiceForUpdate( root: string, ): Promise { - if (isServiceRepairExternallyManaged()) { + if (!isDefaultInstallIdentity(process.env) || isServiceRepairExternallyManaged()) { return NO_GATEWAY_SERVICE_UPDATE; } try { diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index b1b21946069c..0fecb67b9e5d 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -7,6 +7,7 @@ import { withTempDir } from "../test-helpers/temp-dir.js"; import { CONFIG_PATH, DEFAULT_GATEWAY_PORT, + isDefaultInstallIdentity, isDefaultStateDir, isNixMode, normalizeStateDirEnv, @@ -41,6 +42,45 @@ describe("default state directory", () => { }); }); +describe("default install identity", () => { + it("accepts default paths and equivalent explicit overrides", () => { + const home = "/home/test"; + const stateDir = path.join(home, ".openclaw"); + const configPath = path.join(stateDir, "openclaw.json"); + + expect(isDefaultInstallIdentity({ HOME: home }, () => home)).toBe(true); + expect( + isDefaultInstallIdentity( + { HOME: home, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath }, + () => home, + ), + ).toBe(true); + }); + + it("rejects non-default state or config paths", () => { + const home = "/home/test"; + + expect( + isDefaultInstallIdentity({ HOME: home, OPENCLAW_STATE_DIR: "/tmp/copied-state" }, () => home), + ).toBe(false); + expect( + isDefaultInstallIdentity( + { HOME: home, OPENCLAW_CONFIG_PATH: "/tmp/copied-openclaw.json" }, + () => home, + ), + ).toBe(false); + }); + + it("rejects process home overrides that relocate the implicit install", () => { + const accountHome = "/home/test"; + + expect(isDefaultInstallIdentity({ HOME: "/tmp/copied-home" }, () => accountHome)).toBe(false); + expect(isDefaultInstallIdentity({ OPENCLAW_HOME: "/tmp/copied-home" }, () => accountHome)).toBe( + false, + ); + }); +}); + describe("oauth paths", () => { it("prefers OPENCLAW_OAUTH_DIR over OPENCLAW_STATE_DIR", () => { const env = { diff --git a/src/config/paths.ts b/src/config/paths.ts index d9e13ffbb912..aecd004d6f66 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -36,6 +36,10 @@ function resolveDefaultHomeDir(): string { return resolveRequiredHomeDir(process.env, os.homedir); } +function resolveSystemAccountHomeDir(): string { + return os.userInfo().homedir; +} + /** Build a homedir thunk that respects OPENCLAW_HOME for the given env. */ function envHomedir(env: NodeJS.ProcessEnv): () => string { return () => resolveRequiredHomeDir(env, os.homedir); @@ -93,8 +97,8 @@ export function resolveStateDir( return newDir; } -function normalizeStateDirForComparison(stateDir: string): string { - const resolved = path.resolve(stateDir); +function normalizePathForComparison(candidate: string): string { + const resolved = path.resolve(candidate); try { return fs.realpathSync.native(resolved); } catch { @@ -115,8 +119,37 @@ export function isDefaultStateDir( } const effectiveHomedir = () => resolveRequiredHomeDir(env, homedir); return ( - normalizeStateDirForComparison(resolveStateDir(env, effectiveHomedir)) === - normalizeStateDirForComparison(newStateDir(effectiveHomedir)) + normalizePathForComparison(resolveStateDir(env, effectiveHomedir)) === + normalizePathForComparison(newStateDir(effectiveHomedir)) + ); +} + +/** Whether host service management belongs to the active default install identity. */ +export function isDefaultInstallIdentity( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = resolveSystemAccountHomeDir, +): boolean { + const accountHome = resolveRequiredHomeDir({}, homedir); + const accountHomedir = () => accountHome; + if ( + normalizePathForComparison(resolveStateDir(env, envHomedir(env))) !== + normalizePathForComparison(newStateDir(accountHomedir)) + ) { + return false; + } + if (!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)) ); } diff --git a/src/daemon/service.test.ts b/src/daemon/service.test.ts index f2d20f8a0051..38ef74d4d512 100644 --- a/src/daemon/service.test.ts +++ b/src/daemon/service.test.ts @@ -16,6 +16,11 @@ import { } from "./service.js"; import { createMockGatewayService } from "./service.test-helpers.js"; +vi.mock("../config/paths.js", async () => { + const actual = await vi.importActual("../config/paths.js"); + return { ...actual, isDefaultInstallIdentity: () => true }; +}); + function setPlatform(value: NodeJS.Platform) { mockProcessPlatform(value); } diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index ae2351862764..31f1d740eca5 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -29,6 +29,7 @@ import { uiProtocolFreshnessIssueToRepairEffects, } from "../commands/doctor-ui.js"; import { collectDisabledCodexPluginRouteIssues } from "../commands/doctor/shared/codex-route-warnings.js"; +import { isDefaultInstallIdentity } from "../config/paths.js"; import type { ConfigValidationIssue, OpenClawConfig } from "../config/types.openclaw.js"; import { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js"; import type { CronListPageResult } from "../cron/service/list-page-types.js"; @@ -932,6 +933,9 @@ const gatewayPlatformNotesCheck: HealthCheck = { description: "Gateway platform notes are captured as structured findings.", source: "doctor", async detect(ctx) { + if (!isDefaultInstallIdentity(process.env)) { + return []; + } const { collectMacGatewayPlatformWarnings } = await import("../commands/doctor-platform-notes.js"); const warnings = await collectMacGatewayPlatformWarnings(ctx.cfg); diff --git a/src/flows/doctor-health-contribution-runners.gateway.ts b/src/flows/doctor-health-contribution-runners.gateway.ts index 0d38467ac63e..55afa58d8b16 100644 --- a/src/flows/doctor-health-contribution-runners.gateway.ts +++ b/src/flows/doctor-health-contribution-runners.gateway.ts @@ -1,3 +1,6 @@ +import { note } from "../../packages/terminal-core/src/note.js"; +import { isDefaultInstallIdentity } from "../config/paths.js"; +import { NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON } from "../infra/gateway-supervision.js"; import { runCoreContributionHealth } from "./doctor-health-contribution-core.js"; import type { DoctorHealthFlowContext } from "./doctor-health-contribution-types.js"; import { @@ -17,6 +20,10 @@ export async function runClaudeCliHealth(ctx: DoctorHealthFlowContext): Promise< } export async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Promise { + if (!isDefaultInstallIdentity(ctx.env ?? process.env)) { + note(NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON, "Gateway"); + return; + } const { maybeRepairGatewayServiceConfig, maybeScanExtraGatewayServices } = await import("../commands/doctor-gateway-services.js"); const { @@ -66,6 +73,9 @@ export async function runSecurityHealth(ctx: DoctorHealthFlowContext): Promise { + if (!isDefaultInstallIdentity(ctx.env ?? process.env)) { + return; + } const { noteWebFetchProxyDiagnostic } = await import("../commands/doctor-web-fetch-proxy.js"); await noteWebFetchProxyDiagnostic({ cfg: ctx.cfg, env: ctx.env ?? process.env }); } @@ -98,6 +108,9 @@ export async function runDevicePairingHealth(ctx: DoctorHealthFlowContext): Prom } export async function runGatewayDaemonHealth(ctx: DoctorHealthFlowContext): Promise { + if (!isDefaultInstallIdentity(ctx.env ?? process.env)) { + return; + } const { maybeRepairGatewayDaemon } = await import("../commands/doctor-gateway-daemon-flow.js"); await maybeRepairGatewayDaemon({ cfg: ctx.cfg, diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index f9adcd49d195..10d2fee6e5cb 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -18,6 +18,7 @@ import { runDoctorLintChecks } from "./doctor-lint-flow.js"; import type { HealthCheck, HealthFinding } from "./health-checks.js"; const mocks = vi.hoisted(() => ({ + isDefaultInstallIdentity: vi.fn(() => true), maybeRunConfiguredPluginInstallReleaseStep: vi.fn(), registerBundledHealthChecks: vi.fn(), runDoctorHealthRepairs: vi.fn(), @@ -162,6 +163,11 @@ const mocks = vi.hoisted(() => ({ getSkillCuratorDoctorWarning: vi.fn(), })); +vi.mock("../config/paths.js", async () => { + const actual = await vi.importActual("../config/paths.js"); + return { ...actual, isDefaultInstallIdentity: mocks.isDefaultInstallIdentity }; +}); + const DOCTOR_GATEWAY_HEALTH_ID = "doctor:gateway-health"; vi.mock("../commands/doctor/shared/release-configured-plugin-installs.js", () => ({ diff --git a/src/gateway/server-startup-copied-auth-secretref.test.ts b/src/gateway/server-startup-copied-auth-secretref.test.ts new file mode 100644 index 000000000000..cbee1a474949 --- /dev/null +++ b/src/gateway/server-startup-copied-auth-secretref.test.ts @@ -0,0 +1,82 @@ +/** Gateway startup regression coverage for copied auth stores with omitted SecretRef providers. */ +import { afterEach, describe, expect, it } from "vitest"; +import { resolveDefaultAgentDir } from "../agents/agent-scope-config.js"; +import { + deletePersistedAuthProfileStoreRaw, + readPersistedAuthProfileStoreRaw, + writePersistedAuthProfileStoreRaw, +} from "../agents/auth-profiles/sqlite.js"; +import { writeConfigFile, type OpenClawConfig } from "../config/config.js"; +import { resolveAuthProfileSecretOwnerId } from "../secrets/runtime-auth-profile-owner.js"; +import { + clearSecretsRuntimeSnapshot, + getActiveSecretsRuntimeSnapshot, +} from "../secrets/runtime.js"; +import { getFreePort, installGatewayTestHooks, startGatewayServer } from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +describe("Gateway startup copied auth SecretRef isolation", () => { + let server: Awaited> | undefined; + let agentDir: string | undefined; + + afterEach(async () => { + await server?.close(); + server = undefined; + if (agentDir) { + deletePersistedAuthProfileStoreRaw(agentDir); + agentDir = undefined; + } + clearSecretsRuntimeSnapshot(); + }); + + it("starts degraded when copied auth state references an omitted SecretRef provider", async () => { + const profileId = "openai:copied"; + const config: OpenClawConfig = { + gateway: { mode: "local", bind: "loopback", auth: { mode: "none" } }, + agents: { defaults: { model: { primary: "openai/gpt-5.4" } } }, + auth: { order: { openai: [profileId] } }, + }; + agentDir = resolveDefaultAgentDir(config); + writePersistedAuthProfileStoreRaw( + { + version: 1, + profiles: { + [profileId]: { + type: "api_key", + provider: "openai", + keyRef: { source: "file", provider: "clawrouter_key", id: "value" }, + }, + }, + }, + agentDir, + ); + expect(readPersistedAuthProfileStoreRaw(agentDir)).toMatchObject({ + profiles: { [profileId]: { provider: "openai" } }, + }); + await writeConfigFile(config); + + const port = await getFreePort(); + server = await startGatewayServer(port, { auth: { mode: "none" } }); + const ready = await fetch(`http://127.0.0.1:${port}/readyz`); + + expect(ready.status).toBe(200); + const ownerId = resolveAuthProfileSecretOwnerId({ agentDir, profileId }); + expect(getActiveSecretsRuntimeSnapshot()?.degradedOwners).toMatchObject([ + { + ownerKind: "account", + ownerId, + state: "unavailable", + reason: "secret provider is not configured", + }, + ]); + expect(getActiveSecretsRuntimeSnapshot()?.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_OWNER_UNAVAILABLE", + path: `${agentDir}.auth-profiles.${profileId}.key`, + }), + ]), + ); + }); +}); diff --git a/src/infra/gateway-supervision.test.ts b/src/infra/gateway-supervision.test.ts index 3b095674a6f4..ac7311e3f403 100644 --- a/src/infra/gateway-supervision.test.ts +++ b/src/infra/gateway-supervision.test.ts @@ -3,6 +3,7 @@ import { assertGatewayServiceMutationAllowed, formatExternalSupervisorUpdateRequired, isGatewayExternallySupervised, + NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON, } from "./gateway-supervision.js"; // The env variable name is part of the observable contract the messages @@ -34,6 +35,20 @@ describe("gateway supervision", () => { ); }); + it.each([ + { OPENCLAW_STATE_DIR: "/tmp/copied-state" }, + { OPENCLAW_CONFIG_PATH: "/tmp/copied-openclaw.json" }, + ])("blocks native service mutation for non-default install identity %#", (override) => { + expect(() => + assertGatewayServiceMutationAllowed("restart the gateway", { + HOME: "/home/operator", + ...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.`, + ); + }); + it("explains why self-update must be delegated", () => { expect(formatExternalSupervisorUpdateRequired()).toContain( "stop the gateway, update and finalize the runtime, then restart it safely", diff --git a/src/infra/gateway-supervision.ts b/src/infra/gateway-supervision.ts index 1fbe84c2a89d..455146e0d172 100644 --- a/src/infra/gateway-supervision.ts +++ b/src/infra/gateway-supervision.ts @@ -1,6 +1,10 @@ // Defines gateway lifecycle ownership shared by service, restart, and update paths. +import { isDefaultInstallIdentity } from "../config/paths.js"; + const GATEWAY_SUPERVISOR_MODE_ENV = "OPENCLAW_SUPERVISOR_MODE"; export const EXTERNAL_SUPERVISOR_UPDATE_REQUIRED_REASON = "external-supervisor-update-required"; +export const NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON = + "service management skipped: non-default state dir or config path"; type GatewaySupervisorMode = "auto" | "external"; @@ -35,4 +39,9 @@ export function assertGatewayServiceMutationAllowed( if (isGatewayExternallySupervised(env)) { throw new Error(formatExternalSupervisorActionRequired(action)); } + 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}.`, + ); + } } diff --git a/src/secrets/resolve-errors.ts b/src/secrets/resolve-errors.ts index 46abc5a6dc48..27f1605aa5be 100644 --- a/src/secrets/resolve-errors.ts +++ b/src/secrets/resolve-errors.ts @@ -8,7 +8,10 @@ type SecretRefResolutionCode = | "SECRET_REF_PROVIDER_ERROR" | "SECRET_REF_PROVIDER_CONTRACT"; -type SecretProviderResolutionCode = "SECRET_PROVIDER_INVALID" | "SECRET_PROVIDER_UNAVAILABLE"; +type SecretProviderResolutionCode = + | "SECRET_PROVIDER_INVALID" + | "SECRET_PROVIDER_NOT_CONFIGURED" + | "SECRET_PROVIDER_UNAVAILABLE"; export type SecretResolutionFailureReason = | "secret provider failed" diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index e4ad1143146e..f77d43fd540c 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -151,7 +151,7 @@ function resolveConfiguredProvider(params: { return { source: "env" }; } throw providerResolutionError({ - code: "SECRET_PROVIDER_INVALID", + code: "SECRET_PROVIDER_NOT_CONFIGURED", source: ref.source, provider: ref.provider, message: `Secret provider "${ref.provider}" is not configured (ref: ${ref.source}:${ref.provider}:${ref.id}).`, diff --git a/src/secrets/runtime-auth-store-inline-refs.test.ts b/src/secrets/runtime-auth-store-inline-refs.test.ts index e2aa14bc322b..ba17bea74662 100644 --- a/src/secrets/runtime-auth-store-inline-refs.test.ts +++ b/src/secrets/runtime-auth-store-inline-refs.test.ts @@ -182,4 +182,47 @@ describe("secrets runtime snapshot inline auth-store refs", () => { }); expect(profiles?.[healthyProfileId]).toMatchObject({ key: "anthropic-runtime-key" }); }); + + it("isolates an auth profile whose SecretRef provider is absent from copied config", async () => { + const agentDir = "/tmp/openclaw-agent-copied-auth-store"; + const profileId = "openai:copied"; + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + auth: { order: { openai: [profileId] } }, + }), + env: {}, + agentDirs: [agentDir], + allowUnavailableSecretOwners: true, + loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, + loadAuthStore: () => + loadAuthStoreWithProfiles({ + [profileId]: { + type: "api_key", + provider: "openai", + keyRef: { source: "file", provider: "clawrouter_key", id: "value" }, + }, + }), + }); + + expect(snapshot.degradedOwners).toMatchObject([ + { + ownerKind: "account", + ownerId: resolveAuthProfileSecretOwnerId({ agentDir, profileId }), + state: "unavailable", + reason: "secret provider is not configured", + }, + ]); + expect(snapshot.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_OWNER_UNAVAILABLE", + path: `${agentDir}.auth-profiles.${profileId}.key`, + }), + ]), + ); + expect(snapshot.authStores[0]?.store.profiles[profileId]).toMatchObject({ + key: undefined, + keyRef: { source: "file", provider: "clawrouter_key", id: "value" }, + }); + }); }); diff --git a/src/secrets/runtime-degraded-state.ts b/src/secrets/runtime-degraded-state.ts index eeebc2d89ac1..05536e6a2fc9 100644 --- a/src/secrets/runtime-degraded-state.ts +++ b/src/secrets/runtime-degraded-state.ts @@ -8,6 +8,7 @@ import { export type SecretDegradationReason = | SecretResolutionFailureReason + | "secret provider is not configured" | "resolved secret value was invalid" | "secret reference is not allowed for this provider" | "secret reference was not materialized by the active runtime" @@ -108,6 +109,7 @@ export function classifySecretResolutionErrorDegradations(error: unknown): Secre export function redactSecretDegradationReason(reason: string): SecretDegradationReason { switch (reason) { case "secret provider failed": + case "secret provider is not configured": case "secret provider policy denied resolution": case "secret provider response violated its contract": case "secret reference is not allowed for this provider": diff --git a/src/secrets/runtime-owner-assignments.ts b/src/secrets/runtime-owner-assignments.ts index ca4485e43f67..9483456dc7e1 100644 --- a/src/secrets/runtime-owner-assignments.ts +++ b/src/secrets/runtime-owner-assignments.ts @@ -85,6 +85,25 @@ function assignmentOwnerKey(assignment: SecretAssignment): string { return `${getSecretAssignmentSource(assignment)}\0${assignment.ownerKind}\0${assignment.ownerId}`; } +const AUTH_STORE_PROVIDER_UNCONFIGURED_REASON = "secret provider is not configured" as const; + +function resolveOwnerFailureReason(params: { + assignments: SecretAssignment[]; + error: unknown; + fallback: SecretDegradationReason | undefined; +}): SecretDegradationReason | undefined { + if (params.fallback) { + return params.fallback; + } + const owner = params.assignments[0]; + return owner && + getSecretAssignmentSource(owner) === "auth-store" && + isProviderScopedSecretResolutionError(params.error) && + params.error.code === "SECRET_PROVIDER_NOT_CONFIGURED" + ? AUTH_STORE_PROVIDER_UNCONFIGURED_REASON + : undefined; +} + function groupAssignmentsByOwner(assignments: SecretAssignment[]): SecretAssignment[][] { const groups = new Map(); for (const assignment of assignments) { @@ -182,11 +201,14 @@ function associateAssignmentFailureOwners(params: { .map(assignmentOwnerKey), ), ); - const reason = + const sharedReason = validationFailures.length > 0 ? "resolved secret value was invalid" : describeSecretResolutionError(params.error); - if (!reason) { + const authStoreProviderUnconfigured = + isProviderScopedSecretResolutionError(params.error) && + params.error.code === "SECRET_PROVIDER_NOT_CONFIGURED"; + if (!sharedReason && !authStoreProviderUnconfigured) { return; } const owners = groupAssignmentsByOwner(params.assignments).flatMap((assignments) => { @@ -201,6 +223,14 @@ function associateAssignmentFailureOwners(params: { if (!failureMatched) { return []; } + const reason = resolveOwnerFailureReason({ + assignments, + error: params.error, + fallback: sharedReason, + }); + if (!reason) { + return []; + } const degradedOwner = createDegradedOwner(assignments, reason); return [ { @@ -279,6 +309,14 @@ function associateAssignmentFailureOwners(params: { if (refs.length === 0) { return []; } + const reason = + sharedReason ?? + (source === "auth-store" && authStoreProviderUnconfigured + ? AUTH_STORE_PROVIDER_UNCONFIGURED_REASON + : undefined); + if (!reason) { + return []; + } return [ { ownerKind: owner.ownerKind, @@ -355,10 +393,17 @@ function assertOwnerCanBeIsolated( error: unknown, ): SecretDegradationReason { const owner = assignments[0]!; - const reason = describeSecretResolutionError(error); + const reason = resolveOwnerFailureReason({ + assignments, + error, + fallback: describeSecretResolutionError(error), + }); + const isolatableFailure = + reason === AUTH_STORE_PROVIDER_UNCONFIGURED_REASON || + (reason !== undefined && isRetryableSecretDegradationReason(reason)); if ( !reason || - !isRetryableSecretDegradationReason(reason) || + !isolatableFailure || owner.ownerKind === "unknown" || owner.requiredForGateway || owner.disposition === "fail-closed"