503 lines
16 KiB
TypeScript
503 lines
16 KiB
TypeScript
import type { Writable } from "node:stream";
|
|
import { readBestEffortConfig, readConfigFileSnapshot } from "../../config/config.js";
|
|
import { formatConfigIssueLines } from "../../config/issue-format.js";
|
|
import { resolveIsNixMode } from "../../config/paths.js";
|
|
import { checkTokenDrift } from "../../daemon/service-audit.js";
|
|
import type { GatewayServiceRestartResult } from "../../daemon/service-types.js";
|
|
import { describeGatewayServiceRestart, startGatewayService } from "../../daemon/service.js";
|
|
import type { GatewayService } from "../../daemon/service.js";
|
|
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
|
|
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
|
|
import { isGatewaySecretRefUnavailableError } from "../../gateway/credentials.js";
|
|
import { isWSL } from "../../infra/wsl.js";
|
|
import { defaultRuntime } from "../../runtime.js";
|
|
import { resolveGatewayTokenForDriftCheck } from "./gateway-token-drift.js";
|
|
import {
|
|
buildDaemonServiceSnapshot,
|
|
createDaemonActionContext,
|
|
type DaemonActionResponse,
|
|
} from "./response.js";
|
|
import { filterContainerGenericHints } from "./shared.js";
|
|
|
|
type DaemonLifecycleOptions = {
|
|
json?: boolean;
|
|
};
|
|
|
|
type RestartPostCheckContext = {
|
|
json: boolean;
|
|
stdout: Writable;
|
|
warnings: string[];
|
|
fail: (message: string, hints?: string[]) => void;
|
|
};
|
|
|
|
type ServiceRecoveryResult = {
|
|
result: "started" | "stopped" | "restarted";
|
|
message?: string;
|
|
warnings?: string[];
|
|
loaded?: boolean;
|
|
};
|
|
|
|
type ServiceRecoveryContext = {
|
|
json: boolean;
|
|
stdout: Writable;
|
|
fail: (message: string, hints?: string[]) => void;
|
|
};
|
|
|
|
async function maybeAugmentSystemdHints(hints: string[]): Promise<string[]> {
|
|
if (process.platform !== "linux") {
|
|
return hints;
|
|
}
|
|
const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false);
|
|
if (systemdAvailable) {
|
|
return hints;
|
|
}
|
|
return [
|
|
...hints,
|
|
...renderSystemdUnavailableHints({ wsl: await isWSL(), kind: "generic_unavailable" }),
|
|
];
|
|
}
|
|
|
|
function emitActionMessage(params: {
|
|
json: boolean;
|
|
emit: ReturnType<typeof createDaemonActionContext>["emit"];
|
|
payload: Omit<DaemonActionResponse, "action">;
|
|
}) {
|
|
params.emit(params.payload);
|
|
if (!params.json && params.payload.message) {
|
|
defaultRuntime.log(params.payload.message);
|
|
}
|
|
}
|
|
|
|
async function handleServiceNotLoaded(params: {
|
|
serviceNoun: string;
|
|
service: GatewayService;
|
|
loaded: boolean;
|
|
renderStartHints: () => string[];
|
|
json: boolean;
|
|
emit: ReturnType<typeof createDaemonActionContext>["emit"];
|
|
}) {
|
|
const hints = filterContainerGenericHints(
|
|
await maybeAugmentSystemdHints(params.renderStartHints()),
|
|
);
|
|
params.emit({
|
|
ok: true,
|
|
result: "not-loaded",
|
|
message: `${params.serviceNoun} service ${params.service.notLoadedText}.`,
|
|
hints,
|
|
service: buildDaemonServiceSnapshot(params.service, params.loaded),
|
|
});
|
|
if (!params.json) {
|
|
defaultRuntime.log(`${params.serviceNoun} service ${params.service.notLoadedText}.`);
|
|
for (const hint of hints) {
|
|
defaultRuntime.log(`Start with: ${hint}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function resolveServiceLoadedOrFail(params: {
|
|
serviceNoun: string;
|
|
service: GatewayService;
|
|
fail: ReturnType<typeof createDaemonActionContext>["fail"];
|
|
}): Promise<boolean | null> {
|
|
try {
|
|
return await params.service.isLoaded({ env: process.env });
|
|
} catch (err) {
|
|
params.fail(`${params.serviceNoun} service check failed: ${String(err)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Best-effort config validation. Returns a string describing the issues if
|
|
* config exists and is invalid, or null if config is valid/missing/unreadable.
|
|
*
|
|
* Note: This reads the config file snapshot in the current CLI environment.
|
|
* Configs using env vars only available in the service context (launchd/systemd)
|
|
* may produce false positives, but the check is intentionally best-effort —
|
|
* a false positive here is safer than a crash on startup. (#35862)
|
|
*/
|
|
async function getConfigValidationError(): Promise<string | null> {
|
|
try {
|
|
const snapshot = await readConfigFileSnapshot();
|
|
if (!snapshot.exists || snapshot.valid) {
|
|
return null;
|
|
}
|
|
return snapshot.issues.length > 0
|
|
? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n")
|
|
: "Unknown validation issue.";
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function runServiceUninstall(params: {
|
|
serviceNoun: string;
|
|
service: GatewayService;
|
|
opts?: DaemonLifecycleOptions;
|
|
stopBeforeUninstall: boolean;
|
|
assertNotLoadedAfterUninstall: boolean;
|
|
}) {
|
|
const json = Boolean(params.opts?.json);
|
|
const { stdout, emit, fail } = createDaemonActionContext({ action: "uninstall", json });
|
|
|
|
if (resolveIsNixMode(process.env)) {
|
|
fail("Nix mode detected; service uninstall is disabled.");
|
|
return;
|
|
}
|
|
|
|
let loaded = false;
|
|
try {
|
|
loaded = await params.service.isLoaded({ env: process.env });
|
|
} catch {
|
|
loaded = false;
|
|
}
|
|
if (loaded && params.stopBeforeUninstall) {
|
|
try {
|
|
await params.service.stop({ env: process.env, stdout });
|
|
} catch {
|
|
// Best-effort stop; final loaded check gates success when enabled.
|
|
}
|
|
}
|
|
try {
|
|
await params.service.uninstall({ env: process.env, stdout });
|
|
} catch (err) {
|
|
fail(`${params.serviceNoun} uninstall failed: ${String(err)}`);
|
|
return;
|
|
}
|
|
|
|
loaded = false;
|
|
try {
|
|
loaded = await params.service.isLoaded({ env: process.env });
|
|
} catch {
|
|
loaded = false;
|
|
}
|
|
if (loaded && params.assertNotLoadedAfterUninstall) {
|
|
fail(`${params.serviceNoun} service still loaded after uninstall.`);
|
|
return;
|
|
}
|
|
emit({
|
|
ok: true,
|
|
result: "uninstalled",
|
|
service: buildDaemonServiceSnapshot(params.service, loaded),
|
|
});
|
|
}
|
|
|
|
export async function runServiceStart(params: {
|
|
serviceNoun: string;
|
|
service: GatewayService;
|
|
renderStartHints: () => string[];
|
|
opts?: DaemonLifecycleOptions;
|
|
onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise<ServiceRecoveryResult | null>;
|
|
}) {
|
|
const json = Boolean(params.opts?.json);
|
|
const { stdout, emit, fail } = createDaemonActionContext({ action: "start", json });
|
|
const loaded = await resolveServiceLoadedOrFail({
|
|
serviceNoun: params.serviceNoun,
|
|
service: params.service,
|
|
fail,
|
|
});
|
|
|
|
if (loaded === null) {
|
|
return;
|
|
}
|
|
// Pre-flight config validation (#35862) — run for both loaded and not-loaded
|
|
// to prevent launching from invalid config in any start path.
|
|
{
|
|
const configError = await getConfigValidationError();
|
|
if (configError) {
|
|
fail(
|
|
`${params.serviceNoun} aborted: config is invalid.\n${configError}\nFix the config and retry, or run "openclaw doctor" to repair.`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
if (!loaded) {
|
|
try {
|
|
const handled = await params.onNotLoaded?.({ json, stdout, fail });
|
|
if (handled) {
|
|
emit({
|
|
ok: true,
|
|
result: handled.result,
|
|
message: handled.message,
|
|
warnings: handled.warnings,
|
|
service: buildDaemonServiceSnapshot(params.service, handled.loaded ?? false),
|
|
});
|
|
if (!json && handled.message) {
|
|
defaultRuntime.log(handled.message);
|
|
}
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
const hints = params.renderStartHints();
|
|
fail(`${params.serviceNoun} start failed: ${String(err)}`, hints);
|
|
return;
|
|
}
|
|
}
|
|
try {
|
|
const startResult = await startGatewayService(params.service, { env: process.env, stdout });
|
|
if (startResult.outcome === "missing-install") {
|
|
await handleServiceNotLoaded({
|
|
serviceNoun: params.serviceNoun,
|
|
service: params.service,
|
|
loaded: startResult.state.loaded,
|
|
renderStartHints: params.renderStartHints,
|
|
json,
|
|
emit,
|
|
});
|
|
return;
|
|
}
|
|
if (startResult.outcome === "scheduled") {
|
|
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, {
|
|
outcome: "scheduled",
|
|
});
|
|
emitActionMessage({
|
|
json,
|
|
emit,
|
|
payload: {
|
|
ok: true,
|
|
result: "scheduled",
|
|
message: restartStatus.message,
|
|
service: buildDaemonServiceSnapshot(params.service, startResult.state.loaded),
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
emit({
|
|
ok: true,
|
|
result: "started",
|
|
service: buildDaemonServiceSnapshot(params.service, startResult.state.loaded),
|
|
});
|
|
} catch (err) {
|
|
const hints = params.renderStartHints();
|
|
fail(`${params.serviceNoun} start failed: ${String(err)}`, hints);
|
|
return;
|
|
}
|
|
}
|
|
|
|
export async function runServiceStop(params: {
|
|
serviceNoun: string;
|
|
service: GatewayService;
|
|
opts?: DaemonLifecycleOptions;
|
|
onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise<ServiceRecoveryResult | null>;
|
|
}) {
|
|
const json = Boolean(params.opts?.json);
|
|
const { stdout, emit, fail } = createDaemonActionContext({ action: "stop", json });
|
|
|
|
const loaded = await resolveServiceLoadedOrFail({
|
|
serviceNoun: params.serviceNoun,
|
|
service: params.service,
|
|
fail,
|
|
});
|
|
if (loaded === null) {
|
|
return;
|
|
}
|
|
if (!loaded) {
|
|
try {
|
|
const handled = await params.onNotLoaded?.({ json, stdout, fail });
|
|
if (handled) {
|
|
emit({
|
|
ok: true,
|
|
result: handled.result,
|
|
message: handled.message,
|
|
warnings: handled.warnings,
|
|
service: buildDaemonServiceSnapshot(params.service, false),
|
|
});
|
|
if (!json && handled.message) {
|
|
defaultRuntime.log(handled.message);
|
|
}
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
fail(`${params.serviceNoun} stop failed: ${String(err)}`);
|
|
return;
|
|
}
|
|
emit({
|
|
ok: true,
|
|
result: "not-loaded",
|
|
message: `${params.serviceNoun} service ${params.service.notLoadedText}.`,
|
|
service: buildDaemonServiceSnapshot(params.service, loaded),
|
|
});
|
|
if (!json) {
|
|
defaultRuntime.log(`${params.serviceNoun} service ${params.service.notLoadedText}.`);
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
await params.service.stop({ env: process.env, stdout });
|
|
} catch (err) {
|
|
fail(`${params.serviceNoun} stop failed: ${String(err)}`);
|
|
return;
|
|
}
|
|
|
|
let stopped = false;
|
|
try {
|
|
stopped = await params.service.isLoaded({ env: process.env });
|
|
} catch {
|
|
stopped = false;
|
|
}
|
|
emit({
|
|
ok: true,
|
|
result: "stopped",
|
|
service: buildDaemonServiceSnapshot(params.service, stopped),
|
|
});
|
|
}
|
|
|
|
export async function runServiceRestart(params: {
|
|
serviceNoun: string;
|
|
service: GatewayService;
|
|
renderStartHints: () => string[];
|
|
opts?: DaemonLifecycleOptions;
|
|
checkTokenDrift?: boolean;
|
|
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<GatewayServiceRestartResult | void>;
|
|
onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise<ServiceRecoveryResult | null>;
|
|
}): Promise<boolean> {
|
|
const json = Boolean(params.opts?.json);
|
|
const { stdout, emit, fail } = createDaemonActionContext({ action: "restart", json });
|
|
const warnings: string[] = [];
|
|
let handledRecovery: ServiceRecoveryResult | null = null;
|
|
let recoveredLoadedState: boolean | null = null;
|
|
const emitScheduledRestart = (
|
|
restartStatus: ReturnType<typeof describeGatewayServiceRestart>,
|
|
serviceLoaded: boolean,
|
|
) => {
|
|
emitActionMessage({
|
|
json,
|
|
emit,
|
|
payload: {
|
|
ok: true,
|
|
result: restartStatus.daemonActionResult,
|
|
message: restartStatus.message,
|
|
service: buildDaemonServiceSnapshot(params.service, serviceLoaded),
|
|
warnings: warnings.length ? warnings : undefined,
|
|
},
|
|
});
|
|
return true;
|
|
};
|
|
|
|
const loaded = await resolveServiceLoadedOrFail({
|
|
serviceNoun: params.serviceNoun,
|
|
service: params.service,
|
|
fail,
|
|
});
|
|
if (loaded === null) {
|
|
return false;
|
|
}
|
|
|
|
// Pre-flight config validation: check before any restart action (including
|
|
// onNotLoaded which may send SIGUSR1 to an unmanaged process). (#35862)
|
|
{
|
|
const configError = await getConfigValidationError();
|
|
if (configError) {
|
|
fail(
|
|
`${params.serviceNoun} aborted: config is invalid.\n${configError}\nFix the config and retry, or run "openclaw doctor" to repair.`,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!loaded) {
|
|
try {
|
|
handledRecovery = (await params.onNotLoaded?.({ json, stdout, fail })) ?? null;
|
|
} catch (err) {
|
|
fail(`${params.serviceNoun} restart failed: ${String(err)}`);
|
|
return false;
|
|
}
|
|
if (!handledRecovery) {
|
|
await handleServiceNotLoaded({
|
|
serviceNoun: params.serviceNoun,
|
|
service: params.service,
|
|
loaded,
|
|
renderStartHints: params.renderStartHints,
|
|
json,
|
|
emit,
|
|
});
|
|
return false;
|
|
}
|
|
if (handledRecovery.warnings?.length) {
|
|
warnings.push(...handledRecovery.warnings);
|
|
}
|
|
recoveredLoadedState = handledRecovery.loaded ?? null;
|
|
}
|
|
|
|
if (loaded && params.checkTokenDrift) {
|
|
// Check for token drift before restart (service token vs config token)
|
|
try {
|
|
const command = await params.service.readCommand(process.env);
|
|
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN;
|
|
const cfg = await readBestEffortConfig();
|
|
const driftEnv = {
|
|
...process.env,
|
|
...command?.environment,
|
|
};
|
|
const configToken = await resolveGatewayTokenForDriftCheck({ cfg, env: driftEnv });
|
|
const driftIssue = checkTokenDrift({ serviceToken, configToken });
|
|
if (driftIssue) {
|
|
const warning = driftIssue.detail
|
|
? `${driftIssue.message} ${driftIssue.detail}`
|
|
: driftIssue.message;
|
|
warnings.push(warning);
|
|
if (!json) {
|
|
defaultRuntime.log(`\n⚠️ ${driftIssue.message}`);
|
|
if (driftIssue.detail) {
|
|
defaultRuntime.log(` ${driftIssue.detail}\n`);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (isGatewaySecretRefUnavailableError(err, "gateway.auth.token")) {
|
|
const warning =
|
|
"Unable to verify gateway token drift: gateway.auth.token SecretRef is configured but unavailable in this command path.";
|
|
warnings.push(warning);
|
|
if (!json) {
|
|
defaultRuntime.log(`\n⚠️ ${warning}\n`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
let restartResult: GatewayServiceRestartResult = { outcome: "completed" };
|
|
if (loaded) {
|
|
restartResult = await params.service.restart({ env: process.env, stdout });
|
|
}
|
|
let restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
|
|
if (restartStatus.scheduled) {
|
|
return emitScheduledRestart(restartStatus, loaded || recoveredLoadedState === true);
|
|
}
|
|
if (params.postRestartCheck) {
|
|
const postRestartResult = await params.postRestartCheck({ json, stdout, warnings, fail });
|
|
if (postRestartResult) {
|
|
restartStatus = describeGatewayServiceRestart(params.serviceNoun, postRestartResult);
|
|
if (restartStatus.scheduled) {
|
|
return emitScheduledRestart(restartStatus, loaded || recoveredLoadedState === true);
|
|
}
|
|
}
|
|
}
|
|
let restarted = loaded;
|
|
if (loaded) {
|
|
try {
|
|
restarted = await params.service.isLoaded({ env: process.env });
|
|
} catch {
|
|
restarted = true;
|
|
}
|
|
} else if (recoveredLoadedState !== null) {
|
|
restarted = recoveredLoadedState;
|
|
}
|
|
emit({
|
|
ok: true,
|
|
result: "restarted",
|
|
message: handledRecovery?.message,
|
|
service: buildDaemonServiceSnapshot(params.service, restarted),
|
|
warnings: warnings.length ? warnings : undefined,
|
|
});
|
|
if (!json && handledRecovery?.message) {
|
|
defaultRuntime.log(handledRecovery.message);
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
const hints = params.renderStartHints();
|
|
fail(`${params.serviceNoun} restart failed: ${String(err)}`, hints);
|
|
return false;
|
|
}
|
|
}
|