mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 10:21:34 +00:00
fix(onboard): survive inference owner drift and prefer subscription logins in guided setup (#112378)
* fix(onboard): survive inference owner drift and verify credentials route-aware
Guided onboarding crashed ("Could not start the CLI") on the openai-api-key
candidate when OPENAI_API_KEY was set and a Codex CLI login existed:
- owner revalidation resolved provider auth without the route model's
id/api, so the transport gate never applied and profile-first discovery
picked the Codex-imported ChatGPT OAuth profile instead of the env key
the probe ran with — a deterministic fingerprint mismatch
- that mismatch escaped the activation persistence path as a plain throw,
crashing the wizard ladder instead of moving to the next candidate
Owner re-resolution now carries resolved model transport facts (fail closed
when the model cannot be resolved), and all setup revalidation sites route
through one helper whose drift error activation maps to
{ ok: false, status: "auth" }.
* feat(onboard): prefer logged-in subscription CLIs over env API keys in setup ladder
* fix(onboard): promote codex only on verifiable ChatGPT OAuth tokens
* fix(onboard): surface detected AI candidates in the top auth-choice tier
* fix(onboard): name detected candidates and the current model clearly
* test(setup): mock model resolution in the bound-session lock test
* fix(system-agent): carry verified model transport facts
This commit is contained in:
@@ -180,7 +180,7 @@ setup workspace ~/Projects/work
|
||||
`setup` preserves the verified effective model. It does not configure or
|
||||
replace inference.
|
||||
|
||||
If inference is missing or its live check fails, leave OpenClaw and run `openclaw onboard`. Guided onboarding detects configured models, API keys, and authenticated local CLIs, asks each candidate for a real reply, and persists only a passing route. OpenClaw starts immediately after that boundary and can then configure the workspace, Gateway, channels, agents, plugins, and other optional features.
|
||||
If inference is missing or its live check fails, leave OpenClaw and run `openclaw onboard`. Guided onboarding tries the configured model first, then authenticated subscription CLIs, API keys, and remaining supported CLIs; it asks each candidate for a real reply and persists only a passing route. OpenClaw starts immediately after that boundary and can then configure the workspace, Gateway, channels, agents, plugins, and other optional features.
|
||||
|
||||
The macOS app skips this ladder entirely when it reaches a configured Gateway
|
||||
whose default agent already has a configured model; it opens the normal agent
|
||||
|
||||
@@ -603,6 +603,8 @@ export async function runPreparedEmbeddedLoop(
|
||||
startedAtMs: started,
|
||||
provider,
|
||||
modelId,
|
||||
modelTransportId: effectiveModel.id ?? modelId,
|
||||
modelTransportApi: effectiveModel.api ?? model.api,
|
||||
authProfileId: lastProfileId,
|
||||
profileFailureStore,
|
||||
attemptAuthProfileStore,
|
||||
|
||||
@@ -1636,6 +1636,8 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
|
||||
expect.objectContaining({
|
||||
authProfileId: "openai:work",
|
||||
agentHarnessId: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelApi: "openai-responses",
|
||||
authFingerprint: "resolved-secretref-fingerprint",
|
||||
runtimeOwnerKind: "plugin-harness",
|
||||
runtimeOwnerId: "codex",
|
||||
@@ -1719,6 +1721,8 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
|
||||
expect(onSuccessfulAuthBinding).toHaveBeenCalledWith({
|
||||
authProfileId: "openai:work",
|
||||
agentHarnessId: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelApi: "openai-responses",
|
||||
runtimeOwnerFingerprint: expect.any(String),
|
||||
runtimeOwnerKind: "plugin-harness",
|
||||
runtimeOwnerId: "codex",
|
||||
|
||||
@@ -75,6 +75,8 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: {
|
||||
apiKeyInfo: ResolvedProviderAuth | null;
|
||||
attempt: EmbeddedRunAttemptResult;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelApi: string;
|
||||
agentHarnessId: string;
|
||||
pluginHarnessOwnsTransport: boolean;
|
||||
pluginHarnessOwnsAuthBootstrap: boolean;
|
||||
@@ -141,6 +143,8 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: {
|
||||
input.onSuccessfulAuthBinding?.({
|
||||
...(input.profileId ? { authProfileId: input.profileId } : {}),
|
||||
agentHarnessId: input.agentHarnessId,
|
||||
modelId: input.modelId,
|
||||
modelApi: input.modelApi,
|
||||
...(authFingerprint ? { authFingerprint } : {}),
|
||||
...(runtimeOwnerFingerprint ? { runtimeOwnerFingerprint } : {}),
|
||||
...(runtimeOwnerKind ? { runtimeOwnerKind } : {}),
|
||||
|
||||
@@ -98,6 +98,8 @@ export async function resolveEmbeddedRunTerminal(input: {
|
||||
startedAtMs: number;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelTransportId: string;
|
||||
modelTransportApi: string;
|
||||
authProfileId?: string;
|
||||
profileFailureStore: AuthProfileStore;
|
||||
attemptAuthProfileStore: AuthProfileStore;
|
||||
@@ -447,6 +449,8 @@ function completeEmbeddedRun(
|
||||
apiKeyInfo: input.apiKeyInfo,
|
||||
attempt: input.attempt,
|
||||
provider: input.provider,
|
||||
modelId: input.modelTransportId,
|
||||
modelApi: input.modelTransportApi,
|
||||
agentHarnessId: input.agentHarnessId,
|
||||
pluginHarnessOwnsTransport: input.pluginHarnessOwnsTransport,
|
||||
pluginHarnessOwnsAuthBootstrap: input.pluginHarnessOwnsAuthBootstrap,
|
||||
|
||||
@@ -7,6 +7,10 @@ export type AgentExecutionAuthBinding = {
|
||||
authProfileId?: string;
|
||||
/** Exact embedded harness that completed the successful turn, including openclaw. */
|
||||
agentHarnessId?: string;
|
||||
/** Exact model selected by the successful embedded run. */
|
||||
modelId?: string;
|
||||
/** Exact transport used to select that run's credential. */
|
||||
modelApi?: string;
|
||||
/** Non-reversible identity hash; credential material never leaves the runner. */
|
||||
authFingerprint?: string;
|
||||
/** Runtime-owned principal/session shape used when credentials are intentionally opaque. */
|
||||
|
||||
@@ -19,6 +19,7 @@ export type AuthChoiceGroup = {
|
||||
value: AuthChoiceGroupId;
|
||||
label: string;
|
||||
hint?: string;
|
||||
methodMessage?: string;
|
||||
providerIds?: string[];
|
||||
options: AuthChoiceOption[];
|
||||
};
|
||||
|
||||
@@ -315,4 +315,80 @@ describe("promptAuthChoiceGrouped", () => {
|
||||
]);
|
||||
expect(result).toBe("minimax-cn-api");
|
||||
});
|
||||
|
||||
it("features caller-supplied groups first and excludes them from More", async () => {
|
||||
buildAuthChoiceGroups.mockReturnValue({
|
||||
groups: [
|
||||
openAIGroup(),
|
||||
authChoiceGroup("anthropic", "Anthropic", [["apiKey", "Anthropic API key"]], true),
|
||||
authChoiceGroup("minimax", "MiniMax", [["minimax-api", "MiniMax API key"]]),
|
||||
],
|
||||
skipOption: { value: "skip", label: "Skip for now" },
|
||||
});
|
||||
const providerPrompts: Array<Array<{ value: unknown; label: string }>> = [];
|
||||
const prompter = createPromptHarness(async (params) => {
|
||||
if (params.message !== "Model/auth provider") {
|
||||
throw new Error(`unexpected prompt ${params.message}`);
|
||||
}
|
||||
providerPrompts.push(params.options);
|
||||
return providerPrompts.length === 1 ? "__more" : "minimax";
|
||||
});
|
||||
|
||||
const result = await promptAuthChoiceGrouped({
|
||||
prompter,
|
||||
store: EMPTY_STORE,
|
||||
includeSkip: true,
|
||||
additionalGroups: [
|
||||
{
|
||||
...authChoiceGroup("detected-ai", "Detected AI", [["candidate:codex-cli", "Codex CLI"]]),
|
||||
hint: "Codex CLI",
|
||||
},
|
||||
authChoiceGroup("recommended-ai", "Recommended AI", [
|
||||
["candidate:openai-api-key", "OpenAI API key"],
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
expect(providerPrompts[0]?.map((option) => option.value)).toEqual([
|
||||
"detected-ai",
|
||||
"recommended-ai",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"__more",
|
||||
"skip",
|
||||
]);
|
||||
expect(providerPrompts[0]?.[0]).toMatchObject({
|
||||
value: "detected-ai",
|
||||
hint: "Codex CLI",
|
||||
});
|
||||
expect(providerPrompts[1]?.map((option) => option.value)).toEqual(["minimax", "__back"]);
|
||||
expect(result).toBe("minimax-api");
|
||||
});
|
||||
|
||||
it("uses a caller-supplied method prompt when provided", async () => {
|
||||
buildAuthChoiceGroups.mockReturnValue({ groups: [], skipOption: undefined });
|
||||
const messages: string[] = [];
|
||||
const prompter = createPromptHarness(async (params) => {
|
||||
messages.push(params.message);
|
||||
return params.message === "Model/auth provider" ? "detected-ai" : "candidate:codex-cli";
|
||||
});
|
||||
|
||||
const result = await promptAuthChoiceGrouped({
|
||||
prompter,
|
||||
store: EMPTY_STORE,
|
||||
includeSkip: false,
|
||||
additionalGroups: [
|
||||
{
|
||||
...authChoiceGroup("detected-ai", "Detected on this machine", [
|
||||
["candidate:codex-cli", "Codex CLI"],
|
||||
["candidate:claude-cli", "Claude Code"],
|
||||
]),
|
||||
methodMessage: "Use which detected AI?",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(messages).toEqual(["Model/auth provider", "Use which detected AI?"]);
|
||||
expect(result).toBe("candidate:codex-cli");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,14 +82,19 @@ export async function promptAuthChoiceGrouped(
|
||||
options: group.options.filter((option) => params.allowedChoices?.has(option.value)),
|
||||
}))
|
||||
: groups;
|
||||
const availableGroups = [...filteredGroups, ...(params.additionalGroups ?? [])].filter(
|
||||
const availableBuiltInGroups = filteredGroups.filter((group) => group.options.length > 0);
|
||||
const additionalGroups = (params.additionalGroups ?? []).filter(
|
||||
(group) => group.options.length > 0,
|
||||
);
|
||||
const availableGroups = [...availableBuiltInGroups, ...additionalGroups];
|
||||
const groupById = new Map(availableGroups.map((group) => [group.value, group] as const));
|
||||
const featuredGroups = availableGroups
|
||||
.filter(isFeaturedAuthChoiceGroup)
|
||||
.toSorted(compareAuthChoiceGroups);
|
||||
const moreGroups = availableGroups
|
||||
// Caller-supplied groups carry pre-vetted context such as detected onboarding routes.
|
||||
// Keep them ahead of the generic catalog instead of demoting them under More.
|
||||
const featuredGroups = [
|
||||
...additionalGroups,
|
||||
...availableBuiltInGroups.filter(isFeaturedAuthChoiceGroup).toSorted(compareAuthChoiceGroups),
|
||||
];
|
||||
const moreGroups = availableBuiltInGroups
|
||||
.filter((group) => !isFeaturedAuthChoiceGroup(group))
|
||||
.toSorted(compareAuthChoiceGroups);
|
||||
const configuredModelRef = resolveConfiguredModelRef(params.config);
|
||||
@@ -109,7 +114,7 @@ export async function promptAuthChoiceGrouped(
|
||||
return expectDefined(group.options[0], "options entry at 0").value;
|
||||
}
|
||||
return (await params.prompter.select({
|
||||
message: `${group.label} auth method`,
|
||||
message: group.methodMessage ?? `${group.label} auth method`,
|
||||
options: [
|
||||
...(keepCurrentOption ? [keepCurrentOption] : []),
|
||||
...group.options,
|
||||
|
||||
@@ -127,7 +127,9 @@ export async function runManualStage(params: {
|
||||
? [
|
||||
{
|
||||
value: "detected-ai",
|
||||
label: t("wizard.guided.detectedTitle"),
|
||||
label: t("wizard.guided.detectedGroupLabel"),
|
||||
hint: params.detection.candidates.map((candidate) => candidate.label).join(", "),
|
||||
methodMessage: t("wizard.guided.detectedGroupPrompt"),
|
||||
options: detectedOptions,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -69,7 +69,7 @@ function existingModelCandidate() {
|
||||
return {
|
||||
kind: "existing-model",
|
||||
label: "Current model",
|
||||
detail: "already configured",
|
||||
detail: "acme/workspace-model — already configured",
|
||||
modelRef: "acme/workspace-model",
|
||||
recommended: false,
|
||||
credentials: true,
|
||||
@@ -282,17 +282,64 @@ describe("runGuidedOnboarding custodian flow", () => {
|
||||
it("keeps the working route when other options are explored and skipped", async () => {
|
||||
promptAuthChoiceGrouped.mockResolvedValueOnce("skip");
|
||||
const prompter = createWizardPrompter(undefined, { selectValues: ["full", "other"] });
|
||||
const deps = setupDeps({ prompter });
|
||||
const deps = setupDeps({
|
||||
prompter,
|
||||
detect: vi.fn(async () =>
|
||||
detection({
|
||||
candidates: [candidate("claude-cli", "Claude Code"), candidate("codex-cli", "Codex")],
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
|
||||
expect(promptAuthChoiceGrouped).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
additionalGroups: [
|
||||
expect.objectContaining({
|
||||
label: "Detected on this machine",
|
||||
hint: "Claude Code, Codex",
|
||||
methodMessage: "Use which detected AI?",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const notes = JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls);
|
||||
expect(notes).toContain("Keeping the working AI you already have.");
|
||||
expect(notes).not.toContain("Add AI later");
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
|
||||
});
|
||||
|
||||
it("names the configured model in the recommended route confirmation", async () => {
|
||||
const prompter = createWizardPrompter(undefined, { selectValues: ["full", "use"] });
|
||||
const deps = setupDeps({
|
||||
prompter,
|
||||
detect: vi.fn(async () => detection({ candidates: [existingModelCandidate()] })),
|
||||
activate: vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
modelRef: "acme/workspace-model",
|
||||
latencyMs: 125,
|
||||
lines: ["Default model: acme/workspace-model"],
|
||||
})),
|
||||
});
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(prompter.select).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
message: "Use Current model (acme/workspace-model)?",
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
value: "use",
|
||||
label: "Continue with Current model (acme/workspace-model) — recommended",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("quips about detected coding agents", async () => {
|
||||
const prompter = createWizardPrompter();
|
||||
const deps = setupDeps({
|
||||
|
||||
@@ -78,7 +78,7 @@ function existingModelCandidate() {
|
||||
return {
|
||||
kind: "existing-model",
|
||||
label: "Current model",
|
||||
detail: "already configured",
|
||||
detail: "acme/workspace-model — already configured",
|
||||
modelRef: "acme/workspace-model",
|
||||
recommended: false,
|
||||
credentials: true,
|
||||
|
||||
@@ -286,7 +286,10 @@ async function runGuidedOnboardingFlow(
|
||||
});
|
||||
if (attempt.kind === "success") {
|
||||
resultLines = activationLines(attempt.result);
|
||||
successLabel = candidate.label;
|
||||
successLabel =
|
||||
candidate.kind === "existing-model"
|
||||
? `${candidate.label} (${candidate.modelRef})`
|
||||
: candidate.label;
|
||||
break;
|
||||
}
|
||||
// The verification probe runs outside the configured workspace (setup never
|
||||
|
||||
@@ -56,37 +56,130 @@ describe("detectInferenceBackends", () => {
|
||||
expect(candidates).toEqual([]);
|
||||
});
|
||||
|
||||
it("orders the ladder: existing model, env keys, then CLI logins", async () => {
|
||||
it("orders the ladder: existing model, logged-in subscriptions, env keys, then fallback CLIs", async () => {
|
||||
const candidates = await detectInferenceBackends({
|
||||
config: { agents: { defaults: { model: "zai/glm-5.2" } } },
|
||||
env: { OPENAI_API_KEY: "sk-x", ANTHROPIC_API_KEY: "sk-y" },
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true, codex: true }),
|
||||
probeLocalCommand: probeDeps({ claude: true, codex: true, gemini: true }),
|
||||
readClaudeCliCredentials: () => ({ type: "oauth" }),
|
||||
readCodexCliCredentials: () => ({ type: "oauth" }),
|
||||
readGeminiCliCredentials: () => ({ type: "oauth" }),
|
||||
randomInt: () => 0,
|
||||
},
|
||||
});
|
||||
expect(candidates.map((candidate) => candidate.kind)).toEqual([
|
||||
"existing-model",
|
||||
"claude-cli",
|
||||
"codex-cli",
|
||||
"openai-api-key",
|
||||
"anthropic-api-key",
|
||||
"gemini-cli",
|
||||
]);
|
||||
expect(candidates[0]?.modelRef).toBe("zai/glm-5.2");
|
||||
expect(candidates[0]?.detail).toBe("zai/glm-5.2 — already configured");
|
||||
expect(candidates[1]?.modelRef).toBe(CLAUDE_CLI_DEFAULT_MODEL_REF);
|
||||
expect(candidates[2]?.modelRef).toBe(CODEX_APP_SERVER_DEFAULT_MODEL_REF);
|
||||
expect(candidates[3]?.modelRef).toBe(OPENAI_API_DEFAULT_MODEL_REF);
|
||||
expect(candidates[4]?.modelRef).toBe(ANTHROPIC_API_DEFAULT_MODEL_REF);
|
||||
});
|
||||
|
||||
it("ranks a logged-in Codex subscription before an OpenAI environment key", async () => {
|
||||
const candidates = await detectInferenceBackends({
|
||||
env: { OPENAI_API_KEY: "sk-x" },
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ codex: true }),
|
||||
readCodexCliCredentials: () => ({ type: "oauth" }),
|
||||
},
|
||||
});
|
||||
expect(candidates.slice(0, 3).map((candidate) => candidate.kind)).toEqual([
|
||||
"existing-model",
|
||||
"openai-api-key",
|
||||
|
||||
expect(candidates.map((candidate) => candidate.kind)).toEqual(["codex-cli", "openai-api-key"]);
|
||||
});
|
||||
|
||||
it("keeps status-only Codex login after env keys without verifiable OAuth tokens", async () => {
|
||||
const candidates = await detectInferenceBackends({
|
||||
env: { OPENAI_API_KEY: "sk-x" },
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ codex: true }),
|
||||
readCodexCliCredentials: () => null,
|
||||
detectCodexLoginState: async () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(candidates.map((candidate) => candidate.kind)).toEqual(["openai-api-key", "codex-cli"]);
|
||||
expect(candidates[1]).toMatchObject({ credentials: true, detail: "logged in" });
|
||||
});
|
||||
|
||||
it("keeps API-key-helper-backed Claude after environment keys", async () => {
|
||||
const candidates = await detectInferenceBackends({
|
||||
env: { ANTHROPIC_API_KEY: "sk-y" },
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true }),
|
||||
readClaudeCliCredentials: () => ({ type: "api_key_helper" }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(candidates.map((candidate) => candidate.kind)).toEqual([
|
||||
"anthropic-api-key",
|
||||
"claude-cli",
|
||||
]);
|
||||
expect(candidates[1]).toMatchObject({ credentials: true, detail: "logged in" });
|
||||
});
|
||||
|
||||
it("keeps an Anthropic environment key ahead of unknown Claude credentials", async () => {
|
||||
const candidates = await detectInferenceBackends({
|
||||
env: { ANTHROPIC_API_KEY: "sk-y" },
|
||||
platform: "darwin",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true }),
|
||||
readClaudeCliCredentials: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(candidates.map((candidate) => candidate.kind)).toEqual([
|
||||
"anthropic-api-key",
|
||||
"claude-cli",
|
||||
]);
|
||||
expect(candidates[1]?.credentials).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a logged-in Gemini CLI after environment keys", async () => {
|
||||
const candidates = await detectInferenceBackends({
|
||||
env: { OPENAI_API_KEY: "sk-x" },
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ gemini: true }),
|
||||
readGeminiCliCredentials: () => ({ type: "oauth" }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(candidates.map((candidate) => candidate.kind)).toEqual(["openai-api-key", "gemini-cli"]);
|
||||
});
|
||||
|
||||
it("keeps the existing model first and definitively logged-out CLIs last", async () => {
|
||||
const candidates = await detectInferenceBackends({
|
||||
config: { agents: { defaults: { model: "zai/glm-5.2" } } },
|
||||
env: { OPENAI_API_KEY: "sk-x" },
|
||||
platform: "linux",
|
||||
deps: {
|
||||
probeLocalCommand: probeDeps({ claude: true, codex: true, gemini: true }),
|
||||
readClaudeCliCredentials: () => null,
|
||||
readCodexCliCredentials: () => ({ type: "oauth" }),
|
||||
readGeminiCliCredentials: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(candidates.map((candidate) => candidate.kind)).toEqual([
|
||||
"existing-model",
|
||||
"codex-cli",
|
||||
"openai-api-key",
|
||||
"claude-cli",
|
||||
"gemini-cli",
|
||||
]);
|
||||
expect(
|
||||
candidates
|
||||
.slice(3)
|
||||
.map((candidate) => candidate.kind)
|
||||
.toSorted(),
|
||||
).toEqual(["claude-cli", "codex-cli"]);
|
||||
expect(candidates[0]?.modelRef).toBe("zai/glm-5.2");
|
||||
expect(candidates[1]?.modelRef).toBe(OPENAI_API_DEFAULT_MODEL_REF);
|
||||
expect(candidates[2]?.modelRef).toBe(ANTHROPIC_API_DEFAULT_MODEL_REF);
|
||||
expect(
|
||||
candidates
|
||||
.slice(3)
|
||||
.map((candidate) => candidate.modelRef)
|
||||
.toSorted(),
|
||||
).toEqual([CLAUDE_CLI_DEFAULT_MODEL_REF, CODEX_APP_SERVER_DEFAULT_MODEL_REF].toSorted());
|
||||
});
|
||||
|
||||
it("prefers the configured default agent model over the global default", async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope-config.js";
|
||||
import {
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
readGeminiCliCredentialsCached,
|
||||
} from "../agents/cli-credentials.js";
|
||||
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
GEMINI_CLI_DEFAULT_MODEL_REF,
|
||||
detectAmbientInferenceBackends,
|
||||
type InferenceBackendCandidate,
|
||||
type InferenceBackendKind,
|
||||
} from "./onboard-inference-ambient.js";
|
||||
|
||||
export {
|
||||
@@ -41,6 +43,7 @@ type DetectInferenceBackendsDeps = {
|
||||
readClaudeCliCredentials?: () => { type: string } | null;
|
||||
readCodexCliCredentials?: () => { type: string } | null;
|
||||
readGeminiCliCredentials?: () => { type: string } | null;
|
||||
detectCodexLoginState?: typeof detectCodexLoginState;
|
||||
randomInt?: (maxExclusive: number) => number;
|
||||
};
|
||||
|
||||
@@ -174,6 +177,9 @@ export async function detectInferenceBackends(
|
||||
const readClaude =
|
||||
options.deps?.readClaudeCliCredentials ??
|
||||
(() => readClaudeCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 60_000 }));
|
||||
const readCodex =
|
||||
options.deps?.readCodexCliCredentials ??
|
||||
(() => readCodexCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 60_000 }));
|
||||
const readGemini =
|
||||
options.deps?.readGeminiCliCredentials ??
|
||||
(() => readGeminiCliCredentialsCached({ ttlMs: 60_000 }));
|
||||
@@ -191,17 +197,18 @@ export async function detectInferenceBackends(
|
||||
cfg: options.config ?? {},
|
||||
...(defaultAgentId ? { agentId: defaultAgentId } : {}),
|
||||
});
|
||||
const modelRef = `${resolved.provider}/${resolved.model}`;
|
||||
candidates.push({
|
||||
kind: "existing-model",
|
||||
// Approval and activation bind to the executable target, not a mutable
|
||||
// alias spelling. The authored config itself remains untouched.
|
||||
modelRef: `${resolved.provider}/${resolved.model}`,
|
||||
modelRef,
|
||||
label: "Current model",
|
||||
detail: "already configured",
|
||||
detail: `${modelRef} — already configured`,
|
||||
credentials: true,
|
||||
});
|
||||
}
|
||||
candidates.push(...detectAmbientInferenceBackends(env));
|
||||
const envCandidates = detectAmbientInferenceBackends(env);
|
||||
|
||||
const [claudeProbe, codexProbe, geminiProbe] = await Promise.all([
|
||||
probe("claude"),
|
||||
@@ -209,12 +216,17 @@ export async function detectInferenceBackends(
|
||||
probe("gemini"),
|
||||
]);
|
||||
const cliCandidates: InferenceBackendCandidate[] = [];
|
||||
const subscriptionPromotionEligibleCliKinds = new Set<InferenceBackendKind>();
|
||||
if (claudeProbe.found && !claudeProbe.timedOut) {
|
||||
const claudeCredential = readClaude();
|
||||
const credentials = detectCliCredentialState({
|
||||
probe: claudeProbe,
|
||||
hasStoredCredentials: readClaude() !== null,
|
||||
hasStoredCredentials: claudeCredential !== null,
|
||||
platform,
|
||||
});
|
||||
if (credentials === true && claudeCredential?.type === "oauth") {
|
||||
subscriptionPromotionEligibleCliKinds.add("claude-cli");
|
||||
}
|
||||
cliCandidates.push({
|
||||
kind: "claude-cli",
|
||||
modelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
@@ -224,13 +236,21 @@ export async function detectInferenceBackends(
|
||||
});
|
||||
}
|
||||
if (codexProbe.found && !codexProbe.timedOut) {
|
||||
const credentials = options.deps?.readCodexCliCredentials
|
||||
? detectCliCredentialState({
|
||||
probe: codexProbe,
|
||||
hasStoredCredentials: options.deps.readCodexCliCredentials() !== null,
|
||||
platform,
|
||||
})
|
||||
: await detectCodexLoginState(probe, codexProbe.command);
|
||||
const codexCredential = readCodex();
|
||||
const credentials = options.deps?.detectCodexLoginState
|
||||
? await options.deps.detectCodexLoginState(probe, codexProbe.command)
|
||||
: options.deps?.readCodexCliCredentials
|
||||
? detectCliCredentialState({
|
||||
probe: codexProbe,
|
||||
hasStoredCredentials: codexCredential !== null,
|
||||
platform,
|
||||
})
|
||||
: await detectCodexLoginState(probe, codexProbe.command);
|
||||
// Promote only prompt-free ChatGPT OAuth tokens. Status-only logins may be metered;
|
||||
// keychain-only ChatGPT users conservatively stay usable in the fallback tier.
|
||||
if (credentials === true && codexCredential?.type === "oauth") {
|
||||
subscriptionPromotionEligibleCliKinds.add("codex-cli");
|
||||
}
|
||||
cliCandidates.push({
|
||||
kind: "codex-cli",
|
||||
modelRef: CODEX_APP_SERVER_DEFAULT_MODEL_REF,
|
||||
@@ -251,14 +271,24 @@ export async function detectInferenceBackends(
|
||||
credentials,
|
||||
});
|
||||
}
|
||||
// Claude Code and Codex are equivalent subscription-backed choices. When both
|
||||
// may be usable, randomize their first-test order instead of encoding a preference.
|
||||
// Claude Code and Codex share rank within a credential tier. Randomize before
|
||||
// partitioning so logged-in and unknown ties keep no provider preference.
|
||||
randomizeClaudeCodexTie(cliCandidates, options.deps?.randomInt ?? randomInt);
|
||||
// Stable partition: definitively logged-out installs still sink below usable or
|
||||
// keychain-unknown candidates; Gemini retains its documented fallback position.
|
||||
const loggedInSubscriptionCliCandidates = cliCandidates.filter(
|
||||
(candidate) =>
|
||||
candidate.credentials === true && subscriptionPromotionEligibleCliKinds.has(candidate.kind),
|
||||
);
|
||||
const remainingCliCandidates = cliCandidates.filter(
|
||||
(candidate) => !loggedInSubscriptionCliCandidates.includes(candidate),
|
||||
);
|
||||
// Verified flat-rate subscription logins outrank metered environment keys.
|
||||
// Existing models stay first so guided setup never silently replaces one.
|
||||
candidates.push(
|
||||
...cliCandidates.filter((candidate) => candidate.credentials !== false),
|
||||
...cliCandidates.filter((candidate) => candidate.credentials === false),
|
||||
...loggedInSubscriptionCliCandidates,
|
||||
...envCandidates,
|
||||
// Unknown login states and Gemini remain fallbacks; definitive logouts sink last.
|
||||
...remainingCliCandidates.filter((candidate) => candidate.credentials !== false),
|
||||
...remainingCliCandidates.filter((candidate) => candidate.credentials === false),
|
||||
);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -202,6 +202,8 @@ describe("runSystemAgentTurn", () => {
|
||||
authProfileId: "openai:p2",
|
||||
authFingerprint,
|
||||
agentHarnessId: "openclaw",
|
||||
modelId: executionRoute.model,
|
||||
modelApi: "openai-responses",
|
||||
},
|
||||
deps: authDeps,
|
||||
});
|
||||
|
||||
@@ -124,7 +124,12 @@ describe("OpenClaw configured-model planner", () => {
|
||||
const binding = await createSystemAgentVerifiedInferenceBinding({
|
||||
configuredRoute,
|
||||
executionRoute: { ...configuredRoute, authProfileId: "openai:p2" },
|
||||
auth: { authProfileId: "openai:p2", authFingerprint },
|
||||
auth: {
|
||||
authProfileId: "openai:p2",
|
||||
authFingerprint,
|
||||
modelId: configuredRoute.model,
|
||||
modelApi: "openai-responses",
|
||||
},
|
||||
deps: authDeps,
|
||||
});
|
||||
const runEmbeddedAgent = vi.fn(async () => ({
|
||||
|
||||
@@ -162,7 +162,12 @@ async function createAmbientVerifiedBinding(config: OpenClawConfig) {
|
||||
return await createSystemAgentVerifiedInferenceBinding({
|
||||
configuredRoute: route,
|
||||
executionRoute: route,
|
||||
auth: { authFingerprint, ...harnessBinding.auth },
|
||||
auth: {
|
||||
authFingerprint,
|
||||
modelId: route.model,
|
||||
modelApi: route.provider === "anthropic" ? "anthropic-messages" : "openai-responses",
|
||||
...harnessBinding.auth,
|
||||
},
|
||||
deps: harnessBinding.deps,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -312,6 +312,13 @@ function successfulRun(provider: string, model: string, params?: SuccessfulRunPa
|
||||
: {
|
||||
...successfulAgentHarnessBinding(params),
|
||||
authFingerprint: "test-credential-owner",
|
||||
modelId: model,
|
||||
modelApi:
|
||||
provider === "anthropic"
|
||||
? "anthropic-messages"
|
||||
: provider === "groq"
|
||||
? "openai-completions"
|
||||
: "openai-responses",
|
||||
...(params?.authProfileId ? { authProfileId: params.authProfileId } : {}),
|
||||
},
|
||||
);
|
||||
@@ -733,7 +740,7 @@ describe("detectSetupInference", () => {
|
||||
kind: "existing-model",
|
||||
modelRef: "openai/gpt-5.5",
|
||||
label: "Current model",
|
||||
detail: "already configured",
|
||||
detail: "openai/gpt-5.5 — already configured",
|
||||
credentials: true,
|
||||
},
|
||||
]);
|
||||
@@ -1681,6 +1688,35 @@ describe("activateSetupInference", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an auth failure when the verified owner drifts during persistence", async () => {
|
||||
const configHarness = createConfigTransformHarness();
|
||||
const result = await activateSetupInference({
|
||||
kind: "openai-api-key",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: {
|
||||
runEmbeddedAgent: vi.fn(successfulRunner("openai", "gpt-5.6")) as never,
|
||||
transformConfigWithPendingPluginInstalls: configHarness.transform as never,
|
||||
// The real revalidation throws when the current route owner no longer
|
||||
// matches the probe credential (e.g. a Codex-imported OAuth profile
|
||||
// outranking the probed env key). The ladder needs a failure result.
|
||||
createSystemAgentVerifiedInferenceBinding: vi.fn(async () => {
|
||||
throw new Error(
|
||||
"The successful inference credential is no longer the active route owner.",
|
||||
);
|
||||
}) as never,
|
||||
createTempDir: makeTempDir,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
status: "auth",
|
||||
error: expect.stringContaining("verified inference owner changed"),
|
||||
});
|
||||
expect(configHarness.current()).toEqual({});
|
||||
});
|
||||
|
||||
it("revalidates a stable CLI runtime owner at the config commit boundary", async () => {
|
||||
const configHarness = createConfigTransformHarness();
|
||||
const resolveCliRuntimeOwnerFingerprint = vi.fn(async () => "test-runtime-owner");
|
||||
@@ -1754,28 +1790,36 @@ describe("activateSetupInference", () => {
|
||||
params.onSuccessfulAuthBinding?.({
|
||||
...successfulAgentHarnessBinding(params),
|
||||
authFingerprint: initialAuthFingerprint,
|
||||
modelId: "claude-opus-4-8",
|
||||
modelApi: "anthropic-messages",
|
||||
});
|
||||
return successfulRun("anthropic", "claude-opus-4-8");
|
||||
});
|
||||
|
||||
await expect(
|
||||
activateSetupInference({
|
||||
kind: "anthropic-api-key",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: {
|
||||
runEmbeddedAgent: runEmbeddedAgent as never,
|
||||
resolveApiKeyForProvider: vi.fn(async () => ({
|
||||
apiKey: "rotated-env-key",
|
||||
source: "env:ANTHROPIC_API_KEY",
|
||||
mode: "api-key",
|
||||
})) as never,
|
||||
createSystemAgentVerifiedInferenceBinding,
|
||||
transformConfigWithPendingPluginInstalls: configHarness.transform as never,
|
||||
createTempDir: makeTempDir,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("active route owner");
|
||||
const result = await activateSetupInference({
|
||||
kind: "anthropic-api-key",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: {
|
||||
runEmbeddedAgent: runEmbeddedAgent as never,
|
||||
resolveApiKeyForProvider: vi.fn(async () => ({
|
||||
apiKey: "rotated-env-key",
|
||||
source: "env:ANTHROPIC_API_KEY",
|
||||
mode: "api-key",
|
||||
})) as never,
|
||||
createSystemAgentVerifiedInferenceBinding,
|
||||
transformConfigWithPendingPluginInstalls: configHarness.transform as never,
|
||||
createTempDir: makeTempDir,
|
||||
},
|
||||
});
|
||||
|
||||
// Owner drift is a failure result, not a throw: the guided-onboarding
|
||||
// ladder must be able to continue to its next candidate.
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
status: "auth",
|
||||
error: expect.stringContaining("active route owner"),
|
||||
});
|
||||
expect(configHarness.transform).toHaveBeenCalledOnce();
|
||||
expect(configHarness.current()).toEqual({});
|
||||
});
|
||||
@@ -2427,6 +2471,8 @@ describe("activateSetupInference", () => {
|
||||
authProfileId: "groq:fallback",
|
||||
...successfulAgentHarnessBinding(params),
|
||||
authFingerprint: "fallback-owner",
|
||||
modelId: "llama-3.3-70b-versatile",
|
||||
modelApi: "openai-completions",
|
||||
});
|
||||
return successfulRun("groq", "llama-3.3-70b-versatile");
|
||||
},
|
||||
@@ -2685,42 +2731,49 @@ describe("activateSetupInference", () => {
|
||||
authProfileId: profileId,
|
||||
...successfulAgentHarnessBinding(params),
|
||||
authFingerprint,
|
||||
modelId: "llama-3.3-70b-versatile",
|
||||
modelApi: "openai-completions",
|
||||
});
|
||||
return successfulRun("groq", "llama-3.3-70b-versatile");
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
activateSetupInference({
|
||||
kind: "api-key",
|
||||
authChoice: "groq-api-key",
|
||||
apiKey: "submitted-key",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: {
|
||||
readConfigFileSnapshot: vi.fn(async () => ({
|
||||
exists: true,
|
||||
valid: true,
|
||||
config: initialConfig,
|
||||
runtimeConfig: initialConfig,
|
||||
})) as never,
|
||||
resolvePluginProviders: () => [createGroqSetupProvider()],
|
||||
resolveManifestProviderAuthChoice: groqSetupChoice,
|
||||
runEmbeddedAgent: runEmbeddedAgent as never,
|
||||
resolveApiKeyForProvider: vi.fn(async (params: { profileId?: string }) => ({
|
||||
apiKey: "different-real-store-key",
|
||||
profileId: params.profileId,
|
||||
source: `profile:${params.profileId}`,
|
||||
mode: "api-key",
|
||||
})) as never,
|
||||
createSystemAgentVerifiedInferenceBinding,
|
||||
transformConfigWithPendingPluginInstalls: configHarness.transform as never,
|
||||
createTempDir: makeTempDir,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("active route owner");
|
||||
const result = await activateSetupInference({
|
||||
kind: "api-key",
|
||||
authChoice: "groq-api-key",
|
||||
apiKey: "submitted-key",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: {
|
||||
readConfigFileSnapshot: vi.fn(async () => ({
|
||||
exists: true,
|
||||
valid: true,
|
||||
config: initialConfig,
|
||||
runtimeConfig: initialConfig,
|
||||
})) as never,
|
||||
resolvePluginProviders: () => [createGroqSetupProvider()],
|
||||
resolveManifestProviderAuthChoice: groqSetupChoice,
|
||||
runEmbeddedAgent: runEmbeddedAgent as never,
|
||||
resolveApiKeyForProvider: vi.fn(async (params: { profileId?: string }) => ({
|
||||
apiKey: "different-real-store-key",
|
||||
profileId: params.profileId,
|
||||
source: `profile:${params.profileId}`,
|
||||
mode: "api-key",
|
||||
})) as never,
|
||||
createSystemAgentVerifiedInferenceBinding,
|
||||
transformConfigWithPendingPluginInstalls: configHarness.transform as never,
|
||||
createTempDir: makeTempDir,
|
||||
},
|
||||
});
|
||||
|
||||
// Owner drift resolves to an auth failure after rolling back the staged
|
||||
// credential; a throw here used to crash the onboarding wizard.
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
status: "auth",
|
||||
error: expect.stringContaining("active route owner"),
|
||||
});
|
||||
expect(configHarness.current()).toEqual(canonicalizeAgentEntriesForTest(initialConfig));
|
||||
expect(
|
||||
Object.keys(readAuthProfileStoreForTest(agentDir).profiles).filter((id) =>
|
||||
@@ -5135,12 +5188,16 @@ describe("verifySetupInference", () => {
|
||||
authProfileId?: string;
|
||||
agentHarnessId?: string;
|
||||
authFingerprint?: string;
|
||||
modelId?: string;
|
||||
modelApi?: string;
|
||||
}) => void;
|
||||
}) => {
|
||||
params.onSuccessfulAuthBinding?.({
|
||||
authProfileId: "openai:p2",
|
||||
agentHarnessId: "openclaw",
|
||||
authFingerprint: verifiedAuthFingerprint,
|
||||
modelId: "gpt-5.5",
|
||||
modelApi: "openai-responses",
|
||||
});
|
||||
return successfulRun("openai", "gpt-5.5");
|
||||
},
|
||||
@@ -5202,6 +5259,8 @@ describe("verifySetupInference", () => {
|
||||
authProfileId: profileId,
|
||||
...successfulAgentHarnessBinding(params),
|
||||
authFingerprint,
|
||||
modelId: "gpt-5.5",
|
||||
modelApi: "openai-responses",
|
||||
});
|
||||
return successfulRun("openai", "gpt-5.5");
|
||||
});
|
||||
@@ -5287,6 +5346,8 @@ describe("verifySetupInference", () => {
|
||||
authProfileId: profileId,
|
||||
...successfulAgentHarnessBinding(params),
|
||||
authFingerprint,
|
||||
modelId: "gpt-5.6-sol",
|
||||
modelApi: "openai-responses",
|
||||
});
|
||||
return successfulRun("openai", "gpt-5.6-sol");
|
||||
});
|
||||
|
||||
@@ -197,6 +197,15 @@ class SetupInferenceActivationUnavailableError extends Error {
|
||||
override name = "SetupInferenceActivationUnavailableError";
|
||||
}
|
||||
|
||||
/**
|
||||
* The live-tested owner no longer matches current config. Activation maps this
|
||||
* to `{ ok: false, status: "auth" }` so the guided-onboarding ladder can move
|
||||
* to its next candidate instead of crashing the CLI.
|
||||
*/
|
||||
class SetupInferenceOwnerDriftError extends Error {
|
||||
override name = "SetupInferenceOwnerDriftError";
|
||||
}
|
||||
|
||||
export type VerifySetupInferenceResult =
|
||||
| {
|
||||
ok: true;
|
||||
@@ -1612,6 +1621,9 @@ export async function activateSetupInference(
|
||||
if (error instanceof SetupInferenceActivationUnavailableError) {
|
||||
return { ok: false, status: "unavailable", error: redacted };
|
||||
}
|
||||
if (error instanceof SetupInferenceOwnerDriftError) {
|
||||
return { ok: false, status: "auth", error: redacted };
|
||||
}
|
||||
if (error instanceof SetupInferenceActivationIndeterminateError) {
|
||||
throw new SetupInferenceActivationIndeterminateError(redacted);
|
||||
}
|
||||
@@ -2018,23 +2030,12 @@ async function activateSetupInferenceUnredacted(
|
||||
"The default-agent inference route could not be resolved after its live test. Review the current model/auth/runtime settings and retry.",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const binding = await revalidateSetupInferenceOwner({
|
||||
route: latestResolvedRoute,
|
||||
auth: test.auth,
|
||||
deps,
|
||||
});
|
||||
if (!hasSameOwnerPluginArtifacts(binding, stagedOwnerPluginArtifacts)) {
|
||||
throw new Error("inference owner plugin runtime changed during its live test");
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: "auth",
|
||||
error:
|
||||
"The verified inference owner changed before activation completed. Retry the inference check.",
|
||||
};
|
||||
}
|
||||
await revalidateStableSetupInferenceOwner({
|
||||
route: latestResolvedRoute,
|
||||
auth: test.auth,
|
||||
stagedOwnerPluginArtifacts,
|
||||
deps,
|
||||
});
|
||||
}
|
||||
if (needsPersistence) {
|
||||
const { stripPendingPluginInstallRecords } =
|
||||
@@ -2232,14 +2233,12 @@ async function activateSetupInferenceUnredacted(
|
||||
"The source config no longer matches the verified candidate, so it was not saved. Review the current config and retry.",
|
||||
);
|
||||
}
|
||||
const binding = await revalidateSetupInferenceOwner({
|
||||
await revalidateStableSetupInferenceOwner({
|
||||
route: nextResolvedRoute,
|
||||
auth: test.auth,
|
||||
stagedOwnerPluginArtifacts,
|
||||
deps,
|
||||
});
|
||||
if (!hasSameOwnerPluginArtifacts(binding, stagedOwnerPluginArtifacts)) {
|
||||
throw new Error("inference owner plugin runtime changed during its live test");
|
||||
}
|
||||
// Once this callback returns, the config writer owns the candidate.
|
||||
// Any later throw may be post-commit and needs reconciliation.
|
||||
throwIfSetupInferenceCancelled(params);
|
||||
@@ -2411,6 +2410,41 @@ function hasSameOwnerPluginArtifacts(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidate the successful probe's owner against current config. Any drift
|
||||
* throws SetupInferenceOwnerDriftError, which activation returns as an auth
|
||||
* failure result — a throw that escapes here would crash the onboarding ladder.
|
||||
*/
|
||||
async function revalidateStableSetupInferenceOwner(params: {
|
||||
route: SystemAgentConfiguredRoute;
|
||||
auth: AgentExecutionAuthBinding;
|
||||
stagedOwnerPluginArtifacts: SystemAgentOwnerPluginArtifactSnapshot | undefined;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
}): Promise<SystemAgentVerifiedInferenceBinding> {
|
||||
let binding: SystemAgentVerifiedInferenceBinding;
|
||||
try {
|
||||
binding = await revalidateSetupInferenceOwner({
|
||||
route: params.route,
|
||||
auth: params.auth,
|
||||
deps: params.deps,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new SetupInferenceOwnerDriftError(
|
||||
`The verified inference owner changed before activation completed. Retry the inference check. (${formatErrorMessage(error)})`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (
|
||||
!params.stagedOwnerPluginArtifacts ||
|
||||
!hasSameOwnerPluginArtifacts(binding, params.stagedOwnerPluginArtifacts)
|
||||
) {
|
||||
throw new SetupInferenceOwnerDriftError(
|
||||
"The verified inference owner changed before activation completed. Retry the inference check. (The owner plugin runtime changed during its live test.)",
|
||||
);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
type VerifySetupInferenceParams = {
|
||||
kind?: "existing-model";
|
||||
agentId?: string;
|
||||
@@ -2787,17 +2821,12 @@ export async function verifySetupInferenceConfig(params: {
|
||||
}
|
||||
if (params.requireExecutionOwner || params.onVerifiedExecution) {
|
||||
try {
|
||||
const binding = await revalidateSetupInferenceOwner({
|
||||
const binding = await revalidateStableSetupInferenceOwner({
|
||||
route: configuredRoute!,
|
||||
auth: test.auth,
|
||||
stagedOwnerPluginArtifacts,
|
||||
deps,
|
||||
});
|
||||
if (
|
||||
!stagedOwnerPluginArtifacts ||
|
||||
!hasSameOwnerPluginArtifacts(binding, stagedOwnerPluginArtifacts)
|
||||
) {
|
||||
throw new Error("inference owner plugin runtime changed during its live test");
|
||||
}
|
||||
params.onVerifiedExecution?.(test.auth, binding);
|
||||
} catch {
|
||||
return {
|
||||
|
||||
@@ -154,6 +154,9 @@ export async function createSystemAgentVerifiedInferenceTestFixture(
|
||||
...(profileId ? { authProfileId: profileId } : {}),
|
||||
authFingerprint,
|
||||
agentHarnessId,
|
||||
modelId: configuredRoute.model,
|
||||
modelApi:
|
||||
configuredRoute.provider === "anthropic" ? "anthropic-messages" : "openai-responses",
|
||||
...(agentHarnessId === "openclaw"
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -216,6 +216,8 @@ async function bindingFor(
|
||||
auth: {
|
||||
authProfileId: "openai:verified",
|
||||
authFingerprint,
|
||||
modelId: route.model,
|
||||
modelApi: route.provider === "anthropic" ? "anthropic-messages" : "openai-responses",
|
||||
...(agentHarnessId
|
||||
? {
|
||||
agentHarnessId,
|
||||
@@ -333,6 +335,91 @@ describe("verified OpenClaw inference binding", () => {
|
||||
).rejects.toThrow("active secret unavailable");
|
||||
});
|
||||
|
||||
it("reuses the successful model transport facts for owner re-resolution", async () => {
|
||||
// The successful run already resolved the model under its selected auth
|
||||
// plan. Revalidation must carry that exact tuple forward instead of
|
||||
// repeating catalog and provider discovery in the authority hot path.
|
||||
const envConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5.6",
|
||||
models: { "openai/gpt-5.6": { agentRuntime: { id: "openclaw" } } },
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const route = await resolveSystemAgentConfiguredRouteFromConfig(envConfig);
|
||||
if (!route) {
|
||||
throw new Error("missing test route");
|
||||
}
|
||||
const envAuth = {
|
||||
apiKey: "env-key",
|
||||
source: "env: OPENAI_API_KEY",
|
||||
mode: "api-key" as const,
|
||||
};
|
||||
const authFingerprint = fingerprintResolvedProviderAuth(envAuth);
|
||||
if (!authFingerprint) {
|
||||
throw new Error("missing test env fingerprint");
|
||||
}
|
||||
const resolveAuth = vi.fn(async () => envAuth);
|
||||
const binding = await createSystemAgentVerifiedInferenceBinding({
|
||||
configuredRoute: route,
|
||||
executionRoute: route,
|
||||
auth: {
|
||||
authFingerprint,
|
||||
agentHarnessId: "openclaw",
|
||||
modelId: "gpt-5.6",
|
||||
modelApi: "openai-responses",
|
||||
},
|
||||
deps: {
|
||||
...pluginArtifactDeps(),
|
||||
resolveApiKeyForProvider: resolveAuth as never,
|
||||
},
|
||||
});
|
||||
|
||||
expect(binding.auth.authFingerprint).toBe(authFingerprint);
|
||||
expect(resolveAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modelId: "gpt-5.6", modelApi: "openai-responses" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when a credential-backed run omits its model transport facts", async () => {
|
||||
const envConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5.6",
|
||||
models: { "openai/gpt-5.6": { agentRuntime: { id: "openclaw" } } },
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const route = await resolveSystemAgentConfiguredRouteFromConfig(envConfig);
|
||||
if (!route) {
|
||||
throw new Error("missing test route");
|
||||
}
|
||||
const envAuth = {
|
||||
apiKey: "env-key",
|
||||
source: "env: OPENAI_API_KEY",
|
||||
mode: "api-key" as const,
|
||||
};
|
||||
const authFingerprint = fingerprintResolvedProviderAuth(envAuth);
|
||||
if (!authFingerprint) {
|
||||
throw new Error("missing test env fingerprint");
|
||||
}
|
||||
const resolveAuth = vi.fn(async () => envAuth);
|
||||
|
||||
await expect(
|
||||
createSystemAgentVerifiedInferenceBinding({
|
||||
configuredRoute: route,
|
||||
executionRoute: route,
|
||||
auth: { authFingerprint, agentHarnessId: "openclaw" },
|
||||
deps: {
|
||||
...pluginArtifactDeps(),
|
||||
resolveApiKeyForProvider: resolveAuth as never,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("no longer the active route owner");
|
||||
expect(resolveAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts and revalidates an opaque CLI owner emitted after a successful turn", async () => {
|
||||
const cliConfig = {
|
||||
agents: { defaults: { model: "claude-cli/claude-opus-4-8" } },
|
||||
@@ -838,6 +925,8 @@ describe("verified OpenClaw inference binding", () => {
|
||||
authProfileId: "openai:verified",
|
||||
authFingerprint,
|
||||
agentHarnessId: "openclaw",
|
||||
modelId: configuredRoute.model,
|
||||
modelApi: "openai-responses",
|
||||
},
|
||||
deps: { ...authDeps(), ...pluginArtifactDeps() },
|
||||
});
|
||||
@@ -1003,6 +1092,8 @@ describe("verified OpenClaw inference binding", () => {
|
||||
authProfileId: "openai:verified",
|
||||
authFingerprint,
|
||||
agentHarnessId: "codex",
|
||||
modelId: route.model,
|
||||
modelApi: "openai-responses",
|
||||
runtimeOwnerKind: "plugin-harness",
|
||||
runtimeOwnerId: "codex",
|
||||
...codexRuntimeArtifactAuth,
|
||||
|
||||
@@ -113,6 +113,8 @@ export type SystemAgentVerifiedInferenceBinding = Readonly<{
|
||||
auth: Readonly<{
|
||||
authProfileId?: string;
|
||||
agentHarnessId?: string;
|
||||
modelId?: string;
|
||||
modelApi?: string;
|
||||
authFingerprint: string;
|
||||
proofKind?: "runtime-owner";
|
||||
runtimeOwnerKind?: OpaqueRuntimeOwnerKind;
|
||||
@@ -487,6 +489,8 @@ export function captureSystemAgentOwnerPluginArtifacts(params: {
|
||||
async function resolveCurrentAuthFingerprint(params: {
|
||||
route: SystemAgentConfiguredRoute;
|
||||
authProfileId?: string;
|
||||
modelId?: string;
|
||||
modelApi?: string;
|
||||
skipLocalCredential?: boolean;
|
||||
deps: SystemAgentVerifiedInferenceDeps;
|
||||
}): Promise<string | undefined> {
|
||||
@@ -576,6 +580,9 @@ async function resolveCurrentAuthFingerprint(params: {
|
||||
deps: params.deps,
|
||||
});
|
||||
}
|
||||
if (!params.modelId || !params.modelApi) {
|
||||
return undefined;
|
||||
}
|
||||
const resolveAuth = params.deps.resolveApiKeyForProvider ?? resolveApiKeyForProvider;
|
||||
const auth = await resolveAuth({
|
||||
provider: params.route.provider,
|
||||
@@ -588,6 +595,8 @@ async function resolveCurrentAuthFingerprint(params: {
|
||||
),
|
||||
profileId: params.authProfileId,
|
||||
lockedProfile: true,
|
||||
modelId: params.modelId,
|
||||
modelApi: params.modelApi,
|
||||
secretSentinels: false,
|
||||
});
|
||||
if (auth.profileId !== params.authProfileId || !auth.apiKey) {
|
||||
@@ -600,6 +609,11 @@ async function resolveCurrentAuthFingerprint(params: {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Credential selection is transport-sensitive. Reuse the facts from the
|
||||
// successful run so this authority hot path cannot pick a different owner.
|
||||
if (!params.modelId || !params.modelApi) {
|
||||
return undefined;
|
||||
}
|
||||
const resolveAuth = params.deps.resolveApiKeyForProvider ?? resolveApiKeyForProvider;
|
||||
const auth = await resolveAuth({
|
||||
provider: params.route.provider,
|
||||
@@ -613,6 +627,8 @@ async function resolveCurrentAuthFingerprint(params: {
|
||||
...(params.authProfileId
|
||||
? { profileId: params.authProfileId, lockedProfile: true as const }
|
||||
: {}),
|
||||
modelId: params.modelId,
|
||||
modelApi: params.modelApi,
|
||||
secretSentinels: true,
|
||||
});
|
||||
if (params.authProfileId && auth.profileId !== params.authProfileId) {
|
||||
@@ -631,6 +647,8 @@ export async function createSystemAgentVerifiedInferenceBinding(params: {
|
||||
const runConfig = structuredClone(params.executionRoute.runConfig);
|
||||
const execution = { ...params.executionRoute, runConfig } as SystemAgentConfiguredRoute;
|
||||
const authProfileId = params.auth.authProfileId ?? execution.authProfileId;
|
||||
const modelId = params.auth.modelId?.trim();
|
||||
const modelApi = params.auth.modelApi?.trim();
|
||||
if (authProfileId) {
|
||||
execution.authProfileId = authProfileId;
|
||||
}
|
||||
@@ -725,6 +743,8 @@ export async function createSystemAgentVerifiedInferenceBinding(params: {
|
||||
: resolveCurrentAuthFingerprint({
|
||||
route: execution,
|
||||
...(authProfileId ? { authProfileId } : {}),
|
||||
...(modelId ? { modelId } : {}),
|
||||
...(modelApi ? { modelApi } : {}),
|
||||
...(params.auth.skipLocalCredential ? { skipLocalCredential: true } : {}),
|
||||
deps,
|
||||
}));
|
||||
@@ -763,6 +783,8 @@ export async function createSystemAgentVerifiedInferenceBinding(params: {
|
||||
auth: {
|
||||
...(authProfileId ? { authProfileId } : {}),
|
||||
...(successfulHarnessId ? { agentHarnessId: successfulHarnessId } : {}),
|
||||
...(modelId ? { modelId } : {}),
|
||||
...(modelApi ? { modelApi } : {}),
|
||||
authFingerprint,
|
||||
...(proofKind === "runtime-owner" ? { proofKind } : {}),
|
||||
...(params.auth.runtimeOwnerKind ? { runtimeOwnerKind: params.auth.runtimeOwnerKind } : {}),
|
||||
@@ -917,6 +939,8 @@ export async function resolveSystemAgentVerifiedInferenceRoute(
|
||||
: resolveCurrentAuthFingerprint({
|
||||
route: currentExecution,
|
||||
...(binding.auth.authProfileId ? { authProfileId: binding.auth.authProfileId } : {}),
|
||||
...(binding.auth.modelId ? { modelId: binding.auth.modelId } : {}),
|
||||
...(binding.auth.modelApi ? { modelApi: binding.auth.modelApi } : {}),
|
||||
...(binding.auth.skipLocalCredential ? { skipLocalCredential: true } : {}),
|
||||
deps,
|
||||
})
|
||||
|
||||
@@ -309,6 +309,8 @@ export const en = {
|
||||
completeWithoutAi: "OpenClaw setup is saved. Connect AI before opening chat.",
|
||||
detected: "AI detection complete.",
|
||||
detectedCandidate: "{label} — {detail}{recommended}",
|
||||
detectedGroupLabel: "Detected on this machine",
|
||||
detectedGroupPrompt: "Use which detected AI?",
|
||||
detectedTitle: "AI found",
|
||||
detecting: "Looking for AI you already use…",
|
||||
enterApiKey: "Enter API key — {label}",
|
||||
|
||||
@@ -302,6 +302,8 @@ export const zh_CN = {
|
||||
completeWithoutAi: "OpenClaw 设置已保存。连接 AI 后再打开聊天。",
|
||||
detected: "AI 检测完成。",
|
||||
detectedCandidate: "{label} — {detail}{recommended}",
|
||||
detectedGroupLabel: "在这台机器上检测到的 AI",
|
||||
detectedGroupPrompt: "要使用哪个检测到的 AI?",
|
||||
detectedTitle: "找到的 AI",
|
||||
detecting: "正在查找你已使用的 AI…",
|
||||
enterApiKey: "输入 API key — {label}",
|
||||
|
||||
@@ -302,6 +302,8 @@ export const zh_TW = {
|
||||
completeWithoutAi: "OpenClaw 設定已儲存。連接 AI 後再開啟聊天。",
|
||||
detected: "AI 偵測完成。",
|
||||
detectedCandidate: "{label} — {detail}{recommended}",
|
||||
detectedGroupLabel: "在這台機器上偵測到的 AI",
|
||||
detectedGroupPrompt: "要使用哪個偵測到的 AI?",
|
||||
detectedTitle: "找到的 AI",
|
||||
detecting: "正在尋找你已使用的 AI…",
|
||||
enterApiKey: "輸入 API key — {label}",
|
||||
|
||||
Reference in New Issue
Block a user