mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix: complete Codex login handoff during onboarding (#107174)
This commit is contained in:
@@ -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" } },
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" } },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user