mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(system-agent): split setup inference phases (#113028)
* refactor(system-agent): split setup inference phases * fix(system-agent): preserve activation helper types * fix(system-agent): keep read-only inference verification * test(system-agent): track moved lint suppressions
This commit is contained in:
committed by
GitHub
parent
8108b9637b
commit
5bd719fb9c
@@ -1043,7 +1043,6 @@ src/status/status-message.ts
|
||||
src/system-agent/chat-engine.test.ts
|
||||
src/system-agent/chat-engine.ts
|
||||
src/system-agent/setup-inference.test.ts
|
||||
src/system-agent/setup-inference.ts
|
||||
src/system-agent/verified-inference.test.ts
|
||||
src/system-agent/verified-inference.ts
|
||||
src/tasks/task-executor.test.ts
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { AgentExecutionAuthBinding } from "../agents/execution-auth-binding.js";
|
||||
import { applyAutoLocalModelLean } from "../config/local-model-lean-auto.js";
|
||||
import { applyMergePatch } from "../config/merge-patch.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { normalizePluginTargetConfig } from "../plugins/config-state.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import {
|
||||
projectDefaultInferenceRoute,
|
||||
resolveSystemAgentConfiguredRouteFromConfig,
|
||||
sameDefaultInferenceRoute,
|
||||
} from "./inference-route.js";
|
||||
import type { SystemAgentConfiguredRoute } from "./inference-route.js";
|
||||
import { createSystemAgentModelSelectionUpdater } from "./setup-apply.js";
|
||||
import {
|
||||
SetupInferenceActivationIndeterminateError,
|
||||
SetupInferenceActivationUnavailableError,
|
||||
log,
|
||||
throwIfSetupInferenceCancelled,
|
||||
type ActivateSetupInferenceDeps,
|
||||
type ActivateSetupInferenceParams,
|
||||
type ActivateSetupInferenceResult,
|
||||
} from "./setup-inference-core.js";
|
||||
import {
|
||||
configReferencesManualAuthProfiles,
|
||||
isCodexInstallRecordPersisted,
|
||||
manualAuthProfilesPersisted,
|
||||
persistManualAuthProfiles,
|
||||
rollbackManualAuthProfiles,
|
||||
applyManualAuthConfig,
|
||||
type ManualAuthPersistenceReceipt,
|
||||
runSetupInferenceTest,
|
||||
} from "./setup-inference-persist.js";
|
||||
import {
|
||||
projectSetupTargetModelMetadata,
|
||||
resolveSetupAgentRuntimeId,
|
||||
type SetupInferenceTestPlan,
|
||||
} from "./setup-inference-plan-helpers.js";
|
||||
import type { SystemAgentOwnerPluginArtifactSnapshot } from "./verified-inference.js";
|
||||
|
||||
type ProjectedInferenceRoute = Awaited<ReturnType<typeof projectDefaultInferenceRoute>>;
|
||||
|
||||
export type SetupInferenceActivationPersistenceState = {
|
||||
committedConfig: OpenClawConfig | undefined;
|
||||
autoLocalModelLeanApplied: boolean;
|
||||
codexInstallOwnership: "unknown" | "owned" | "unowned";
|
||||
};
|
||||
|
||||
export async function persistActivatedSetupInference(input: {
|
||||
params: ActivateSetupInferenceParams;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
plan: SetupInferenceTestPlan;
|
||||
testPlan: SetupInferenceTestPlan;
|
||||
test: Extract<Awaited<ReturnType<typeof runSetupInferenceTest>>, { ok: true }>;
|
||||
codexPluginPatch: unknown;
|
||||
pendingCodexInstall: PluginInstallRecord | undefined;
|
||||
cfg: OpenClawConfig;
|
||||
sourceCfg: OpenClawConfig;
|
||||
verifiedRoute: ProjectedInferenceRoute;
|
||||
baselineRoute: ProjectedInferenceRoute;
|
||||
stagedRoute: NonNullable<ProjectedInferenceRoute["route"]>;
|
||||
stagedOwnerPluginArtifacts: SystemAgentOwnerPluginArtifactSnapshot;
|
||||
baselineTargetModelMetadata: unknown;
|
||||
sourceTargetModelMetadata: unknown;
|
||||
readSnapshot: NonNullable<ActivateSetupInferenceDeps["readConfigFileSnapshot"]>;
|
||||
hasPreparedAuthProfiles: boolean;
|
||||
state: SetupInferenceActivationPersistenceState;
|
||||
revalidateOwner: (params: {
|
||||
route: SystemAgentConfiguredRoute;
|
||||
auth: AgentExecutionAuthBinding;
|
||||
stagedOwnerPluginArtifacts: SystemAgentOwnerPluginArtifactSnapshot | undefined;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
}) => Promise<unknown>;
|
||||
}): Promise<ActivateSetupInferenceResult | undefined> {
|
||||
const {
|
||||
params,
|
||||
deps,
|
||||
plan,
|
||||
testPlan,
|
||||
test,
|
||||
codexPluginPatch,
|
||||
pendingCodexInstall,
|
||||
cfg,
|
||||
sourceCfg,
|
||||
verifiedRoute,
|
||||
baselineRoute,
|
||||
stagedRoute,
|
||||
stagedOwnerPluginArtifacts,
|
||||
baselineTargetModelMetadata,
|
||||
sourceTargetModelMetadata,
|
||||
readSnapshot,
|
||||
hasPreparedAuthProfiles,
|
||||
state,
|
||||
revalidateOwner,
|
||||
} = input;
|
||||
let committedConfig: OpenClawConfig | undefined;
|
||||
let { codexInstallOwnership } = state;
|
||||
|
||||
const { stripPendingPluginInstallRecords } = await import("../plugins/install-record-commit.js");
|
||||
const agentRuntimeId = resolveSetupAgentRuntimeId(params.kind);
|
||||
const selectModel = plan.persistModelRef
|
||||
? await createSystemAgentModelSelectionUpdater({
|
||||
model: plan.persistModelRef,
|
||||
...(agentRuntimeId ? { agentRuntimeId } : {}),
|
||||
...(plan.manualAuth && plan.authProfileId ? { authProfileId: plan.authProfileId } : {}),
|
||||
})
|
||||
: undefined;
|
||||
const stageCandidate = (
|
||||
current: OpenClawConfig,
|
||||
configKind: "runtime" | "source",
|
||||
): OpenClawConfig => {
|
||||
let next = codexPluginPatch === undefined ? current : stripPendingPluginInstallRecords(current);
|
||||
if (plan.manualAuth) {
|
||||
next = applyManualAuthConfig(
|
||||
next,
|
||||
plan.manualAuth,
|
||||
configKind,
|
||||
deps.enablePluginInConfig ?? enablePluginInConfig,
|
||||
);
|
||||
}
|
||||
if (codexPluginPatch !== undefined) {
|
||||
const patched = applyMergePatch(next, codexPluginPatch) as OpenClawConfig;
|
||||
const enabledCodex = enablePluginInConfig(
|
||||
normalizePluginTargetConfig(patched, "codex"),
|
||||
"codex",
|
||||
);
|
||||
if (!enabledCodex.enabled) {
|
||||
throw new SetupInferenceActivationUnavailableError(
|
||||
`Could not enable the Codex runtime plugin: ${enabledCodex.reason ?? "plugin disabled"}.`,
|
||||
);
|
||||
}
|
||||
next = enabledCodex.config;
|
||||
}
|
||||
next = applyAutoLocalModelLean({
|
||||
config: next,
|
||||
providerId: testPlan.provider,
|
||||
modelRef: plan.modelRef,
|
||||
}).config;
|
||||
next = selectModel ? selectModel(next) : next;
|
||||
if (!pendingCodexInstall) {
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
plugins: {
|
||||
...next.plugins,
|
||||
installs: { codex: pendingCodexInstall },
|
||||
},
|
||||
};
|
||||
};
|
||||
// Pending install records are probe-only discovery input. The config
|
||||
// writer moves them into the installed-plugin index before committing,
|
||||
// so post-write reconciliation must compare against the stripped route
|
||||
// and verify the exact index record separately below.
|
||||
const persistedRoute = pendingCodexInstall
|
||||
? await projectDefaultInferenceRoute(
|
||||
stripPendingPluginInstallRecords(stageCandidate(cfg, "runtime")),
|
||||
)
|
||||
: verifiedRoute;
|
||||
// Runtime config may materialize provider defaults that are intentionally
|
||||
// absent from authored config. Compare source writes against the candidate
|
||||
// produced from the original source shape, without ignoring concurrent rows.
|
||||
const expectedSourceCandidateRoute = await projectDefaultInferenceRoute(
|
||||
stageCandidate(sourceCfg, "source"),
|
||||
);
|
||||
// Resolve every fallible config-commit dependency before writing a
|
||||
// credential into the real agent store. From this point onward, any
|
||||
// failure is inside the rollback boundary below.
|
||||
const transformConfig =
|
||||
deps.transformConfigWithPendingPluginInstalls ??
|
||||
(await import("../plugins/install-record-commit.js")).transformConfigWithPendingPluginInstalls;
|
||||
let manualAuthReceipt: ManualAuthPersistenceReceipt | undefined;
|
||||
if (hasPreparedAuthProfiles && plan.manualAuth) {
|
||||
throwIfSetupInferenceCancelled(params);
|
||||
const initialCandidate = stageCandidate(cfg, "runtime");
|
||||
const initialRoute = await projectDefaultInferenceRoute(initialCandidate);
|
||||
const resolvedRoute = await resolveSystemAgentConfiguredRouteFromConfig(initialCandidate);
|
||||
if (
|
||||
!sameDefaultInferenceRoute(initialRoute, verifiedRoute) ||
|
||||
!resolvedRoute ||
|
||||
resolvedRoute.modelLabel !== plan.modelRef ||
|
||||
resolvedRoute.authProfileId !== plan.authProfileId
|
||||
) {
|
||||
throw new Error(
|
||||
"The default-agent inference route changed during its live test, so the verified credential was not saved. Review the current model/auth/runtime settings and retry.",
|
||||
);
|
||||
}
|
||||
const persistedManualAuth = await persistManualAuthProfiles({
|
||||
profiles: plan.manualAuth.profiles,
|
||||
agentDir: resolvedRoute.agentDir,
|
||||
deps,
|
||||
});
|
||||
if (persistedManualAuth.status === "unknown") {
|
||||
const rolledBack = await rollbackManualAuthProfiles(persistedManualAuth.receipt, deps);
|
||||
if (rolledBack) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error:
|
||||
"Could not confirm the credential write, so it was rolled back. Try again in a moment.",
|
||||
};
|
||||
}
|
||||
throw new SetupInferenceActivationIndeterminateError(
|
||||
"Inference activation could not confirm whether its verified credential was saved or rolled back. No config commit was attempted; run openclaw doctor --fix before retrying.",
|
||||
);
|
||||
}
|
||||
if (persistedManualAuth.status === "not-persisted") {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error: "Could not save the verified credential; try again in a moment.",
|
||||
};
|
||||
}
|
||||
manualAuthReceipt = persistedManualAuth.receipt;
|
||||
}
|
||||
let commitMayHaveStarted = false;
|
||||
try {
|
||||
throwIfSetupInferenceCancelled(params);
|
||||
const committed = await transformConfig({
|
||||
base: "source",
|
||||
// The transform stays side-effect free so a config conflict can retry
|
||||
// without replaying credential writes in another agent directory.
|
||||
// Setup changes only hot-reloadable model, agent, and plugin-entry surfaces.
|
||||
// Publish the verified route now so the next turn cannot reuse the old harness.
|
||||
afterWrite: { mode: "auto" },
|
||||
transform: async (current, context) => {
|
||||
const latestRuntime = context.snapshot.runtimeConfig ?? context.snapshot.config;
|
||||
// Validate that the candidate is still admissible before reporting
|
||||
// broader route drift, so policy revocations retain their actionable error.
|
||||
const stagedRuntime = stageCandidate(latestRuntime, "runtime");
|
||||
const latestBaseline = await projectDefaultInferenceRoute(latestRuntime);
|
||||
if (!sameDefaultInferenceRoute(latestBaseline, baselineRoute)) {
|
||||
throw new Error(
|
||||
"The default-agent inference route changed during its live test, so the verified candidate was not saved. Review the current model/auth/runtime settings and retry.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
projectSetupTargetModelMetadata(latestRuntime, stagedRoute.modelLabel),
|
||||
baselineTargetModelMetadata,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"The target model metadata changed during its live inference test, so the verified candidate was not saved. Review the current model settings and retry.",
|
||||
);
|
||||
}
|
||||
const currentRoute = await projectDefaultInferenceRoute(stagedRuntime);
|
||||
if (!sameDefaultInferenceRoute(currentRoute, verifiedRoute)) {
|
||||
throw new Error(
|
||||
"The default-agent inference route changed during its live test, so the verified candidate was not saved. Review the current model/auth/runtime settings and retry.",
|
||||
);
|
||||
}
|
||||
const resolvedRoute = await resolveSystemAgentConfiguredRouteFromConfig(stagedRuntime);
|
||||
if (
|
||||
!resolvedRoute ||
|
||||
resolvedRoute.modelLabel !== plan.modelRef ||
|
||||
(plan.authProfileId && resolvedRoute.authProfileId !== plan.authProfileId)
|
||||
) {
|
||||
throw new Error(
|
||||
"The latest default-agent route no longer matches the verified candidate, so it was not saved. Review the current config and retry.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
projectSetupTargetModelMetadata(current, stagedRoute.modelLabel),
|
||||
sourceTargetModelMetadata,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"The authored target model metadata changed during its live inference test, so the verified candidate was not saved. Review the current model settings and retry.",
|
||||
);
|
||||
}
|
||||
const autoLocalModelLean = applyAutoLocalModelLean({
|
||||
config: current,
|
||||
providerId: testPlan.provider,
|
||||
modelRef: plan.modelRef,
|
||||
});
|
||||
const nextConfig = stageCandidate(current, "source");
|
||||
const nextRouteProjection = await projectDefaultInferenceRoute(nextConfig);
|
||||
const nextResolvedRoute = await resolveSystemAgentConfiguredRouteFromConfig(nextConfig);
|
||||
if (
|
||||
!sameDefaultInferenceRoute(nextRouteProjection, expectedSourceCandidateRoute) ||
|
||||
!nextResolvedRoute ||
|
||||
nextResolvedRoute.modelLabel !== plan.modelRef ||
|
||||
(plan.authProfileId && nextResolvedRoute.authProfileId !== plan.authProfileId)
|
||||
) {
|
||||
throw new Error(
|
||||
"The source config no longer matches the verified candidate, so it was not saved. Review the current config and retry.",
|
||||
);
|
||||
}
|
||||
await revalidateOwner({
|
||||
route: nextResolvedRoute,
|
||||
auth: test.auth,
|
||||
stagedOwnerPluginArtifacts,
|
||||
deps,
|
||||
});
|
||||
// Once this callback returns, the config writer owns the candidate.
|
||||
// Any later throw may be post-commit and needs reconciliation.
|
||||
throwIfSetupInferenceCancelled(params);
|
||||
params.onCommitStarted?.();
|
||||
commitMayHaveStarted = true;
|
||||
state.autoLocalModelLeanApplied = autoLocalModelLean.enabled;
|
||||
return { nextConfig };
|
||||
},
|
||||
});
|
||||
committedConfig = committed.nextConfig;
|
||||
if (pendingCodexInstall) {
|
||||
codexInstallOwnership = "owned";
|
||||
}
|
||||
} catch (error) {
|
||||
if (!commitMayHaveStarted) {
|
||||
if (manualAuthReceipt) {
|
||||
const rolledBack = await rollbackManualAuthProfiles(manualAuthReceipt, deps);
|
||||
if (!rolledBack) {
|
||||
throw new SetupInferenceActivationIndeterminateError(
|
||||
"Inference activation stopped before its config commit, but could not confirm removal of its staged credential. Run openclaw doctor --fix before retrying.",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const reconciledSnapshot = await readSnapshot().catch(() => null);
|
||||
const reconciledRuntime =
|
||||
reconciledSnapshot?.exists && reconciledSnapshot.valid
|
||||
? (reconciledSnapshot.runtimeConfig ?? reconciledSnapshot.config)
|
||||
: undefined;
|
||||
const reconciledRoute = reconciledRuntime
|
||||
? await projectDefaultInferenceRoute(reconciledRuntime)
|
||||
: undefined;
|
||||
const codexInstallPersisted = pendingCodexInstall
|
||||
? await isCodexInstallRecordPersisted(pendingCodexInstall, deps)
|
||||
: true;
|
||||
const committedDespiteError =
|
||||
reconciledRoute !== undefined &&
|
||||
sameDefaultInferenceRoute(reconciledRoute, persistedRoute) &&
|
||||
(!manualAuthReceipt || manualAuthProfilesPersisted(manualAuthReceipt, deps)) &&
|
||||
codexInstallPersisted;
|
||||
if (pendingCodexInstall) {
|
||||
codexInstallOwnership = committedDespiteError ? "owned" : "unowned";
|
||||
}
|
||||
if (!committedDespiteError) {
|
||||
if (manualAuthReceipt) {
|
||||
if (
|
||||
!reconciledRuntime ||
|
||||
configReferencesManualAuthProfiles(reconciledRuntime, manualAuthReceipt)
|
||||
) {
|
||||
throw new SetupInferenceActivationIndeterminateError(
|
||||
"Inference activation could not confirm its config commit state. The verified credential was retained because the current config may reference it. Run openclaw doctor --fix before retrying.",
|
||||
);
|
||||
}
|
||||
const rolledBack = await rollbackManualAuthProfiles(manualAuthReceipt, deps);
|
||||
if (!rolledBack) {
|
||||
throw new SetupInferenceActivationIndeterminateError(
|
||||
"Inference activation failed and its staged credential could not be rolled back. Run openclaw doctor --fix before retrying.",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
committedConfig = reconciledSnapshot?.sourceConfig ?? reconciledRuntime;
|
||||
log.warn("Inference activation committed successfully despite a post-write cleanup error.");
|
||||
}
|
||||
|
||||
state.committedConfig = committedConfig;
|
||||
state.codexInstallOwnership = codexInstallOwnership;
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
type CodexCliApiKeyCredential,
|
||||
readCodexCliActiveApiKey,
|
||||
} from "../agents/cli-credentials.js";
|
||||
import { createMergePatch } from "../config/io.write-prepare.js";
|
||||
import { applyAutoLocalModelLean } from "../config/local-model-lean-auto.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { normalizePluginTargetConfig } from "../plugins/config-state.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { appendSystemAgentAuditEntry } from "./audit.js";
|
||||
import {
|
||||
projectDefaultInferenceRoute,
|
||||
resolveSystemAgentConfiguredRouteFromConfig,
|
||||
sameDefaultInferenceRoute,
|
||||
} from "./inference-route.js";
|
||||
import { applySystemAgentModelSelection, createQuickstartNotePrompter } from "./setup-apply.js";
|
||||
import {
|
||||
persistActivatedSetupInference,
|
||||
type SetupInferenceActivationPersistenceState,
|
||||
} from "./setup-inference-activate-persist.js";
|
||||
import {
|
||||
AUTO_LOCAL_MODEL_LEAN_ANNOUNCEMENT,
|
||||
type ActivateSetupInferenceParams,
|
||||
type ActivateSetupInferenceResult,
|
||||
SetupInferenceActivationIndeterminateError,
|
||||
SetupInferenceActivationUnavailableError,
|
||||
SetupInferenceCancelledError,
|
||||
SetupInferenceOwnerDriftError,
|
||||
invalidSetupConfigError,
|
||||
resolveSetupInferenceWorkspace,
|
||||
throwIfSetupInferenceCancelled,
|
||||
} from "./setup-inference-core.js";
|
||||
import { revalidateStableSetupInferenceOwner } from "./setup-inference-owner.js";
|
||||
import {
|
||||
cleanupSetupInferenceTempDir,
|
||||
persistManualAuthProfiles,
|
||||
reloadCodexRegistryAfterActivation,
|
||||
retainUnownedCodexInstall,
|
||||
runSetupInferenceTest,
|
||||
} from "./setup-inference-persist.js";
|
||||
import {
|
||||
configureCodexCliPreparedAuth,
|
||||
projectSetupTargetModelMetadata,
|
||||
resolveSetupAgentRuntimeId,
|
||||
} from "./setup-inference-plan-helpers.js";
|
||||
import { buildTestPlan } from "./setup-inference-plan.js";
|
||||
import {
|
||||
captureSystemAgentOwnerPluginArtifacts,
|
||||
type SystemAgentOwnerPluginArtifactSnapshot,
|
||||
} from "./verified-inference.js";
|
||||
|
||||
/**
|
||||
* Test one candidate with a real completion, then persist it as the setup
|
||||
* default. Manual credentials are tested from a temporary auth store and
|
||||
* copied into the real agent store only after success. A managed Codex install
|
||||
* record may remain after a failed probe because the installed package already exists.
|
||||
*/
|
||||
export async function activateSetupInference(
|
||||
params: ActivateSetupInferenceParams,
|
||||
): Promise<ActivateSetupInferenceResult> {
|
||||
const codexCliApiKey = resolveCodexCliSetupApiKey(params);
|
||||
try {
|
||||
const result = await activateSetupInferenceUnredacted(params, codexCliApiKey ?? undefined);
|
||||
if (result.ok) {
|
||||
return {
|
||||
...result,
|
||||
lines: await Promise.all(
|
||||
result.lines.map((line) =>
|
||||
redactSetupInferenceError(line, params.apiKey, codexCliApiKey?.key),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
error: await redactSetupInferenceError(result.error, params.apiKey, codexCliApiKey?.key),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const redacted = await redactSetupInferenceError(message, params.apiKey, codexCliApiKey?.key);
|
||||
if (error instanceof SetupInferenceCancelledError || params.signal?.aborted) {
|
||||
return { ok: false, status: "unavailable", error: "Provider login was cancelled." };
|
||||
}
|
||||
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);
|
||||
}
|
||||
// oxlint-disable-next-line preserve-caught-error -- The original cause can contain the submitted setup secret.
|
||||
throw new Error(redacted);
|
||||
}
|
||||
}
|
||||
|
||||
async function activateSetupInferenceUnredacted(
|
||||
params: ActivateSetupInferenceParams,
|
||||
codexCliApiKey?: CodexCliApiKeyCredential,
|
||||
): Promise<ActivateSetupInferenceResult> {
|
||||
const deps = params.deps ?? {};
|
||||
const readSnapshot =
|
||||
deps.readConfigFileSnapshot ?? (await import("../config/config.js")).readConfigFileSnapshot;
|
||||
const snapshot = await readSnapshot();
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
throw new Error(invalidSetupConfigError(snapshot));
|
||||
}
|
||||
const cfg: OpenClawConfig = snapshot.exists ? (snapshot.runtimeConfig ?? snapshot.config) : {};
|
||||
const sourceCfg: OpenClawConfig = snapshot.exists
|
||||
? (snapshot.sourceConfig ?? snapshot.config)
|
||||
: {};
|
||||
const workspace = params.workspace?.trim()
|
||||
? resolveUserPath(params.workspace)
|
||||
: (
|
||||
await resolveSetupInferenceWorkspace({
|
||||
configExists: snapshot.exists,
|
||||
configValid: snapshot.valid,
|
||||
})
|
||||
).workspace;
|
||||
|
||||
const tempDir = await (
|
||||
deps.createTempDir ?? (() => fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-inference-")))
|
||||
)();
|
||||
const testAgentDir = path.join(tempDir, "agent");
|
||||
let pendingCodexInstall: PluginInstallRecord | undefined;
|
||||
let codexInstallOwnership: "unknown" | "owned" | "unowned" = "unknown";
|
||||
let codexRegistryNeedsReload = false;
|
||||
let codexRegistryReloaded = false;
|
||||
try {
|
||||
const plan = await buildTestPlan({
|
||||
kind: params.kind,
|
||||
...(params.modelRef !== undefined ? { modelRef: params.modelRef } : {}),
|
||||
...(params.authChoice !== undefined ? { authChoice: params.authChoice } : {}),
|
||||
...(params.apiKey !== undefined ? { apiKey: params.apiKey } : {}),
|
||||
cfg,
|
||||
sourceCfg,
|
||||
workspaceDir: tempDir,
|
||||
pluginWorkspaceDir: workspace,
|
||||
agentDir: testAgentDir,
|
||||
runtime: params.runtime,
|
||||
...(params.prompter ? { prompter: params.prompter } : {}),
|
||||
...(params.signal ? { signal: params.signal } : {}),
|
||||
...(params.isCancelled ? { isCancelled: params.isCancelled } : {}),
|
||||
...(params.kind === "provider-auth"
|
||||
? { isRemoteProviderAuth: params.surface === "gateway" }
|
||||
: {}),
|
||||
...(codexCliApiKey ? { codexCliApiKey } : {}),
|
||||
deps,
|
||||
});
|
||||
if ("error" in plan) {
|
||||
return {
|
||||
ok: false,
|
||||
status: plan.status ?? "unavailable",
|
||||
error: plan.error,
|
||||
};
|
||||
}
|
||||
|
||||
const hasPreparedAuthProfiles = (plan.manualAuth?.profiles.length ?? 0) > 0;
|
||||
let testPlan = plan;
|
||||
if (plan.persistModelRef) {
|
||||
const agentRuntimeId = resolveSetupAgentRuntimeId(params.kind);
|
||||
const stagedConfig = await applySystemAgentModelSelection({
|
||||
config: plan.config,
|
||||
model: plan.persistModelRef,
|
||||
...(agentRuntimeId ? { agentRuntimeId } : {}),
|
||||
...(plan.manualAuth && plan.authProfileId ? { authProfileId: plan.authProfileId } : {}),
|
||||
});
|
||||
testPlan = {
|
||||
...plan,
|
||||
config: stagedConfig,
|
||||
routeAgentId: resolveDefaultAgentId(stagedConfig),
|
||||
};
|
||||
}
|
||||
|
||||
let codexPluginPatch: unknown;
|
||||
if (params.kind === "codex-cli") {
|
||||
const { stripPendingPluginInstallRecords } =
|
||||
await import("../plugins/install-record-commit.js");
|
||||
// This explicit Codex CLI choice owns its runtime independently of the
|
||||
// user's existing OpenAI provider route (which may use a custom base URL).
|
||||
const codexInstallBase = stripPendingPluginInstallRecords(testPlan.config);
|
||||
const enabledCodexBase = enablePluginInConfig(
|
||||
normalizePluginTargetConfig(codexInstallBase, "codex"),
|
||||
"codex",
|
||||
);
|
||||
if (!enabledCodexBase.enabled) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error: `Could not enable the Codex runtime plugin: ${enabledCodexBase.reason ?? "plugin disabled"}.`,
|
||||
};
|
||||
}
|
||||
const ensureCodex =
|
||||
deps.ensureCodexRuntimePlugin ??
|
||||
(await import("../commands/codex-runtime-plugin-install.js"))
|
||||
.ensureCodexRuntimePluginForModelSelection;
|
||||
const ensured = await ensureCodex({
|
||||
cfg: enabledCodexBase.config,
|
||||
model: plan.modelRef,
|
||||
agentId: testPlan.routeAgentId,
|
||||
prompter: createQuickstartNotePrompter(params.runtime),
|
||||
runtime: params.runtime,
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
if (!ensured.installed) {
|
||||
return {
|
||||
ok: false,
|
||||
status: ensured.status === "timed_out" ? "timeout" : "unavailable",
|
||||
error:
|
||||
ensured.status === "timed_out"
|
||||
? "Codex runtime plugin installation timed out. Try again."
|
||||
: ensured.reason
|
||||
? `Could not enable the Codex runtime plugin: ${ensured.reason}.`
|
||||
: "Could not install the Codex runtime plugin. Try again once the plugin is available.",
|
||||
};
|
||||
}
|
||||
codexRegistryNeedsReload = true;
|
||||
pendingCodexInstall = ensured.cfg.plugins?.installs?.codex;
|
||||
if (pendingCodexInstall) {
|
||||
// The managed package exists before inference can run. Mark this
|
||||
// generation retained now so a process exit cannot strand unowned bytes.
|
||||
const codexInstallRetained = await retainUnownedCodexInstall({
|
||||
record: pendingCodexInstall,
|
||||
verifyOwnership: false,
|
||||
deps,
|
||||
});
|
||||
if (!codexInstallRetained) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error:
|
||||
"Could not retain the staged Codex runtime safely. No inference route was changed; retry after checking the plugin storage directory.",
|
||||
};
|
||||
}
|
||||
}
|
||||
const normalizedCodexConfig = normalizePluginTargetConfig(ensured.cfg, "codex");
|
||||
const enabledCodex = enablePluginInConfig(
|
||||
configureCodexCliPreparedAuth(normalizedCodexConfig),
|
||||
"codex",
|
||||
);
|
||||
if (!enabledCodex.enabled) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error: `Could not enable the Codex runtime plugin: ${enabledCodex.reason ?? "plugin disabled"}.`,
|
||||
};
|
||||
}
|
||||
// Discovery needs the just-installed package record during the probe, but
|
||||
// install ownership remains transient until inference succeeds.
|
||||
const stagedCodexConfig = enabledCodex.config;
|
||||
codexPluginPatch = createMergePatch(
|
||||
codexInstallBase,
|
||||
stripPendingPluginInstallRecords(stagedCodexConfig),
|
||||
);
|
||||
testPlan = {
|
||||
...testPlan,
|
||||
config: stagedCodexConfig,
|
||||
};
|
||||
|
||||
// The Gateway registry predates a runtime installed by this request.
|
||||
// Refresh and load the exact Codex harness before auth snapshots it.
|
||||
const refreshPluginRegistry =
|
||||
deps.refreshPluginRegistryAfterConfigMutation ??
|
||||
(await import("../plugins/registry-refresh.js")).refreshPluginRegistryAfterConfigMutation;
|
||||
let registryRefreshWarning: string | undefined;
|
||||
await refreshPluginRegistry({
|
||||
config: testPlan.config,
|
||||
reason: "source-changed",
|
||||
workspaceDir: workspace,
|
||||
policyPluginIds: ["codex"],
|
||||
traceCommand: "openclaw-setup-probe",
|
||||
logger: { warn: (message) => (registryRefreshWarning = message) },
|
||||
});
|
||||
const ensureHarnessPlugin =
|
||||
deps.ensureSelectedAgentHarnessPlugin ??
|
||||
(await import("../agents/harness/runtime-plugin.js")).ensureSelectedAgentHarnessPlugin;
|
||||
try {
|
||||
await ensureHarnessPlugin({
|
||||
provider: testPlan.provider,
|
||||
modelId: testPlan.model,
|
||||
config: testPlan.config,
|
||||
agentId: testPlan.routeAgentId,
|
||||
agentHarnessRuntimeOverride: "codex",
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
} catch (error) {
|
||||
const loadError = `Could not load the Codex runtime plugin: ${formatErrorMessage(error)}`;
|
||||
return {
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error: registryRefreshWarning ? `${registryRefreshWarning} ${loadError}` : loadError,
|
||||
};
|
||||
}
|
||||
}
|
||||
const baselineRoute = await projectDefaultInferenceRoute(cfg);
|
||||
const verifiedRoute = await projectDefaultInferenceRoute(testPlan.config);
|
||||
const stagedRoute = verifiedRoute.route;
|
||||
const stagedExecutionRoute = await resolveSystemAgentConfiguredRouteFromConfig(testPlan.config);
|
||||
if (
|
||||
!stagedRoute ||
|
||||
!stagedExecutionRoute ||
|
||||
stagedRoute.runner !== testPlan.runner ||
|
||||
stagedRoute.provider !== testPlan.provider ||
|
||||
stagedRoute.model !== testPlan.model ||
|
||||
stagedRoute.modelLabel !== plan.modelRef ||
|
||||
(plan.authProfileId && stagedRoute.authProfileId !== plan.authProfileId)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error:
|
||||
"The staged default-agent route does not match the requested inference candidate. Review model runtime policy and retry.",
|
||||
};
|
||||
}
|
||||
const baselineTargetModelMetadata = projectSetupTargetModelMetadata(
|
||||
cfg,
|
||||
stagedRoute.modelLabel,
|
||||
);
|
||||
const sourceTargetModelMetadata = projectSetupTargetModelMetadata(
|
||||
sourceCfg,
|
||||
stagedRoute.modelLabel,
|
||||
);
|
||||
// OpenClaw executes through the reserved agent id but reuses the default
|
||||
// route's agent directory. Only a submitted key stays in the isolated store.
|
||||
if (testPlan.runner === "embedded" && stagedRoute.runner === "embedded") {
|
||||
testPlan = {
|
||||
...testPlan,
|
||||
config: stagedExecutionRoute.runConfig,
|
||||
agentDir: hasPreparedAuthProfiles ? testAgentDir : stagedRoute.agentDir,
|
||||
agentHarnessRuntimeOverride: stagedRoute.agentHarnessRuntimeOverride,
|
||||
};
|
||||
} else {
|
||||
testPlan = {
|
||||
...testPlan,
|
||||
config: stagedExecutionRoute.runConfig,
|
||||
...(!hasPreparedAuthProfiles ? { agentDir: stagedRoute.agentDir } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (hasPreparedAuthProfiles && plan.manualAuth) {
|
||||
const staged = await persistManualAuthProfiles({
|
||||
profiles: plan.manualAuth.profiles,
|
||||
agentDir: testAgentDir,
|
||||
deps,
|
||||
});
|
||||
if (staged.status !== "persisted") {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error:
|
||||
"Could not stage the credential for its live inference test; try again in a moment.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let stagedOwnerPluginArtifacts: SystemAgentOwnerPluginArtifactSnapshot;
|
||||
try {
|
||||
stagedOwnerPluginArtifacts = (
|
||||
deps.captureSystemAgentOwnerPluginArtifacts ?? captureSystemAgentOwnerPluginArtifacts
|
||||
)({
|
||||
config: stagedExecutionRoute.runConfig,
|
||||
executionRoute: stagedExecutionRoute,
|
||||
deps,
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error:
|
||||
"Could not bind the staged inference plugin runtime. Refresh or reinstall the plugin and retry.",
|
||||
};
|
||||
}
|
||||
|
||||
if (params.signal?.aborted || params.isCancelled?.()) {
|
||||
return { ok: false, status: "unavailable", error: "Provider login was cancelled." };
|
||||
}
|
||||
let test: Awaited<ReturnType<typeof runSetupInferenceTest>>;
|
||||
try {
|
||||
test = await runSetupInferenceTest({
|
||||
plan: testPlan,
|
||||
tempDir,
|
||||
deps,
|
||||
// The setup probe is evidence, not an auth-store mutation. Manual keys
|
||||
// already exist in the isolated store and every other route stays read-only.
|
||||
authProfileStateMode: "read-only",
|
||||
requireExecutionOwner: true,
|
||||
...(params.signal ? { signal: params.signal } : {}),
|
||||
});
|
||||
throwIfSetupInferenceCancelled(params);
|
||||
} catch (error) {
|
||||
if (error instanceof SetupInferenceCancelledError || params.signal?.aborted) {
|
||||
return { ok: false, status: "unavailable", error: "Provider login was cancelled." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!test.ok) {
|
||||
return test;
|
||||
}
|
||||
if (plan.authProfileId && test.auth.authProfileId !== plan.authProfileId) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "auth",
|
||||
error: `The inference run used profile "${test.auth.authProfileId ?? "unknown"}" instead of the configured profile "${plan.authProfileId}". No model or credential route was saved.`,
|
||||
};
|
||||
}
|
||||
|
||||
const autoLocalModelLeanUpdate = applyAutoLocalModelLean({
|
||||
config: sourceCfg,
|
||||
providerId: testPlan.provider,
|
||||
modelRef: plan.modelRef,
|
||||
});
|
||||
const needsPersistence =
|
||||
plan.persistModelRef !== undefined ||
|
||||
plan.manualAuth !== undefined ||
|
||||
codexPluginPatch !== undefined ||
|
||||
pendingCodexInstall !== undefined ||
|
||||
autoLocalModelLeanUpdate.changed;
|
||||
if (
|
||||
!test.auth.authFingerprint &&
|
||||
(!test.auth.runtimeOwnerFingerprint ||
|
||||
!test.auth.runtimeOwnerKind ||
|
||||
!test.auth.runtimeOwnerId?.trim())
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error:
|
||||
"Inference succeeded, but its runtime did not report an owner that OpenClaw can safely reuse. No model or credential route was saved.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
testPlan.runner === "cli" &&
|
||||
(!test.auth.runtimeArtifactFingerprint || !test.auth.runtimeArtifactId?.trim())
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error:
|
||||
"Inference succeeded, but its CLI executable/package artifact could not be safely reused. No model or credential route was saved.",
|
||||
};
|
||||
}
|
||||
if (testPlan.runner === "embedded") {
|
||||
const successfulHarnessId = test.auth.agentHarnessId?.trim();
|
||||
if (
|
||||
!successfulHarnessId ||
|
||||
(testPlan.agentHarnessRuntimeOverride !== "auto" &&
|
||||
successfulHarnessId !== testPlan.agentHarnessRuntimeOverride)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error:
|
||||
"Inference succeeded, but its exact agent harness could not be safely reused. No model or credential route was saved.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
successfulHarnessId !== "openclaw" &&
|
||||
(test.auth.runtimeOwnerKind !== "plugin-harness" ||
|
||||
test.auth.runtimeOwnerId?.trim() !== successfulHarnessId ||
|
||||
!test.auth.runtimeArtifactFingerprint ||
|
||||
!test.auth.runtimeArtifactId?.trim())
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error:
|
||||
"Inference succeeded, but its agent harness artifact could not be safely reused. No model or credential route was saved.",
|
||||
};
|
||||
}
|
||||
}
|
||||
let committedConfig: OpenClawConfig | undefined;
|
||||
let autoLocalModelLeanApplied = false;
|
||||
if (!needsPersistence) {
|
||||
const latestSnapshot = await readSnapshot();
|
||||
const latestRuntime =
|
||||
latestSnapshot.exists && latestSnapshot.valid
|
||||
? (latestSnapshot.runtimeConfig ?? latestSnapshot.config)
|
||||
: undefined;
|
||||
const latestRoute = latestRuntime
|
||||
? await projectDefaultInferenceRoute(latestRuntime)
|
||||
: undefined;
|
||||
if (!latestRoute || !sameDefaultInferenceRoute(latestRoute, verifiedRoute)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error:
|
||||
"The default-agent inference route changed during its live test. Review the current model/auth/runtime settings and retry.",
|
||||
};
|
||||
}
|
||||
const latestResolvedRoute = latestRuntime
|
||||
? await resolveSystemAgentConfiguredRouteFromConfig(latestRuntime)
|
||||
: null;
|
||||
if (!latestResolvedRoute) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error:
|
||||
"The default-agent inference route could not be resolved after its live test. Review the current model/auth/runtime settings and retry.",
|
||||
};
|
||||
}
|
||||
await revalidateStableSetupInferenceOwner({
|
||||
route: latestResolvedRoute,
|
||||
auth: test.auth,
|
||||
stagedOwnerPluginArtifacts,
|
||||
deps,
|
||||
});
|
||||
}
|
||||
if (needsPersistence) {
|
||||
const persistenceState: SetupInferenceActivationPersistenceState = {
|
||||
committedConfig,
|
||||
autoLocalModelLeanApplied,
|
||||
codexInstallOwnership,
|
||||
};
|
||||
const persistenceFailure = await persistActivatedSetupInference({
|
||||
params,
|
||||
deps,
|
||||
plan,
|
||||
testPlan,
|
||||
test,
|
||||
codexPluginPatch,
|
||||
pendingCodexInstall,
|
||||
cfg,
|
||||
sourceCfg,
|
||||
verifiedRoute,
|
||||
baselineRoute,
|
||||
stagedRoute,
|
||||
stagedOwnerPluginArtifacts,
|
||||
baselineTargetModelMetadata,
|
||||
sourceTargetModelMetadata,
|
||||
readSnapshot,
|
||||
hasPreparedAuthProfiles,
|
||||
state: persistenceState,
|
||||
revalidateOwner: revalidateStableSetupInferenceOwner,
|
||||
});
|
||||
if (persistenceFailure) {
|
||||
return persistenceFailure;
|
||||
}
|
||||
({ committedConfig, autoLocalModelLeanApplied, codexInstallOwnership } = persistenceState);
|
||||
}
|
||||
if (codexRegistryNeedsReload && committedConfig) {
|
||||
codexRegistryReloaded = await reloadCodexRegistryAfterActivation({
|
||||
readSnapshot,
|
||||
workspaceDir: workspace,
|
||||
deps,
|
||||
});
|
||||
if (!codexRegistryReloaded) {
|
||||
throw new SetupInferenceActivationIndeterminateError(
|
||||
"Inference activation committed, but the active plugin registry could not be reloaded. Restart the Gateway before using Codex inference.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const announceAutoLocalModelLean =
|
||||
autoLocalModelLeanApplied &&
|
||||
committedConfig?.agents?.defaults?.experimental?.localModelLean === true;
|
||||
let lines = [
|
||||
`Inference verified: ${plan.modelRef}`,
|
||||
...(announceAutoLocalModelLean ? [AUTO_LOCAL_MODEL_LEAN_ANNOUNCEMENT] : []),
|
||||
];
|
||||
if (params.surface === "gateway" && params.recordSetupAudit !== false) {
|
||||
const after = await readSnapshot().catch(() => null);
|
||||
try {
|
||||
await appendSystemAgentAuditEntry({
|
||||
operation: "openclaw.setup",
|
||||
summary: "Verified and configured AI access through OpenClaw setup",
|
||||
configPath: after?.path ?? snapshot.path,
|
||||
configHashBefore: snapshot.hash ?? null,
|
||||
configHashAfter: after?.hash ?? null,
|
||||
details: { modelRef: plan.modelRef, inferenceKind: params.kind },
|
||||
});
|
||||
} catch (error) {
|
||||
// Inference is already verified and its route may already be durable.
|
||||
// Surface audit failure as a warning instead of misreporting setup failure.
|
||||
const warning = `Inference setup completed, but OpenClaw could not record its audit entry: ${formatErrorMessage(error)}`;
|
||||
params.runtime.error?.(warning);
|
||||
lines = [...lines, warning];
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
modelRef: plan.modelRef,
|
||||
latencyMs: test.latencyMs,
|
||||
lines,
|
||||
};
|
||||
} finally {
|
||||
let codexCleanupError: SetupInferenceActivationIndeterminateError | undefined;
|
||||
if (pendingCodexInstall && codexInstallOwnership !== "owned") {
|
||||
// Reassert after probing: a partial install-index commit may have cleared
|
||||
// the early marker even though the matching model route never committed.
|
||||
const retained = await retainUnownedCodexInstall({
|
||||
record: pendingCodexInstall,
|
||||
verifyOwnership: false,
|
||||
deps,
|
||||
});
|
||||
if (!retained) {
|
||||
codexCleanupError = new SetupInferenceActivationIndeterminateError(
|
||||
"Inference activation stopped before its Codex runtime package could be retained safely. Restart the Gateway before retrying.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (codexRegistryNeedsReload && !codexRegistryReloaded) {
|
||||
// The probe loaded discovery against staged config. Restore the live
|
||||
// registry from the latest persisted config before another request runs.
|
||||
codexRegistryReloaded = await reloadCodexRegistryAfterActivation({
|
||||
readSnapshot,
|
||||
workspaceDir: workspace,
|
||||
deps,
|
||||
});
|
||||
if (!codexRegistryReloaded) {
|
||||
codexCleanupError = new SetupInferenceActivationIndeterminateError(
|
||||
"Inference activation could not restore the active plugin registry after its Codex probe. Restart the Gateway before retrying.",
|
||||
);
|
||||
}
|
||||
}
|
||||
await cleanupSetupInferenceTempDir({ tempDir, deps, runtime: params.runtime });
|
||||
if (codexCleanupError) {
|
||||
// oxlint-disable-next-line no-unsafe-finally -- an indeterminate plugin cleanup must supersede a stale success result
|
||||
throw codexCleanupError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexCliSetupApiKey(
|
||||
params: ActivateSetupInferenceParams,
|
||||
): CodexCliApiKeyCredential | null {
|
||||
if (params.kind !== "codex-cli") {
|
||||
return null;
|
||||
}
|
||||
const reader = params.deps?.readCodexCliActiveApiKey ?? readCodexCliActiveApiKey;
|
||||
return reader({ allowKeychainPrompt: true });
|
||||
}
|
||||
|
||||
export async function redactSetupInferenceError(
|
||||
message: string,
|
||||
...apiKeys: Array<string | undefined>
|
||||
): Promise<string> {
|
||||
const secrets = new Set(
|
||||
apiKeys
|
||||
.flatMap((apiKey) => [apiKey, apiKey?.trim()])
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
let redacted = message;
|
||||
for (const secret of Array.from(secrets).toSorted((a, b) => b.length - a.length)) {
|
||||
redacted = redacted.split(secret).join("[redacted]");
|
||||
}
|
||||
const { redactToolPayloadText } = await import("../logging/redact.js");
|
||||
return redactToolPayloadText(redacted);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
|
||||
import {
|
||||
loadAuthProfileStoreForRuntime,
|
||||
updateAuthProfileStoreWithLock,
|
||||
} from "../agents/auth-profiles/store.js";
|
||||
import { readCodexCliActiveApiKey } from "../agents/cli-credentials.js";
|
||||
import type { AgentExecutionAuthBinding } from "../agents/execution-auth-binding.js";
|
||||
import {
|
||||
detectInferenceBackends,
|
||||
type InferenceBackendKind,
|
||||
} from "../commands/onboard-inference.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import {
|
||||
type ProviderAuthChoiceMetadata,
|
||||
resolveManifestProviderAuthChoice,
|
||||
resolveManifestProviderAuthChoices,
|
||||
} from "../plugins/provider-auth-choices.js";
|
||||
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
|
||||
import type { SetupRecommendedInstall } from "../plugins/recommended-tool-installs.js";
|
||||
import type { ProviderAuthResult } from "../plugins/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { loadAuthoredSetupConfig } from "./onboarding-welcome.js";
|
||||
import { probeLocalCommand } from "./probes.js";
|
||||
import type {
|
||||
SetupInferenceAuthOption,
|
||||
SetupInferenceManualProvider,
|
||||
} from "./setup-inference-auth-options.js";
|
||||
import { resolveSetupInferenceCandidateBrandId } from "./setup-inference-brand.js";
|
||||
import {
|
||||
captureSystemAgentOwnerPluginArtifacts,
|
||||
type createSystemAgentVerifiedInferenceBinding,
|
||||
type SystemAgentVerifiedInferenceBinding,
|
||||
type SystemAgentVerifiedInferenceDeps,
|
||||
} from "./verified-inference.js";
|
||||
|
||||
export const log = createSubsystemLogger("system-agent/setup-inference");
|
||||
|
||||
/**
|
||||
* Inference is the one required onboarding step (docs/cli/setup.md
|
||||
* "Setup bootstrap"). This module gives structured clients (macOS app) the
|
||||
* same ladder the conversation uses, with one hard guarantee: a candidate is
|
||||
* persisted as the default model only after a real completion round-trips.
|
||||
* A failing candidate must never leave config pointing at a broken model.
|
||||
*/
|
||||
export const SETUP_INFERENCE_TEST_TIMEOUT_MS = 90_000;
|
||||
|
||||
export const SETUP_INFERENCE_TEST_PROMPT = "Reply with the single word OK. Do not use tools.";
|
||||
|
||||
const PROVIDER_AUTO_SETUP_KIND_PREFIX = "provider-auto:";
|
||||
|
||||
export const AUTO_LOCAL_MODEL_LEAN_ANNOUNCEMENT =
|
||||
"This model is small, so I set up the lean surface — switching to a bigger model later lifts it.";
|
||||
|
||||
export type ProviderAutoSetupInferenceKind = `provider-auto:${string}`;
|
||||
|
||||
export type SetupInferenceKind = InferenceBackendKind | ProviderAutoSetupInferenceKind;
|
||||
|
||||
export type SetupInferenceCandidate = {
|
||||
kind: SetupInferenceKind;
|
||||
/** Canonical provider identity for clients with bundled brand artwork. */
|
||||
brandId?: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
modelRef: string;
|
||||
/** @deprecated Gateway wire compatibility for older macOS clients. Always false. */
|
||||
recommended: false;
|
||||
credentials?: boolean;
|
||||
icon?: string;
|
||||
website?: string;
|
||||
};
|
||||
|
||||
export type SetupInferenceUnavailableCandidate = {
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type SetupInferenceDetection = {
|
||||
candidates: SetupInferenceCandidate[];
|
||||
/** Installed integrations that cannot safely run the tool-free setup probe. */
|
||||
unavailableCandidates: SetupInferenceUnavailableCandidate[];
|
||||
/** Text-inference key/token methods exposed by installed provider manifests. */
|
||||
manualProviders: SetupInferenceManualProvider[];
|
||||
/** Interactive provider-owned browser and device-code sign-in methods. */
|
||||
authOptions: SetupInferenceAuthOption[];
|
||||
/** Curated tools clients can offer when no existing AI access is detected. */
|
||||
recommendedInstalls: SetupRecommendedInstall[];
|
||||
/** Resolved workspace the setup apply would use (display + default). */
|
||||
workspace: string;
|
||||
configuredModel?: string;
|
||||
/** The connected Gateway already has a configured default-agent model. */
|
||||
setupComplete: boolean;
|
||||
};
|
||||
|
||||
export type SetupInferenceStatus =
|
||||
| "ok"
|
||||
| "auth"
|
||||
| "rate_limit"
|
||||
| "billing"
|
||||
| "timeout"
|
||||
| "format"
|
||||
| "unavailable"
|
||||
| "unknown";
|
||||
|
||||
export type SetupInferenceFailureStatus = Exclude<SetupInferenceStatus, "ok">;
|
||||
|
||||
export type ActivateSetupInferenceResult =
|
||||
| { ok: true; modelRef: string; latencyMs: number; lines: string[] }
|
||||
| { ok: false; status: SetupInferenceFailureStatus; error: string };
|
||||
|
||||
/**
|
||||
* The config commit may have happened, so callers must verify current setup
|
||||
* instead of treating this like a definitive candidate failure and retrying.
|
||||
*/
|
||||
export class SetupInferenceActivationIndeterminateError extends Error {
|
||||
override name = "SetupInferenceActivationIndeterminateError";
|
||||
}
|
||||
|
||||
export 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.
|
||||
*/
|
||||
export class SetupInferenceOwnerDriftError extends Error {
|
||||
override name = "SetupInferenceOwnerDriftError";
|
||||
}
|
||||
|
||||
export type VerifySetupInferenceResult =
|
||||
| {
|
||||
ok: true;
|
||||
modelRef: string;
|
||||
latencyMs: number;
|
||||
authProfiles?: ProviderAuthResult["profiles"];
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
status: SetupInferenceFailureStatus;
|
||||
error: string;
|
||||
authProfiles?: ProviderAuthResult["profiles"];
|
||||
};
|
||||
|
||||
export type CompleteSetupInferenceResult =
|
||||
| { ok: true; modelRef: string; latencyMs: number; text: string }
|
||||
| { ok: false; status: SetupInferenceFailureStatus; error: string };
|
||||
|
||||
export type BoundVerifySetupInferenceResult =
|
||||
| {
|
||||
ok: true;
|
||||
modelRef: string;
|
||||
latencyMs: number;
|
||||
binding: SystemAgentVerifiedInferenceBinding;
|
||||
}
|
||||
| { ok: false; status: SetupInferenceFailureStatus; error: string };
|
||||
|
||||
export type ActivateSetupInferenceParams = {
|
||||
kind: SetupInferenceKind | "api-key" | "provider-auth";
|
||||
/** Exact explicit model to probe and persist instead of the route's starter model. */
|
||||
modelRef?: string;
|
||||
/** Manual step only: provider-auth choice returned by detection. */
|
||||
authChoice?: string;
|
||||
/** Manual step only: the pasted API key or token. Never logged. */
|
||||
apiKey?: string;
|
||||
workspace?: string;
|
||||
surface: "cli" | "gateway";
|
||||
/** False when an enclosing persistent-operation boundary owns the setup audit. */
|
||||
recordSetupAudit?: boolean;
|
||||
runtime: RuntimeEnv;
|
||||
/** Interactive provider login transport, required for `provider-auth`. */
|
||||
prompter?: WizardPrompter;
|
||||
/** Cancels provider-owned browser callbacks and device-code polling. */
|
||||
signal?: AbortSignal;
|
||||
/** Session cancellation gate; interactive credentials must never persist after cancel. */
|
||||
isCancelled?: () => boolean;
|
||||
onCommitStarted?: () => void;
|
||||
deps?: ActivateSetupInferenceDeps;
|
||||
};
|
||||
|
||||
export class SetupInferenceCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Provider login was cancelled.");
|
||||
}
|
||||
}
|
||||
|
||||
export function throwIfSetupInferenceCancelled(
|
||||
params: Pick<ActivateSetupInferenceParams, "signal" | "isCancelled">,
|
||||
): void {
|
||||
if (params.signal?.aborted || params.isCancelled?.()) {
|
||||
throw new SetupInferenceCancelledError();
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForProviderAuth<T>(
|
||||
promise: Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
if (!signal) {
|
||||
return await promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new SetupInferenceCancelledError();
|
||||
}
|
||||
let rejectAborted: ((reason: unknown) => void) | undefined;
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
rejectAborted = reject;
|
||||
});
|
||||
const onAbort = () => rejectAborted?.(new SetupInferenceCancelledError());
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
return await Promise.race([promise, aborted]);
|
||||
} finally {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
type SetupInferenceRunEmbeddedAgent = (
|
||||
params: Parameters<typeof import("../agents/embedded-agent.js").runEmbeddedAgent>[0] & {
|
||||
onSuccessfulAuthBinding?: (binding: AgentExecutionAuthBinding) => void;
|
||||
authProfileStateMode?: "read-write" | "read-only";
|
||||
preparedModelRuntimeMode?: "isolated-read-only";
|
||||
},
|
||||
) => ReturnType<typeof import("../agents/embedded-agent.js").runEmbeddedAgent>;
|
||||
|
||||
export type ActivateSetupInferenceDeps = {
|
||||
readConfigFileSnapshot?: typeof import("../config/config.js").readConfigFileSnapshot;
|
||||
runEmbeddedAgent?: SetupInferenceRunEmbeddedAgent;
|
||||
runCliAgent?: typeof import("../agents/cli-runner.js").runCliAgent;
|
||||
ensureCodexRuntimePlugin?: typeof import("../commands/codex-runtime-plugin-install.js").ensureCodexRuntimePluginForModelSelection;
|
||||
ensureSelectedAgentHarnessPlugin?: typeof import("../agents/harness/runtime-plugin.js").ensureSelectedAgentHarnessPlugin;
|
||||
transformConfigWithPendingPluginInstalls?: typeof import("../plugins/install-record-commit.js").transformConfigWithPendingPluginInstalls;
|
||||
refreshPluginRegistryAfterConfigMutation?: typeof import("../plugins/registry-refresh.js").refreshPluginRegistryAfterConfigMutation;
|
||||
ensurePluginRegistryLoaded?: typeof import("../plugins/runtime/runtime-registry-loader.js").ensurePluginRegistryLoaded;
|
||||
resolvePluginProviders?: typeof resolvePluginProviders;
|
||||
resolveManifestProviderAuthChoice?: typeof resolveManifestProviderAuthChoice;
|
||||
enablePluginInConfig?: typeof enablePluginInConfig;
|
||||
updateAuthProfileStoreWithLock?: typeof updateAuthProfileStoreWithLock;
|
||||
loadPersistedAuthProfileStore?: typeof loadPersistedAuthProfileStore;
|
||||
loadAuthProfileStoreForRuntime?: typeof loadAuthProfileStoreForRuntime;
|
||||
ensureAuthProfileStore?: typeof import("../agents/auth-profiles/store.js").ensureAuthProfileStore;
|
||||
resolveCliAuthBindingFingerprint?: typeof import("../agents/cli-auth-epoch.js").resolveCliAuthBindingFingerprint;
|
||||
resolveCliRuntimeArtifactFingerprint?: typeof import("../agents/cli-auth-epoch.js").resolveCliRuntimeArtifactFingerprint;
|
||||
resolveCliRuntimeOwnerFingerprint?: typeof import("../agents/cli-auth-epoch.js").resolveCliRuntimeOwnerFingerprint;
|
||||
resolveApiKeyForProvider?: typeof import("../agents/model-auth.js").resolveApiKeyForProvider;
|
||||
readCodexCliActiveApiKey?: typeof readCodexCliActiveApiKey;
|
||||
loadPluginRegistrySnapshot?: SystemAgentVerifiedInferenceDeps["loadPluginRegistrySnapshot"];
|
||||
fingerprintPluginRuntimeArtifact?: SystemAgentVerifiedInferenceDeps["fingerprintPluginRuntimeArtifact"];
|
||||
captureSystemAgentOwnerPluginArtifacts?: typeof captureSystemAgentOwnerPluginArtifacts;
|
||||
createSystemAgentVerifiedInferenceBinding?: typeof createSystemAgentVerifiedInferenceBinding;
|
||||
readPersistedInstalledPluginIndexInstallRecords?: typeof import("../plugins/installed-plugin-index-records.js").readPersistedInstalledPluginIndexInstallRecords;
|
||||
markRetainedManagedNpmInstall?: typeof import("../plugins/managed-npm-retention.js").markRetainedManagedNpmInstall;
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache?: typeof import("../plugins/installed-plugin-index-records.js").clearLoadInstalledPluginIndexInstallRecordsCache;
|
||||
clearPluginMetadataLifecycleCaches?: typeof import("../plugins/plugin-metadata-lifecycle.js").clearPluginMetadataLifecycleCaches;
|
||||
invalidatePluginRuntimeDiscoveryAfterConfigMutation?: typeof import("../plugins/registry-refresh.js").invalidatePluginRuntimeDiscoveryAfterConfigMutation;
|
||||
disposeOpenClawAgentDatabaseByPath?: typeof import("../state/openclaw-agent-db.js").disposeOpenClawAgentDatabaseByPath;
|
||||
createTempDir?: () => Promise<string>;
|
||||
removeTempDir?: (dir: string) => Promise<void>;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type DetectSetupInferenceDeps = {
|
||||
detectInferenceBackends?: typeof detectInferenceBackends;
|
||||
probeLocalCommand?: typeof probeLocalCommand;
|
||||
resolveManifestProviderAuthChoices?: typeof resolveManifestProviderAuthChoices;
|
||||
resolvePluginProviders?: typeof resolvePluginProviders;
|
||||
enablePluginInConfig?: typeof enablePluginInConfig;
|
||||
};
|
||||
|
||||
export function toProviderAutoSetupKind(choiceId: string): ProviderAutoSetupInferenceKind {
|
||||
return `${PROVIDER_AUTO_SETUP_KIND_PREFIX}${encodeURIComponent(choiceId)}`;
|
||||
}
|
||||
|
||||
export function parseProviderAutoSetupChoiceId(kind: string): string | undefined {
|
||||
if (!kind.startsWith(PROVIDER_AUTO_SETUP_KIND_PREFIX)) {
|
||||
return undefined;
|
||||
}
|
||||
const encoded = kind.slice(PROVIDER_AUTO_SETUP_KIND_PREFIX.length);
|
||||
if (!encoded) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(encoded) || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidSetupConfigError(snapshot: {
|
||||
path: string;
|
||||
issues?: Array<{ path?: string; message: string }>;
|
||||
}): string {
|
||||
const issue = snapshot.issues?.[0];
|
||||
const detail = issue ? ` (${issue.path ? `${issue.path}: ` : ""}${issue.message})` : "";
|
||||
return `OpenClaw config ${snapshot.path} is invalid${detail}. Fix it before running setup.`;
|
||||
}
|
||||
|
||||
export function resolveCandidatePresentation(
|
||||
candidate: Pick<SetupInferenceCandidate, "kind" | "modelRef">,
|
||||
authChoices: readonly ProviderAuthChoiceMetadata[],
|
||||
): Pick<SetupInferenceCandidate, "brandId" | "icon" | "website"> {
|
||||
const choice = authChoices.find(
|
||||
(entry) =>
|
||||
entry.choiceId === candidate.kind ||
|
||||
entry.deprecatedChoiceIds?.includes(candidate.kind) === true,
|
||||
);
|
||||
const brandId = resolveSetupInferenceCandidateBrandId(candidate, choice?.providerId);
|
||||
return {
|
||||
...(brandId ? { brandId } : {}),
|
||||
...(choice?.icon ? { icon: choice.icon } : {}),
|
||||
...(choice?.website ? { website: choice.website } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveSetupInferenceWorkspace(params: {
|
||||
configExists: boolean;
|
||||
configValid: boolean;
|
||||
}): Promise<{ workspace: string; hasAuthoredSetup: boolean }> {
|
||||
const { authoredConfig, hasAuthoredSetup } = await loadAuthoredSetupConfig(params);
|
||||
const { DEFAULT_WORKSPACE } = await import("../commands/onboard-helpers.js");
|
||||
return {
|
||||
workspace: resolveUserPath(
|
||||
authoredConfig?.agents?.defaults?.workspace?.trim() || DEFAULT_WORKSPACE,
|
||||
),
|
||||
hasAuthoredSetup,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { resolveAgentEffectiveModelPrimary, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import { detectInferenceBackends } from "../commands/onboard-inference.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import {
|
||||
type ProviderAuthChoiceMetadata,
|
||||
resolveManifestProviderAuthChoices,
|
||||
} from "../plugins/provider-auth-choices.js";
|
||||
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
|
||||
import { listRecommendedToolInstalls } from "../plugins/recommended-tool-installs.js";
|
||||
import { probeLocalCommand } from "./probes.js";
|
||||
import {
|
||||
listSetupInferenceAuthOptions,
|
||||
listSetupInferenceManualProviders,
|
||||
supportsSetupTextInference,
|
||||
} from "./setup-inference-auth-options.js";
|
||||
import {
|
||||
type DetectSetupInferenceDeps,
|
||||
type SetupInferenceCandidate,
|
||||
type SetupInferenceDetection,
|
||||
type SetupInferenceUnavailableCandidate,
|
||||
invalidSetupConfigError,
|
||||
log,
|
||||
resolveCandidatePresentation,
|
||||
resolveSetupInferenceWorkspace,
|
||||
toProviderAutoSetupKind,
|
||||
} from "./setup-inference-core.js";
|
||||
import { parseRef } from "./setup-inference-plan-helpers.js";
|
||||
|
||||
/**
|
||||
* Manual setup options only — no CLI probing, no credential discovery. Used
|
||||
* when guarded onboarding declines the "look around" step: the option lists
|
||||
* derive from config and plugin manifests, never from scanning the machine.
|
||||
*/
|
||||
export async function listManualSetupInferenceOptions(
|
||||
deps: DetectSetupInferenceDeps = {},
|
||||
): Promise<
|
||||
Pick<SetupInferenceDetection, "manualProviders" | "authOptions" | "workspace" | "setupComplete">
|
||||
> {
|
||||
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
throw new Error(invalidSetupConfigError(snapshot));
|
||||
}
|
||||
const cfg = snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {};
|
||||
const { workspace } = await resolveSetupInferenceWorkspace({
|
||||
configExists: snapshot.exists,
|
||||
configValid: snapshot.valid,
|
||||
});
|
||||
const authChoices = (
|
||||
deps.resolveManifestProviderAuthChoices ?? resolveManifestProviderAuthChoices
|
||||
)({
|
||||
config: cfg,
|
||||
workspaceDir: workspace,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
includeWorkspacePlugins: false,
|
||||
}).filter(
|
||||
(choice) => (deps.enablePluginInConfig ?? enablePluginInConfig)(cfg, choice.pluginId).enabled,
|
||||
);
|
||||
return {
|
||||
manualProviders: listSetupInferenceManualProviders(authChoices),
|
||||
authOptions: listSetupInferenceAuthOptions(authChoices),
|
||||
workspace,
|
||||
// Derived from config only (no probing): a pre-existing default model must
|
||||
// keep classifying the install as configured even when scanning declined.
|
||||
setupComplete: Boolean(resolveAgentEffectiveModelPrimary(cfg, resolveDefaultAgentId(cfg))),
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectSetupInference(
|
||||
deps: DetectSetupInferenceDeps = {},
|
||||
): Promise<SetupInferenceDetection> {
|
||||
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
throw new Error(invalidSetupConfigError(snapshot));
|
||||
}
|
||||
const cfg = snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {};
|
||||
const detected = await (deps.detectInferenceBackends ?? detectInferenceBackends)({ config: cfg });
|
||||
// Gemini CLI has no hard tool-off mode: wildcard exclusions can be
|
||||
// overridden by admin policy and do not stop discovery or MCP startup.
|
||||
// Keep normal agent support, but never offer it for the setup safety probe.
|
||||
const unavailableCandidates: SetupInferenceUnavailableCandidate[] = detected
|
||||
.filter((candidate) => candidate.kind === "gemini-cli")
|
||||
.map((candidate) => ({
|
||||
id: candidate.kind,
|
||||
label: candidate.label,
|
||||
detail: candidate.detail,
|
||||
reason:
|
||||
"Can't be auto-tested safely here. Use 'Gemini CLI OAuth' or a Gemini API key instead.",
|
||||
}));
|
||||
const probe = deps.probeLocalCommand ?? probeLocalCommand;
|
||||
const [antigravity, pi, opencode] = await Promise.all([
|
||||
probe("agy"),
|
||||
probe("pi"),
|
||||
probe("opencode"),
|
||||
]);
|
||||
if (antigravity.found && !antigravity.timedOut) {
|
||||
unavailableCandidates.push({
|
||||
id: "antigravity-cli",
|
||||
label: "Antigravity CLI",
|
||||
detail: "installed",
|
||||
reason:
|
||||
"Can't be auto-tested safely here. Sign in with a provider or use an API key instead.",
|
||||
});
|
||||
}
|
||||
if (pi.found && !pi.timedOut) {
|
||||
unavailableCandidates.push({
|
||||
id: "pi-cli",
|
||||
label: "Pi CLI",
|
||||
detail: "installed",
|
||||
reason:
|
||||
"Pi CLI is installed, but its whole-agent sessions require separate setup and are not a reusable guided-setup inference route.",
|
||||
});
|
||||
}
|
||||
if (opencode.found && !opencode.timedOut) {
|
||||
unavailableCandidates.push({
|
||||
id: "opencode-cli",
|
||||
label: "OpenCode CLI",
|
||||
detail: "installed",
|
||||
reason:
|
||||
"OpenCode CLI is installed, but its ACP harness requires separate setup and is not a reusable guided-setup inference route.",
|
||||
});
|
||||
}
|
||||
const raw = detected.filter((candidate) => candidate.kind !== "gemini-cli");
|
||||
const { workspace } = await resolveSetupInferenceWorkspace({
|
||||
configExists: snapshot.exists,
|
||||
configValid: snapshot.valid,
|
||||
});
|
||||
const authChoices = (
|
||||
deps.resolveManifestProviderAuthChoices ?? resolveManifestProviderAuthChoices
|
||||
)({
|
||||
config: cfg,
|
||||
workspaceDir: workspace,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
includeWorkspacePlugins: false,
|
||||
}).filter(
|
||||
(choice) => (deps.enablePluginInConfig ?? enablePluginInConfig)(cfg, choice.pluginId).enabled,
|
||||
);
|
||||
const candidates: SetupInferenceCandidate[] = raw.map((candidate) =>
|
||||
// Released macOS clients require this field. Keep it false so the wire
|
||||
// contract remains decodable without expressing a provider preference.
|
||||
Object.assign(
|
||||
candidate,
|
||||
{ recommended: false as const },
|
||||
resolveCandidatePresentation(candidate, authChoices),
|
||||
),
|
||||
);
|
||||
const configuredModel = candidates.find(
|
||||
(candidate) => candidate.kind === "existing-model",
|
||||
)?.modelRef;
|
||||
const discoveryChoices = authChoices.filter(
|
||||
(choice) =>
|
||||
choice.appGuidedDiscovery === true && supportsSetupTextInference(choice.onboardingScopes),
|
||||
);
|
||||
if (discoveryChoices.length > 0) {
|
||||
let discoveryConfig = cfg;
|
||||
const enabledChoices: ProviderAuthChoiceMetadata[] = [];
|
||||
for (const choice of discoveryChoices) {
|
||||
const enabled = (deps.enablePluginInConfig ?? enablePluginInConfig)(
|
||||
discoveryConfig,
|
||||
choice.pluginId,
|
||||
);
|
||||
if (!enabled.enabled) {
|
||||
continue;
|
||||
}
|
||||
discoveryConfig = enabled.config;
|
||||
enabledChoices.push(choice);
|
||||
}
|
||||
const providers = (deps.resolvePluginProviders ?? resolvePluginProviders)({
|
||||
config: discoveryConfig,
|
||||
workspaceDir: workspace,
|
||||
mode: "setup",
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
onlyPluginIds: [...new Set(enabledChoices.map((choice) => choice.pluginId))],
|
||||
});
|
||||
const discovered = await Promise.all(
|
||||
enabledChoices.map(async (choice): Promise<SetupInferenceCandidate | null> => {
|
||||
const provider = providers.find(
|
||||
(candidate) =>
|
||||
candidate.pluginId === choice.pluginId &&
|
||||
normalizeProviderId(candidate.id) === normalizeProviderId(choice.providerId),
|
||||
);
|
||||
const method = provider?.auth.find((candidate) => candidate.id === choice.methodId);
|
||||
if (!method?.appGuidedSetup) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const candidate = await method.appGuidedSetup.detect({
|
||||
config: discoveryConfig,
|
||||
env: process.env,
|
||||
workspaceDir: workspace,
|
||||
});
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
const ref = parseRef(candidate.modelRef);
|
||||
if (
|
||||
!ref.model ||
|
||||
normalizeProviderId(ref.provider) !== normalizeProviderId(choice.providerId)
|
||||
) {
|
||||
log.warn(
|
||||
`Ignoring invalid app-guided model ${candidate.modelRef} from ${choice.choiceId}.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return Object.assign(
|
||||
{
|
||||
kind: toProviderAutoSetupKind(choice.choiceId),
|
||||
brandId: choice.providerId,
|
||||
label: choice.choiceLabel,
|
||||
detail: candidate.detail?.trim() || "available locally",
|
||||
modelRef: candidate.modelRef,
|
||||
recommended: false as const,
|
||||
credentials: true,
|
||||
},
|
||||
choice.icon ? { icon: choice.icon } : {},
|
||||
choice.website ? { website: choice.website } : {},
|
||||
);
|
||||
} catch (error) {
|
||||
log.debug(
|
||||
`App-guided discovery failed for ${choice.choiceId}: ${formatErrorMessage(error)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
candidates.push(...discovered.filter((candidate) => candidate !== null));
|
||||
}
|
||||
return {
|
||||
candidates,
|
||||
unavailableCandidates,
|
||||
manualProviders: listSetupInferenceManualProviders(authChoices),
|
||||
authOptions: listSetupInferenceAuthOptions(authChoices),
|
||||
recommendedInstalls: listRecommendedToolInstalls(),
|
||||
workspace,
|
||||
...(configuredModel ? { configuredModel } : {}),
|
||||
setupComplete: Boolean(configuredModel),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { AgentExecutionAuthBinding } from "../agents/execution-auth-binding.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { SystemAgentConfiguredRoute } from "./inference-route.js";
|
||||
import { revalidateSetupInferenceOwner } from "./revalidate-inference-owner.js";
|
||||
import {
|
||||
type ActivateSetupInferenceDeps,
|
||||
SetupInferenceOwnerDriftError,
|
||||
} from "./setup-inference-core.js";
|
||||
import type {
|
||||
SystemAgentOwnerPluginArtifactSnapshot,
|
||||
SystemAgentVerifiedInferenceBinding,
|
||||
} from "./verified-inference.js";
|
||||
|
||||
function hasSameOwnerPluginArtifacts(
|
||||
binding: SystemAgentVerifiedInferenceBinding,
|
||||
snapshot: SystemAgentOwnerPluginArtifactSnapshot,
|
||||
): boolean {
|
||||
return (
|
||||
isDeepStrictEqual(binding.ownerPluginIds, snapshot.ownerPluginIds) &&
|
||||
isDeepStrictEqual(binding.ownerPluginArtifacts, snapshot.ownerPluginArtifacts)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { listAgentEntries } from "../agents/agent-scope.js";
|
||||
import { normalizeAuthProfileCredential } from "../agents/auth-profiles/credential-normalize.js";
|
||||
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
|
||||
import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js";
|
||||
import type { AgentExecutionAuthBinding } from "../agents/execution-auth-binding.js";
|
||||
import { describeFailoverError } from "../agents/failover-error.js";
|
||||
import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js";
|
||||
import { applyMergePatch } from "../config/merge-patch.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import type { ProviderAuthResult } from "../plugins/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import {
|
||||
type ActivateSetupInferenceDeps,
|
||||
SETUP_INFERENCE_TEST_PROMPT,
|
||||
SETUP_INFERENCE_TEST_TIMEOUT_MS,
|
||||
SetupInferenceCancelledError,
|
||||
type SetupInferenceFailureStatus,
|
||||
log,
|
||||
} from "./setup-inference-core.js";
|
||||
import {
|
||||
type RunResult,
|
||||
type SetupInferenceTestPlan,
|
||||
extractRunTerminalError,
|
||||
extractRunText,
|
||||
extractRunWinnerError,
|
||||
mapFailoverReasonToSetupStatus,
|
||||
resolveStrictSetupAuthProfileError,
|
||||
resolveToolFreeCliSetupError,
|
||||
} from "./setup-inference-plan-helpers.js";
|
||||
import { resolveSetupInferenceProbeStreamParams } from "./setup-inference-probe.js";
|
||||
|
||||
export async function cleanupSetupInferenceTempDir(params: {
|
||||
tempDir: string;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
runtime?: RuntimeEnv;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const disposeDatabase =
|
||||
params.deps.disposeOpenClawAgentDatabaseByPath ??
|
||||
(await import("../state/openclaw-agent-db.js")).disposeOpenClawAgentDatabaseByPath;
|
||||
disposeDatabase(path.join(params.tempDir, "agent", "openclaw-agent.sqlite"));
|
||||
} catch {
|
||||
// Windows cannot remove an open SQLite file. Keep cleanup nonfatal, but
|
||||
// always try the directory removal so callers do not retain probe secrets.
|
||||
log.warn("Could not dispose the temporary inference auth database.");
|
||||
}
|
||||
try {
|
||||
await (
|
||||
params.deps.removeTempDir ?? ((dir: string) => fs.rm(dir, { recursive: true, force: true }))
|
||||
)(params.tempDir);
|
||||
} catch (error) {
|
||||
// Cleanup happens after the inference result or durable activation. It must
|
||||
// never turn a verified/committed route into a failed client RPC.
|
||||
params.runtime?.error?.(
|
||||
`Could not remove temporary AI setup files: ${formatErrorMessage(error)}`,
|
||||
);
|
||||
log.warn("Could not remove the temporary inference test directory.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function isCodexInstallRecordPersisted(
|
||||
record: PluginInstallRecord,
|
||||
deps: ActivateSetupInferenceDeps,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const readInstallRecords =
|
||||
deps.readPersistedInstalledPluginIndexInstallRecords ??
|
||||
(await import("../plugins/installed-plugin-index-records.js"))
|
||||
.readPersistedInstalledPluginIndexInstallRecords;
|
||||
const currentInstallRecords = await readInstallRecords();
|
||||
return currentInstallRecords !== null && isDeepStrictEqual(currentInstallRecords.codex, record);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function retainUnownedCodexInstall(params: {
|
||||
record: PluginInstallRecord;
|
||||
verifyOwnership: boolean;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
}): Promise<boolean> {
|
||||
if (params.verifyOwnership && (await isCodexInstallRecordPersisted(params.record, params.deps))) {
|
||||
return true;
|
||||
}
|
||||
if (params.record.source !== "npm" || !params.record.installPath?.trim()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
// Never delete an unowned generation: recovery/startup cleanup skips the
|
||||
// marker, a successful install commit clears it, and later install/GC may
|
||||
// safely reuse or remove the bytes.
|
||||
const markRetained =
|
||||
params.deps.markRetainedManagedNpmInstall ??
|
||||
(await import("../plugins/managed-npm-retention.js")).markRetainedManagedNpmInstall;
|
||||
const marked = await markRetained({
|
||||
packageDir: params.record.installPath,
|
||||
pluginId: "codex",
|
||||
reason: "openclaw-inference-activation-not-committed",
|
||||
});
|
||||
if (!marked) {
|
||||
log.warn("Could not retain the uncommitted Codex runtime package generation.");
|
||||
}
|
||||
return marked;
|
||||
} catch {
|
||||
// Retention is best effort and marker-after-adoption is non-destructive.
|
||||
// A later install or GC may still reuse or remove the unowned generation.
|
||||
log.warn("Could not retain the uncommitted Codex runtime package generation.");
|
||||
return false;
|
||||
} finally {
|
||||
await clearUnownedCodexInstallCaches(params.deps);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearUnownedCodexInstallCaches(deps: ActivateSetupInferenceDeps): Promise<void> {
|
||||
try {
|
||||
const clearInstallRecords =
|
||||
deps.clearLoadInstalledPluginIndexInstallRecordsCache ??
|
||||
(await import("../plugins/installed-plugin-index-records.js"))
|
||||
.clearLoadInstalledPluginIndexInstallRecordsCache;
|
||||
clearInstallRecords();
|
||||
} catch {
|
||||
log.warn("Could not clear the plugin install-record cache after failed Codex activation.");
|
||||
}
|
||||
try {
|
||||
const clearPluginMetadata =
|
||||
deps.clearPluginMetadataLifecycleCaches ??
|
||||
(await import("../plugins/plugin-metadata-lifecycle.js")).clearPluginMetadataLifecycleCaches;
|
||||
clearPluginMetadata();
|
||||
} catch {
|
||||
log.warn("Could not clear plugin metadata caches after failed Codex activation.");
|
||||
}
|
||||
try {
|
||||
const invalidateRuntimeDiscovery =
|
||||
deps.invalidatePluginRuntimeDiscoveryAfterConfigMutation ??
|
||||
(await import("../plugins/registry-refresh.js"))
|
||||
.invalidatePluginRuntimeDiscoveryAfterConfigMutation;
|
||||
await invalidateRuntimeDiscovery({ logger: log });
|
||||
} catch {
|
||||
log.warn("Could not clear plugin runtime discovery after failed Codex activation.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function reloadCodexRegistryAfterActivation(params: {
|
||||
readSnapshot: () => Promise<
|
||||
Awaited<ReturnType<typeof import("../config/config.js").readConfigFileSnapshot>>
|
||||
>;
|
||||
workspaceDir: string;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
}): Promise<boolean> {
|
||||
let snapshot: Awaited<ReturnType<typeof import("../config/config.js").readConfigFileSnapshot>>;
|
||||
try {
|
||||
snapshot = await params.readSnapshot();
|
||||
} catch {
|
||||
log.warn("Could not read config while reloading the plugin registry after Codex activation.");
|
||||
return false;
|
||||
}
|
||||
const runtimeConfig =
|
||||
snapshot.exists && snapshot.valid
|
||||
? (snapshot.runtimeConfig ?? snapshot.config)
|
||||
: ({} satisfies OpenClawConfig);
|
||||
const sourceConfig =
|
||||
snapshot.exists && snapshot.valid
|
||||
? (snapshot.sourceConfig ?? snapshot.config)
|
||||
: ({} satisfies OpenClawConfig);
|
||||
try {
|
||||
const refreshPluginRegistry =
|
||||
params.deps.refreshPluginRegistryAfterConfigMutation ??
|
||||
(await import("../plugins/registry-refresh.js")).refreshPluginRegistryAfterConfigMutation;
|
||||
await refreshPluginRegistry({
|
||||
config: sourceConfig,
|
||||
reason: "source-changed",
|
||||
workspaceDir: params.workspaceDir,
|
||||
logger: log,
|
||||
});
|
||||
} catch {
|
||||
log.warn("Could not refresh persisted plugin registry metadata after Codex activation.");
|
||||
}
|
||||
try {
|
||||
const ensurePluginRegistryLoaded =
|
||||
params.deps.ensurePluginRegistryLoaded ??
|
||||
(await import("../plugins/runtime/runtime-registry-loader.js")).ensurePluginRegistryLoaded;
|
||||
ensurePluginRegistryLoaded({
|
||||
scope: "all",
|
||||
config: runtimeConfig,
|
||||
activationSourceConfig: sourceConfig,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
log.warn("Could not reload the active plugin registry after Codex inference activation.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isMergePatchObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergePatchConflicts(base: unknown, current: unknown, patch: unknown): boolean {
|
||||
if (!isMergePatchObject(patch)) {
|
||||
return !isDeepStrictEqual(base, current);
|
||||
}
|
||||
const baseIsObject = isMergePatchObject(base);
|
||||
const currentIsObject = isMergePatchObject(current);
|
||||
if (baseIsObject !== currentIsObject) {
|
||||
return true;
|
||||
}
|
||||
if (!baseIsObject && !currentIsObject && !isDeepStrictEqual(base, current)) {
|
||||
return true;
|
||||
}
|
||||
const baseRecord = baseIsObject ? base : {};
|
||||
const currentRecord = currentIsObject ? current : {};
|
||||
return Object.entries(patch).some(([key, childPatch]) =>
|
||||
mergePatchConflicts(baseRecord[key], currentRecord[key], childPatch),
|
||||
);
|
||||
}
|
||||
|
||||
export function applyManualAuthConfig(
|
||||
config: OpenClawConfig,
|
||||
manualAuth: NonNullable<SetupInferenceTestPlan["manualAuth"]>,
|
||||
configKind: "runtime" | "source",
|
||||
enablePlugin: typeof enablePluginInConfig = enablePluginInConfig,
|
||||
): OpenClawConfig {
|
||||
let enabledConfig = config;
|
||||
if (manualAuth.pluginId) {
|
||||
const enableResult = enablePlugin(config, manualAuth.pluginId);
|
||||
if (!enableResult.enabled) {
|
||||
throw new Error(`Provider plugin ${manualAuth.pluginId} is ${enableResult.reason}.`);
|
||||
}
|
||||
enabledConfig = enableResult.config;
|
||||
}
|
||||
// Runtime validation includes resolved defaults; source validation must compare
|
||||
// only authored state so normal materialization cannot impersonate a concurrent edit.
|
||||
const configBase =
|
||||
configKind === "runtime" ? manualAuth.runtimeConfigBase : manualAuth.sourceConfigBase;
|
||||
if (mergePatchConflicts(configBase, enabledConfig, manualAuth.configPatch)) {
|
||||
throw new Error(
|
||||
"Provider configuration changed during the live inference test, so the verified credential was not saved. Review the current provider settings and retry.",
|
||||
);
|
||||
}
|
||||
return applyMergePatch(enabledConfig, manualAuth.configPatch) as OpenClawConfig;
|
||||
}
|
||||
|
||||
export type ManualAuthPersistenceReceipt = {
|
||||
agentDir: string;
|
||||
profiles: Array<{
|
||||
profileId: string;
|
||||
credential: ReturnType<typeof normalizeAuthProfileCredential>;
|
||||
}>;
|
||||
/** Profiles created by this activation; rollback must not delete prior identical entries. */
|
||||
insertedProfileIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
type ManualAuthProfilesReadback = "present" | "absent" | "mismatch" | "unknown";
|
||||
|
||||
type ManualAuthPersistenceResult =
|
||||
| { status: "persisted"; receipt: ManualAuthPersistenceReceipt }
|
||||
| { status: "not-persisted" }
|
||||
| { status: "unknown"; receipt: ManualAuthPersistenceReceipt };
|
||||
|
||||
function modelSelectionReferencesProfile(value: unknown, profileIds: ReadonlySet<string>): boolean {
|
||||
if (typeof value === "string") {
|
||||
const profile = splitTrailingAuthProfile(value).profile;
|
||||
return profile !== undefined && profileIds.has(profile);
|
||||
}
|
||||
if (!isMergePatchObject(value)) {
|
||||
return false;
|
||||
}
|
||||
if (modelSelectionReferencesProfile(value.primary, profileIds)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
Array.isArray(value.fallbacks) &&
|
||||
value.fallbacks.some((fallback) => modelSelectionReferencesProfile(fallback, profileIds))
|
||||
);
|
||||
}
|
||||
|
||||
export function configReferencesManualAuthProfiles(
|
||||
config: OpenClawConfig,
|
||||
receipt: ManualAuthPersistenceReceipt,
|
||||
): boolean {
|
||||
const profileIds = new Set(receipt.profiles.map((profile) => profile.profileId));
|
||||
if (Object.keys(config.auth?.profiles ?? {}).some((profileId) => profileIds.has(profileId))) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
Object.values(config.auth?.order ?? {}).some((order) =>
|
||||
order.some((profileId) => profileIds.has(profileId)),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (modelSelectionReferencesProfile(config.agents?.defaults?.model, profileIds)) {
|
||||
return true;
|
||||
}
|
||||
return listAgentEntries(config).some((agent) =>
|
||||
modelSelectionReferencesProfile(agent.model, profileIds),
|
||||
);
|
||||
}
|
||||
|
||||
function readManualAuthProfiles(
|
||||
receipt: ManualAuthPersistenceReceipt,
|
||||
deps: ActivateSetupInferenceDeps,
|
||||
): ManualAuthProfilesReadback {
|
||||
let store: ReturnType<typeof loadPersistedAuthProfileStore>;
|
||||
try {
|
||||
store = (deps.loadPersistedAuthProfileStore ?? loadPersistedAuthProfileStore)(receipt.agentDir);
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
if (!store) {
|
||||
return "unknown";
|
||||
}
|
||||
if (
|
||||
receipt.profiles.every((profile) =>
|
||||
isDeepStrictEqual(store.profiles[profile.profileId], profile.credential),
|
||||
)
|
||||
) {
|
||||
return "present";
|
||||
}
|
||||
if (receipt.profiles.every((profile) => store.profiles[profile.profileId] === undefined)) {
|
||||
return "absent";
|
||||
}
|
||||
return "mismatch";
|
||||
}
|
||||
|
||||
export function manualAuthProfilesPersisted(
|
||||
receipt: ManualAuthPersistenceReceipt,
|
||||
deps: ActivateSetupInferenceDeps,
|
||||
): boolean {
|
||||
return readManualAuthProfiles(receipt, deps) === "present";
|
||||
}
|
||||
|
||||
export async function persistManualAuthProfiles(params: {
|
||||
profiles: ProviderAuthResult["profiles"];
|
||||
agentDir: string;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
}): Promise<ManualAuthPersistenceResult> {
|
||||
const profiles = params.profiles.map((profile) => ({
|
||||
profileId: profile.profileId,
|
||||
credential: normalizeAuthProfileCredential(profile.credential),
|
||||
}));
|
||||
const insertedProfileIds = new Set<string>();
|
||||
const receipt = { agentDir: params.agentDir, profiles, insertedProfileIds };
|
||||
let collision = false;
|
||||
const update = params.deps.updateAuthProfileStoreWithLock ?? updateAuthProfileStoreWithLock;
|
||||
const updated = await update({
|
||||
agentDir: params.agentDir,
|
||||
saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
|
||||
updater: (store) => {
|
||||
let changed = false;
|
||||
for (const profile of profiles) {
|
||||
const existing = store.profiles[profile.profileId];
|
||||
if (existing && !isDeepStrictEqual(existing, profile.credential)) {
|
||||
collision = true;
|
||||
return false;
|
||||
}
|
||||
if (!existing) {
|
||||
store.profiles[profile.profileId] = profile.credential;
|
||||
insertedProfileIds.add(profile.profileId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
},
|
||||
});
|
||||
if (collision) {
|
||||
return { status: "not-persisted" };
|
||||
}
|
||||
// The store helper can report a post-commit chmod failure as null. Read back
|
||||
// the exact unique profiles before deciding whether the transaction failed.
|
||||
const readback = readManualAuthProfiles(receipt, params.deps);
|
||||
if (updated !== null || readback === "present") {
|
||||
return { status: "persisted", receipt };
|
||||
}
|
||||
return readback === "absent" ? { status: "not-persisted" } : { status: "unknown", receipt };
|
||||
}
|
||||
|
||||
export async function rollbackManualAuthProfiles(
|
||||
receipt: ManualAuthPersistenceReceipt,
|
||||
deps: ActivateSetupInferenceDeps,
|
||||
): Promise<boolean> {
|
||||
if (receipt.insertedProfileIds.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const update = deps.updateAuthProfileStoreWithLock ?? updateAuthProfileStoreWithLock;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
let updated: Awaited<ReturnType<typeof update>> = null;
|
||||
try {
|
||||
updated = await update({
|
||||
agentDir: receipt.agentDir,
|
||||
saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
|
||||
updater: (store) => {
|
||||
let changed = false;
|
||||
for (const profile of receipt.profiles) {
|
||||
if (!receipt.insertedProfileIds.has(profile.profileId)) {
|
||||
continue;
|
||||
}
|
||||
if (isDeepStrictEqual(store.profiles[profile.profileId], profile.credential)) {
|
||||
delete store.profiles[profile.profileId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// A thrown write may still have committed. Only readback or a later
|
||||
// locked attempt may prove removal; otherwise the caller reports the
|
||||
// activation as indeterminate.
|
||||
}
|
||||
if (
|
||||
updated &&
|
||||
receipt.profiles.every(
|
||||
(profile) =>
|
||||
!receipt.insertedProfileIds.has(profile.profileId) ||
|
||||
updated.profiles[profile.profileId] === undefined,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
let persistedStore: ReturnType<typeof loadPersistedAuthProfileStore>;
|
||||
try {
|
||||
persistedStore = (deps.loadPersistedAuthProfileStore ?? loadPersistedAuthProfileStore)(
|
||||
receipt.agentDir,
|
||||
);
|
||||
} catch {
|
||||
persistedStore = null;
|
||||
}
|
||||
if (
|
||||
persistedStore &&
|
||||
receipt.profiles.every(
|
||||
(profile) =>
|
||||
!receipt.insertedProfileIds.has(profile.profileId) ||
|
||||
persistedStore.profiles[profile.profileId] === undefined,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function runSetupInferenceTest(params: {
|
||||
plan: SetupInferenceTestPlan;
|
||||
prompt?: string;
|
||||
tempDir: string;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
authProfileStateMode: "read-write" | "read-only";
|
||||
requireExecutionOwner: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<
|
||||
| { ok: true; latencyMs: number; auth: AgentExecutionAuthBinding; text: string }
|
||||
| {
|
||||
ok: false;
|
||||
status: SetupInferenceFailureStatus;
|
||||
error: string;
|
||||
}
|
||||
> {
|
||||
const { plan, tempDir, deps, authProfileStateMode, requireExecutionOwner } = params;
|
||||
// Keep probe prefixes aligned with the logging filters; provider transports can also use the
|
||||
// session id as cache affinity, so this ephemeral id must stay under OpenAI's 64-character cap.
|
||||
const runId = `probe-setup-inference-${randomUUID()}`;
|
||||
const sessionId = runId;
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const timeoutMs = deps.timeoutMs ?? SETUP_INFERENCE_TEST_TIMEOUT_MS;
|
||||
const started = Date.now();
|
||||
let successfulAuth: AgentExecutionAuthBinding | undefined;
|
||||
try {
|
||||
if (plan.runner === "cli") {
|
||||
const unsupportedError = resolveToolFreeCliSetupError(plan);
|
||||
if (unsupportedError) {
|
||||
return { ok: false, status: "unavailable", error: unsupportedError };
|
||||
}
|
||||
}
|
||||
const strictProfileError = resolveStrictSetupAuthProfileError({
|
||||
plan,
|
||||
workspaceDir: tempDir,
|
||||
deps,
|
||||
});
|
||||
if (strictProfileError) {
|
||||
return { ok: false, status: "auth", error: strictProfileError };
|
||||
}
|
||||
|
||||
let result: RunResult;
|
||||
if (plan.runner === "cli") {
|
||||
const runCli = deps.runCliAgent ?? (await import("../agents/cli-runner.js")).runCliAgent;
|
||||
result = (await runCli({
|
||||
sessionId,
|
||||
sessionKey: `temp:setup-inference:${runId}`,
|
||||
agentId: plan.agentId ?? "openclaw",
|
||||
trigger: "manual",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
...(plan.agentDir ? { agentDir: plan.agentDir } : {}),
|
||||
config: plan.config,
|
||||
prompt: params.prompt ?? SETUP_INFERENCE_TEST_PROMPT,
|
||||
provider: plan.provider,
|
||||
model: plan.model,
|
||||
...(plan.authProfileId ? { authProfileId: plan.authProfileId } : {}),
|
||||
timeoutMs,
|
||||
runId,
|
||||
messageChannel: "openclaw",
|
||||
messageProvider: "openclaw",
|
||||
executionMode: "side-question",
|
||||
disableTools: true,
|
||||
cleanupCliLiveSessionOnRunEnd: true,
|
||||
onSuccessfulAuthBinding: (binding) => {
|
||||
successfulAuth = binding;
|
||||
},
|
||||
...(params.signal ? { abortSignal: params.signal } : {}),
|
||||
})) as RunResult;
|
||||
} else {
|
||||
const runEmbedded =
|
||||
deps.runEmbeddedAgent ?? (await import("../agents/embedded-agent.js")).runEmbeddedAgent;
|
||||
result = (await runEmbedded({
|
||||
sessionId,
|
||||
sessionKey: `temp:setup-inference:${runId}`,
|
||||
agentId: plan.agentId ?? "openclaw",
|
||||
trigger: "manual",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
...(plan.agentDir ? { agentDir: plan.agentDir } : {}),
|
||||
config: plan.config,
|
||||
prompt: params.prompt ?? SETUP_INFERENCE_TEST_PROMPT,
|
||||
provider: plan.provider,
|
||||
model: plan.model,
|
||||
...(plan.authProfileId
|
||||
? { authProfileId: plan.authProfileId, authProfileIdSource: "user" as const }
|
||||
: {}),
|
||||
authProfileStateMode,
|
||||
preparedModelRuntimeMode: "isolated-read-only",
|
||||
...(plan.cleanupBundleMcpOnRunEnd ? { cleanupBundleMcpOnRunEnd: true } : {}),
|
||||
...(plan.agentHarnessRuntimeOverride
|
||||
? { agentHarnessRuntimeOverride: plan.agentHarnessRuntimeOverride }
|
||||
: {}),
|
||||
timeoutMs,
|
||||
runId,
|
||||
lane: `session:probe-setup-inference:${plan.provider}`,
|
||||
thinkLevel: "off",
|
||||
reasoningLevel: "off",
|
||||
verboseLevel: "off",
|
||||
// The 32-token probe cap is sized for the "reply OK" verification
|
||||
// prompt only. Custom completions pass no explicit cap: the stream
|
||||
// layer then applies the resolved model's own required maxTokens
|
||||
// budget, which both bounds output and never exceeds provider limits.
|
||||
...(params.prompt === undefined
|
||||
? resolveSetupInferenceProbeStreamParams(plan.agentHarnessRuntimeOverride)
|
||||
: {}),
|
||||
disableTools: true,
|
||||
modelRun: true,
|
||||
messageChannel: "openclaw",
|
||||
messageProvider: "openclaw",
|
||||
onSuccessfulAuthBinding: (binding) => {
|
||||
successfulAuth = binding;
|
||||
},
|
||||
...(params.signal ? { abortSignal: params.signal } : {}),
|
||||
})) as RunResult;
|
||||
}
|
||||
if (params.signal?.aborted) {
|
||||
throw new SetupInferenceCancelledError();
|
||||
}
|
||||
const terminalError = extractRunTerminalError(result);
|
||||
if (terminalError) {
|
||||
const described = describeFailoverError(new Error(terminalError));
|
||||
return {
|
||||
ok: false,
|
||||
status: mapFailoverReasonToSetupStatus(described.reason),
|
||||
error: described.message,
|
||||
};
|
||||
}
|
||||
const text = extractRunText(result)?.trim();
|
||||
if (!text) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "format",
|
||||
error: "The model started but did not send a reply. Try again or pick another option.",
|
||||
};
|
||||
}
|
||||
const winnerError = extractRunWinnerError(plan, result);
|
||||
if (winnerError) {
|
||||
return { ok: false, status: "format", error: winnerError };
|
||||
}
|
||||
if (requireExecutionOwner && !successfulAuth) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error:
|
||||
"Inference succeeded, but its runtime did not report an owner that OpenClaw can safely reuse.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
latencyMs: Date.now() - started,
|
||||
text,
|
||||
auth:
|
||||
successfulAuth ??
|
||||
(!requireExecutionOwner && plan.authProfileId ? { authProfileId: plan.authProfileId } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
const described = describeFailoverError(error);
|
||||
return {
|
||||
ok: false,
|
||||
status: mapFailoverReasonToSetupStatus(described.reason),
|
||||
error: described.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { loadAuthProfileStoreForRuntime } from "../agents/auth-profiles/store.js";
|
||||
import { resolveCliBackendConfig } from "../agents/cli-backends.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
legacyModelKey,
|
||||
modelKey,
|
||||
normalizeProviderId,
|
||||
resolveModelRefFromString,
|
||||
} from "../agents/model-selection.js";
|
||||
import { resolveProviderIdForAuth } from "../agents/provider-auth-aliases.js";
|
||||
import { buildAgentRuntimeAuthPlan } from "../agents/runtime-plan/auth.js";
|
||||
import { GEMINI_CLI_DEFAULT_MODEL_REF } from "../commands/onboard-inference.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { ProviderAuthResult } from "../plugins/types.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
type ActivateSetupInferenceDeps,
|
||||
type ActivateSetupInferenceParams,
|
||||
type SetupInferenceFailureStatus,
|
||||
parseProviderAutoSetupChoiceId,
|
||||
} from "./setup-inference-core.js";
|
||||
|
||||
export type SetupInferenceTestPlan = {
|
||||
runner: "cli" | "embedded";
|
||||
provider: string;
|
||||
model: string;
|
||||
modelRef: string;
|
||||
config: OpenClawConfig;
|
||||
/** Execution identity used by the real OpenClaw turn. */
|
||||
agentId?: string;
|
||||
/** Default-agent owner whose model/runtime config is being selected. */
|
||||
routeAgentId?: string;
|
||||
agentDir?: string;
|
||||
agentHarnessRuntimeOverride?: string;
|
||||
cleanupBundleMcpOnRunEnd?: boolean;
|
||||
authProfileId?: string;
|
||||
/** Model to persist as default on success; undefined keeps the current one. */
|
||||
persistModelRef?: string;
|
||||
manualAuth?: {
|
||||
profiles: ProviderAuthResult["profiles"];
|
||||
runtimeConfigBase: OpenClawConfig;
|
||||
sourceConfigBase: OpenClawConfig;
|
||||
configPatch: unknown;
|
||||
pluginId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function configureCodexCliPreparedAuth(cfg: OpenClawConfig): OpenClawConfig {
|
||||
const entry = cfg.plugins?.entries?.codex;
|
||||
const pluginConfig = entry?.config ?? {};
|
||||
const appServer =
|
||||
pluginConfig.appServer && typeof pluginConfig.appServer === "object"
|
||||
? pluginConfig.appServer
|
||||
: {};
|
||||
return {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
codex: {
|
||||
...entry,
|
||||
config: {
|
||||
...pluginConfig,
|
||||
appServer: { ...appServer, transport: "stdio", homeScope: "agent" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RunResult = {
|
||||
payloads?: Array<{ text?: string; isError?: boolean }>;
|
||||
meta?: {
|
||||
executionTrace?: { winnerProvider?: string; winnerModel?: string };
|
||||
finalAssistantVisibleText?: string;
|
||||
finalAssistantRawText?: string;
|
||||
livenessState?: string;
|
||||
error?: { kind?: string; message?: string };
|
||||
};
|
||||
};
|
||||
|
||||
export function extractRunText(result: RunResult): string | undefined {
|
||||
return (
|
||||
result.meta?.finalAssistantVisibleText ??
|
||||
result.meta?.finalAssistantRawText ??
|
||||
result.payloads
|
||||
?.map((payload) => payload.text?.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
export function extractRunTerminalError(result: RunResult): string | undefined {
|
||||
const errorPayload = result.payloads?.find((payload) => payload.isError === true)?.text?.trim();
|
||||
const hasMetaError = result.meta?.error !== undefined;
|
||||
const metaError = result.meta?.error?.message?.trim();
|
||||
const livenessState = result.meta?.livenessState?.trim().toLowerCase();
|
||||
if (
|
||||
!errorPayload &&
|
||||
!hasMetaError &&
|
||||
livenessState !== "blocked" &&
|
||||
livenessState !== "abandoned"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
metaError ||
|
||||
errorPayload ||
|
||||
(livenessState ? `Inference ended in the ${livenessState} state.` : "Inference failed.")
|
||||
);
|
||||
}
|
||||
|
||||
export function extractRunWinnerError(
|
||||
plan: SetupInferenceTestPlan,
|
||||
result: RunResult,
|
||||
): string | undefined {
|
||||
const winnerProvider = result.meta?.executionTrace?.winnerProvider?.trim();
|
||||
const winnerModel = result.meta?.executionTrace?.winnerModel?.trim();
|
||||
if (!winnerProvider || !winnerModel) {
|
||||
return "The inference run did not report which provider and model produced its reply.";
|
||||
}
|
||||
if (winnerProvider === plan.provider && winnerModel === plan.model) {
|
||||
return undefined;
|
||||
}
|
||||
return `The inference run answered through ${winnerProvider}/${winnerModel} instead of the requested ${plan.provider}/${plan.model}. Disable model-routing overrides or choose the working route directly, then retry.`;
|
||||
}
|
||||
|
||||
export function resolveToolFreeCliSetupError(plan: SetupInferenceTestPlan): string | undefined {
|
||||
if (plan.runner !== "cli") {
|
||||
return undefined;
|
||||
}
|
||||
const backend = resolveCliBackendConfig(
|
||||
plan.provider,
|
||||
plan.config,
|
||||
plan.agentId ? { agentId: plan.agentId } : {},
|
||||
);
|
||||
if (backend?.sideQuestionToolMode === "disabled") {
|
||||
return undefined;
|
||||
}
|
||||
const geminiCliProvider = parseRef(GEMINI_CLI_DEFAULT_MODEL_REF).provider;
|
||||
if (backend?.nativeToolMode === "none" && plan.provider !== geminiCliProvider) {
|
||||
return undefined;
|
||||
}
|
||||
return plan.provider === geminiCliProvider
|
||||
? "Gemini CLI cannot be used for inference-gated setup because it has no hard tool-free mode. Choose Claude Code, Codex, or an API-key provider; normal Gemini CLI agent runs remain available after setup."
|
||||
: `CLI backend ${backend?.id ?? plan.provider} cannot be used for inference-gated setup because it has no hard tool-free mode. Choose another inference provider.`;
|
||||
}
|
||||
|
||||
export function resolveStrictSetupAuthProfileError(params: {
|
||||
plan: SetupInferenceTestPlan;
|
||||
workspaceDir: string;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
}): string | undefined {
|
||||
const profileId = params.plan.authProfileId?.trim();
|
||||
if (!profileId) {
|
||||
return undefined;
|
||||
}
|
||||
const loadStore = params.deps.loadAuthProfileStoreForRuntime ?? loadAuthProfileStoreForRuntime;
|
||||
const store = loadStore(params.plan.agentDir, {
|
||||
readOnly: true,
|
||||
allowKeychainPrompt: false,
|
||||
config: params.plan.config,
|
||||
externalCliProviderIds: [params.plan.provider],
|
||||
});
|
||||
const credential = store.profiles[profileId];
|
||||
if (!credential) {
|
||||
return `No credentials found for the configured setup profile "${profileId}".`;
|
||||
}
|
||||
|
||||
if (params.plan.runner === "embedded") {
|
||||
const authPlan = buildAgentRuntimeAuthPlan({
|
||||
provider: params.plan.provider,
|
||||
authProfileProvider: credential.provider,
|
||||
authProfileMode: credential.type,
|
||||
sessionAuthProfileId: profileId,
|
||||
config: params.plan.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
harnessId: params.plan.agentHarnessRuntimeOverride,
|
||||
harnessRuntime: params.plan.agentHarnessRuntimeOverride,
|
||||
allowHarnessAuthProfileForwarding: true,
|
||||
});
|
||||
if (authPlan.forwardedAuthProfileId === profileId) {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
const aliasContext = {
|
||||
config: params.plan.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
};
|
||||
try {
|
||||
const runProvider = resolveProviderIdForAuth(params.plan.provider, aliasContext);
|
||||
const profileProvider = resolveProviderIdForAuth(credential.provider, aliasContext);
|
||||
if (runProvider === profileProvider) {
|
||||
return undefined;
|
||||
}
|
||||
} catch {
|
||||
return `Could not verify that configured setup profile "${profileId}" belongs to the selected ${params.plan.provider} inference route.`;
|
||||
}
|
||||
}
|
||||
|
||||
return `Configured setup profile "${profileId}" belongs to ${credential.provider}, not the selected ${params.plan.provider} inference route.`;
|
||||
}
|
||||
|
||||
export function parseRef(modelRef: string): { provider: string; model: string } {
|
||||
const slash = modelRef.indexOf("/");
|
||||
return slash === -1
|
||||
? { provider: modelRef, model: "" }
|
||||
: { provider: modelRef.slice(0, slash), model: modelRef.slice(slash + 1) };
|
||||
}
|
||||
|
||||
export function projectSetupTargetModelMetadata(config: OpenClawConfig, modelRef: string): unknown {
|
||||
const target = parseRef(modelRef);
|
||||
const canonicalKey = modelKey(target.provider, target.model);
|
||||
const keys = new Set(
|
||||
[
|
||||
canonicalKey,
|
||||
legacyModelKey(target.provider, target.model),
|
||||
`${target.provider}/${canonicalKey}`,
|
||||
].filter((key): key is string => Boolean(key)),
|
||||
);
|
||||
const project = (models: Record<string, unknown> | undefined) =>
|
||||
Object.fromEntries(
|
||||
[...keys].map((key) => [
|
||||
key,
|
||||
Object.hasOwn(models ?? {}, key)
|
||||
? { exists: true, value: structuredClone(models?.[key]) }
|
||||
: { exists: false },
|
||||
]),
|
||||
);
|
||||
const defaultAgentId = resolveDefaultAgentId(config);
|
||||
const agent = listAgentEntries(config).find(
|
||||
(entry) => normalizeAgentId(entry.id) === defaultAgentId,
|
||||
);
|
||||
return {
|
||||
defaultAgentId,
|
||||
defaults: project(config.agents?.defaults?.models),
|
||||
agent: project(agent?.models),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSetupAgentRuntimeId(
|
||||
kind: ActivateSetupInferenceParams["kind"],
|
||||
): string | undefined {
|
||||
if (kind === "codex-cli") {
|
||||
return "codex";
|
||||
}
|
||||
if (
|
||||
kind === "openai-api-key" ||
|
||||
kind === "anthropic-api-key" ||
|
||||
kind === "api-key" ||
|
||||
kind === "provider-auth" ||
|
||||
parseProviderAutoSetupChoiceId(kind) !== undefined
|
||||
) {
|
||||
return "openclaw";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function mapFailoverReasonToSetupStatus(
|
||||
reason?: string | null,
|
||||
): SetupInferenceFailureStatus {
|
||||
if (reason === "auth" || reason === "auth_permanent") {
|
||||
return "auth";
|
||||
}
|
||||
if (reason === "rate_limit" || reason === "overloaded") {
|
||||
return "rate_limit";
|
||||
}
|
||||
if (reason === "billing") {
|
||||
return "billing";
|
||||
}
|
||||
if (reason === "timeout") {
|
||||
return "timeout";
|
||||
}
|
||||
if (reason === "format" || reason === "model_not_found") {
|
||||
return "format";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function prepareManualAuthForActivation(params: {
|
||||
baseConfig: OpenClawConfig;
|
||||
preparedConfig: OpenClawConfig;
|
||||
profiles: ProviderAuthResult["profiles"];
|
||||
selectedProfileId: string;
|
||||
modelRef: string;
|
||||
providerId: string;
|
||||
pluginId?: string;
|
||||
}): {
|
||||
config: OpenClawConfig;
|
||||
profiles: ProviderAuthResult["profiles"];
|
||||
selectedProfileId: string;
|
||||
} {
|
||||
const selectedProfile = params.profiles.find(
|
||||
(profile) => profile.profileId === params.selectedProfileId,
|
||||
);
|
||||
if (!selectedProfile) {
|
||||
throw new Error("The selected setup credential was not returned by its provider.");
|
||||
}
|
||||
const provider = normalizeProviderId(selectedProfile.credential.provider) || "provider";
|
||||
const selectedProfileId = `${provider}:setup-${randomUUID()}`;
|
||||
const profile = { ...selectedProfile, profileId: selectedProfileId };
|
||||
const config = projectManualInferenceConfig({
|
||||
...params,
|
||||
selectedProfile,
|
||||
selectedProfileId,
|
||||
});
|
||||
return {
|
||||
config,
|
||||
profiles: [profile],
|
||||
selectedProfileId,
|
||||
};
|
||||
}
|
||||
|
||||
function copySelectedModelMetadata(params: {
|
||||
target: OpenClawConfig;
|
||||
prepared: OpenClawConfig;
|
||||
modelRef: string;
|
||||
}): void {
|
||||
const preparedDefaultModels = params.prepared.agents?.defaults?.models;
|
||||
if (preparedDefaultModels && Object.hasOwn(preparedDefaultModels, params.modelRef)) {
|
||||
params.target.agents = {
|
||||
...params.target.agents,
|
||||
defaults: {
|
||||
...params.target.agents?.defaults,
|
||||
models: {
|
||||
...params.target.agents?.defaults?.models,
|
||||
[params.modelRef]: structuredClone(
|
||||
expectDefined(
|
||||
preparedDefaultModels[params.modelRef],
|
||||
"prepared default models entry at params.model ref",
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const defaultAgentId = resolveDefaultAgentId(params.target);
|
||||
const preparedAgent = listAgentEntries(params.prepared).find(
|
||||
(agent) => normalizeAgentId(agent.id) === defaultAgentId,
|
||||
);
|
||||
if (!preparedAgent?.models || !Object.hasOwn(preparedAgent.models, params.modelRef)) {
|
||||
return;
|
||||
}
|
||||
const targetEntryKey = Object.keys(params.target.agents?.entries ?? {}).find(
|
||||
(agentId) => normalizeAgentId(agentId) === defaultAgentId,
|
||||
);
|
||||
if (!targetEntryKey || !params.target.agents?.entries?.[targetEntryKey]) {
|
||||
return;
|
||||
}
|
||||
const nextEntries = structuredClone(params.target.agents.entries);
|
||||
const targetAgent = expectDefined(nextEntries[targetEntryKey], "target agent entry");
|
||||
targetAgent.models = {
|
||||
...targetAgent.models,
|
||||
[params.modelRef]: structuredClone(
|
||||
expectDefined(preparedAgent.models[params.modelRef], "models entry at params.model ref"),
|
||||
),
|
||||
};
|
||||
params.target.agents = { ...params.target.agents, entries: nextEntries };
|
||||
}
|
||||
|
||||
function findSelectedProviderConfigKey(
|
||||
config: OpenClawConfig,
|
||||
providerId: string,
|
||||
): string | undefined {
|
||||
const providers = config.models?.providers;
|
||||
if (!providers) {
|
||||
return undefined;
|
||||
}
|
||||
if (Object.hasOwn(providers, providerId)) {
|
||||
return providerId;
|
||||
}
|
||||
const normalizedProvider = normalizeProviderId(providerId);
|
||||
return Object.keys(providers).find(
|
||||
(candidate) => normalizeProviderId(candidate) === normalizedProvider,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider auth hooks are untrusted setup input. Carry only the selected
|
||||
* inference route's config into the probe; OpenClaw owns every other setup
|
||||
* surface after intelligence exists.
|
||||
*/
|
||||
export function projectManualInferenceConfig(params: {
|
||||
baseConfig: OpenClawConfig;
|
||||
preparedConfig: OpenClawConfig;
|
||||
selectedProfile?: ProviderAuthResult["profiles"][number];
|
||||
selectedProfileId?: string;
|
||||
modelRef: string;
|
||||
providerId: string;
|
||||
pluginId?: string;
|
||||
}): OpenClawConfig {
|
||||
const config = structuredClone(params.baseConfig);
|
||||
if (params.selectedProfile && params.selectedProfileId) {
|
||||
const metadata = params.preparedConfig.auth?.profiles?.[params.selectedProfile.profileId] ?? {
|
||||
provider: params.selectedProfile.credential.provider,
|
||||
mode: params.selectedProfile.credential.type,
|
||||
};
|
||||
config.auth = {
|
||||
...config.auth,
|
||||
profiles: {
|
||||
...config.auth?.profiles,
|
||||
[params.selectedProfileId]: structuredClone(metadata),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const providerConfigKey = findSelectedProviderConfigKey(params.preparedConfig, params.providerId);
|
||||
if (providerConfigKey) {
|
||||
const preparedProvider = params.preparedConfig.models?.providers?.[providerConfigKey];
|
||||
if (preparedProvider === undefined) {
|
||||
throw new Error(`Prepared provider config missing for ${providerConfigKey}`);
|
||||
}
|
||||
config.models = {
|
||||
...config.models,
|
||||
providers: {
|
||||
...config.models?.providers,
|
||||
[providerConfigKey]: structuredClone(preparedProvider),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (params.pluginId) {
|
||||
const preparedEntry = params.preparedConfig.plugins?.entries?.[params.pluginId];
|
||||
if (preparedEntry !== undefined) {
|
||||
config.plugins = {
|
||||
...config.plugins,
|
||||
entries: {
|
||||
...config.plugins?.entries,
|
||||
[params.pluginId]: structuredClone(preparedEntry),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
copySelectedModelMetadata({
|
||||
target: config,
|
||||
prepared: params.preparedConfig,
|
||||
modelRef: params.modelRef,
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
export function canonicalizeSetupModelRef(params: {
|
||||
cfg: OpenClawConfig;
|
||||
raw: string;
|
||||
defaultProvider: string;
|
||||
}): string {
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: params.defaultProvider,
|
||||
});
|
||||
const resolved = resolveModelRefFromString({
|
||||
cfg: params.cfg,
|
||||
raw: params.raw,
|
||||
defaultProvider: params.defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
return resolved ? `${resolved.ref.provider}/${resolved.ref.model}` : params.raw;
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
|
||||
import type { CodexCliApiKeyCredential } from "../agents/cli-credentials.js";
|
||||
import { CliExecutionAuthProfileError } from "../agents/cli-execution-auth.js";
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import {
|
||||
ANTHROPIC_API_DEFAULT_MODEL_REF,
|
||||
CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
CODEX_APP_SERVER_DEFAULT_MODEL_REF,
|
||||
GEMINI_CLI_DEFAULT_MODEL_REF,
|
||||
OPENAI_API_DEFAULT_MODEL_REF,
|
||||
} from "../commands/onboard-inference.js";
|
||||
import { createMergePatch } from "../config/io.write-prepare.js";
|
||||
import {
|
||||
normalizeAgentModelRefForConfig,
|
||||
resolveAgentModelPrimaryValue,
|
||||
} from "../config/model-input.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import {
|
||||
applyProviderPluginAuthMethodResultConfig,
|
||||
runProviderPluginAuthMethodUnpersisted,
|
||||
} from "../plugins/provider-auth-choice.js";
|
||||
import {
|
||||
type ProviderAuthChoiceMetadata,
|
||||
resolveManifestProviderAuthChoice,
|
||||
} from "../plugins/provider-auth-choices.js";
|
||||
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
|
||||
import type { ProviderAuthMethod, ProviderAuthResult } from "../plugins/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { resolveSystemAgentConfiguredRouteFromConfig } from "./inference-route.js";
|
||||
import { createQuickstartNotePrompter } from "./setup-apply.js";
|
||||
import {
|
||||
supportsSetupManualSecret,
|
||||
supportsSetupTextInference,
|
||||
} from "./setup-inference-auth-options.js";
|
||||
import {
|
||||
type ActivateSetupInferenceDeps,
|
||||
SetupInferenceCancelledError,
|
||||
type SetupInferenceFailureStatus,
|
||||
type SetupInferenceKind,
|
||||
parseProviderAutoSetupChoiceId,
|
||||
throwIfSetupInferenceCancelled,
|
||||
waitForProviderAuth,
|
||||
} from "./setup-inference-core.js";
|
||||
import {
|
||||
type SetupInferenceTestPlan,
|
||||
canonicalizeSetupModelRef,
|
||||
parseRef,
|
||||
prepareManualAuthForActivation,
|
||||
projectManualInferenceConfig,
|
||||
} from "./setup-inference-plan-helpers.js";
|
||||
|
||||
export async function buildTestPlan(params: {
|
||||
kind: SetupInferenceKind | "api-key" | "provider-auth";
|
||||
modelRef?: string;
|
||||
authChoice?: string;
|
||||
apiKey?: string;
|
||||
cfg: OpenClawConfig;
|
||||
sourceCfg: OpenClawConfig;
|
||||
workspaceDir: string;
|
||||
pluginWorkspaceDir: string;
|
||||
agentDir: string;
|
||||
runtime: RuntimeEnv;
|
||||
prompter?: WizardPrompter;
|
||||
signal?: AbortSignal;
|
||||
isCancelled?: () => boolean;
|
||||
isRemoteProviderAuth?: boolean;
|
||||
routeAgentId?: string;
|
||||
codexCliApiKey?: CodexCliApiKeyCredential;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
}): Promise<SetupInferenceTestPlan | { error: string; status?: SetupInferenceFailureStatus }> {
|
||||
const { kind, cfg, workspaceDir } = params;
|
||||
const resolveRouteModelRef = (defaultModelRef: string): string | { error: string } => {
|
||||
const modelRef = params.modelRef?.trim() || defaultModelRef;
|
||||
const selected = parseRef(modelRef);
|
||||
const expected = parseRef(defaultModelRef);
|
||||
if (
|
||||
!selected.model ||
|
||||
normalizeProviderId(selected.provider) !== normalizeProviderId(expected.provider)
|
||||
) {
|
||||
return { error: `${modelRef} is not compatible with the ${kind} inference route.` };
|
||||
}
|
||||
return modelRef;
|
||||
};
|
||||
const providerAutoChoiceId = parseProviderAutoSetupChoiceId(kind);
|
||||
if (providerAutoChoiceId) {
|
||||
const choice = (
|
||||
params.deps.resolveManifestProviderAuthChoice ?? resolveManifestProviderAuthChoice
|
||||
)(providerAutoChoiceId, {
|
||||
config: cfg,
|
||||
workspaceDir: params.pluginWorkspaceDir,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
includeWorkspacePlugins: false,
|
||||
});
|
||||
if (
|
||||
!choice ||
|
||||
choice.appGuidedDiscovery !== true ||
|
||||
!supportsSetupTextInference(choice.onboardingScopes)
|
||||
) {
|
||||
return { error: "That detected provider is no longer available on this Gateway." };
|
||||
}
|
||||
const enablePlugin = params.deps.enablePluginInConfig ?? enablePluginInConfig;
|
||||
const enableResult = enablePlugin(cfg, choice.pluginId);
|
||||
if (!enableResult.enabled) {
|
||||
return { error: `${choice.choiceLabel} is disabled (${enableResult.reason ?? "blocked"}).` };
|
||||
}
|
||||
const sourceEnableResult = enablePlugin(params.sourceCfg, choice.pluginId);
|
||||
if (!sourceEnableResult.enabled) {
|
||||
return {
|
||||
error: `${choice.choiceLabel} is disabled (${sourceEnableResult.reason ?? "blocked"}).`,
|
||||
};
|
||||
}
|
||||
const providers = (params.deps.resolvePluginProviders ?? resolvePluginProviders)({
|
||||
config: enableResult.config,
|
||||
workspaceDir: params.pluginWorkspaceDir,
|
||||
mode: "setup",
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
onlyPluginIds: [choice.pluginId],
|
||||
});
|
||||
const provider = providers.find(
|
||||
(candidate) =>
|
||||
candidate.pluginId === choice.pluginId &&
|
||||
normalizeProviderId(candidate.id) === normalizeProviderId(choice.providerId),
|
||||
);
|
||||
const method = provider?.auth.find((candidate) => candidate.id === choice.methodId);
|
||||
if (!provider || !method?.appGuidedSetup) {
|
||||
return { error: "That detected provider is no longer available on this Gateway." };
|
||||
}
|
||||
const modelRef = params.modelRef?.trim();
|
||||
if (!modelRef) {
|
||||
return { error: "The detected provider model is missing. Run detection again." };
|
||||
}
|
||||
try {
|
||||
const result = await method.appGuidedSetup.prepare({
|
||||
config: enableResult.config,
|
||||
env: process.env,
|
||||
workspaceDir: params.pluginWorkspaceDir,
|
||||
modelRef,
|
||||
...(params.signal ? { signal: params.signal } : {}),
|
||||
});
|
||||
const preparedModelRef = result?.defaultModel
|
||||
? normalizeAgentModelRefForConfig(result.defaultModel)
|
||||
: "";
|
||||
if (!result || preparedModelRef !== modelRef) {
|
||||
return {
|
||||
error: `${choice.choiceLabel} could not prepare the detected model. Run detection again.`,
|
||||
};
|
||||
}
|
||||
const ref = parseRef(modelRef);
|
||||
if (
|
||||
!ref.model ||
|
||||
normalizeProviderId(ref.provider) !== normalizeProviderId(choice.providerId)
|
||||
) {
|
||||
return { error: `${choice.choiceLabel} returned an invalid detected model.` };
|
||||
}
|
||||
const preparedConfig = applyProviderPluginAuthMethodResultConfig({
|
||||
config: enableResult.config,
|
||||
result,
|
||||
});
|
||||
const matchingProfile = result.profiles.find(
|
||||
(profile) =>
|
||||
normalizeProviderId(profile.credential.provider) === normalizeProviderId(ref.provider),
|
||||
);
|
||||
if (result.profiles.length > 0 && !matchingProfile) {
|
||||
return {
|
||||
error: `${choice.choiceLabel} did not return credentials for its detected model.`,
|
||||
};
|
||||
}
|
||||
const prepared = matchingProfile
|
||||
? prepareManualAuthForActivation({
|
||||
baseConfig: enableResult.config,
|
||||
preparedConfig,
|
||||
profiles: result.profiles,
|
||||
selectedProfileId: matchingProfile.profileId,
|
||||
modelRef,
|
||||
providerId: ref.provider,
|
||||
pluginId: choice.pluginId,
|
||||
})
|
||||
: {
|
||||
config: projectManualInferenceConfig({
|
||||
baseConfig: enableResult.config,
|
||||
preparedConfig,
|
||||
modelRef,
|
||||
providerId: ref.provider,
|
||||
pluginId: choice.pluginId,
|
||||
}),
|
||||
profiles: [] as ProviderAuthResult["profiles"],
|
||||
selectedProfileId: undefined,
|
||||
};
|
||||
return {
|
||||
runner: "embedded",
|
||||
...ref,
|
||||
modelRef,
|
||||
agentDir: params.agentDir,
|
||||
config: prepared.config,
|
||||
agentId: "openclaw",
|
||||
routeAgentId: resolveDefaultAgentId(prepared.config),
|
||||
...(prepared.selectedProfileId ? { authProfileId: prepared.selectedProfileId } : {}),
|
||||
persistModelRef: modelRef,
|
||||
manualAuth: {
|
||||
profiles: prepared.profiles,
|
||||
runtimeConfigBase: enableResult.config,
|
||||
sourceConfigBase: sourceEnableResult.config,
|
||||
configPatch: createMergePatch(enableResult.config, prepared.config),
|
||||
pluginId: choice.pluginId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `${choice.choiceLabel} could not prepare app-guided setup: ${formatErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
switch (kind) {
|
||||
case "existing-model": {
|
||||
let route;
|
||||
try {
|
||||
route = await resolveSystemAgentConfiguredRouteFromConfig(cfg, params.routeAgentId, {
|
||||
loadAuthProfileStoreForRuntime: params.deps.loadAuthProfileStoreForRuntime,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CliExecutionAuthProfileError) {
|
||||
return { error: error.message, status: "auth" as const };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!route) {
|
||||
return { error: "No configured default-agent inference route is available." };
|
||||
}
|
||||
const requestedModelRef = params.modelRef?.trim();
|
||||
const requestedTarget = requestedModelRef
|
||||
? canonicalizeSetupModelRef({
|
||||
cfg,
|
||||
raw: requestedModelRef,
|
||||
defaultProvider: route.provider,
|
||||
})
|
||||
: undefined;
|
||||
if (requestedModelRef && requestedTarget !== route.modelLabel) {
|
||||
return {
|
||||
error: `The configured default model changed from ${requestedModelRef} to ${route.modelLabel}. Try setup again.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
runner: route.runner,
|
||||
provider: route.provider,
|
||||
model: route.model,
|
||||
modelRef: route.modelLabel,
|
||||
config: route.runConfig,
|
||||
agentId: "openclaw",
|
||||
routeAgentId: route.agentId,
|
||||
agentDir: route.agentDir,
|
||||
...(route.runner === "embedded"
|
||||
? { agentHarnessRuntimeOverride: route.agentHarnessRuntimeOverride }
|
||||
: {}),
|
||||
...(route.authProfileId ? { authProfileId: route.authProfileId } : {}),
|
||||
};
|
||||
}
|
||||
case "claude-cli": {
|
||||
const modelRef = resolveRouteModelRef(CLAUDE_CLI_DEFAULT_MODEL_REF);
|
||||
if (typeof modelRef !== "string") {
|
||||
return modelRef;
|
||||
}
|
||||
const ref = parseRef(modelRef);
|
||||
return {
|
||||
runner: "cli",
|
||||
...ref,
|
||||
modelRef,
|
||||
config: cfg,
|
||||
agentId: "openclaw",
|
||||
routeAgentId: resolveDefaultAgentId(cfg),
|
||||
persistModelRef: modelRef,
|
||||
};
|
||||
}
|
||||
case "gemini-cli": {
|
||||
const modelRef = resolveRouteModelRef(GEMINI_CLI_DEFAULT_MODEL_REF);
|
||||
if (typeof modelRef !== "string") {
|
||||
return modelRef;
|
||||
}
|
||||
const ref = parseRef(modelRef);
|
||||
return {
|
||||
runner: "cli",
|
||||
...ref,
|
||||
modelRef,
|
||||
config: cfg,
|
||||
agentId: "openclaw",
|
||||
routeAgentId: resolveDefaultAgentId(cfg),
|
||||
persistModelRef: modelRef,
|
||||
};
|
||||
}
|
||||
case "codex-cli": {
|
||||
const modelRef = resolveRouteModelRef(CODEX_APP_SERVER_DEFAULT_MODEL_REF);
|
||||
if (typeof modelRef !== "string") {
|
||||
return modelRef;
|
||||
}
|
||||
const ref = parseRef(modelRef);
|
||||
if (params.codexCliApiKey) {
|
||||
const preparedAuth = prepareManualAuthForActivation({
|
||||
baseConfig: cfg,
|
||||
preparedConfig: cfg,
|
||||
profiles: [
|
||||
{
|
||||
profileId: "openai:codex-cli-api-key",
|
||||
credential: params.codexCliApiKey,
|
||||
},
|
||||
],
|
||||
selectedProfileId: "openai:codex-cli-api-key",
|
||||
modelRef,
|
||||
providerId: ref.provider,
|
||||
});
|
||||
return {
|
||||
runner: "embedded",
|
||||
...ref,
|
||||
modelRef,
|
||||
agentHarnessRuntimeOverride: "codex",
|
||||
config: preparedAuth.config,
|
||||
agentId: "openclaw",
|
||||
routeAgentId: resolveDefaultAgentId(preparedAuth.config),
|
||||
agentDir: params.agentDir,
|
||||
cleanupBundleMcpOnRunEnd: true,
|
||||
authProfileId: preparedAuth.selectedProfileId,
|
||||
persistModelRef: modelRef,
|
||||
manualAuth: {
|
||||
profiles: preparedAuth.profiles,
|
||||
runtimeConfigBase: cfg,
|
||||
sourceConfigBase: params.sourceCfg,
|
||||
configPatch: createMergePatch(cfg, preparedAuth.config),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
runner: "embedded",
|
||||
...ref,
|
||||
modelRef,
|
||||
agentHarnessRuntimeOverride: "codex",
|
||||
config: cfg,
|
||||
agentId: "openclaw",
|
||||
routeAgentId: resolveDefaultAgentId(cfg),
|
||||
agentDir: params.agentDir,
|
||||
cleanupBundleMcpOnRunEnd: true,
|
||||
persistModelRef: modelRef,
|
||||
};
|
||||
}
|
||||
case "openai-api-key": {
|
||||
const modelRef = resolveRouteModelRef(OPENAI_API_DEFAULT_MODEL_REF);
|
||||
if (typeof modelRef !== "string") {
|
||||
return modelRef;
|
||||
}
|
||||
const ref = parseRef(modelRef);
|
||||
return {
|
||||
runner: "embedded",
|
||||
...ref,
|
||||
modelRef,
|
||||
config: cfg,
|
||||
agentId: "openclaw",
|
||||
routeAgentId: resolveDefaultAgentId(cfg),
|
||||
persistModelRef: modelRef,
|
||||
};
|
||||
}
|
||||
case "anthropic-api-key": {
|
||||
const modelRef = resolveRouteModelRef(ANTHROPIC_API_DEFAULT_MODEL_REF);
|
||||
if (typeof modelRef !== "string") {
|
||||
return modelRef;
|
||||
}
|
||||
const ref = parseRef(modelRef);
|
||||
return {
|
||||
runner: "embedded",
|
||||
...ref,
|
||||
modelRef,
|
||||
config: cfg,
|
||||
agentId: "openclaw",
|
||||
routeAgentId: resolveDefaultAgentId(cfg),
|
||||
persistModelRef: modelRef,
|
||||
};
|
||||
}
|
||||
case "api-key":
|
||||
case "provider-auth": {
|
||||
const interactive = kind === "provider-auth";
|
||||
const apiKey = params.apiKey?.trim();
|
||||
if (!interactive && !apiKey) {
|
||||
return { error: "Enter an API key or token first." };
|
||||
}
|
||||
const authChoice = params.authChoice?.trim();
|
||||
const choice = authChoice
|
||||
? (params.deps.resolveManifestProviderAuthChoice ?? resolveManifestProviderAuthChoice)(
|
||||
authChoice,
|
||||
{
|
||||
config: cfg,
|
||||
workspaceDir: params.pluginWorkspaceDir,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
includeWorkspacePlugins: false,
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
if (
|
||||
!choice ||
|
||||
!supportsSetupTextInference(choice.onboardingScopes) ||
|
||||
(!interactive && !supportsSetupManualSecret(choice)) ||
|
||||
(interactive && (choice.assistantVisibility === "manual-only" || !choice.appGuidedAuth))
|
||||
) {
|
||||
return {
|
||||
error: interactive
|
||||
? "That provider login is not available on this Gateway."
|
||||
: "That key-based provider is not available on this Gateway.",
|
||||
};
|
||||
}
|
||||
const enablePlugin = params.deps.enablePluginInConfig ?? enablePluginInConfig;
|
||||
const enableResult = enablePlugin(cfg, choice.pluginId);
|
||||
if (!enableResult.enabled) {
|
||||
return {
|
||||
error: `${choice.choiceLabel} is disabled (${enableResult.reason ?? "blocked"}).`,
|
||||
};
|
||||
}
|
||||
const sourceEnableResult = enablePlugin(params.sourceCfg, choice.pluginId);
|
||||
if (!sourceEnableResult.enabled) {
|
||||
return {
|
||||
error: `${choice.choiceLabel} is disabled (${sourceEnableResult.reason ?? "blocked"}).`,
|
||||
};
|
||||
}
|
||||
const providers = (params.deps.resolvePluginProviders ?? resolvePluginProviders)({
|
||||
config: enableResult.config,
|
||||
workspaceDir: params.pluginWorkspaceDir,
|
||||
mode: "setup",
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
onlyPluginIds: [choice.pluginId],
|
||||
});
|
||||
const provider = providers.find(
|
||||
(candidate) =>
|
||||
candidate.pluginId === choice.pluginId &&
|
||||
normalizeProviderId(candidate.id) === normalizeProviderId(choice.providerId),
|
||||
);
|
||||
const method = provider?.auth.find((candidate) => candidate.id === choice.methodId);
|
||||
const resolved = provider && method ? { provider, method } : null;
|
||||
if (
|
||||
!resolved ||
|
||||
!supportsSetupTextInference(resolved.method.wizard?.onboardingScopes) ||
|
||||
(interactive && resolved.method.kind !== "oauth" && resolved.method.kind !== "device_code")
|
||||
) {
|
||||
return {
|
||||
error: interactive
|
||||
? "That provider login is not available on this Gateway."
|
||||
: "That key-based provider is not available on this Gateway.",
|
||||
};
|
||||
}
|
||||
let result: ProviderAuthResult;
|
||||
let preparedConfig: OpenClawConfig;
|
||||
try {
|
||||
if (interactive) {
|
||||
if (!params.prompter) {
|
||||
return { error: "This provider login requires an interactive setup session." };
|
||||
}
|
||||
throwIfSetupInferenceCancelled(params);
|
||||
result = await waitForProviderAuth(
|
||||
runProviderPluginAuthMethodUnpersisted({
|
||||
config: enableResult.config,
|
||||
runtime: params.runtime,
|
||||
...(params.signal ? { signal: params.signal } : {}),
|
||||
isRemote: params.isRemoteProviderAuth,
|
||||
prompter: params.prompter,
|
||||
method: resolved.method,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir,
|
||||
}),
|
||||
params.signal,
|
||||
);
|
||||
throwIfSetupInferenceCancelled(params);
|
||||
preparedConfig = applyProviderPluginAuthMethodResultConfig({
|
||||
config: enableResult.config,
|
||||
result,
|
||||
});
|
||||
} else if (resolved.method.kind === "api_key" || resolved.method.kind === "token") {
|
||||
result = await runProviderPluginAuthMethodUnpersisted({
|
||||
config: enableResult.config,
|
||||
runtime: params.runtime,
|
||||
prompter: createQuickstartNotePrompter(params.runtime),
|
||||
method: resolved.method,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir,
|
||||
secretInputMode: "plaintext",
|
||||
allowSecretRefPrompt: false,
|
||||
opts: { token: apiKey!, tokenProvider: resolved.provider.id },
|
||||
});
|
||||
preparedConfig = applyProviderPluginAuthMethodResultConfig({
|
||||
config: enableResult.config,
|
||||
result,
|
||||
});
|
||||
} else {
|
||||
const prepared = await runProviderManualSecretMethod({
|
||||
config: enableResult.config,
|
||||
baseConfig: cfg,
|
||||
choice,
|
||||
method: resolved.method,
|
||||
apiKey: apiKey!,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir,
|
||||
});
|
||||
result = prepared.result;
|
||||
preparedConfig = prepared.config;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SetupInferenceCancelledError || params.signal?.aborted) {
|
||||
return { error: "Provider login was cancelled." };
|
||||
}
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
error: `${resolved.provider.label} could not prepare this ${interactive ? "login" : "credential"} for app-guided setup: ${detail}`,
|
||||
};
|
||||
}
|
||||
const modelRef = result.defaultModel
|
||||
? normalizeAgentModelRefForConfig(result.defaultModel)
|
||||
: "";
|
||||
if (!modelRef || result.profiles.length === 0) {
|
||||
return {
|
||||
error: `${resolved.provider.label} does not expose a starter model for app-guided setup.`,
|
||||
};
|
||||
}
|
||||
const ref = parseRef(modelRef);
|
||||
if (!ref.model) {
|
||||
return {
|
||||
error: `${resolved.provider.label} returned an invalid starter model.`,
|
||||
};
|
||||
}
|
||||
const matchingProfile = result.profiles.find(
|
||||
(profile) =>
|
||||
normalizeProviderId(profile.credential.provider) === normalizeProviderId(ref.provider),
|
||||
);
|
||||
if (!matchingProfile) {
|
||||
return {
|
||||
error: `${resolved.provider.label} did not return credentials for its starter model.`,
|
||||
};
|
||||
}
|
||||
const preparedAuth = prepareManualAuthForActivation({
|
||||
baseConfig: enableResult.config,
|
||||
preparedConfig,
|
||||
profiles: result.profiles,
|
||||
selectedProfileId: matchingProfile.profileId,
|
||||
modelRef,
|
||||
providerId: ref.provider,
|
||||
...(resolved.provider.pluginId ? { pluginId: resolved.provider.pluginId } : {}),
|
||||
});
|
||||
return {
|
||||
runner: "embedded",
|
||||
...ref,
|
||||
modelRef,
|
||||
agentDir: params.agentDir,
|
||||
config: preparedAuth.config,
|
||||
agentId: "openclaw",
|
||||
routeAgentId: resolveDefaultAgentId(preparedAuth.config),
|
||||
authProfileId: preparedAuth.selectedProfileId,
|
||||
persistModelRef: modelRef,
|
||||
manualAuth: {
|
||||
profiles: preparedAuth.profiles,
|
||||
runtimeConfigBase: enableResult.config,
|
||||
sourceConfigBase: sourceEnableResult.config,
|
||||
configPatch: createMergePatch(enableResult.config, preparedAuth.config),
|
||||
...(resolved.provider.pluginId ? { pluginId: resolved.provider.pluginId } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { error: `Unknown inference choice "${kind}".` };
|
||||
}
|
||||
}
|
||||
|
||||
async function runProviderManualSecretMethod(params: {
|
||||
config: OpenClawConfig;
|
||||
baseConfig: OpenClawConfig;
|
||||
choice: ProviderAuthChoiceMetadata;
|
||||
method: ProviderAuthMethod;
|
||||
apiKey: string;
|
||||
agentDir: string;
|
||||
workspaceDir: string;
|
||||
}): Promise<{ result: ProviderAuthResult; config: OpenClawConfig }> {
|
||||
const optionKey = params.choice.optionKey;
|
||||
const runNonInteractive = params.method.runNonInteractive;
|
||||
if (!optionKey || !params.choice.cliOption || !runNonInteractive) {
|
||||
throw new Error("Provider does not expose app-guided secret setup.");
|
||||
}
|
||||
|
||||
let methodError = "";
|
||||
const isolatedRuntime: RuntimeEnv = {
|
||||
log: () => {},
|
||||
error: (...args) => {
|
||||
methodError = args.map(String).join(" ");
|
||||
},
|
||||
// Provider CLI methods use exit for validation failures. Convert it to a
|
||||
// request-local failure so app-guided setup can never stop the Gateway.
|
||||
exit: (code) => {
|
||||
throw new Error(methodError || `Provider setup exited with code ${code}.`);
|
||||
},
|
||||
};
|
||||
const configured = await runNonInteractive({
|
||||
authChoice: params.choice.choiceId,
|
||||
config: params.config,
|
||||
baseConfig: params.baseConfig,
|
||||
opts: { [optionKey]: params.apiKey, secretInputMode: "plaintext" },
|
||||
runtime: isolatedRuntime,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
resolveApiKey: async (input) =>
|
||||
typeof input.flagValue === "string" && input.flagValue.trim()
|
||||
? { key: input.flagValue.trim(), source: "flag" }
|
||||
: null,
|
||||
toApiKeyCredential: ({ provider, resolved, email, metadata }) => ({
|
||||
type: "api_key",
|
||||
provider,
|
||||
key: resolved.key,
|
||||
...(email ? { email } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
}),
|
||||
});
|
||||
if (!configured) {
|
||||
throw new Error(methodError || "Provider setup did not produce a configuration.");
|
||||
}
|
||||
|
||||
const store = loadPersistedAuthProfileStore(params.agentDir);
|
||||
const profiles = Object.entries(store?.profiles ?? {}).map(([profileId, credential]) => ({
|
||||
profileId,
|
||||
credential,
|
||||
}));
|
||||
const previousModel = resolveAgentModelPrimaryValue(params.config.agents?.defaults?.model);
|
||||
const configuredModel = resolveAgentModelPrimaryValue(configured.agents?.defaults?.model);
|
||||
const configuredProvider = configuredModel ? parseRef(configuredModel).provider : undefined;
|
||||
// Dynamic provider setup can rediscover the already-selected model while
|
||||
// repairing credentials. It is valid only when the provider still owns it.
|
||||
const configuredModelOwnedByProvider =
|
||||
configuredProvider !== undefined &&
|
||||
normalizeProviderId(configuredProvider) === normalizeProviderId(params.choice.providerId);
|
||||
const defaultModel =
|
||||
configuredModel && (configuredModel !== previousModel || configuredModelOwnedByProvider)
|
||||
? configuredModel
|
||||
: params.method.starterModel;
|
||||
if (profiles.length === 0 || !defaultModel) {
|
||||
throw new Error("Provider setup did not produce credentials and a starter model.");
|
||||
}
|
||||
return {
|
||||
result: { profiles, defaultModel },
|
||||
config: configured,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { resolveAgentEffectiveModelPrimary, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { loadAuthProfileStoreForRuntime } from "../agents/auth-profiles/store.js";
|
||||
import type { AgentExecutionAuthBinding } from "../agents/execution-auth-binding.js";
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { ProviderAuthResult } from "../plugins/types.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import {
|
||||
projectInferenceRoute,
|
||||
resolveSystemAgentConfiguredRouteFromConfig,
|
||||
sameDefaultInferenceRoute,
|
||||
type SystemAgentConfiguredRoute,
|
||||
} from "./inference-route.js";
|
||||
import { redactSetupInferenceError } from "./setup-inference-activate.js";
|
||||
import {
|
||||
type ActivateSetupInferenceDeps,
|
||||
type BoundVerifySetupInferenceResult,
|
||||
type CompleteSetupInferenceResult,
|
||||
type VerifySetupInferenceResult,
|
||||
invalidSetupConfigError,
|
||||
} from "./setup-inference-core.js";
|
||||
import { revalidateStableSetupInferenceOwner } from "./setup-inference-owner.js";
|
||||
import {
|
||||
cleanupSetupInferenceTempDir,
|
||||
persistManualAuthProfiles,
|
||||
runSetupInferenceTest,
|
||||
} from "./setup-inference-persist.js";
|
||||
import type { SetupInferenceTestPlan } from "./setup-inference-plan-helpers.js";
|
||||
import { buildTestPlan } from "./setup-inference-plan.js";
|
||||
import {
|
||||
captureSystemAgentOwnerPluginArtifacts,
|
||||
hasCurrentSystemAgentOwnerPluginArtifacts,
|
||||
resolveSystemAgentVerifiedInferenceRoute,
|
||||
type SystemAgentOwnerPluginArtifactSnapshot,
|
||||
type SystemAgentVerifiedInferenceBinding,
|
||||
type SystemAgentVerifiedInferenceDeps,
|
||||
} from "./verified-inference.js";
|
||||
|
||||
type VerifySetupInferenceParams = {
|
||||
kind?: "existing-model";
|
||||
agentId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
timeoutMs?: number;
|
||||
deps?: ActivateSetupInferenceDeps;
|
||||
};
|
||||
|
||||
/** Live-test the configured default model without changing config or auth state. */
|
||||
export function verifySetupInference(
|
||||
params: VerifySetupInferenceParams & { bindSession: true },
|
||||
): Promise<BoundVerifySetupInferenceResult>;
|
||||
|
||||
export function verifySetupInference(
|
||||
params: VerifySetupInferenceParams & { bindSession?: false },
|
||||
): Promise<VerifySetupInferenceResult>;
|
||||
|
||||
export async function verifySetupInference(
|
||||
params: VerifySetupInferenceParams & { bindSession?: boolean },
|
||||
): Promise<VerifySetupInferenceResult | BoundVerifySetupInferenceResult> {
|
||||
const deps: ActivateSetupInferenceDeps = {
|
||||
...params.deps,
|
||||
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
|
||||
};
|
||||
const readSnapshot =
|
||||
deps.readConfigFileSnapshot ?? (await import("../config/config.js")).readConfigFileSnapshot;
|
||||
const snapshot = await readSnapshot();
|
||||
if (!snapshot.exists) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error: "No OpenClaw config exists. Run `openclaw onboard` first.",
|
||||
};
|
||||
}
|
||||
if (!snapshot.valid) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "format",
|
||||
error: invalidSetupConfigError(snapshot),
|
||||
};
|
||||
}
|
||||
const cfg: OpenClawConfig = snapshot.runtimeConfig ?? snapshot.config;
|
||||
const baselineRoute = await projectInferenceRoute(cfg, params.agentId);
|
||||
let verifiedBinding: SystemAgentVerifiedInferenceBinding | undefined;
|
||||
const verification = await verifySetupInferenceConfig({
|
||||
config: cfg,
|
||||
runtime: params.runtime,
|
||||
requireExecutionOwner: params.bindSession === true,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
|
||||
...(params.deps ? { deps: params.deps } : {}),
|
||||
...(params.bindSession
|
||||
? {
|
||||
onVerifiedExecution: (
|
||||
_auth: AgentExecutionAuthBinding,
|
||||
binding: SystemAgentVerifiedInferenceBinding,
|
||||
) => {
|
||||
verifiedBinding = binding;
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (!verification.ok) {
|
||||
return verification;
|
||||
}
|
||||
const latestSnapshot = await readSnapshot().catch(() => null);
|
||||
const latestConfig =
|
||||
latestSnapshot?.exists && latestSnapshot.valid
|
||||
? (latestSnapshot.runtimeConfig ?? latestSnapshot.config)
|
||||
: undefined;
|
||||
const latestRoute = latestConfig
|
||||
? await projectInferenceRoute(latestConfig, params.agentId)
|
||||
: undefined;
|
||||
if (!latestRoute || !sameDefaultInferenceRoute(baselineRoute, latestRoute)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error:
|
||||
"The inference route changed during its live test. Review current model/auth/runtime settings and retry.",
|
||||
};
|
||||
}
|
||||
if (!params.bindSession) {
|
||||
return verification;
|
||||
}
|
||||
const configuredRoute = await resolveSystemAgentConfiguredRouteFromConfig(cfg, params.agentId);
|
||||
if (!configuredRoute || !verifiedBinding) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error:
|
||||
"The successful inference run did not report an exact execution binding. Retry setup before starting OpenClaw.",
|
||||
};
|
||||
}
|
||||
return { ...verification, binding: verifiedBinding };
|
||||
}
|
||||
|
||||
type BoundSetupInferenceVerifier = (params: {
|
||||
runtime: RuntimeEnv;
|
||||
bindSession: true;
|
||||
agentId?: string;
|
||||
deps?: ActivateSetupInferenceDeps;
|
||||
}) => Promise<BoundVerifySetupInferenceResult>;
|
||||
|
||||
export type ResolvePersistentApplyInferenceDeps = SystemAgentVerifiedInferenceDeps & {
|
||||
resolveVerifiedInferenceRoute?: typeof resolveSystemAgentVerifiedInferenceRoute;
|
||||
hasCurrentOwnerPluginArtifacts?: typeof hasCurrentSystemAgentOwnerPluginArtifacts;
|
||||
verifyBoundInference?: BoundSetupInferenceVerifier;
|
||||
};
|
||||
|
||||
function executionRouteIdentity(route: SystemAgentConfiguredRoute): unknown {
|
||||
const { runConfig: _runConfig, ...identity } = route;
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict credentials need only the static owner check. Opaque runtimes can
|
||||
* prove liveness only by completing another exact turn at the side-effect
|
||||
* boundary; the result must still be the original frozen route.
|
||||
*/
|
||||
export async function resolvePersistentApplyInference(params: {
|
||||
binding: SystemAgentVerifiedInferenceBinding;
|
||||
runtime: RuntimeEnv;
|
||||
deps?: ResolvePersistentApplyInferenceDeps;
|
||||
}): Promise<SystemAgentConfiguredRoute | null> {
|
||||
const deps = params.deps ?? {};
|
||||
const resolveVerified =
|
||||
deps.resolveVerifiedInferenceRoute ?? resolveSystemAgentVerifiedInferenceRoute;
|
||||
const initialRoute = await resolveVerified(params.binding, deps);
|
||||
if (!initialRoute) {
|
||||
return null;
|
||||
}
|
||||
const hasCurrentOwnerPluginArtifacts =
|
||||
deps.hasCurrentOwnerPluginArtifacts ?? hasCurrentSystemAgentOwnerPluginArtifacts;
|
||||
if (!(await hasCurrentOwnerPluginArtifacts(params.binding, deps))) {
|
||||
return null;
|
||||
}
|
||||
if (params.binding.auth.proofKind !== "runtime-owner") {
|
||||
return initialRoute;
|
||||
}
|
||||
|
||||
const verifyBound = deps.verifyBoundInference ?? verifySetupInference;
|
||||
const live = await verifyBound({
|
||||
runtime: params.runtime,
|
||||
bindSession: true,
|
||||
agentId: params.binding.execution.agentId,
|
||||
deps,
|
||||
});
|
||||
if (
|
||||
!live.ok ||
|
||||
!isDeepStrictEqual(live.binding.configuredRoute, params.binding.configuredRoute) ||
|
||||
!isDeepStrictEqual(
|
||||
executionRouteIdentity(live.binding.execution),
|
||||
executionRouteIdentity(params.binding.execution),
|
||||
) ||
|
||||
!isDeepStrictEqual(live.binding.executionFingerprint, params.binding.executionFingerprint) ||
|
||||
!isDeepStrictEqual(live.binding.ownerPluginIds, params.binding.ownerPluginIds) ||
|
||||
!isDeepStrictEqual(live.binding.ownerPluginArtifacts, params.binding.ownerPluginArtifacts) ||
|
||||
!isDeepStrictEqual(live.binding.auth, params.binding.auth)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// The live probe is not a lock. Recheck the authored route after it returns,
|
||||
// then keep using the original frozen execution snapshot.
|
||||
const finalRoute = await resolveVerified(params.binding, deps);
|
||||
if (!finalRoute || !(await hasCurrentOwnerPluginArtifacts(params.binding, deps))) {
|
||||
return null;
|
||||
}
|
||||
return finalRoute;
|
||||
}
|
||||
|
||||
/** Live-test a staged default-agent route before any caller persists it. */
|
||||
export async function verifySetupInferenceConfig(params: {
|
||||
config: OpenClawConfig;
|
||||
/** Candidate profiles staged in the isolated probe store, never the real agent store. */
|
||||
authProfiles?: ProviderAuthResult["profiles"];
|
||||
agentId?: string;
|
||||
/** Explicit isolated agent directory for staged onboarding verification. */
|
||||
agentDir?: string;
|
||||
runtime: RuntimeEnv;
|
||||
timeoutMs?: number;
|
||||
deps?: ActivateSetupInferenceDeps;
|
||||
/** Internal session gate: capture only the final exact successful credential. */
|
||||
onVerifiedExecution?: (
|
||||
auth: AgentExecutionAuthBinding,
|
||||
binding: SystemAgentVerifiedInferenceBinding,
|
||||
) => void;
|
||||
/** Reject a successful turn unless its runner reports the exact execution owner. */
|
||||
requireExecutionOwner?: boolean;
|
||||
}): Promise<VerifySetupInferenceResult> {
|
||||
const deps: ActivateSetupInferenceDeps = {
|
||||
...params.deps,
|
||||
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
|
||||
};
|
||||
const cfg = params.config;
|
||||
const routeAgentId = normalizeAgentId(params.agentId ?? resolveDefaultAgentId(cfg));
|
||||
if (!resolveAgentEffectiveModelPrimary(cfg, routeAgentId)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error: "No agent model is configured. Run `openclaw onboard` first.",
|
||||
};
|
||||
}
|
||||
const tempDir = await (
|
||||
deps.createTempDir ?? (() => fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-inference-")))
|
||||
)();
|
||||
try {
|
||||
const builtPlan = await buildTestPlan({
|
||||
kind: "existing-model",
|
||||
cfg,
|
||||
sourceCfg: cfg,
|
||||
workspaceDir: tempDir,
|
||||
pluginWorkspaceDir: tempDir,
|
||||
agentDir: path.join(tempDir, "agent"),
|
||||
runtime: params.runtime,
|
||||
routeAgentId,
|
||||
deps,
|
||||
});
|
||||
if ("error" in builtPlan) {
|
||||
return {
|
||||
ok: false,
|
||||
status: builtPlan.status ?? "unavailable",
|
||||
error: builtPlan.error,
|
||||
};
|
||||
}
|
||||
let plan: SetupInferenceTestPlan = params.agentDir
|
||||
? { ...builtPlan, agentDir: params.agentDir }
|
||||
: builtPlan;
|
||||
if (params.authProfiles && params.authProfiles.length > 0) {
|
||||
const selectedProfile = plan.authProfileId
|
||||
? params.authProfiles.find((profile) => profile.profileId === plan.authProfileId)
|
||||
: params.authProfiles.find(
|
||||
(profile) =>
|
||||
normalizeProviderId(profile.credential.provider) ===
|
||||
normalizeProviderId(plan.provider),
|
||||
);
|
||||
if (!selectedProfile) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "auth",
|
||||
error: plan.authProfileId
|
||||
? "The staged credential does not match the configured auth profile."
|
||||
: "The staged credential does not belong to the configured inference provider.",
|
||||
};
|
||||
}
|
||||
const stagedAgentDir = path.join(tempDir, "agent");
|
||||
const staged = await persistManualAuthProfiles({
|
||||
profiles: params.authProfiles,
|
||||
agentDir: stagedAgentDir,
|
||||
deps,
|
||||
});
|
||||
if (staged.status !== "persisted") {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error:
|
||||
"Could not stage the credential for its live inference test; try again in a moment.",
|
||||
};
|
||||
}
|
||||
plan = {
|
||||
...plan,
|
||||
agentDir: stagedAgentDir,
|
||||
authProfileId: selectedProfile.profileId,
|
||||
};
|
||||
}
|
||||
const readStagedAuthProfiles = (): ProviderAuthResult["profiles"] | undefined => {
|
||||
if (!params.authProfiles || params.authProfiles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const loadStore = deps.loadAuthProfileStoreForRuntime ?? loadAuthProfileStoreForRuntime;
|
||||
const { profiles } = loadStore(plan.agentDir, {
|
||||
readOnly: true,
|
||||
allowKeychainPrompt: false,
|
||||
config: plan.config,
|
||||
externalCliProviderIds: [plan.provider],
|
||||
});
|
||||
return params.authProfiles.map((profile) => {
|
||||
const credential = profiles[profile.profileId];
|
||||
if (!credential) {
|
||||
throw new Error("staged profile missing after verification");
|
||||
}
|
||||
return { profileId: profile.profileId, credential };
|
||||
});
|
||||
};
|
||||
const retainStagedAuthProfiles = () => {
|
||||
try {
|
||||
return { ok: true as const, authProfiles: readStagedAuthProfiles() };
|
||||
} catch {
|
||||
return {
|
||||
ok: false as const,
|
||||
result: {
|
||||
ok: false as const,
|
||||
status: "unknown" as const,
|
||||
error: "Could not retain the credential after its live inference test.",
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
const requiresExecutionOwner =
|
||||
params.requireExecutionOwner === true || params.onVerifiedExecution !== undefined;
|
||||
let configuredRoute:
|
||||
| NonNullable<Awaited<ReturnType<typeof resolveSystemAgentConfiguredRouteFromConfig>>>
|
||||
| undefined;
|
||||
let stagedOwnerPluginArtifacts: SystemAgentOwnerPluginArtifactSnapshot | undefined;
|
||||
if (requiresExecutionOwner) {
|
||||
configuredRoute =
|
||||
(await resolveSystemAgentConfiguredRouteFromConfig(cfg, routeAgentId)) ?? undefined;
|
||||
if (!configuredRoute) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error: "The verified inference route could not be resolved for owner validation.",
|
||||
};
|
||||
}
|
||||
try {
|
||||
stagedOwnerPluginArtifacts = (
|
||||
deps.captureSystemAgentOwnerPluginArtifacts ?? captureSystemAgentOwnerPluginArtifacts
|
||||
)({
|
||||
config: cfg,
|
||||
executionRoute: configuredRoute,
|
||||
deps,
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error:
|
||||
"Could not bind the configured inference plugin runtime. Refresh or reinstall the plugin and retry.",
|
||||
};
|
||||
}
|
||||
}
|
||||
let test = await runSetupInferenceTest({
|
||||
plan,
|
||||
tempDir,
|
||||
deps,
|
||||
authProfileStateMode: "read-only",
|
||||
requireExecutionOwner: requiresExecutionOwner,
|
||||
});
|
||||
let retained = retainStagedAuthProfiles();
|
||||
if (!retained.ok) {
|
||||
return retained.result;
|
||||
}
|
||||
let authProfiles = retained.authProfiles;
|
||||
if (test.ok) {
|
||||
const verifiedProfileId = test.auth.authProfileId;
|
||||
if (plan.authProfileId && verifiedProfileId !== plan.authProfileId) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "auth",
|
||||
error: `The inference run used profile "${verifiedProfileId ?? "unknown"}" instead of the configured profile "${plan.authProfileId}".`,
|
||||
...(authProfiles ? { authProfiles } : {}),
|
||||
};
|
||||
}
|
||||
if (params.onVerifiedExecution && !plan.authProfileId && verifiedProfileId) {
|
||||
// Auto-selection may rotate through several profiles before succeeding.
|
||||
// Re-run once with the winner locked so the session proof cannot bind a
|
||||
// credential that never completed an exact, non-rotating turn.
|
||||
test = await runSetupInferenceTest({
|
||||
plan: { ...plan, authProfileId: verifiedProfileId },
|
||||
tempDir,
|
||||
deps,
|
||||
authProfileStateMode: "read-only",
|
||||
requireExecutionOwner: true,
|
||||
});
|
||||
retained = retainStagedAuthProfiles();
|
||||
if (!retained.ok) {
|
||||
return retained.result;
|
||||
}
|
||||
authProfiles = retained.authProfiles;
|
||||
if (!test.ok) {
|
||||
return {
|
||||
...test,
|
||||
error: await redactSetupInferenceError(test.error),
|
||||
...(authProfiles ? { authProfiles } : {}),
|
||||
};
|
||||
}
|
||||
if (test.auth.authProfileId !== verifiedProfileId) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "auth",
|
||||
error: "The selected inference credential changed during its locked verification.",
|
||||
...(authProfiles ? { authProfiles } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (params.requireExecutionOwner || params.onVerifiedExecution) {
|
||||
try {
|
||||
const binding = await revalidateStableSetupInferenceOwner({
|
||||
route: configuredRoute!,
|
||||
auth: test.auth,
|
||||
stagedOwnerPluginArtifacts,
|
||||
deps,
|
||||
});
|
||||
params.onVerifiedExecution?.(test.auth, binding);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: "auth",
|
||||
error:
|
||||
"The verified inference owner changed before validation completed. Retry the inference check.",
|
||||
...(authProfiles ? { authProfiles } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
latencyMs: test.latencyMs,
|
||||
modelRef: plan.modelRef,
|
||||
...(authProfiles ? { authProfiles } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...test,
|
||||
error: await redactSetupInferenceError(test.error),
|
||||
...(authProfiles ? { authProfiles } : {}),
|
||||
};
|
||||
} finally {
|
||||
await cleanupSetupInferenceTempDir({ tempDir, deps, runtime: params.runtime });
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one tool-free completion through the configured setup inference route. */
|
||||
export async function completeSetupInference(params: {
|
||||
prompt: string;
|
||||
runtime: RuntimeEnv;
|
||||
timeoutMs?: number;
|
||||
deps?: ActivateSetupInferenceDeps;
|
||||
}): Promise<CompleteSetupInferenceResult> {
|
||||
const readSnapshot =
|
||||
params.deps?.readConfigFileSnapshot ??
|
||||
(await import("../config/config.js")).readConfigFileSnapshot;
|
||||
const snapshot = await readSnapshot();
|
||||
if (!snapshot.exists) {
|
||||
return { ok: false, status: "unavailable", error: "No OpenClaw config exists." };
|
||||
}
|
||||
if (!snapshot.valid) {
|
||||
return { ok: false, status: "format", error: invalidSetupConfigError(snapshot) };
|
||||
}
|
||||
return await completeSetupInferenceConfig({
|
||||
config: snapshot.runtimeConfig ?? snapshot.config,
|
||||
prompt: params.prompt,
|
||||
runtime: params.runtime,
|
||||
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
|
||||
...(params.deps ? { deps: params.deps } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Config-injected variant used by setup clients and live provider tests. */
|
||||
export async function completeSetupInferenceConfig(params: {
|
||||
config: OpenClawConfig;
|
||||
prompt: string;
|
||||
runtime: RuntimeEnv;
|
||||
timeoutMs?: number;
|
||||
deps?: ActivateSetupInferenceDeps;
|
||||
}): Promise<CompleteSetupInferenceResult> {
|
||||
const deps: ActivateSetupInferenceDeps = {
|
||||
...params.deps,
|
||||
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
|
||||
};
|
||||
const routeAgentId = normalizeAgentId(resolveDefaultAgentId(params.config));
|
||||
if (!resolveAgentEffectiveModelPrimary(params.config, routeAgentId)) {
|
||||
return { ok: false, status: "unavailable", error: "No agent model is configured." };
|
||||
}
|
||||
const tempDir = await (
|
||||
deps.createTempDir ?? (() => fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-inference-")))
|
||||
)();
|
||||
try {
|
||||
const plan = await buildTestPlan({
|
||||
kind: "existing-model",
|
||||
cfg: params.config,
|
||||
sourceCfg: params.config,
|
||||
workspaceDir: tempDir,
|
||||
pluginWorkspaceDir: tempDir,
|
||||
agentDir: path.join(tempDir, "agent"),
|
||||
runtime: params.runtime,
|
||||
routeAgentId,
|
||||
deps,
|
||||
});
|
||||
if ("error" in plan) {
|
||||
return {
|
||||
ok: false,
|
||||
status: plan.status ?? "unavailable",
|
||||
error: plan.error,
|
||||
};
|
||||
}
|
||||
const result = await runSetupInferenceTest({
|
||||
plan,
|
||||
prompt: params.prompt,
|
||||
tempDir,
|
||||
deps,
|
||||
authProfileStateMode: "read-only",
|
||||
requireExecutionOwner: false,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return { ...result, error: await redactSetupInferenceError(result.error) };
|
||||
}
|
||||
if (plan.authProfileId && result.auth.authProfileId !== plan.authProfileId) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "auth",
|
||||
error: "The inference completion used a different credential than the configured route.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
modelRef: plan.modelRef,
|
||||
latencyMs: result.latencyMs,
|
||||
text: result.text,
|
||||
};
|
||||
} finally {
|
||||
await cleanupSetupInferenceTempDir({ tempDir, deps, runtime: params.runtime });
|
||||
}
|
||||
}
|
||||
+30
-3609
File diff suppressed because it is too large
Load Diff
@@ -232,8 +232,8 @@ describe("production lint suppressions", () => {
|
||||
"src/plugins/runtime/types-channel.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugins/trusted-tool-policy.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/state/config-machine-state.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/system-agent/setup-inference.ts|no-unsafe-finally|1",
|
||||
"src/system-agent/setup-inference.ts|preserve-caught-error|1",
|
||||
"src/system-agent/setup-inference-activate.ts|no-unsafe-finally|1",
|
||||
"src/system-agent/setup-inference-activate.ts|preserve-caught-error|1",
|
||||
"src/tasks/task-registry.sqlite.shared.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/test-utils/vitest-mock-fn.ts|typescript/no-explicit-any|1",
|
||||
"src/utils.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
|
||||
Reference in New Issue
Block a user