fix(onboard): run noninteractive migration imports

This commit is contained in:
Vincent Koc
2026-05-01 03:21:13 -07:00
parent 6fb9e9e558
commit c7a91f9632
3 changed files with 194 additions and 0 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
- WhatsApp: stage `qrcode` through root mirrored runtime dependencies so packaged QR pairing can render from staged plugin-runtime-deps installs. Fixes #75394. Thanks @FelipeX2001.
- Discord/voice: apply per-channel Discord `systemPrompt` overrides to voice transcript turns by forwarding the trusted channel prompt through the voice agent run. Fixes #47095. Thanks @qearlyao.
- Gateway/agent: reject strict `openclaw agent --deliver` requests with missing delivery targets before starting the agent run, so users do not wait for a completed turn that cannot send anywhere. Thanks @vincentkoc.
- Setup/import: honor non-interactive `--import-from` onboarding flags by running the migration import path instead of silently completing normal setup without importing anything. Thanks @vincentkoc.
- Discord/voice: run voice-channel turns under a voice-output policy that hides the agent `tts` tool and asks for spoken reply text, so `/vc join` sessions synthesize and play agent replies instead of ending with `NO_REPLY`. Fixes #61536. Thanks @aounakram.
- Plugins/runtime-deps: include packaged OpenClaw identity in bundled plugin loader cache keys, so same-path package upgrades stop reusing stale versioned runtime-deps mirrors. Fixes #75045. Thanks @sahilsatralkar.
- Plugin SDK: restore reply-prefix and reply-pipeline helpers on the deprecated root/compat SDK surface so external plugins still using `openclaw/plugin-sdk` do not fail message dispatch after update. Fixes #75171. Thanks @zhangxiliang.

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { makeTempWorkspace } from "../test-helpers/workspace.js";
import { captureEnv } from "../test-utils/env.js";
@@ -15,6 +16,14 @@ type InstallGatewayDaemonResult = Awaited<ReturnType<typeof installGatewayDaemon
const installGatewayDaemonNonInteractiveMock = vi.hoisted(() =>
vi.fn(async (): Promise<InstallGatewayDaemonResult> => ({ installed: true })),
);
const createPreMigrationBackupMock = vi.hoisted(() => vi.fn(async () => undefined));
const migrationProviderMock = vi.hoisted(() => ({
id: "hermes",
label: "Hermes",
description: "Hermes migration provider",
plan: vi.fn(),
apply: vi.fn(),
}));
const healthCommandMock = vi.hoisted(() => vi.fn(async () => {}));
const gatewayServiceMock = vi.hoisted(() => ({
label: "LaunchAgent",
@@ -136,6 +145,20 @@ vi.mock("./post-config-runtime-deps.js", () => ({
preparePostConfigBundledRuntimeDeps: preparePostConfigBundledRuntimeDepsMock,
}));
vi.mock("../plugins/migration-provider-runtime.js", () => ({
resolvePluginMigrationProviders: () => [migrationProviderMock],
resolvePluginMigrationProvider: ({ providerId }: { providerId: string }) =>
providerId === migrationProviderMock.id ? migrationProviderMock : undefined,
}));
vi.mock("./migrate/apply.js", async (importActual) => {
const actual = await importActual<typeof import("./migrate/apply.js")>();
return {
...actual,
createPreMigrationBackup: createPreMigrationBackupMock,
};
});
vi.mock("../daemon/service.js", () => ({
resolveGatewayService: () => gatewayServiceMock,
}));
@@ -316,7 +339,11 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
afterEach(() => {
waitForGatewayReachableMock = undefined;
testConfigStore.clear();
ensureWorkspaceAndSessionsMock.mockClear();
installGatewayDaemonNonInteractiveMock.mockClear();
createPreMigrationBackupMock.mockClear();
migrationProviderMock.plan.mockReset();
migrationProviderMock.apply.mockReset();
healthCommandMock.mockClear();
gatewayServiceMock.isLoaded.mockClear();
gatewayServiceMock.readRuntime.mockClear();
@@ -399,6 +426,85 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
});
}, 60_000);
it("applies non-interactive migration imports instead of ignoring import flags", async () => {
await withStateDir("state-noninteractive-import-", async (stateDir) => {
const source = path.join(stateDir, "hermes-home");
const workspace = path.join(stateDir, "openclaw");
const planned: MigrationPlan = {
providerId: "hermes",
source,
target: workspace,
summary: {
total: 1,
planned: 1,
migrated: 0,
skipped: 0,
conflicts: 0,
errors: 0,
sensitive: 0,
},
items: [
{
id: "workspace:AGENTS.md",
kind: "workspace",
action: "copy",
status: "planned",
source: path.join(source, "AGENTS.md"),
target: path.join(workspace, "AGENTS.md"),
},
],
};
const applied: MigrationApplyResult = {
...planned,
summary: {
...planned.summary,
planned: 0,
migrated: 1,
},
items: planned.items.map((item) => ({ ...item, status: "migrated" as const })),
};
migrationProviderMock.plan.mockResolvedValueOnce(planned);
migrationProviderMock.apply.mockResolvedValueOnce(applied);
await runNonInteractiveSetup(
{
nonInteractive: true,
mode: "local",
workspace,
authChoice: "skip",
skipHealth: true,
importFrom: "hermes",
importSource: source,
},
runtime,
);
expect(migrationProviderMock.plan).toHaveBeenCalledWith(
expect.objectContaining({
source,
includeSecrets: false,
overwrite: false,
config: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({ workspace }),
}),
}),
}),
);
expect(migrationProviderMock.apply).toHaveBeenCalledWith(
expect.objectContaining({
source,
reportDir: expect.stringContaining(path.join(stateDir, "migration", "hermes")),
}),
planned,
);
expect(readTestConfig().agents?.defaults?.workspace).toBe(workspace);
expect(ensureWorkspaceAndSessionsMock).not.toHaveBeenCalled();
expect(preparePostConfigBundledRuntimeDepsMock).not.toHaveBeenCalled();
expect(healthCommandMock).not.toHaveBeenCalled();
});
}, 60_000);
it("writes gateway.remote url/token", async () => {
await withStateDir("state-remote-", async (_stateDir) => {
const port = getPseudoPort(30_000);

View File

@@ -1,12 +1,94 @@
import { formatCliCommand } from "../cli/command-format.js";
import { replaceConfigFile } from "../config/config.js";
import { readConfigFileSnapshot } from "../config/io.js";
import { logConfigUpdated } from "../config/logging.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { runNonInteractiveLocalSetup } from "./onboard-non-interactive/local.js";
import { runNonInteractiveRemoteSetup } from "./onboard-non-interactive/remote.js";
import type { OnboardOptions } from "./onboard-types.js";
function createNonInteractiveMigrationPrompter(runtime: RuntimeEnv): WizardPrompter {
const unavailable = (message: string): never => {
throw new Error(
`Non-interactive migration import needs explicit flags before prompting: ${message}`,
);
};
return {
async intro(title) {
runtime.log(title);
},
async outro(message) {
runtime.log(message);
},
async note(message, title) {
runtime.log(title ? `${title}\n${message}` : message);
},
async select(params) {
unavailable(params.message);
},
async multiselect(params) {
unavailable(params.message);
},
async text(params) {
unavailable(params.message);
},
async confirm(params) {
unavailable(params.message);
},
progress(label) {
runtime.log(label);
return {
update(message) {
runtime.log(message);
},
stop(message) {
if (message) {
runtime.log(message);
}
},
};
},
};
}
async function runNonInteractiveMigrationImport(params: {
opts: OnboardOptions;
runtime: RuntimeEnv;
baseConfig: OpenClawConfig;
baseHash?: string;
}) {
const providerId = params.opts.importFrom?.trim();
if (!providerId) {
params.runtime.error("--import-from is required for non-interactive migration import.");
params.runtime.exit(1);
return;
}
const { detectSetupMigrationSources, runSetupMigrationImport } =
await import("../wizard/setup.migration-import.js");
const detections = await detectSetupMigrationSources({
config: params.baseConfig,
runtime: params.runtime,
});
await runSetupMigrationImport({
opts: { ...params.opts, importFrom: providerId, nonInteractive: true },
baseConfig: params.baseConfig,
detections,
prompter: createNonInteractiveMigrationPrompter(params.runtime),
runtime: params.runtime,
async commitConfigFile(config) {
await replaceConfigFile({
nextConfig: config,
...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}),
});
logConfigUpdated(params.runtime);
return config;
},
});
}
export async function runNonInteractiveSetup(
opts: OnboardOptions,
runtime: RuntimeEnv = defaultRuntime,
@@ -32,6 +114,11 @@ export async function runNonInteractiveSetup(
return;
}
if (opts.importFrom || opts.importSource || opts.importSecrets || opts.flow === "import") {
await runNonInteractiveMigrationImport({ opts, runtime, baseConfig, baseHash: snapshot.hash });
return;
}
if (mode === "remote") {
await runNonInteractiveRemoteSetup({ opts, runtime, baseConfig, baseHash: snapshot.hash });
return;