When a caller forwards live channel/to via deliveryContext but omits accountId (e.g. /tools/invoke without x-openclaw-account-id), the update.run handler was using paramsDeliveryContext as-is, dropping any account binding that existed in the session store. The restart sentinel would then be written without accountId, causing scheduleRestartSentinelWake to deliver using the channel default account — misrouting in multi-account setups. Apply the same accountId fallback pattern that config.ts already uses: when paramsDeliveryContext is present but its accountId is undefined, merge in extractedDeliveryContext.accountId as fallback. See #18612.
152 lines
5.5 KiB
TypeScript
152 lines
5.5 KiB
TypeScript
import { loadConfig } from "../../config/config.js";
|
|
import { extractDeliveryInfo } from "../../config/sessions.js";
|
|
import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js";
|
|
import {
|
|
formatDoctorNonInteractiveHint,
|
|
type RestartSentinelPayload,
|
|
writeRestartSentinel,
|
|
} from "../../infra/restart-sentinel.js";
|
|
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
|
import { normalizeUpdateChannel } from "../../infra/update-channels.js";
|
|
import { runGatewayUpdate } from "../../infra/update-runner.js";
|
|
import { formatControlPlaneActor, resolveControlPlaneActor } from "../control-plane-audit.js";
|
|
import { validateUpdateRunParams } from "../protocol/index.js";
|
|
import { parseDeliveryContextFromParams, parseRestartRequestParams } from "./restart-request.js";
|
|
import type { GatewayRequestHandlers } from "./types.js";
|
|
import { assertValidParams } from "./validation.js";
|
|
|
|
export const updateHandlers: GatewayRequestHandlers = {
|
|
"update.run": async ({ params, respond, client, context }) => {
|
|
if (!assertValidParams(params, validateUpdateRunParams, "update.run", respond)) {
|
|
return;
|
|
}
|
|
const actor = resolveControlPlaneActor(client);
|
|
const { sessionKey, note, restartDelayMs } = parseRestartRequestParams(params);
|
|
// Prefer live deliveryContext from params over extractDeliveryInfo() (see #18612).
|
|
// Also prefer threadId from params when present — the session-key-derived value
|
|
// is empty for Slack sessions where replyToMode="all" but the key is not :thread:-scoped.
|
|
const { deliveryContext: extractedDeliveryContext, threadId: extractedThreadId } =
|
|
extractDeliveryInfo(sessionKey);
|
|
const paramsDeliveryContext = parseDeliveryContextFromParams(params);
|
|
// When live channel/to is present but accountId is missing (e.g. /tools/invoke without
|
|
// x-openclaw-account-id), fall back to the session-extracted account so the sentinel is
|
|
// not written without account context — which would cause deliveries to use the channel
|
|
// default account and misroute in multi-account setups. See config.ts for the same pattern.
|
|
const deliveryContext =
|
|
paramsDeliveryContext != null
|
|
? {
|
|
...paramsDeliveryContext,
|
|
accountId: paramsDeliveryContext.accountId ?? extractedDeliveryContext?.accountId,
|
|
}
|
|
: extractedDeliveryContext;
|
|
const threadId = paramsDeliveryContext?.threadId ?? extractedThreadId;
|
|
const timeoutMsRaw = (params as { timeoutMs?: unknown }).timeoutMs;
|
|
const timeoutMs =
|
|
typeof timeoutMsRaw === "number" && Number.isFinite(timeoutMsRaw)
|
|
? Math.max(1000, Math.floor(timeoutMsRaw))
|
|
: undefined;
|
|
|
|
let result: Awaited<ReturnType<typeof runGatewayUpdate>>;
|
|
try {
|
|
const config = loadConfig();
|
|
const configChannel = normalizeUpdateChannel(config.update?.channel);
|
|
const root =
|
|
(await resolveOpenClawPackageRoot({
|
|
moduleUrl: import.meta.url,
|
|
argv1: process.argv[1],
|
|
cwd: process.cwd(),
|
|
})) ?? process.cwd();
|
|
result = await runGatewayUpdate({
|
|
timeoutMs,
|
|
cwd: root,
|
|
argv1: process.argv[1],
|
|
channel: configChannel ?? undefined,
|
|
});
|
|
} catch (err) {
|
|
result = {
|
|
status: "error",
|
|
mode: "unknown",
|
|
reason: String(err),
|
|
steps: [],
|
|
durationMs: 0,
|
|
};
|
|
}
|
|
|
|
const payload: RestartSentinelPayload = {
|
|
kind: "update",
|
|
status: result.status,
|
|
ts: Date.now(),
|
|
sessionKey,
|
|
deliveryContext,
|
|
threadId,
|
|
message: note ?? null,
|
|
doctorHint: formatDoctorNonInteractiveHint(),
|
|
stats: {
|
|
mode: result.mode,
|
|
root: result.root ?? undefined,
|
|
before: result.before ?? null,
|
|
after: result.after ?? null,
|
|
steps: result.steps.map((step) => ({
|
|
name: step.name,
|
|
command: step.command,
|
|
cwd: step.cwd,
|
|
durationMs: step.durationMs,
|
|
log: {
|
|
stdoutTail: step.stdoutTail ?? null,
|
|
stderrTail: step.stderrTail ?? null,
|
|
exitCode: step.exitCode ?? null,
|
|
},
|
|
})),
|
|
reason: result.reason ?? null,
|
|
durationMs: result.durationMs,
|
|
},
|
|
};
|
|
|
|
let sentinelPath: string | null = null;
|
|
try {
|
|
sentinelPath = await writeRestartSentinel(payload);
|
|
} catch {
|
|
sentinelPath = null;
|
|
}
|
|
|
|
// Only restart the gateway when the update actually succeeded.
|
|
// Restarting after a failed update leaves the process in a broken state
|
|
// (corrupted node_modules, partial builds) and causes a crash loop.
|
|
const restart =
|
|
result.status === "ok"
|
|
? scheduleGatewaySigusr1Restart({
|
|
delayMs: restartDelayMs,
|
|
reason: "update.run",
|
|
audit: {
|
|
actor: actor.actor,
|
|
deviceId: actor.deviceId,
|
|
clientIp: actor.clientIp,
|
|
changedPaths: [],
|
|
},
|
|
})
|
|
: null;
|
|
context?.logGateway?.info(
|
|
`update.run completed ${formatControlPlaneActor(actor)} changedPaths=<n/a> restartReason=update.run status=${result.status}`,
|
|
);
|
|
if (restart?.coalesced) {
|
|
context?.logGateway?.warn(
|
|
`update.run restart coalesced ${formatControlPlaneActor(actor)} delayMs=${restart.delayMs}`,
|
|
);
|
|
}
|
|
|
|
respond(
|
|
true,
|
|
{
|
|
ok: result.status !== "error",
|
|
result,
|
|
restart,
|
|
sentinel: {
|
|
path: sentinelPath,
|
|
payload,
|
|
},
|
|
},
|
|
undefined,
|
|
);
|
|
},
|
|
};
|