fix(onboard): preflight TTY before reset (#116491)

This commit is contained in:
Peter Steinberger
2026-07-30 11:01:02 -07:00
committed by GitHub
parent 3b355879b8
commit 7f7534253f
2 changed files with 52 additions and 0 deletions

View File

@@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({
runInteractiveSetup: vi.fn(async () => {}),
runGuidedOnboarding: vi.fn(async () => {}),
runNonInteractiveSetup: vi.fn(async () => {}),
hasInteractiveOnboardingTty: vi.fn(() => true),
resolvePluginProviders: vi.fn((): ProviderPlugin[] => [
{
id: "anthropic",
@@ -90,6 +91,10 @@ vi.mock("./onboard-non-interactive.js", () => ({
runNonInteractiveSetup: mocks.runNonInteractiveSetup,
}));
vi.mock("./onboard-interactive-runner.js", () => ({
hasInteractiveOnboardingTty: mocks.hasInteractiveOnboardingTty,
}));
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
resolveGatewayPort: () => 18_789,
@@ -173,6 +178,7 @@ describe("setupWizardCommand", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
mocks.hasInteractiveOnboardingTty.mockReturnValue(true);
mocks.readConfigFileSnapshot.mockResolvedValue({ exists: false, valid: false, config: {} });
});
@@ -228,6 +234,42 @@ describe("setupWizardCommand", () => {
expectResetCall({ scope: "config+creds+sessions", runtime });
});
it.each([
["guided", { reset: true }],
["classic", { reset: true, classic: true }],
] as const)("rejects headless %s onboarding before reset", async (_label, options) => {
const runtime = makeRuntime();
mocks.hasInteractiveOnboardingTty.mockReturnValue(false);
await setupWizardCommand(options, runtime);
expect(runtime.error).toHaveBeenCalledWith(
"Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.readConfigFileSnapshot).not.toHaveBeenCalled();
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("keeps non-interactive reset ordering without a TTY", async () => {
const runtime = makeRuntime();
mocks.hasInteractiveOnboardingTty.mockReturnValue(false);
await setupWizardCommand({ reset: true, nonInteractive: true, acceptRisk: true }, runtime);
expect(mocks.handleReset).toHaveBeenCalledOnce();
expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce();
const resetOrder = mocks.handleReset.mock.invocationCallOrder[0];
const setupOrder = mocks.runNonInteractiveSetup.mock.invocationCallOrder[0];
if (resetOrder === undefined || setupOrder === undefined) {
throw new Error("expected reset and non-interactive setup calls");
}
expect(resetOrder).toBeLessThan(setupOrder);
});
it("uses configured default workspace for --reset when --workspace is not provided", async () => {
const runtime = makeRuntime();
mocks.readConfigFileSnapshot.mockResolvedValue({

View File

@@ -22,6 +22,7 @@ import { resolveProviderInstallCatalogEntries } from "../plugins/provider-instal
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { t } from "../wizard/i18n/index.js";
import {
formatDeprecatedNonInteractiveAuthChoiceError,
isDeprecatedAuthChoice,
@@ -38,6 +39,7 @@ import {
} from "./onboard-custom-config.js";
import { runGuidedOnboarding } from "./onboard-guided.js";
import { DEFAULT_WORKSPACE, handleReset } from "./onboard-helpers.js";
import { hasInteractiveOnboardingTty } from "./onboard-interactive-runner.js";
import { runInteractiveSetup } from "./onboard-interactive.js";
import { runNonInteractiveSetup } from "./onboard-non-interactive.js";
import { resolveNonInteractiveApiKey as resolveNonInteractiveCredential } from "./onboard-non-interactive/api-keys.js";
@@ -536,6 +538,14 @@ export async function setupWizardCommand(
return;
}
if (!normalizedOpts.nonInteractive && !hasInteractiveOnboardingTty()) {
// Reset is destructive, so prove the selected interactive surface can run
// before reading or moving any operator state.
runtime.error(t("wizard.guided.ttyRequired"));
runtime.exit(1);
return;
}
if (process.platform === "win32") {
runtime.log(
[