diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 8dd2c2cbe062..aca004248e4d 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -528,6 +528,7 @@ openclaw gateway restart - `gateway start` is idempotent: when the managed service is already running, it reports the running process and leaves it untouched. A loaded but stopped service is started as before. + - If `gateway start` or `gateway restart` needs to repair a stale service definition, the command refuses when the invoking shell resolves a different state directory, config path, or port than the installed service. Match or unset the conflicting environment overrides, or use `openclaw gateway install --force` to retarget the service intentionally. - Use `gateway restart` to restart a managed service. Do not chain `gateway stop` and `gateway start` as a restart substitute. - In a non-interactive shell, `gateway stop` requires `--force`. Interactive terminals keep the existing prompt-free behavior. For automation and tests, prefer `gateway run --dev` or an isolated `--profile` with a free port. - On macOS, `gateway stop` uses `launchctl bootout` by default, which removes the LaunchAgent from the current boot session without persisting a disable — KeepAlive auto-recovery stays active for future crashes and `gateway start` re-enables cleanly without a manual `launchctl enable`. Pass `--disable` to persistently suppress KeepAlive and RunAtLoad so the gateway does not respawn until the next explicit `gateway start`; use this when a manual stop should survive reboots. diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 668d019a08c2..52fce8413a8c 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -500,7 +500,6 @@ export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) { repairLoadedService: async ({ json, stdout, warn, state, issues }) => await repairLoadedGatewayServiceForStart({ service, - port: expectedPort, json, stdout, warn, @@ -600,7 +599,6 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi const result = await repairLoadedGatewayServiceForStart({ action: "restart", service, - port: configuredPort, json, stdout, warn, diff --git a/src/cli/daemon-cli/start-repair.test.ts b/src/cli/daemon-cli/start-repair.test.ts index 76458129cd95..8a9bfc80c492 100644 --- a/src/cli/daemon-cli/start-repair.test.ts +++ b/src/cli/daemon-cli/start-repair.test.ts @@ -1,5 +1,5 @@ // Start repair tests cover stale service repair install-plan wiring. -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayService, GatewayServiceState } from "../../daemon/service.js"; const buildGatewayInstallPlanMock = vi.hoisted(() => @@ -30,7 +30,24 @@ const buildGatewayInstallPlanMock = vi.hoisted(() => ); const resolveGatewayInstallTokenMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotForWriteMock = vi.hoisted(() => vi.fn()); -const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789)); +const resolveGatewayPortMock = vi.hoisted(() => + vi.fn( + (config: { gateway?: { port?: number } } | undefined, env: NodeJS.ProcessEnv = process.env) => { + const portMatch = env.OPENCLAW_GATEWAY_PORT?.trim().match(/(?:^|:)(\d+)$/); + return Number(portMatch?.[1]) || config?.gateway?.port || 18_789; + }, + ), +); +const resolveStateDirMock = vi.hoisted(() => + vi.fn((env: NodeJS.ProcessEnv) => env.OPENCLAW_STATE_DIR?.trim() || `${env.HOME}/.openclaw`), +); +const resolveConfigPathCandidateMock = vi.hoisted(() => + vi.fn( + (env: NodeJS.ProcessEnv) => + env.OPENCLAW_CONFIG_PATH?.trim() || + `${env.OPENCLAW_STATE_DIR?.trim() || `${env.HOME}/.openclaw`}/openclaw.json`, + ), +); const resolveOpenClawWrapperPathMock = vi.hoisted(() => vi.fn()); const formatGatewayServiceStartRepairIssuesMock = vi.hoisted(() => vi.fn()); const defaultRuntimeLogMock = vi.hoisted(() => vi.fn()); @@ -53,7 +70,9 @@ vi.mock("../../config/io.js", () => ({ })); vi.mock("../../config/paths.js", () => ({ + resolveConfigPathCandidate: resolveConfigPathCandidateMock, resolveGatewayPort: resolveGatewayPortMock, + resolveStateDir: resolveStateDirMock, })); vi.mock("../../daemon/program-args.js", () => ({ @@ -85,6 +104,12 @@ function readFirstInstallPlanArg(): Record { describe("repairLoadedGatewayServiceForStart", () => { beforeEach(() => { + vi.stubEnv("HOME", "/home/openclaw"); + vi.stubEnv("OPENCLAW_CONFIG_PATH", ""); + vi.stubEnv("OPENCLAW_GATEWAY_PORT", ""); + vi.stubEnv("OPENCLAW_HOME", ""); + vi.stubEnv("OPENCLAW_PROFILE", ""); + vi.stubEnv("OPENCLAW_STATE_DIR", ""); buildGatewayInstallPlanMock.mockClear(); resolveGatewayInstallTokenMock.mockReset(); readConfigFileSnapshotForWriteMock.mockReset(); @@ -108,6 +133,10 @@ describe("repairLoadedGatewayServiceForStart", () => { ); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("forwards existing env value-source metadata when repairing stale service definitions", async () => { const installMock = vi.fn(async () => {}); const isLoadedMock = vi.fn(async () => true); @@ -116,6 +145,7 @@ describe("repairLoadedGatewayServiceForStart", () => { isLoaded: isLoadedMock, } as unknown as GatewayService; const existingEnvironment = { + HOME: "/home/openclaw", OPENCLAW_SERVICE_VERSION: "2026.4.24", TELEGRAM_DEFAULT_BOTTOKEN: "existing-env-file-token", }; @@ -153,4 +183,191 @@ describe("repairLoadedGatewayServiceForStart", () => { }), ); }); + + it.each(["start", "restart"] as const)( + "refuses %s repair when ambient state, config, and port target a different service", + async (action) => { + vi.stubEnv("OPENCLAW_STATE_DIR", "/home/openclaw/stress-state"); + vi.stubEnv("OPENCLAW_CONFIG_PATH", "/home/openclaw/stress-state/openclaw.json"); + readConfigFileSnapshotForWriteMock.mockResolvedValue({ + snapshot: { + exists: true, + valid: true, + sourceConfig: { gateway: { port: 18_999 } }, + config: { gateway: { port: 18_999 } }, + }, + writeOptions: { expectedConfigPath: "/home/openclaw/stress-state/openclaw.json" }, + }); + + const originalUnit = [ + "ExecStart=/usr/bin/openclaw gateway --port 18789", + "EnvironmentFile=-/home/openclaw/.openclaw/gateway.systemd.env", + "Environment=OPENCLAW_SERVICE_MANAGED_ENV_KEYS=OPENAI_API_KEY,OPENCLAW_GATEWAY_PASSWORD", + ].join("\n"); + let unit = originalUnit; + const installMock = vi.fn(async () => { + unit = "rewritten"; + }); + const service = { + install: installMock, + isLoaded: vi.fn(async () => true), + } as unknown as GatewayService; + const state: GatewayServiceState = { + installed: true, + loaded: true, + running: false, + env: {}, + command: { + programArguments: ["/usr/bin/openclaw", "gateway", "--port", "18789"], + environment: { + HOME: "/home/openclaw", + OPENAI_API_KEY: "file-backed-openai-key", + OPENCLAW_GATEWAY_PASSWORD: "file-backed-password", + OPENCLAW_GATEWAY_PORT: "18789", + OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENAI_API_KEY,OPENCLAW_GATEWAY_PASSWORD", + }, + environmentValueSources: { + HOME: "inline", + OPENAI_API_KEY: "file", + OPENCLAW_GATEWAY_PASSWORD: "file", + OPENCLAW_GATEWAY_PORT: "inline", + OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "inline", + }, + }, + }; + + const repairParams = { + service, + state, + issues: [{ code: "port-mismatch" as const, message: "old port" }], + json: true, + stdout: process.stdout, + }; + const repair = + action === "restart" + ? repairLoadedGatewayServiceForStart({ ...repairParams, action }) + : repairLoadedGatewayServiceForStart(repairParams); + await expect(repair).rejects.toThrow( + [ + "Refusing to repair the managed Gateway service because the current invocation targets a different Gateway:", + '- OPENCLAW_STATE_DIR: installed="/home/openclaw/.openclaw", ambient="/home/openclaw/stress-state"', + '- OPENCLAW_CONFIG_PATH: installed="/home/openclaw/.openclaw/openclaw.json", ambient="/home/openclaw/stress-state/openclaw.json"', + '- gateway.port: installed="18789", ambient="18999"', + `Run \`openclaw gateway ${action}\` with the installed state directory, config path, and port (or unset conflicting environment overrides). To retarget intentionally, run \`openclaw gateway install --force\`.`, + ].join("\n"), + ); + + expect(unit).toBe(originalUnit); + expect(installMock).not.toHaveBeenCalled(); + expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled(); + expect(resolveGatewayInstallTokenMock).not.toHaveBeenCalled(); + }, + ); + + it("refuses a port-less stale service repair when ambient port overrides its config port", async () => { + vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18999"); + readConfigFileSnapshotForWriteMock.mockResolvedValue({ + snapshot: { + exists: true, + valid: true, + sourceConfig: { gateway: { port: 18_789 } }, + config: { gateway: { port: 18_789 } }, + }, + writeOptions: { expectedConfigPath: "/home/openclaw/.openclaw/openclaw.json" }, + }); + const installMock = vi.fn(async () => {}); + const service = { + install: installMock, + isLoaded: vi.fn(async () => true), + } as unknown as GatewayService; + const state: GatewayServiceState = { + installed: true, + loaded: true, + running: false, + env: {}, + command: { + programArguments: ["/usr/bin/openclaw", "gateway"], + environment: { HOME: "/home/openclaw" }, + }, + }; + + await expect( + repairLoadedGatewayServiceForStart({ + service, + state, + issues: [{ code: "version-mismatch", message: "old service" }], + json: true, + stdout: process.stdout, + }), + ).rejects.toThrow('- gateway.port: installed="18789", ambient="18999"'); + + expect(installMock).not.toHaveBeenCalled(); + expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled(); + }); + + it("resolves installed host-and-port environment syntax before comparing repair targets", async () => { + const installMock = vi.fn(async () => {}); + const service = { + install: installMock, + isLoaded: vi.fn(async () => true), + } as unknown as GatewayService; + const state: GatewayServiceState = { + installed: true, + loaded: true, + running: false, + env: {}, + command: { + programArguments: ["/usr/bin/openclaw", "gateway"], + environment: { + HOME: "/home/openclaw", + OPENCLAW_GATEWAY_PORT: "127.0.0.1:19000", + }, + }, + }; + + await expect( + repairLoadedGatewayServiceForStart({ + service, + state, + issues: [{ code: "version-mismatch", message: "old service" }], + json: true, + stdout: process.stdout, + }), + ).rejects.toThrow('- gateway.port: installed="19000", ambient="18789"'); + + expect(installMock).not.toHaveBeenCalled(); + expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled(); + }); + + it("refuses repair when a legacy service does not identify its installed state directory", async () => { + vi.stubEnv("HOME", "/home/ambient-user"); + const installMock = vi.fn(async () => {}); + const service = { + install: installMock, + isLoaded: vi.fn(async () => true), + } as unknown as GatewayService; + const state: GatewayServiceState = { + installed: true, + loaded: true, + running: false, + env: {}, + command: { + programArguments: ["/usr/bin/openclaw", "gateway", "--port", "18789"], + environment: { OPENCLAW_GATEWAY_PORT: "18789" }, + }, + }; + + await expect( + repairLoadedGatewayServiceForStart({ + service, + state, + issues: [{ code: "version-mismatch", message: "old service" }], + json: true, + stdout: process.stdout, + }), + ).rejects.toThrow("installed state directory cannot be determined"); + + expect(installMock).not.toHaveBeenCalled(); + expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/cli/daemon-cli/start-repair.ts b/src/cli/daemon-cli/start-repair.ts index 59194b83b5ea..c93fd4b435b0 100644 --- a/src/cli/daemon-cli/start-repair.ts +++ b/src/cli/daemon-cli/start-repair.ts @@ -1,9 +1,14 @@ // Start-time service repair: rebuilds stale service definitions before starting Gateway. +import path from "node:path"; import { buildGatewayInstallPlan } from "../../commands/daemon-install-helpers.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../../commands/daemon-runtime.js"; import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js"; import { readConfigFileSnapshotForWrite } from "../../config/io.js"; -import { resolveGatewayPort } from "../../config/paths.js"; +import { + resolveConfigPathCandidate, + resolveGatewayPort, + resolveStateDir, +} from "../../config/paths.js"; import { OPENCLAW_WRAPPER_ENV_KEY, resolveOpenClawWrapperPath } from "../../daemon/program-args.js"; import type { GatewayServiceEnv } from "../../daemon/service-types.js"; import type { @@ -13,13 +18,12 @@ import type { } 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 { parseTcpPortFromArgs } from "../../infra/tcp-port.js"; import { defaultRuntime } from "../../runtime.js"; import { mergeInstallInvocationEnv } from "./install.js"; type GatewayServiceRepairParams = { service: GatewayService; - port?: number; state: GatewayServiceState; issues: GatewayServiceStartRepairIssue[]; json: boolean; @@ -34,6 +38,95 @@ type GatewayServiceRepairResult = { loaded: boolean; }; +const GATEWAY_TARGET_ENV_KEYS = [ + "HOME", + "USERPROFILE", + "OPENCLAW_HOME", + "OPENCLAW_PROFILE", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_GATEWAY_PORT", +] as const; + +function resolveInstalledGatewayTargetEnvironment( + existingEnvironment: Record | undefined, +): NodeJS.ProcessEnv { + const installedEnv: NodeJS.ProcessEnv = {}; + for (const key of GATEWAY_TARGET_ENV_KEYS) { + const value = existingEnvironment?.[key]?.trim(); + if (value) { + installedEnv[key] = value; + } + } + return installedEnv; +} + +function normalizeTargetPath(value: string): string { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +// Start/restart may rewrite a stale service. Refuse before planning when that +// rewrite would adopt a different state, config, or port from the invoking shell. +function assertGatewayRepairTargetMatches(params: { + action: "restart" | "start"; + config: Parameters[0]; + existingEnvironment: Record | undefined; + installedPort: number | null; +}): number { + const installedEnv = resolveInstalledGatewayTargetEnvironment(params.existingEnvironment); + const installedStateOverride = installedEnv.OPENCLAW_STATE_DIR?.trim(); + const installedHome = + installedEnv.OPENCLAW_HOME?.trim() || + installedEnv.HOME?.trim() || + installedEnv.USERPROFILE?.trim(); + if (!installedStateOverride && !installedHome) { + throw new Error( + `Refusing to repair the managed Gateway service because its installed state directory cannot be determined from the service definition. Run \`openclaw gateway install --force\` to replace it intentionally.`, + ); + } + const installedStateDir = resolveStateDir(installedEnv); + const installedConfigPath = resolveConfigPathCandidate(installedEnv); + const ambientStateDir = resolveStateDir(process.env); + const ambientConfigPath = resolveConfigPathCandidate(process.env); + const ambientPort = resolveGatewayPort(params.config, process.env); + const sameConfigPath = + normalizeTargetPath(installedConfigPath) === normalizeTargetPath(ambientConfigPath); + const installedPort = + params.installedPort ?? + (sameConfigPath ? resolveGatewayPort(params.config, installedEnv) : null); + const differences: Array<{ name: string; installed: string; ambient: string }> = []; + + for (const [name, installed, ambient] of [ + ["OPENCLAW_STATE_DIR", installedStateDir, ambientStateDir], + ["OPENCLAW_CONFIG_PATH", installedConfigPath, ambientConfigPath], + ] as const) { + if (normalizeTargetPath(installed) !== normalizeTargetPath(ambient)) { + differences.push({ name, installed, ambient }); + } + } + if (installedPort !== null && installedPort !== ambientPort) { + differences.push({ + name: "gateway.port", + installed: String(installedPort), + ambient: String(ambientPort), + }); + } + if (differences.length === 0) { + return installedPort ?? ambientPort; + } + + const details = differences + .map( + ({ name, installed, ambient }) => + `- ${name}: installed=${JSON.stringify(installed)}, ambient=${JSON.stringify(ambient)}`, + ) + .join("\n"); + throw new Error( + `Refusing to repair the managed Gateway service because the current invocation targets a different Gateway:\n${details}\nRun \`openclaw gateway ${params.action}\` with the installed state directory, config path, and port (or unset conflicting environment overrides). To retarget intentionally, run \`openclaw gateway install --force\`.`, + ); +} + /** Repair a loaded but stale Gateway service definition and report the start result. */ export function repairLoadedGatewayServiceForStart( params: GatewayServiceRepairParams & { action: "restart" }, @@ -55,15 +148,18 @@ export async function repairLoadedGatewayServiceForStart( const cfg = configSnapshot.valid ? configSnapshot.sourceConfig : configSnapshot.config; const existingEnvironment = params.state.command?.environment; const existingEnvironmentValueSources = params.state.command?.environmentValueSources; + const installedPort = parseTcpPortFromArgs(params.state.command?.programArguments); + const port = assertGatewayRepairTargetMatches({ + action: params.action ?? "start", + config: cfg, + existingEnvironment, + installedPort, + }); const installEnv = mergeInstallInvocationEnv({ env: process.env, existingServiceEnv: existingEnvironment, }); const wrapperPath = await resolveOpenClawWrapperPath(installEnv[OPENCLAW_WRAPPER_ENV_KEY]); - const installedPort = - parseTcpPortFromArgs(params.state.command?.programArguments) ?? - parseTcpPort(params.state.command?.environment?.OPENCLAW_GATEWAY_PORT); - const port = params.port ?? installedPort ?? resolveGatewayPort(cfg); const tokenResolution = await resolveGatewayInstallToken({ config: cfg, diff --git a/src/daemon/service-env-render-policy.ts b/src/daemon/service-env-render-policy.ts index 3e318ddaa071..364c82ac848d 100644 --- a/src/daemon/service-env-render-policy.ts +++ b/src/daemon/service-env-render-policy.ts @@ -55,13 +55,17 @@ export function applyManagedServiceEnvRenderPolicy(params: { if (managedKeys.size === 0) { return; } - if (launchAgent) { + // The caller limits these entries to file-backed SecretRefs active in the current config. + // Carry them through both file-backed supervisors or systemd can drop their env file. + if (launchAgent || params.platform === "linux") { addManagedServiceEnvEntries({ plan: params.plan, entries: params.existingEnvironmentFileEnvironment, managedKeys, valueSource: "file", }); + } + if (launchAgent) { addManagedServiceEnvEntries({ plan: params.plan, entries: params.stateDirDotEnvEnvironment, diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index cd690941ae18..8c5beaacfa71 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; // Systemd tests cover Linux service install, start, stop, and status behavior. import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildGatewayInstallPlan } from "../commands/daemon-install-helpers.js"; type ExecFileError = Error & { stderr?: string; @@ -1322,6 +1323,80 @@ describe("stageSystemdService", () => { }); }); + it("round-trips file-managed secrets through parse, repair planning, and emit", async () => { + await withStageFixture(async ({ env, unitPath, envFilePath, stateDir }) => { + const wrapperPath = path.join(stateDir, "openclaw-wrapper"); + const fileBackedOpenAiKey = "file-backed-openai-test-key"; + await fs.writeFile(wrapperPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + await fs.chmod(wrapperPath, 0o755); + await fs.writeFile(envFilePath, `OPENAI_API_KEY=${fileBackedOpenAiKey}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await fs.mkdir(path.dirname(unitPath), { recursive: true }); + await fs.writeFile( + unitPath, + [ + "[Service]", + `ExecStart=${wrapperPath} gateway --port 18789`, + `EnvironmentFile=-${envFilePath}`, + "Environment=HOME=" + env.HOME, + "Environment=OPENCLAW_GATEWAY_PORT=18789", + "Environment=OPENCLAW_SERVICE_MANAGED_ENV_KEYS=OPENAI_API_KEY", + ].join("\n"), + "utf8", + ); + + const command = await readSystemdServiceExecStart(env); + expect(command?.environment?.OPENAI_API_KEY).toBe(fileBackedOpenAiKey); + expect(command?.environmentValueSources?.OPENAI_API_KEY).toBe("file"); + expect(command?.environmentValueSources?.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe("inline"); + + const plan = await buildGatewayInstallPlan({ + env: { ...env, PATH: "/usr/bin:/bin" }, + port: 18_789, + runtime: "node", + platform: "linux", + nodePath: process.execPath, + wrapperPath, + existingEnvironment: command?.environment, + existingEnvironmentValueSources: command?.environmentValueSources, + authStore: { version: 1, profiles: {} }, + config: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + }, + }); + expect(plan.environmentValueSources?.OPENAI_API_KEY).toBe("file"); + expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBe("OPENAI_API_KEY"); + + mockSystemctlStatusOk(); + await stageSystemdService({ + env, + stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream, + ...plan, + }); + + const [rewrittenUnit, rewrittenEnvFile] = await Promise.all([ + fs.readFile(unitPath, "utf8"), + fs.readFile(envFilePath, "utf8"), + ]); + expect(rewrittenUnit).toContain(`EnvironmentFile=-${envFilePath}`); + expect(rewrittenUnit).toContain( + "Environment=OPENCLAW_SERVICE_MANAGED_ENV_KEYS=OPENAI_API_KEY", + ); + expect(rewrittenUnit).not.toContain(fileBackedOpenAiKey); + expect(rewrittenEnvFile).toBe(`OPENAI_API_KEY=${fileBackedOpenAiKey}\n`); + }); + }); + it("matches differently-cased source metadata when writing node file-backed values", async () => { await withStageFixture(async ({ env, stateDir, unitPath, envFilePath, nodeEnvFilePath }) => { await fs.rm(stateDir, { recursive: true, force: true });