mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 07:31:12 +00:00
* fix(wizard): defer config commit until after migration apply succeeds Onboarding migration import commits the config file to disk before the migration provider's apply() runs. If apply() legitimately reports an error on any item, runSetupMigrationImport() throws, but the config is already committed with wizard metadata stamped on it. On the very next onboarding attempt, the freshness gate sees that stamped config and refuses to run the import, telling the user a fresh setup is required. Move the commitConfigFile call to after assertApplySucceeded(), so a failed apply never leaves wizard metadata on disk that blocks the next retry behind the fresh-setup gate. The in-memory config still has wizard metadata applied before the apply context is constructed (so the apply function can reference a concrete target), but the disk write only happens after apply succeeds. Fixes #103262 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(lint): remove useless assignment in migration config commit The `targetConfig = await params.commitConfigFile(targetConfig)` line assigns to `targetConfig` but the value is never read afterwards. Remove the assignment, keeping the side-effect call. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(wizard): preserve config patches from provider apply after commit Restore the targetConfig assignment from commitConfigFile so that config patches written by the provider during apply (e.g. auth profile selections) are carried forward. Without this, the pre-apply snapshot silently replaces the post-apply config, losing provider-authored patches. Fixes #103262 * fix: remove useless assignment to satisfy lint commitConfigFile persists the config to disk; the returned config with patches is already on disk, making the in-memory assignment unnecessary. * fix(wizard): make Hermes import retries recoverable * chore: keep release notes release-owned * fix(wizard): keep retry identity stable across acknowledgement * fix(wizard): satisfy Hermes retry quality gates * test(wizard): use managed migration temp dirs --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
215 lines
7.5 KiB
TypeScript
215 lines
7.5 KiB
TypeScript
// Shared setup-wizard steps used by the classic wizard and the bootstrap onboarding flow.
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import type { GatewayAuthChoice, OnboardOptions } from "../commands/onboard-types.js";
|
|
import { createConfigIO, replaceConfigFile, resolveGatewayPort } from "../config/config.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import {
|
|
commitConfigWriteWithPendingPluginInstalls,
|
|
hasPendingPluginInstallRecords,
|
|
stripPendingPluginInstallRecords,
|
|
unchangedPendingPluginInstallRecordIds,
|
|
} from "../plugins/install-record-commit.js";
|
|
import { isPlainObject } from "../utils.js";
|
|
import { t } from "./i18n/index.js";
|
|
import { WizardCancelledError, type WizardPrompter } from "./prompts.js";
|
|
import {
|
|
getSecurityConfirmMessage,
|
|
getSecurityNoteMessage,
|
|
getSecurityNoteTitle,
|
|
} from "./setup.security-note.js";
|
|
import type { QuickstartGatewayDefaults } from "./setup.types.js";
|
|
|
|
function mergeWizardConfigValueOntoLatest(current: unknown, base: unknown, next: unknown): unknown {
|
|
if (isDeepStrictEqual(next, base)) {
|
|
return current;
|
|
}
|
|
if (isPlainObject(current) && isPlainObject(base) && isPlainObject(next)) {
|
|
const merged: Record<string, unknown> = { ...current };
|
|
const keys = new Set([...Object.keys(current), ...Object.keys(base), ...Object.keys(next)]);
|
|
for (const key of keys) {
|
|
const mergedValue = mergeWizardConfigValueOntoLatest(current[key], base[key], next[key]);
|
|
if (mergedValue === undefined) {
|
|
delete merged[key];
|
|
} else {
|
|
merged[key] = mergedValue;
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
return structuredClone(next);
|
|
}
|
|
|
|
/** Preserve concurrent edits while applying only changes made by an interactive wizard. */
|
|
export function mergeWizardConfigOntoLatest(
|
|
current: OpenClawConfig,
|
|
base: OpenClawConfig,
|
|
next: OpenClawConfig,
|
|
): OpenClawConfig {
|
|
return mergeWizardConfigValueOntoLatest(current, base, next) as OpenClawConfig;
|
|
}
|
|
|
|
/**
|
|
* Config writes go through the pending-plugin-install commit helper so wizard
|
|
* flows never drop install records that a concurrent migration already staged.
|
|
*/
|
|
export async function writeWizardConfigFile(
|
|
configInput: OpenClawConfig,
|
|
opts: {
|
|
allowConfigSizeDrop?: boolean;
|
|
/** Reject the write if config changed after the caller's verified snapshot. */
|
|
baseHash?: string;
|
|
migrationBaseConfig?: OpenClawConfig;
|
|
onPendingPluginInstallMigration?: () => void;
|
|
} = {},
|
|
): Promise<OpenClawConfig> {
|
|
let config = configInput;
|
|
let baseHash = opts.baseHash;
|
|
const allowConfigSizeDrop = opts.allowConfigSizeDrop === true;
|
|
if (!allowConfigSizeDrop && hasPendingPluginInstallRecords(config)) {
|
|
// Explicit undefined means this writer already migrated its baseline; an omitted
|
|
// key cannot distinguish fresh pending records from stale authored metadata.
|
|
if (!Object.hasOwn(opts, "migrationBaseConfig")) {
|
|
throw new Error(
|
|
"Wizard config writes with pending plugin installs must declare migration ownership.",
|
|
);
|
|
}
|
|
const migrationBaseConfig = opts.migrationBaseConfig;
|
|
if (migrationBaseConfig && hasPendingPluginInstallRecords(migrationBaseConfig)) {
|
|
const migration = await commitConfigWriteWithPendingPluginInstalls({
|
|
nextConfig: migrationBaseConfig,
|
|
sourceConfig: migrationBaseConfig,
|
|
writeOptions: { allowConfigSizeDrop: true },
|
|
commit: async (nextConfig, writeOptions) => {
|
|
return await replaceConfigFile({
|
|
nextConfig,
|
|
...(baseHash !== undefined ? { baseHash } : {}),
|
|
...(writeOptions ? { writeOptions } : {}),
|
|
afterWrite: { mode: "auto" },
|
|
});
|
|
},
|
|
});
|
|
baseHash = migration.persistedHash ?? undefined;
|
|
config = stripPendingPluginInstallRecords(
|
|
config,
|
|
unchangedPendingPluginInstallRecordIds(config, migrationBaseConfig),
|
|
);
|
|
opts.onPendingPluginInstallMigration?.();
|
|
}
|
|
}
|
|
const committed = await commitConfigWriteWithPendingPluginInstalls({
|
|
nextConfig: config,
|
|
writeOptions: { allowConfigSizeDrop },
|
|
commit: async (nextConfig, writeOptions) => {
|
|
return await replaceConfigFile({
|
|
nextConfig,
|
|
...(baseHash !== undefined ? { baseHash } : {}),
|
|
...(writeOptions ? { writeOptions } : {}),
|
|
afterWrite: { mode: "auto" },
|
|
});
|
|
},
|
|
});
|
|
return committed.config;
|
|
}
|
|
|
|
export async function readSetupConfigFileSnapshot() {
|
|
return await createConfigIO({ pluginValidation: "skip" }).readConfigFileSnapshot();
|
|
}
|
|
|
|
export async function readValidSetupConfigFile(): Promise<OpenClawConfig> {
|
|
const snapshot = await readSetupConfigFileSnapshot();
|
|
if (!snapshot.valid) {
|
|
throw new Error("Migration target config became invalid. Run `openclaw doctor`.");
|
|
}
|
|
return snapshot.exists ? (snapshot.sourceConfig ?? snapshot.config) : {};
|
|
}
|
|
|
|
/** One-time security acknowledgement; persisted so reruns stay quiet. */
|
|
export async function requireRiskAcknowledgement(params: {
|
|
opts: OnboardOptions;
|
|
prompter: WizardPrompter;
|
|
config: OpenClawConfig;
|
|
}): Promise<OpenClawConfig> {
|
|
if (params.config.wizard?.securityAcknowledgedAt) {
|
|
return params.config;
|
|
}
|
|
if (params.opts.acceptRisk === true) {
|
|
return applySecurityAcknowledgement(params.config);
|
|
}
|
|
|
|
await params.prompter.note(getSecurityNoteMessage(), getSecurityNoteTitle());
|
|
|
|
const ok = await params.prompter.confirm({
|
|
message: getSecurityConfirmMessage(),
|
|
initialValue: true,
|
|
layout: "vertical",
|
|
});
|
|
if (!ok) {
|
|
throw new WizardCancelledError(t("wizard.setup.riskNotAccepted"));
|
|
}
|
|
return applySecurityAcknowledgement(params.config);
|
|
}
|
|
|
|
function applySecurityAcknowledgement(config: OpenClawConfig): OpenClawConfig {
|
|
if (config.wizard?.securityAcknowledgedAt) {
|
|
return config;
|
|
}
|
|
return {
|
|
...config,
|
|
wizard: {
|
|
...config.wizard,
|
|
securityAcknowledgedAt: new Date().toISOString(),
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Derive quickstart gateway defaults, preserving any existing gateway settings. */
|
|
export function resolveQuickstartGatewayDefaults(
|
|
baseConfig: OpenClawConfig,
|
|
): QuickstartGatewayDefaults {
|
|
const hasExisting =
|
|
typeof baseConfig.gateway?.port === "number" ||
|
|
baseConfig.gateway?.bind !== undefined ||
|
|
baseConfig.gateway?.auth?.mode !== undefined ||
|
|
baseConfig.gateway?.auth?.token !== undefined ||
|
|
baseConfig.gateway?.auth?.password !== undefined ||
|
|
baseConfig.gateway?.customBindHost !== undefined ||
|
|
baseConfig.gateway?.tailscale?.mode !== undefined;
|
|
|
|
const bindRaw = baseConfig.gateway?.bind;
|
|
const bind =
|
|
bindRaw === "loopback" ||
|
|
bindRaw === "lan" ||
|
|
bindRaw === "auto" ||
|
|
bindRaw === "custom" ||
|
|
bindRaw === "tailnet"
|
|
? bindRaw
|
|
: "loopback";
|
|
|
|
let authMode: GatewayAuthChoice = "token";
|
|
if (baseConfig.gateway?.auth?.mode === "token" || baseConfig.gateway?.auth?.mode === "password") {
|
|
authMode = baseConfig.gateway.auth.mode;
|
|
} else if (baseConfig.gateway?.auth?.token) {
|
|
authMode = "token";
|
|
} else if (baseConfig.gateway?.auth?.password) {
|
|
authMode = "password";
|
|
}
|
|
|
|
const tailscaleRaw = baseConfig.gateway?.tailscale?.mode;
|
|
const tailscaleMode =
|
|
tailscaleRaw === "off" || tailscaleRaw === "serve" || tailscaleRaw === "funnel"
|
|
? tailscaleRaw
|
|
: "off";
|
|
|
|
return {
|
|
hasExisting,
|
|
port: resolveGatewayPort(baseConfig),
|
|
bind,
|
|
authMode,
|
|
tailscaleMode,
|
|
token: baseConfig.gateway?.auth?.token,
|
|
password: baseConfig.gateway?.auth?.password,
|
|
customBindHost: baseConfig.gateway?.customBindHost,
|
|
tailscaleResetOnExit: baseConfig.gateway?.tailscale?.resetOnExit ?? false,
|
|
};
|
|
}
|