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
This commit is contained in:
Peter Steinberger
2026-07-29 11:09:56 -04:00
committed by GitHub
parent 6ec3dbd92d
commit 383f8947c1
27 changed files with 442 additions and 22 deletions
+5
View File
@@ -87,6 +87,11 @@ const mocks = await vi.hoisted(async () => {
const { runtimeLogs } = mocks;
vi.mock("../config/paths.js", async () => {
const actual = await vi.importActual<typeof import("../config/paths.js")>("../config/paths.js");
return { ...actual, isDefaultInstallIdentity: () => true };
});
vi.mock("./daemon-cli/probe.js", () => ({
probeGatewayStatus: (opts: unknown) => probeGatewayStatus(opts),
}));
@@ -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<typeof import("../../config/paths.js")>("../../config/paths.js");
return { ...actual, isDefaultInstallIdentity: () => true };
});
vi.mock("../../daemon/service.js", () => ({
resolveGatewayService: () => serviceMock,
}));
+17
View File
@@ -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();
+5 -8
View File
@@ -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;
}
+3 -2
View File
@@ -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),
+6
View File
@@ -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,
+2
View File
@@ -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;
@@ -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<typeof import("../config/config.js")>("../config/config.js");
@@ -44,6 +45,11 @@ vi.mock("../config/config.js", async () => {
};
});
vi.mock("../config/paths.js", async () => {
const actual = await vi.importActual<typeof import("../config/paths.js")>("../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);
@@ -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,
@@ -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();
+13 -1
View File
@@ -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<DoctorOptions, "deep"> = {},
): Promise<readonly ExtraGatewayService[]> {
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<OpenClawConfig> {
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;
+7
View File
@@ -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<typeof import("../config/paths.js")>("../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();
+2 -1
View File
@@ -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<GatewayServiceUpdateInspection> {
if (isServiceRepairExternallyManaged()) {
if (!isDefaultInstallIdentity(process.env) || isServiceRepairExternallyManaged()) {
return NO_GATEWAY_SERVICE_UPDATE;
}
try {
+40
View File
@@ -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 = {
+37 -4
View File
@@ -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))
);
}
+5
View File
@@ -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<typeof import("../config/paths.js")>("../config/paths.js");
return { ...actual, isDefaultInstallIdentity: () => true };
});
function setPlatform(value: NodeJS.Platform) {
mockProcessPlatform(value);
}
+4
View File
@@ -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);
@@ -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<void> {
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<v
}
export async function runWebFetchProxyHealth(ctx: DoctorHealthFlowContext): Promise<void> {
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<void> {
if (!isDefaultInstallIdentity(ctx.env ?? process.env)) {
return;
}
const { maybeRepairGatewayDaemon } = await import("../commands/doctor-gateway-daemon-flow.js");
await maybeRepairGatewayDaemon({
cfg: ctx.cfg,
@@ -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<typeof import("../config/paths.js")>("../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", () => ({
@@ -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<ReturnType<typeof startGatewayServer>> | 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`,
}),
]),
);
});
});
+15
View File
@@ -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",
+9
View File
@@ -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}.`,
);
}
}
+4 -1
View File
@@ -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"
+1 -1
View File
@@ -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}).`,
@@ -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" },
});
});
});
+2
View File
@@ -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":
+49 -4
View File
@@ -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<string, SecretAssignment[]>();
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"