fix: complete Codex login handoff during onboarding (#107174)

This commit is contained in:
Jason (Json)
2026-07-13 23:40:55 -06:00
committed by GitHub
parent df5097b6e7
commit 5f8b2a4670
5 changed files with 215 additions and 48 deletions
+144
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as configModule from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import { projectDefaultInferenceRoute } from "./inference-route.js";
@@ -36,6 +37,7 @@ const mocks = vi.hoisted(() => ({
events: [] as string[],
readSnapshot: vi.fn<() => Promise<ConfigSnapshot>>(),
readVerifiedSnapshot: vi.fn<() => Promise<ConfigSnapshot>>(),
readVerifiedSnapshotWithPluginMetadata: vi.fn(),
commit: vi.fn(),
configureGateway: vi.fn(),
ensureWorkspace: vi.fn(),
@@ -47,6 +49,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("../config/config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/config.js")>()),
readConfigFileSnapshot: mocks.readVerifiedSnapshot,
readConfigFileSnapshotWithPluginMetadata: mocks.readVerifiedSnapshotWithPluginMetadata,
}));
vi.mock("../wizard/setup.shared.js", async (importOriginal) => ({
@@ -120,6 +123,56 @@ function snapshot(hash: string | null, config: OpenClawConfig): ConfigSnapshot {
};
}
function codexPluginMetadataSnapshot(homeScope: "agent" | "user") {
return {
manifestRegistry: {
diagnostics: [],
plugins: [
{
id: "codex",
origin: "global",
channels: [],
providers: [],
cliBackends: [],
skills: [],
settingsFiles: [],
hooks: [],
rootDir: "/tmp/codex",
source: "/tmp/codex/index.js",
manifestPath: "/tmp/codex/openclaw.plugin.json",
configSchema: {
type: "object",
additionalProperties: false,
properties: {
codexDynamicToolsLoading: { type: "string", default: "searchable" },
appServer: {
type: "object",
additionalProperties: false,
properties: {
transport: { type: "string", default: "stdio" },
homeScope: { type: "string", default: homeScope },
requestTimeoutMs: { type: "number", default: 60_000 },
},
},
},
},
},
],
},
} as never;
}
function materializePluginDefaults(
config: OpenClawConfig,
pluginMetadataSnapshot: ReturnType<typeof codexPluginMetadataSnapshot>,
): OpenClawConfig {
const result = configModule.validateConfigObjectWithPlugins(config, { pluginMetadataSnapshot });
if (!result.ok) {
throw new Error(result.issues[0]?.message ?? "test config failed validation");
}
return result.config;
}
function baseParams(overrides: Partial<Parameters<typeof applyCrestodianSetup>[0]> = {}) {
return {
workspace: "/tmp/openclaw-workspace",
@@ -190,6 +243,9 @@ describe("applyCrestodianSetup transaction boundaries", () => {
mocks.state.persistedConfig = undefined;
mocks.readSnapshot.mockImplementation(async () => mocks.state.initialSnapshot);
mocks.readVerifiedSnapshot.mockImplementation(async () => mocks.state.initialSnapshot);
mocks.readVerifiedSnapshotWithPluginMetadata.mockImplementation(async () => ({
snapshot: await mocks.readVerifiedSnapshot(),
}));
mocks.commit.mockImplementation(async (params: { transform: CommitTransform }) => {
const result = await params.transform(structuredClone(mocks.state.commitConfig), {
previousHash: mocks.state.commitPreviousHash,
@@ -589,6 +645,94 @@ describe("applyCrestodianSetup transaction boundaries", () => {
expect(mocks.ensureWorkspace).not.toHaveBeenCalled();
});
it("accepts persisted plugin defaults that match the verified runtime route", async () => {
const pluginMetadataSnapshot = codexPluginMetadataSnapshot("agent");
const sourceConfig = {
agents: { defaults: { model: "openai/gpt-5.5" } },
plugins: {
entries: {
codex: {
enabled: true,
config: { appServer: { transport: "stdio", homeScope: "agent" } },
},
},
},
} satisfies OpenClawConfig;
const initialSnapshot = {
...snapshot("probe", sourceConfig),
runtimeConfig: materializePluginDefaults(sourceConfig, pluginMetadataSnapshot),
};
const persistedSnapshot = () => {
const persisted = mocks.state.persistedConfig ?? sourceConfig;
return {
...snapshot("persisted", persisted),
runtimeConfig: materializePluginDefaults(persisted, pluginMetadataSnapshot),
};
};
mocks.state.initialSnapshot = initialSnapshot;
mocks.state.commitConfig = sourceConfig;
mocks.state.commitSnapshot = initialSnapshot;
mocks.readVerifiedSnapshot
.mockResolvedValueOnce(initialSnapshot)
.mockResolvedValueOnce(initialSnapshot)
.mockImplementation(async () => persistedSnapshot());
mocks.readVerifiedSnapshotWithPluginMetadata.mockImplementation(async () => ({
snapshot: persistedSnapshot(),
pluginMetadataSnapshot,
}));
await applyCrestodianSetup(
baseParams({
expectedInferenceRoute: await projectDefaultInferenceRoute(initialSnapshot.runtimeConfig),
}),
);
expect(mocks.ensureWorkspace).toHaveBeenCalledOnce();
});
it("rejects a materialized route that differs from the inference proof", async () => {
const sourceConfig = {
agents: { defaults: { model: "openai/gpt-5.5" } },
} satisfies OpenClawConfig;
const materializedConfig = {
agents: { defaults: { model: "anthropic/claude-opus-4-8" } },
} satisfies OpenClawConfig;
const verifiedSnapshot = snapshot("probe", sourceConfig);
const persistedSnapshot = () => {
const persisted = mocks.state.persistedConfig ?? sourceConfig;
return {
...snapshot("persisted", persisted),
runtimeConfig: materializedConfig,
};
};
mocks.state.initialSnapshot = verifiedSnapshot;
mocks.state.commitConfig = sourceConfig;
mocks.state.commitSnapshot = verifiedSnapshot;
mocks.readVerifiedSnapshot
.mockResolvedValueOnce(verifiedSnapshot)
.mockResolvedValueOnce(verifiedSnapshot)
.mockImplementation(async () => persistedSnapshot());
mocks.readVerifiedSnapshotWithPluginMetadata.mockImplementation(async () => ({
snapshot: persistedSnapshot(),
}));
const validate = vi
.spyOn(configModule, "validateConfigObjectWithPlugins")
.mockReturnValue({ ok: true, config: materializedConfig, warnings: [] });
try {
await expect(
applyCrestodianSetup(
baseParams({
expectedInferenceRoute: await projectDefaultInferenceRoute(sourceConfig),
}),
),
).rejects.toThrow("materialized inference route");
} finally {
validate.mockRestore();
}
expect(mocks.ensureWorkspace).not.toHaveBeenCalled();
});
it("stops stale continuation before the next persistent effect", async () => {
const initial = {
agents: { defaults: { model: "openai/gpt-5.5" } },
+32 -37
View File
@@ -2,8 +2,10 @@
import { isDeepStrictEqual } from "node:util";
import {
readConfigFileSnapshot,
readConfigFileSnapshotWithPluginMetadata,
resolveConfigSnapshotHash,
resolveGatewayPort,
validateConfigObjectWithPlugins,
} from "../config/config.js";
import { applyMergePatch } from "../config/merge-patch.js";
import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js";
@@ -19,6 +21,7 @@ import {
sameDefaultInferenceRoute,
type DefaultInferenceRouteProjection,
} from "./inference-route.js";
import { requireValidCrestodianSetupSnapshot } from "./setup-config-snapshot.js";
/**
* The whole first-run setup as one approved operation: the user says "yes" in
@@ -69,34 +72,6 @@ type CrestodianSetupApplyHooks = {
commit<T>(effect: () => Promise<T> | T): Promise<T>;
};
const CRESTODIAN_AGENT_ID = normalizeAgentId("crestodian");
function requireValidSetupSnapshot(snapshot: ConfigFileSnapshot): {
sourceConfig: OpenClawConfig;
runtimeConfig: OpenClawConfig;
} {
if (snapshot.exists && !snapshot.valid) {
const issue = snapshot.issues?.[0];
const detail = issue ? ` (${issue.path ? `${issue.path}: ` : ""}${issue.message})` : "";
throw new Error(
`OpenClaw config ${shortenHomePath(snapshot.path)} is invalid${detail}. Fix it before running setup.`,
);
}
const sourceConfig = snapshot.exists ? (snapshot.sourceConfig ?? snapshot.config) : {};
const runtimeConfig = snapshot.exists ? (snapshot.runtimeConfig ?? snapshot.config) : {};
if (
runtimeConfig.agents?.list?.some((entry) => normalizeAgentId(entry.id) === CRESTODIAN_AGENT_ID)
) {
throw new Error(
'Agent id "crestodian" is reserved for the setup assistant. Rename that configured agent, then retry setup.',
);
}
return {
sourceConfig,
runtimeConfig,
};
}
/** Prompter for quickstart-only flows: notes go to the log, prompts fail loud. */
export function createQuickstartNotePrompter(runtime: RuntimeEnv): WizardPrompter {
const unexpected = (kind: string) => {
@@ -295,7 +270,7 @@ export async function applyCrestodianSetup(
]);
const snapshot = await readSetupConfigFileSnapshot();
const snapshotConfig = requireValidSetupSnapshot(snapshot);
const snapshotConfig = requireValidCrestodianSetupSnapshot(snapshot);
if (hasExpectedConfigHash && resolveConfigSnapshotHash(snapshot) !== expectedConfigHash) {
throw new Error("OpenClaw config changed while AI access was being tested. Try setup again.");
@@ -423,7 +398,7 @@ export async function applyCrestodianSetup(
afterWrite: { mode: "auto" },
writeOptions: { allowConfigSizeDrop: false },
transform: async (currentConfig, context) => {
const currentSnapshot = requireValidSetupSnapshot(context.snapshot);
const currentSnapshot = requireValidCrestodianSetupSnapshot(context.snapshot);
if (hasExpectedConfigHash && context.previousHash !== expectedConfigHash) {
throw new Error(
"OpenClaw config changed while AI access was being tested. Try setup again.",
@@ -439,14 +414,14 @@ export async function applyCrestodianSetup(
const finalizedConfig = finalizeConfig
? finalizeConfig(setupCandidate.nextConfig, currentSnapshot.sourceConfig)
: setupCandidate.nextConfig;
const expectedPersistedRoute = params.expectedInferenceRoute
const expectedSourceRoute = params.expectedInferenceRoute
? await projectDefaultInferenceRoute(finalizedConfig)
: undefined;
if (
params.expectedInferenceRoute &&
(!params.expectedInferenceRoute.route ||
!expectedPersistedRoute?.route ||
!isDeepStrictEqual(expectedPersistedRoute.route, params.expectedInferenceRoute.route))
!expectedSourceRoute?.route ||
!isDeepStrictEqual(expectedSourceRoute.route, params.expectedInferenceRoute.route))
) {
throw new Error(
"The setup candidate no longer preserves the exact verified inference route, so it was not saved. Retry setup from the current Crestodian session.",
@@ -457,7 +432,7 @@ export async function applyCrestodianSetup(
assertCommitPreconditions?.();
return {
nextConfig: finalizedConfig,
result: { expectedPersistedRoute, settings: setupCandidate.settings },
result: { settings: setupCandidate.settings },
};
},
}),
@@ -468,9 +443,29 @@ export async function applyCrestodianSetup(
throw new Error("Crestodian setup committed without resolved Gateway settings.");
}
if (params.expectedInferenceRoute) {
const afterSnapshot = await readSetupConfigFileSnapshot();
requireValidSetupSnapshot(afterSnapshot);
await assertVerifiedRoute(afterSnapshot, committed.result?.expectedPersistedRoute, "after");
const afterRead = await readConfigFileSnapshotWithPluginMetadata();
const afterSnapshot = afterRead.snapshot;
requireValidCrestodianSetupSnapshot(afterSnapshot);
const expectedRuntime = validateConfigObjectWithPlugins(committed.nextConfig, {
env: process.env,
pluginMetadataSnapshot: afterRead.pluginMetadataSnapshot,
});
if (!expectedRuntime.ok) {
const issue = expectedRuntime.issues[0];
const detail = issue ? ` (${issue.path ? `${issue.path}: ` : ""}${issue.message})` : "";
throw new Error(
`OpenClaw could not validate the setup route after its config write${detail}. No further setup effects were applied. Retry setup from the current Crestodian session.`,
);
}
const expectedPersistedRoute = await projectDefaultInferenceRoute(expectedRuntime.config);
await assertVerifiedRoute(afterSnapshot, expectedPersistedRoute, "after");
// Plugin defaults are part of the access-tested runtime route. Reject a
// metadata change that would make the committed config run differently.
if (!isDeepStrictEqual(expectedPersistedRoute.route, params.expectedInferenceRoute.route)) {
throw new Error(
"The materialized inference route no longer matches the exact verified route, so no further setup effects were applied. Retry setup from the current Crestodian session.",
);
}
}
const lines: string[] = [
+28
View File
@@ -0,0 +1,28 @@
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { shortenHomePath } from "../utils.js";
const CRESTODIAN_AGENT_ID = normalizeAgentId("crestodian");
export function requireValidCrestodianSetupSnapshot(snapshot: ConfigFileSnapshot): {
sourceConfig: OpenClawConfig;
runtimeConfig: OpenClawConfig;
} {
if (snapshot.exists && !snapshot.valid) {
const issue = snapshot.issues?.[0];
const detail = issue ? ` (${issue.path ? `${issue.path}: ` : ""}${issue.message})` : "";
throw new Error(
`OpenClaw config ${shortenHomePath(snapshot.path)} is invalid${detail}. Fix it before running setup.`,
);
}
const sourceConfig = snapshot.exists ? (snapshot.sourceConfig ?? snapshot.config) : {};
const runtimeConfig = snapshot.exists ? (snapshot.runtimeConfig ?? snapshot.config) : {};
if (
runtimeConfig.agents?.list?.some((entry) => normalizeAgentId(entry.id) === CRESTODIAN_AGENT_ID)
) {
throw new Error(
'Agent id "crestodian" is reserved for the setup assistant. Rename that configured agent, then retry setup.',
);
}
return { sourceConfig, runtimeConfig };
}
+8 -8
View File
@@ -3384,7 +3384,7 @@ describe("activateSetupInference", () => {
command: "codex",
mode: "yolo",
transport: "stdio",
homeScope: "user",
homeScope: "agent",
},
},
},
@@ -3431,7 +3431,7 @@ describe("activateSetupInference", () => {
command: "codex",
mode: "yolo",
transport: "stdio",
homeScope: "user",
homeScope: "agent",
},
},
},
@@ -3544,7 +3544,7 @@ describe("activateSetupInference", () => {
config: expect.objectContaining({
appServer: expect.objectContaining({
transport: "stdio",
homeScope: "user",
homeScope: "agent",
}),
}),
}),
@@ -3567,7 +3567,7 @@ describe("activateSetupInference", () => {
entries: {
codex: {
enabled: true,
config: { appServer: { transport: "stdio", homeScope: "user" } },
config: { appServer: { transport: "stdio", homeScope: "agent" } },
},
},
},
@@ -4452,7 +4452,7 @@ describe("activateSetupInference Codex configuration", () => {
expect(result.ok).toBe(true);
expect(persistedConfig.plugins?.entries?.codex).toMatchObject({
enabled: true,
config: { appServer: { transport: "stdio", homeScope: "user" } },
config: { appServer: { transport: "stdio", homeScope: "agent" } },
});
expect(persistedConfig.plugins?.entries?.codex?.config?.supervision).toEqual(
testCase.expectedSupervision,
@@ -4485,7 +4485,7 @@ describe("activateSetupInference Codex configuration", () => {
expect(persistedConfig.plugins?.entries?.codex).toMatchObject({
enabled: true,
config: {
appServer: { transport: "stdio", homeScope: "user" },
appServer: { transport: "stdio", homeScope: "agent" },
discovery: { enabled: true },
supervision: { enabled: false, allowRawTranscripts: true },
},
@@ -4518,7 +4518,7 @@ describe("activateSetupInference Codex configuration", () => {
codex: {
enabled: true,
config: {
appServer: { transport: "stdio", url: "ws://127.0.0.1:4500", homeScope: "user" },
appServer: { transport: "stdio", url: "ws://127.0.0.1:4500", homeScope: "agent" },
supervision: { enabled: false },
},
},
@@ -4544,7 +4544,7 @@ describe("activateSetupInference Codex configuration", () => {
expect(result.ok).toBe(true);
expect(persistedConfig.plugins?.entries?.codex).toMatchObject({
enabled: true,
config: { appServer: { transport: "stdio", homeScope: "user" } },
config: { appServer: { transport: "stdio", homeScope: "agent" } },
});
});
+3 -3
View File
@@ -433,7 +433,7 @@ type SetupInferenceTestPlan = {
};
};
function configureCodexCliNativeAuth(cfg: OpenClawConfig): OpenClawConfig {
function configureCodexCliPreparedAuth(cfg: OpenClawConfig): OpenClawConfig {
const entry = cfg.plugins?.entries?.codex;
const pluginConfig = entry?.config ?? {};
const appServer =
@@ -450,7 +450,7 @@ function configureCodexCliNativeAuth(cfg: OpenClawConfig): OpenClawConfig {
...entry,
config: {
...pluginConfig,
appServer: { ...appServer, transport: "stdio", homeScope: "user" },
appServer: { ...appServer, transport: "stdio", homeScope: "agent" },
},
},
},
@@ -1424,7 +1424,7 @@ async function activateSetupInferenceUnredacted(
}
const normalizedCodexConfig = normalizePluginTargetConfig(ensured.cfg, "codex");
const enabledCodex = enablePluginInConfig(
configureCodexCliNativeAuth(normalizedCodexConfig),
configureCodexCliPreparedAuth(normalizedCodexConfig),
"codex",
);
if (!enabledCodex.enabled) {