chore: merge origin/main into main

This commit is contained in:
Peter Steinberger
2026-02-22 13:42:52 +00:00
304 changed files with 17041 additions and 5502 deletions

View File

@@ -4,10 +4,12 @@ import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import * as cliRunnerModule from "../agents/cli-runner.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { OpenClawConfig } from "../config/config.js";
import * as configModule from "../config/config.js";
import * as sessionsModule from "../config/sessions.js";
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createPluginRuntime } from "../plugins/runtime/index.js";
@@ -40,6 +42,7 @@ const runtime: RuntimeEnv = {
};
const configSpy = vi.spyOn(configModule, "loadConfig");
const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent");
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "openclaw-agent-" });
@@ -79,6 +82,13 @@ function writeSessionStoreSeed(
beforeEach(() => {
vi.clearAllMocks();
runCliAgentSpy.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
} as never);
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
@@ -146,6 +156,28 @@ describe("agentCommand", () => {
});
});
it("resolves resumed session transcript path from custom session store directory", async () => {
await withTempHome(async (home) => {
const customStoreDir = path.join(home, "custom-state");
const store = path.join(customStoreDir, "sessions.json");
writeSessionStoreSeed(store, {});
mockConfig(home, store);
const resolveSessionFilePathSpy = vi.spyOn(sessionsModule, "resolveSessionFilePath");
await agentCommand({ message: "resume me", sessionId: "session-custom-123" }, runtime);
const matchingCall = resolveSessionFilePathSpy.mock.calls.find(
(call) => call[0] === "session-custom-123",
);
expect(matchingCall?.[2]).toEqual(
expect.objectContaining({
agentId: "main",
sessionsDir: customStoreDir,
}),
);
});
});
it("does not duplicate agent events from embedded runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import {
listAgentIds,
resolveAgentDir,
@@ -45,6 +44,7 @@ import {
resolveAndPersistSessionFile,
resolveAgentIdFromSessionKey,
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptPath,
type SessionEntry,
updateSessionStore,
@@ -510,9 +510,11 @@ export async function agentCommand(
});
}
}
let sessionFile = resolveSessionFilePath(sessionId, sessionEntry, {
const sessionPathOpts = resolveSessionFilePathOptions({
agentId: sessionAgentId,
storePath,
});
let sessionFile = resolveSessionFilePath(sessionId, sessionEntry, sessionPathOpts);
if (sessionStore && sessionKey) {
const threadIdFromSessionKey = parseSessionThreadInfo(sessionKey).threadId;
const fallbackSessionFile = !sessionEntry?.sessionFile
@@ -528,8 +530,8 @@ export async function agentCommand(
sessionStore,
storePath,
sessionEntry,
agentId: sessionAgentId,
sessionsDir: path.dirname(storePath),
agentId: sessionPathOpts?.agentId,
sessionsDir: sessionPathOpts?.sessionsDir,
fallbackSessionFile,
});
sessionFile = resolvedSessionFile.sessionFile;

View File

@@ -0,0 +1,208 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
ensureApiKeyFromOptionEnvOrPrompt,
ensureApiKeyFromEnvOrPrompt,
maybeApplyApiKeyFromOption,
normalizeTokenProviderInput,
} from "./auth-choice.apply-helpers.js";
const ORIGINAL_MINIMAX_API_KEY = process.env.MINIMAX_API_KEY;
const ORIGINAL_MINIMAX_OAUTH_TOKEN = process.env.MINIMAX_OAUTH_TOKEN;
function restoreMinimaxEnv(): void {
if (ORIGINAL_MINIMAX_API_KEY === undefined) {
delete process.env.MINIMAX_API_KEY;
} else {
process.env.MINIMAX_API_KEY = ORIGINAL_MINIMAX_API_KEY;
}
if (ORIGINAL_MINIMAX_OAUTH_TOKEN === undefined) {
delete process.env.MINIMAX_OAUTH_TOKEN;
} else {
process.env.MINIMAX_OAUTH_TOKEN = ORIGINAL_MINIMAX_OAUTH_TOKEN;
}
}
function createPrompter(params?: {
confirm?: WizardPrompter["confirm"];
note?: WizardPrompter["note"];
text?: WizardPrompter["text"];
}): WizardPrompter {
return {
confirm: params?.confirm ?? (vi.fn(async () => true) as WizardPrompter["confirm"]),
note: params?.note ?? (vi.fn(async () => undefined) as WizardPrompter["note"]),
text: params?.text ?? (vi.fn(async () => "prompt-key") as WizardPrompter["text"]),
} as unknown as WizardPrompter;
}
afterEach(() => {
restoreMinimaxEnv();
vi.restoreAllMocks();
});
describe("normalizeTokenProviderInput", () => {
it("trims and lowercases non-empty values", () => {
expect(normalizeTokenProviderInput(" HuGgInGfAcE ")).toBe("huggingface");
expect(normalizeTokenProviderInput("")).toBeUndefined();
});
});
describe("maybeApplyApiKeyFromOption", () => {
it("stores normalized token when provider matches", async () => {
const setCredential = vi.fn(async () => undefined);
const result = await maybeApplyApiKeyFromOption({
token: " opt-key ",
tokenProvider: "huggingface",
expectedProviders: ["huggingface"],
normalize: (value) => value.trim(),
setCredential,
});
expect(result).toBe("opt-key");
expect(setCredential).toHaveBeenCalledWith("opt-key");
});
it("matches provider with whitespace/case normalization", async () => {
const setCredential = vi.fn(async () => undefined);
const result = await maybeApplyApiKeyFromOption({
token: " opt-key ",
tokenProvider: " HuGgInGfAcE ",
expectedProviders: ["huggingface"],
normalize: (value) => value.trim(),
setCredential,
});
expect(result).toBe("opt-key");
expect(setCredential).toHaveBeenCalledWith("opt-key");
});
it("skips when provider does not match", async () => {
const setCredential = vi.fn(async () => undefined);
const result = await maybeApplyApiKeyFromOption({
token: "opt-key",
tokenProvider: "openai",
expectedProviders: ["huggingface"],
normalize: (value) => value.trim(),
setCredential,
});
expect(result).toBeUndefined();
expect(setCredential).not.toHaveBeenCalled();
});
});
describe("ensureApiKeyFromEnvOrPrompt", () => {
it("uses env credential when user confirms", async () => {
process.env.MINIMAX_API_KEY = "env-key";
delete process.env.MINIMAX_OAUTH_TOKEN;
const confirm = vi.fn(async () => true);
const text = vi.fn(async () => "prompt-key");
const setCredential = vi.fn(async () => undefined);
const result = await ensureApiKeyFromEnvOrPrompt({
provider: "minimax",
envLabel: "MINIMAX_API_KEY",
promptMessage: "Enter key",
normalize: (value) => value.trim(),
validate: () => undefined,
prompter: createPrompter({ confirm, text }),
setCredential,
});
expect(result).toBe("env-key");
expect(setCredential).toHaveBeenCalledWith("env-key");
expect(text).not.toHaveBeenCalled();
});
it("falls back to prompt when env is declined", async () => {
process.env.MINIMAX_API_KEY = "env-key";
delete process.env.MINIMAX_OAUTH_TOKEN;
const confirm = vi.fn(async () => false);
const text = vi.fn(async () => " prompted-key ");
const setCredential = vi.fn(async () => undefined);
const result = await ensureApiKeyFromEnvOrPrompt({
provider: "minimax",
envLabel: "MINIMAX_API_KEY",
promptMessage: "Enter key",
normalize: (value) => value.trim(),
validate: () => undefined,
prompter: createPrompter({ confirm, text }),
setCredential,
});
expect(result).toBe("prompted-key");
expect(setCredential).toHaveBeenCalledWith("prompted-key");
expect(text).toHaveBeenCalledWith(
expect.objectContaining({
message: "Enter key",
}),
);
});
});
describe("ensureApiKeyFromOptionEnvOrPrompt", () => {
it("uses opts token and skips note/env/prompt", async () => {
const confirm = vi.fn(async () => true);
const note = vi.fn(async () => undefined);
const text = vi.fn(async () => "prompt-key");
const setCredential = vi.fn(async () => undefined);
const result = await ensureApiKeyFromOptionEnvOrPrompt({
token: " opts-key ",
tokenProvider: " HUGGINGFACE ",
expectedProviders: ["huggingface"],
provider: "huggingface",
envLabel: "HF_TOKEN",
promptMessage: "Enter key",
normalize: (value) => value.trim(),
validate: () => undefined,
prompter: createPrompter({ confirm, note, text }),
setCredential,
noteMessage: "Hugging Face note",
noteTitle: "Hugging Face",
});
expect(result).toBe("opts-key");
expect(setCredential).toHaveBeenCalledWith("opts-key");
expect(note).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect(text).not.toHaveBeenCalled();
});
it("falls back to env flow and shows note when opts provider does not match", async () => {
delete process.env.MINIMAX_OAUTH_TOKEN;
process.env.MINIMAX_API_KEY = "env-key";
const confirm = vi.fn(async () => true);
const note = vi.fn(async () => undefined);
const text = vi.fn(async () => "prompt-key");
const setCredential = vi.fn(async () => undefined);
const result = await ensureApiKeyFromOptionEnvOrPrompt({
token: "opts-key",
tokenProvider: "openai",
expectedProviders: ["minimax"],
provider: "minimax",
envLabel: "MINIMAX_API_KEY",
promptMessage: "Enter key",
normalize: (value) => value.trim(),
validate: () => undefined,
prompter: createPrompter({ confirm, note, text }),
setCredential,
noteMessage: "MiniMax note",
noteTitle: "MiniMax",
});
expect(result).toBe("env-key");
expect(note).toHaveBeenCalledWith("MiniMax note", "MiniMax");
expect(confirm).toHaveBeenCalled();
expect(text).not.toHaveBeenCalled();
expect(setCredential).toHaveBeenCalledWith("env-key");
});
});

View File

@@ -1,4 +1,8 @@
import { resolveEnvApiKey } from "../agents/model-auth.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { formatApiKeyPreview } from "./auth-choice.api-key.js";
import type { ApplyAuthChoiceParams } from "./auth-choice.apply.js";
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
export function createAuthChoiceAgentModelNoter(
params: ApplyAuthChoiceParams,
@@ -13,3 +17,152 @@ export function createAuthChoiceAgentModelNoter(
);
};
}
export interface ApplyAuthChoiceModelState {
config: ApplyAuthChoiceParams["config"];
agentModelOverride: string | undefined;
}
export function createAuthChoiceModelStateBridge(bindings: {
getConfig: () => ApplyAuthChoiceParams["config"];
setConfig: (config: ApplyAuthChoiceParams["config"]) => void;
getAgentModelOverride: () => string | undefined;
setAgentModelOverride: (model: string | undefined) => void;
}): ApplyAuthChoiceModelState {
return {
get config() {
return bindings.getConfig();
},
set config(config) {
bindings.setConfig(config);
},
get agentModelOverride() {
return bindings.getAgentModelOverride();
},
set agentModelOverride(model) {
bindings.setAgentModelOverride(model);
},
};
}
export function createAuthChoiceDefaultModelApplier(
params: ApplyAuthChoiceParams,
state: ApplyAuthChoiceModelState,
): (
options: Omit<
Parameters<typeof applyDefaultModelChoice>[0],
"config" | "setDefaultModel" | "noteAgentModel" | "prompter"
>,
) => Promise<void> {
const noteAgentModel = createAuthChoiceAgentModelNoter(params);
return async (options) => {
const applied = await applyDefaultModelChoice({
config: state.config,
setDefaultModel: params.setDefaultModel,
noteAgentModel,
prompter: params.prompter,
...options,
});
state.config = applied.config;
state.agentModelOverride = applied.agentModelOverride ?? state.agentModelOverride;
};
}
export function normalizeTokenProviderInput(
tokenProvider: string | null | undefined,
): string | undefined {
const normalized = String(tokenProvider ?? "")
.trim()
.toLowerCase();
return normalized || undefined;
}
export async function maybeApplyApiKeyFromOption(params: {
token: string | undefined;
tokenProvider: string | undefined;
expectedProviders: string[];
normalize: (value: string) => string;
setCredential: (apiKey: string) => Promise<void>;
}): Promise<string | undefined> {
const tokenProvider = normalizeTokenProviderInput(params.tokenProvider);
const expectedProviders = params.expectedProviders
.map((provider) => normalizeTokenProviderInput(provider))
.filter((provider): provider is string => Boolean(provider));
if (!params.token || !tokenProvider || !expectedProviders.includes(tokenProvider)) {
return undefined;
}
const apiKey = params.normalize(params.token);
await params.setCredential(apiKey);
return apiKey;
}
export async function ensureApiKeyFromOptionEnvOrPrompt(params: {
token: string | undefined;
tokenProvider: string | undefined;
expectedProviders: string[];
provider: string;
envLabel: string;
promptMessage: string;
normalize: (value: string) => string;
validate: (value: string) => string | undefined;
prompter: WizardPrompter;
setCredential: (apiKey: string) => Promise<void>;
noteMessage?: string;
noteTitle?: string;
}): Promise<string> {
const optionApiKey = await maybeApplyApiKeyFromOption({
token: params.token,
tokenProvider: params.tokenProvider,
expectedProviders: params.expectedProviders,
normalize: params.normalize,
setCredential: params.setCredential,
});
if (optionApiKey) {
return optionApiKey;
}
if (params.noteMessage) {
await params.prompter.note(params.noteMessage, params.noteTitle);
}
return await ensureApiKeyFromEnvOrPrompt({
provider: params.provider,
envLabel: params.envLabel,
promptMessage: params.promptMessage,
normalize: params.normalize,
validate: params.validate,
prompter: params.prompter,
setCredential: params.setCredential,
});
}
export async function ensureApiKeyFromEnvOrPrompt(params: {
provider: string;
envLabel: string;
promptMessage: string;
normalize: (value: string) => string;
validate: (value: string) => string | undefined;
prompter: WizardPrompter;
setCredential: (apiKey: string) => Promise<void>;
}): Promise<string> {
const envKey = resolveEnvApiKey(params.provider);
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing ${params.envLabel} (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
await params.setCredential(envKey.apiKey);
return envKey.apiKey;
}
}
const key = await params.prompter.text({
message: params.promptMessage,
validate: params.validate,
});
const apiKey = params.normalize(String(key ?? ""));
await params.setCredential(apiKey);
return apiKey;
}

File diff suppressed because it is too large Load Diff

View File

@@ -127,4 +127,37 @@ describe("applyAuthChoiceHuggingface", () => {
const parsed = await readAuthProfiles(agentDir);
expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-opts-token");
});
it("accepts mixed-case tokenProvider from opts without prompting", async () => {
const agentDir = await setupTempState();
delete process.env.HF_TOKEN;
delete process.env.HUGGINGFACE_HUB_TOKEN;
const text = vi.fn().mockResolvedValue("hf-text-token");
const select: WizardPrompter["select"] = vi.fn(
async (params) => params.options?.[0]?.value as never,
);
const confirm = vi.fn(async () => true);
const prompter = createHuggingfacePrompter({ text, select, confirm });
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoiceHuggingface({
authChoice: "huggingface-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: " HuGgInGfAcE ",
token: "hf-opts-mixed",
},
});
expect(result).not.toBeNull();
expect(confirm).not.toHaveBeenCalled();
expect(text).not.toHaveBeenCalled();
const parsed = await readAuthProfiles(agentDir);
expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-opts-mixed");
});
});

View File

@@ -2,13 +2,11 @@ import {
discoverHuggingfaceModels,
isHuggingfacePolicyLocked,
} from "../agents/huggingface-models.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js";
import {
formatApiKeyPreview,
normalizeApiKeyInput,
validateApiKeyInput,
} from "./auth-choice.api-key.js";
import { createAuthChoiceAgentModelNoter } from "./auth-choice.apply-helpers.js";
createAuthChoiceAgentModelNoter,
ensureApiKeyFromOptionEnvOrPrompt,
} from "./auth-choice.apply-helpers.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
import { ensureModelAllowlistEntry } from "./model-allowlist.js";
@@ -30,47 +28,23 @@ export async function applyAuthChoiceHuggingface(
let agentModelOverride: string | undefined;
const noteAgentModel = createAuthChoiceAgentModelNoter(params);
let hasCredential = false;
let hfKey = "";
if (!hasCredential && params.opts?.token && params.opts.tokenProvider === "huggingface") {
hfKey = normalizeApiKeyInput(params.opts.token);
await setHuggingfaceApiKey(hfKey, params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
await params.prompter.note(
[
"Hugging Face Inference Providers offer OpenAI-compatible chat completions.",
"Create a token at: https://huggingface.co/settings/tokens (fine-grained, 'Make calls to Inference Providers').",
].join("\n"),
"Hugging Face",
);
}
if (!hasCredential) {
const envKey = resolveEnvApiKey("huggingface");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing Hugging Face token (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
hfKey = envKey.apiKey;
await setHuggingfaceApiKey(hfKey, params.agentDir);
hasCredential = true;
}
}
}
if (!hasCredential) {
const key = await params.prompter.text({
message: "Enter Hugging Face API key (HF token)",
validate: validateApiKeyInput,
});
hfKey = normalizeApiKeyInput(String(key ?? ""));
await setHuggingfaceApiKey(hfKey, params.agentDir);
}
const hfKey = await ensureApiKeyFromOptionEnvOrPrompt({
token: params.opts?.token,
tokenProvider: params.opts?.tokenProvider,
expectedProviders: ["huggingface"],
provider: "huggingface",
envLabel: "Hugging Face token",
promptMessage: "Enter Hugging Face API key (HF token)",
normalize: normalizeApiKeyInput,
validate: validateApiKeyInput,
prompter: params.prompter,
setCredential: async (apiKey) => setHuggingfaceApiKey(apiKey, params.agentDir),
noteMessage: [
"Hugging Face Inference Providers offer OpenAI-compatible chat completions.",
"Create a token at: https://huggingface.co/settings/tokens (fine-grained, 'Make calls to Inference Providers').",
].join("\n"),
noteTitle: "Hugging Face",
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "huggingface:default",
provider: "huggingface",

View File

@@ -0,0 +1,160 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js";
import {
createAuthTestLifecycle,
createExitThrowingRuntime,
createWizardPrompter,
readAuthProfilesForAgent,
setupAuthTestEnv,
} from "./test-wizard-helpers.js";
function createMinimaxPrompter(
params: {
text?: WizardPrompter["text"];
confirm?: WizardPrompter["confirm"];
select?: WizardPrompter["select"];
} = {},
): WizardPrompter {
return createWizardPrompter(
{
text: params.text,
confirm: params.confirm,
select: params.select,
},
{ defaultSelect: "oauth" },
);
}
describe("applyAuthChoiceMiniMax", () => {
const lifecycle = createAuthTestLifecycle([
"OPENCLAW_STATE_DIR",
"OPENCLAW_AGENT_DIR",
"PI_CODING_AGENT_DIR",
"MINIMAX_API_KEY",
"MINIMAX_OAUTH_TOKEN",
]);
async function setupTempState() {
const env = await setupAuthTestEnv("openclaw-minimax-");
lifecycle.setStateDir(env.stateDir);
return env.agentDir;
}
async function readAuthProfiles(agentDir: string) {
return await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string }>;
}>(agentDir);
}
afterEach(async () => {
await lifecycle.cleanup();
});
it("returns null for unrelated authChoice", async () => {
const result = await applyAuthChoiceMiniMax({
authChoice: "openrouter-api-key",
config: {},
prompter: createMinimaxPrompter(),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
});
expect(result).toBeNull();
});
it("uses opts token for minimax-api without prompt", async () => {
const agentDir = await setupTempState();
delete process.env.MINIMAX_API_KEY;
delete process.env.MINIMAX_OAUTH_TOKEN;
const text = vi.fn(async () => "should-not-be-used");
const confirm = vi.fn(async () => true);
const result = await applyAuthChoiceMiniMax({
authChoice: "minimax-api",
config: {},
prompter: createMinimaxPrompter({ text, confirm }),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
opts: {
tokenProvider: "minimax",
token: "mm-opts-token",
},
});
expect(result).not.toBeNull();
expect(result?.config.auth?.profiles?.["minimax:default"]).toMatchObject({
provider: "minimax",
mode: "api_key",
});
expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5");
expect(text).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
const parsed = await readAuthProfiles(agentDir);
expect(parsed.profiles?.["minimax:default"]?.key).toBe("mm-opts-token");
});
it("uses env token for minimax-api-key-cn when confirmed", async () => {
const agentDir = await setupTempState();
process.env.MINIMAX_API_KEY = "mm-env-token";
delete process.env.MINIMAX_OAUTH_TOKEN;
const text = vi.fn(async () => "should-not-be-used");
const confirm = vi.fn(async () => true);
const result = await applyAuthChoiceMiniMax({
authChoice: "minimax-api-key-cn",
config: {},
prompter: createMinimaxPrompter({ text, confirm }),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
});
expect(result).not.toBeNull();
expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({
provider: "minimax-cn",
mode: "api_key",
});
expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax-cn/MiniMax-M2.5");
expect(text).not.toHaveBeenCalled();
expect(confirm).toHaveBeenCalled();
const parsed = await readAuthProfiles(agentDir);
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-env-token");
});
it("uses opts token for minimax-api-key-cn with trimmed/case-insensitive tokenProvider", async () => {
const agentDir = await setupTempState();
delete process.env.MINIMAX_API_KEY;
delete process.env.MINIMAX_OAUTH_TOKEN;
const text = vi.fn(async () => "should-not-be-used");
const confirm = vi.fn(async () => true);
const result = await applyAuthChoiceMiniMax({
authChoice: "minimax-api-key-cn",
config: {},
prompter: createMinimaxPrompter({ text, confirm }),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
opts: {
tokenProvider: " MINIMAX-CN ",
token: "mm-cn-opts-token",
},
});
expect(result).not.toBeNull();
expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({
provider: "minimax-cn",
mode: "api_key",
});
expect(result?.config.agents?.defaults?.model?.primary).toBe("minimax-cn/MiniMax-M2.5");
expect(text).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
const parsed = await readAuthProfiles(agentDir);
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-cn-opts-token");
});
});

View File

@@ -1,13 +1,11 @@
import { resolveEnvApiKey } from "../agents/model-auth.js";
import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js";
import {
formatApiKeyPreview,
normalizeApiKeyInput,
validateApiKeyInput,
} from "./auth-choice.api-key.js";
import { createAuthChoiceAgentModelNoter } from "./auth-choice.apply-helpers.js";
createAuthChoiceDefaultModelApplier,
createAuthChoiceModelStateBridge,
ensureApiKeyFromOptionEnvOrPrompt,
} from "./auth-choice.apply-helpers.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
import {
applyAuthProfileConfig,
applyMinimaxApiConfig,
@@ -24,31 +22,64 @@ export async function applyAuthChoiceMiniMax(
): Promise<ApplyAuthChoiceResult | null> {
let nextConfig = params.config;
let agentModelOverride: string | undefined;
const applyProviderDefaultModel = createAuthChoiceDefaultModelApplier(
params,
createAuthChoiceModelStateBridge({
getConfig: () => nextConfig,
setConfig: (config) => (nextConfig = config),
getAgentModelOverride: () => agentModelOverride,
setAgentModelOverride: (model) => (agentModelOverride = model),
}),
);
const ensureMinimaxApiKey = async (opts: {
profileId: string;
promptMessage: string;
}): Promise<void> => {
let hasCredential = false;
const envKey = resolveEnvApiKey("minimax");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing MINIMAX_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
await setMinimaxApiKey(envKey.apiKey, params.agentDir, opts.profileId);
hasCredential = true;
}
}
if (!hasCredential) {
const key = await params.prompter.text({
message: opts.promptMessage,
validate: validateApiKeyInput,
});
await setMinimaxApiKey(normalizeApiKeyInput(String(key)), params.agentDir, opts.profileId);
}
await ensureApiKeyFromOptionEnvOrPrompt({
token: params.opts?.token,
tokenProvider: params.opts?.tokenProvider,
expectedProviders: ["minimax", "minimax-cn"],
provider: "minimax",
envLabel: "MINIMAX_API_KEY",
promptMessage: opts.promptMessage,
normalize: normalizeApiKeyInput,
validate: validateApiKeyInput,
prompter: params.prompter,
setCredential: async (apiKey) => setMinimaxApiKey(apiKey, params.agentDir, opts.profileId),
});
};
const applyMinimaxApiVariant = async (opts: {
profileId: string;
provider: "minimax" | "minimax-cn";
promptMessage: string;
modelRefPrefix: "minimax" | "minimax-cn";
modelId: string;
applyDefaultConfig: (
config: ApplyAuthChoiceParams["config"],
modelId: string,
) => ApplyAuthChoiceParams["config"];
applyProviderConfig: (
config: ApplyAuthChoiceParams["config"],
modelId: string,
) => ApplyAuthChoiceParams["config"];
}): Promise<ApplyAuthChoiceResult> => {
await ensureMinimaxApiKey({
profileId: opts.profileId,
promptMessage: opts.promptMessage,
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: opts.profileId,
provider: opts.provider,
mode: "api_key",
});
const modelRef = `${opts.modelRefPrefix}/${opts.modelId}`;
await applyProviderDefaultModel({
defaultModel: modelRef,
applyDefaultConfig: (config) => opts.applyDefaultConfig(config, opts.modelId),
applyProviderConfig: (config) => opts.applyProviderConfig(config, opts.modelId),
});
return { config: nextConfig, agentModelOverride };
};
const noteAgentModel = createAuthChoiceAgentModelNoter(params);
if (params.authChoice === "minimax-portal") {
// Let user choose between Global/CN endpoints
const endpoint = await params.prompter.select({
@@ -73,74 +104,36 @@ export async function applyAuthChoiceMiniMax(
params.authChoice === "minimax-api" ||
params.authChoice === "minimax-api-lightning"
) {
const modelId =
params.authChoice === "minimax-api-lightning" ? "MiniMax-M2.5-Lightning" : "MiniMax-M2.5";
await ensureMinimaxApiKey({
profileId: "minimax:default",
promptMessage: "Enter MiniMax API key",
});
nextConfig = applyAuthProfileConfig(nextConfig, {
return await applyMinimaxApiVariant({
profileId: "minimax:default",
provider: "minimax",
mode: "api_key",
promptMessage: "Enter MiniMax API key",
modelRefPrefix: "minimax",
modelId:
params.authChoice === "minimax-api-lightning" ? "MiniMax-M2.5-Lightning" : "MiniMax-M2.5",
applyDefaultConfig: applyMinimaxApiConfig,
applyProviderConfig: applyMinimaxApiProviderConfig,
});
{
const modelRef = `minimax/${modelId}`;
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: modelRef,
applyDefaultConfig: (config) => applyMinimaxApiConfig(config, modelId),
applyProviderConfig: (config) => applyMinimaxApiProviderConfig(config, modelId),
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "minimax-api-key-cn") {
const modelId = "MiniMax-M2.5";
await ensureMinimaxApiKey({
profileId: "minimax-cn:default",
promptMessage: "Enter MiniMax China API key",
});
nextConfig = applyAuthProfileConfig(nextConfig, {
return await applyMinimaxApiVariant({
profileId: "minimax-cn:default",
provider: "minimax-cn",
mode: "api_key",
promptMessage: "Enter MiniMax China API key",
modelRefPrefix: "minimax-cn",
modelId: "MiniMax-M2.5",
applyDefaultConfig: applyMinimaxApiConfigCn,
applyProviderConfig: applyMinimaxApiProviderConfigCn,
});
{
const modelRef = `minimax-cn/${modelId}`;
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: modelRef,
applyDefaultConfig: (config) => applyMinimaxApiConfigCn(config, modelId),
applyProviderConfig: (config) => applyMinimaxApiProviderConfigCn(config, modelId),
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "minimax") {
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
await applyProviderDefaultModel({
defaultModel: "lmstudio/minimax-m2.1-gs32",
applyDefaultConfig: applyMinimaxConfig,
applyProviderConfig: applyMinimaxProviderConfig,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
return { config: nextConfig, agentModelOverride };
}

View File

@@ -3,6 +3,7 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js";
import { GOOGLE_GEMINI_DEFAULT_MODEL } from "./google-gemini-model-default.js";
import {
MINIMAX_CN_API_BASE_URL,
ZAI_CODING_CN_BASE_URL,
@@ -19,6 +20,8 @@ import {
setupAuthTestEnv,
} from "./test-wizard-helpers.js";
type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint;
vi.mock("../providers/github-copilot-auth.js", () => ({
githubCopilotLoginCommand: vi.fn(async () => {}),
}));
@@ -35,6 +38,11 @@ vi.mock("../plugins/providers.js", () => ({
resolvePluginProviders,
}));
const detectZaiEndpoint = vi.hoisted(() => vi.fn<DetectZaiEndpoint>(async () => null));
vi.mock("./zai-endpoint-detect.js", () => ({
detectZaiEndpoint,
}));
type StoredAuthProfile = {
key?: string;
access?: string;
@@ -57,6 +65,15 @@ describe("applyAuthChoice", () => {
"LITELLM_API_KEY",
"AI_GATEWAY_API_KEY",
"CLOUDFLARE_AI_GATEWAY_API_KEY",
"MOONSHOT_API_KEY",
"KIMI_API_KEY",
"GEMINI_API_KEY",
"XIAOMI_API_KEY",
"VENICE_API_KEY",
"OPENCODE_API_KEY",
"TOGETHER_API_KEY",
"QIANFAN_API_KEY",
"SYNTHETIC_API_KEY",
"SSH_TTY",
"CHUTES_CLIENT_ID",
]);
@@ -101,8 +118,10 @@ describe("applyAuthChoice", () => {
afterEach(async () => {
vi.unstubAllGlobals();
resolvePluginProviders.mockClear();
loginOpenAICodexOAuth.mockClear();
resolvePluginProviders.mockReset();
detectZaiEndpoint.mockReset();
detectZaiEndpoint.mockResolvedValue(null);
loginOpenAICodexOAuth.mockReset();
loginOpenAICodexOAuth.mockResolvedValue(null);
await lifecycle.cleanup();
});
@@ -319,6 +338,38 @@ describe("applyAuthChoice", () => {
expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_GLOBAL_BASE_URL);
});
it("uses detected Z.AI endpoint without prompting for endpoint selection", async () => {
await setupTempState();
detectZaiEndpoint.mockResolvedValueOnce({
endpoint: "coding-global",
modelId: "glm-4.5",
baseUrl: ZAI_CODING_GLOBAL_BASE_URL,
note: "Detected coding-global endpoint",
});
const text = vi.fn().mockResolvedValue("zai-detected-key");
const select = vi.fn(async () => "default");
const { prompter, runtime } = createApiKeyPromptHarness({
select: select as WizardPrompter["select"],
text,
});
const result = await applyAuthChoice({
authChoice: "zai-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(detectZaiEndpoint).toHaveBeenCalledWith({ apiKey: "zai-detected-key" });
expect(select).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Select Z.AI endpoint" }),
);
expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_GLOBAL_BASE_URL);
expect(result.config.agents?.defaults?.model?.primary).toBe("zai/glm-4.5");
});
it("maps apiKey + tokenProvider=huggingface to huggingface-api-key flow", async () => {
await setupTempState();
delete process.env.HF_TOKEN;
@@ -349,6 +400,309 @@ describe("applyAuthChoice", () => {
expect((await readAuthProfile("huggingface:default"))?.key).toBe("hf-token-provider-test");
});
it("maps apiKey + tokenProvider=together to together-api-key flow", async () => {
await setupTempState();
const text = vi.fn().mockResolvedValue("should-not-be-used");
const confirm = vi.fn(async () => false);
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
const result = await applyAuthChoice({
authChoice: "apiKey",
config: {},
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: " ToGeThEr ",
token: "sk-together-token-provider-test",
},
});
expect(result.config.auth?.profiles?.["together:default"]).toMatchObject({
provider: "together",
mode: "api_key",
});
expect(result.config.agents?.defaults?.model?.primary).toMatch(/^together\/.+/);
expect(text).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect((await readAuthProfile("together:default"))?.key).toBe(
"sk-together-token-provider-test",
);
});
it("maps apiKey + tokenProvider=KIMI-CODING (case-insensitive) to kimi-code-api-key flow", async () => {
await setupTempState();
const text = vi.fn().mockResolvedValue("should-not-be-used");
const confirm = vi.fn(async () => false);
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
const result = await applyAuthChoice({
authChoice: "apiKey",
config: {},
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: "KIMI-CODING",
token: "sk-kimi-token-provider-test",
},
});
expect(result.config.auth?.profiles?.["kimi-coding:default"]).toMatchObject({
provider: "kimi-coding",
mode: "api_key",
});
expect(result.config.agents?.defaults?.model?.primary).toMatch(/^kimi-coding\/.+/);
expect(text).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect((await readAuthProfile("kimi-coding:default"))?.key).toBe("sk-kimi-token-provider-test");
});
it("maps apiKey + tokenProvider= GOOGLE (case-insensitive/trimmed) to gemini-api-key flow", async () => {
await setupTempState();
const text = vi.fn().mockResolvedValue("should-not-be-used");
const confirm = vi.fn(async () => false);
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
const result = await applyAuthChoice({
authChoice: "apiKey",
config: {},
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: " GOOGLE ",
token: "sk-gemini-token-provider-test",
},
});
expect(result.config.auth?.profiles?.["google:default"]).toMatchObject({
provider: "google",
mode: "api_key",
});
expect(result.config.agents?.defaults?.model?.primary).toBe(GOOGLE_GEMINI_DEFAULT_MODEL);
expect(text).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect((await readAuthProfile("google:default"))?.key).toBe("sk-gemini-token-provider-test");
});
it("maps apiKey + tokenProvider= LITELLM (case-insensitive/trimmed) to litellm-api-key flow", async () => {
await setupTempState();
const text = vi.fn().mockResolvedValue("should-not-be-used");
const confirm = vi.fn(async () => false);
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
const result = await applyAuthChoice({
authChoice: "apiKey",
config: {},
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: " LITELLM ",
token: "sk-litellm-token-provider-test",
},
});
expect(result.config.auth?.profiles?.["litellm:default"]).toMatchObject({
provider: "litellm",
mode: "api_key",
});
expect(result.config.agents?.defaults?.model?.primary).toMatch(/^litellm\/.+/);
expect(text).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect((await readAuthProfile("litellm:default"))?.key).toBe("sk-litellm-token-provider-test");
});
it.each([
{
authChoice: "moonshot-api-key",
tokenProvider: "moonshot",
profileId: "moonshot:default",
provider: "moonshot",
modelPrefix: "moonshot/",
},
{
authChoice: "kimi-code-api-key",
tokenProvider: "kimi-code",
profileId: "kimi-coding:default",
provider: "kimi-coding",
modelPrefix: "kimi-coding/",
},
{
authChoice: "xiaomi-api-key",
tokenProvider: "xiaomi",
profileId: "xiaomi:default",
provider: "xiaomi",
modelPrefix: "xiaomi/",
},
{
authChoice: "venice-api-key",
tokenProvider: "venice",
profileId: "venice:default",
provider: "venice",
modelPrefix: "venice/",
},
{
authChoice: "opencode-zen",
tokenProvider: "opencode",
profileId: "opencode:default",
provider: "opencode",
modelPrefix: "opencode/",
},
{
authChoice: "together-api-key",
tokenProvider: "together",
profileId: "together:default",
provider: "together",
modelPrefix: "together/",
},
{
authChoice: "qianfan-api-key",
tokenProvider: "qianfan",
profileId: "qianfan:default",
provider: "qianfan",
modelPrefix: "qianfan/",
},
{
authChoice: "synthetic-api-key",
tokenProvider: "synthetic",
profileId: "synthetic:default",
provider: "synthetic",
modelPrefix: "synthetic/",
},
] as const)(
"uses opts token for $authChoice without prompting",
async ({ authChoice, tokenProvider, profileId, provider, modelPrefix }) => {
await setupTempState();
const text = vi.fn();
const confirm = vi.fn(async () => false);
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
const token = `sk-${tokenProvider}-test`;
const result = await applyAuthChoice({
authChoice,
config: {},
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider,
token,
},
});
expect(text).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect(result.config.auth?.profiles?.[profileId]).toMatchObject({
provider,
mode: "api_key",
});
expect(result.config.agents?.defaults?.model?.primary?.startsWith(modelPrefix)).toBe(true);
expect((await readAuthProfile(profileId))?.key).toBe(token);
},
);
it("uses opts token for Gemini and keeps global default model when setDefaultModel=false", async () => {
await setupTempState();
const text = vi.fn();
const confirm = vi.fn(async () => false);
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
const result = await applyAuthChoice({
authChoice: "gemini-api-key",
config: { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } } },
prompter,
runtime,
setDefaultModel: false,
opts: {
tokenProvider: "google",
token: "sk-gemini-test",
},
});
expect(text).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect(result.config.auth?.profiles?.["google:default"]).toMatchObject({
provider: "google",
mode: "api_key",
});
expect(result.config.agents?.defaults?.model?.primary).toBe("openai/gpt-4o-mini");
expect(result.agentModelOverride).toBe(GOOGLE_GEMINI_DEFAULT_MODEL);
expect((await readAuthProfile("google:default"))?.key).toBe("sk-gemini-test");
});
it("prompts for Venice API key and shows the Venice note when no token is provided", async () => {
await setupTempState();
process.env.VENICE_API_KEY = "";
const note = vi.fn(async () => {});
const text = vi.fn(async () => "sk-venice-manual");
const prompter = createPrompter({ note, text });
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoice({
authChoice: "venice-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(note).toHaveBeenCalledWith(
expect.stringContaining("privacy-focused inference"),
"Venice AI",
);
expect(text).toHaveBeenCalledWith(
expect.objectContaining({
message: "Enter Venice AI API key",
}),
);
expect(result.config.auth?.profiles?.["venice:default"]).toMatchObject({
provider: "venice",
mode: "api_key",
});
expect((await readAuthProfile("venice:default"))?.key).toBe("sk-venice-manual");
});
it("uses existing SYNTHETIC_API_KEY when selecting synthetic-api-key", async () => {
await setupTempState();
process.env.SYNTHETIC_API_KEY = "sk-synthetic-env";
const text = vi.fn();
const confirm = vi.fn(async () => true);
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
const result = await applyAuthChoice({
authChoice: "synthetic-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("SYNTHETIC_API_KEY"),
}),
);
expect(text).not.toHaveBeenCalled();
expect(result.config.auth?.profiles?.["synthetic:default"]).toMatchObject({
provider: "synthetic",
mode: "api_key",
});
expect(result.config.agents?.defaults?.model?.primary).toMatch(/^synthetic\/.+/);
expect((await readAuthProfile("synthetic:default"))?.key).toBe("sk-synthetic-env");
});
it("does not override the global default model when selecting xai-api-key without setDefaultModel", async () => {
await setupTempState();
@@ -654,6 +1008,39 @@ describe("applyAuthChoice", () => {
delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY;
});
it("uses explicit Cloudflare account/gateway/api key opts without extra prompts", async () => {
await setupTempState();
const text = vi.fn();
const confirm = vi.fn(async () => false);
const { prompter, runtime } = createApiKeyPromptHarness({ text, confirm });
const result = await applyAuthChoice({
authChoice: "cloudflare-ai-gateway-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
opts: {
cloudflareAiGatewayAccountId: "acc-direct",
cloudflareAiGatewayGatewayId: "gw-direct",
cloudflareAiGatewayApiKey: "cf-direct-key",
},
});
expect(confirm).not.toHaveBeenCalled();
expect(text).not.toHaveBeenCalled();
expect(result.config.auth?.profiles?.["cloudflare-ai-gateway:default"]).toMatchObject({
provider: "cloudflare-ai-gateway",
mode: "api_key",
});
expect((await readAuthProfile("cloudflare-ai-gateway:default"))?.key).toBe("cf-direct-key");
expect((await readAuthProfile("cloudflare-ai-gateway:default"))?.metadata).toEqual({
accountId: "acc-direct",
gatewayId: "gw-direct",
});
});
it("writes Chutes OAuth credentials when selecting chutes (remote/manual)", async () => {
await setupTempState();
process.env.SSH_TTY = "1";

View File

@@ -0,0 +1,108 @@
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
const { noteSpy } = vi.hoisted(() => ({
noteSpy: vi.fn(),
}));
vi.mock("../terminal/note.js", () => ({
note: noteSpy,
}));
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
async function runDoctorConfigWithInput(params: {
config: Record<string, unknown>;
repair?: boolean;
}) {
return withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(params.config, null, 2),
"utf-8",
);
return loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: params.repair },
confirm: async () => false,
});
});
}
describe("doctor config flow safe bins", () => {
beforeEach(() => {
noteSpy.mockClear();
});
it("scaffolds missing custom safe-bin profiles on repair but skips interpreter bins", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
tools: {
exec: {
safeBins: ["myfilter", "python3"],
},
},
agents: {
list: [
{
id: "ops",
tools: {
exec: {
safeBins: ["mytool", "node"],
},
},
},
],
},
},
});
const cfg = result.cfg as {
tools?: {
exec?: {
safeBinProfiles?: Record<string, object>;
};
};
agents?: {
list?: Array<{
id: string;
tools?: {
exec?: {
safeBinProfiles?: Record<string, object>;
};
};
}>;
};
};
expect(cfg.tools?.exec?.safeBinProfiles?.myfilter).toEqual({});
expect(cfg.tools?.exec?.safeBinProfiles?.python3).toBeUndefined();
const ops = cfg.agents?.list?.find((entry) => entry.id === "ops");
expect(ops?.tools?.exec?.safeBinProfiles?.mytool).toEqual({});
expect(ops?.tools?.exec?.safeBinProfiles?.node).toBeUndefined();
});
it("warns when interpreter/custom safeBins entries are missing profiles in non-repair mode", async () => {
await runDoctorConfigWithInput({
config: {
tools: {
exec: {
safeBins: ["python3", "myfilter"],
},
},
},
});
expect(noteSpy).toHaveBeenCalledWith(
expect.stringContaining("tools.exec.safeBins includes interpreter/runtime 'python3'"),
"Doctor warnings",
);
expect(noteSpy).toHaveBeenCalledWith(
expect.stringContaining("openclaw doctor --fix"),
"Doctor warnings",
);
});
});

View File

@@ -15,6 +15,10 @@ import {
readConfigFileSnapshot,
} from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import {
listInterpreterLikeSafeBins,
resolveMergedSafeBinProfileFixtures,
} from "../infra/exec-safe-bin-runtime-policy.js";
import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js";
import { note } from "../terminal/note.js";
import { isRecord, resolveHomeDir } from "../utils.js";
@@ -704,6 +708,134 @@ function maybeRepairOpenPolicyAllowFrom(cfg: OpenClawConfig): {
return { config: next, changes };
}
type ExecSafeBinCoverageHit = {
scopePath: string;
bin: string;
isInterpreter: boolean;
};
type ExecSafeBinScopeRef = {
scopePath: string;
safeBins: string[];
exec: Record<string, unknown>;
mergedProfiles: Record<string, unknown>;
};
function normalizeConfiguredSafeBins(entries: unknown): string[] {
if (!Array.isArray(entries)) {
return [];
}
return Array.from(
new Set(
entries
.map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : ""))
.filter((entry) => entry.length > 0),
),
).toSorted();
}
function collectExecSafeBinScopes(cfg: OpenClawConfig): ExecSafeBinScopeRef[] {
const scopes: ExecSafeBinScopeRef[] = [];
const globalExec = asObjectRecord(cfg.tools?.exec);
if (globalExec) {
const safeBins = normalizeConfiguredSafeBins(globalExec.safeBins);
if (safeBins.length > 0) {
scopes.push({
scopePath: "tools.exec",
safeBins,
exec: globalExec,
mergedProfiles:
resolveMergedSafeBinProfileFixtures({
global: globalExec,
}) ?? {},
});
}
}
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
for (const agent of agents) {
if (!agent || typeof agent !== "object" || typeof agent.id !== "string") {
continue;
}
const agentExec = asObjectRecord(agent.tools?.exec);
if (!agentExec) {
continue;
}
const safeBins = normalizeConfiguredSafeBins(agentExec.safeBins);
if (safeBins.length === 0) {
continue;
}
scopes.push({
scopePath: `agents.list.${agent.id}.tools.exec`,
safeBins,
exec: agentExec,
mergedProfiles:
resolveMergedSafeBinProfileFixtures({
global: globalExec,
local: agentExec,
}) ?? {},
});
}
return scopes;
}
function scanExecSafeBinCoverage(cfg: OpenClawConfig): ExecSafeBinCoverageHit[] {
const hits: ExecSafeBinCoverageHit[] = [];
for (const scope of collectExecSafeBinScopes(cfg)) {
const interpreterBins = new Set(listInterpreterLikeSafeBins(scope.safeBins));
for (const bin of scope.safeBins) {
if (scope.mergedProfiles[bin]) {
continue;
}
hits.push({
scopePath: scope.scopePath,
bin,
isInterpreter: interpreterBins.has(bin),
});
}
}
return hits;
}
function maybeRepairExecSafeBinProfiles(cfg: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
warnings: string[];
} {
const next = structuredClone(cfg);
const changes: string[] = [];
const warnings: string[] = [];
for (const scope of collectExecSafeBinScopes(next)) {
const interpreterBins = new Set(listInterpreterLikeSafeBins(scope.safeBins));
const missingBins = scope.safeBins.filter((bin) => !scope.mergedProfiles[bin]);
if (missingBins.length === 0) {
continue;
}
const profileHolder =
asObjectRecord(scope.exec.safeBinProfiles) ?? (scope.exec.safeBinProfiles = {});
for (const bin of missingBins) {
if (interpreterBins.has(bin)) {
warnings.push(
`- ${scope.scopePath}.safeBins includes interpreter/runtime '${bin}' without profile; remove it from safeBins or use explicit allowlist entries.`,
);
continue;
}
if (profileHolder[bin] !== undefined) {
continue;
}
profileHolder[bin] = {};
changes.push(
`- ${scope.scopePath}.safeBinProfiles.${bin}: added scaffold profile {} (review and tighten flags/positionals).`,
);
}
}
if (changes.length === 0 && warnings.length === 0) {
return { config: cfg, changes: [], warnings: [] };
}
return { config: next, changes, warnings };
}
async function maybeMigrateLegacyConfig(): Promise<string[]> {
const changes: string[] = [];
const home = resolveHomeDir();
@@ -859,6 +991,16 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
pendingChanges = true;
cfg = allowFromRepair.config;
}
const safeBinProfileRepair = maybeRepairExecSafeBinProfiles(candidate);
if (safeBinProfileRepair.changes.length > 0) {
note(safeBinProfileRepair.changes.join("\n"), "Doctor changes");
candidate = safeBinProfileRepair.config;
pendingChanges = true;
cfg = safeBinProfileRepair.config;
}
if (safeBinProfileRepair.warnings.length > 0) {
note(safeBinProfileRepair.warnings.join("\n"), "Doctor warnings");
}
} else {
const hits = scanTelegramAllowFromUsernameEntries(candidate);
if (hits.length > 0) {
@@ -892,6 +1034,41 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
"Doctor warnings",
);
}
const safeBinCoverage = scanExecSafeBinCoverage(candidate);
if (safeBinCoverage.length > 0) {
const interpreterHits = safeBinCoverage.filter((hit) => hit.isInterpreter);
const customHits = safeBinCoverage.filter((hit) => !hit.isInterpreter);
const lines: string[] = [];
if (interpreterHits.length > 0) {
for (const hit of interpreterHits.slice(0, 5)) {
lines.push(
`- ${hit.scopePath}.safeBins includes interpreter/runtime '${hit.bin}' without profile.`,
);
}
if (interpreterHits.length > 5) {
lines.push(
`- ${interpreterHits.length - 5} more interpreter/runtime safeBins entries are missing profiles.`,
);
}
}
if (customHits.length > 0) {
for (const hit of customHits.slice(0, 5)) {
lines.push(
`- ${hit.scopePath}.safeBins entry '${hit.bin}' is missing safeBinProfiles.${hit.bin}.`,
);
}
if (customHits.length > 5) {
lines.push(
`- ${customHits.length - 5} more custom safeBins entries are missing profiles.`,
);
}
}
lines.push(
`- Run "${formatCliCommand("openclaw doctor --fix")}" to scaffold missing custom safeBinProfiles entries.`,
);
note(lines.join("\n"), "Doctor warnings");
}
}
const unknown = stripUnknownConfigKeys(candidate);

View File

@@ -48,6 +48,8 @@ describe("noteSecurityWarnings gateway exposure", () => {
const message = lastMessage();
expect(message).toContain("CRITICAL");
expect(message).toContain("without authentication");
expect(message).toContain("Safer remote access");
expect(message).toContain("ssh -N -L 18789:127.0.0.1:18789");
});
it("uses env token to avoid critical warning", async () => {

View File

@@ -42,6 +42,11 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
(resolvedAuth.mode === "token" && hasToken) ||
(resolvedAuth.mode === "password" && hasPassword);
const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`;
const saferRemoteAccessLines = [
" Safer remote access: keep bind loopback and use Tailscale Serve/Funnel or an SSH tunnel.",
" Example tunnel: ssh -N -L 18789:127.0.0.1:18789 user@gateway-host",
" Docs: https://docs.openclaw.ai/gateway/remote",
];
if (isExposed) {
if (!hasSharedSecret) {
@@ -61,6 +66,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
`- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`,
` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
` Fix: ${formatCliCommand("openclaw config set gateway.bind loopback")}`,
...saferRemoteAccessLines,
...authFixLines,
);
} else {
@@ -68,6 +74,7 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
warnings.push(
`- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`,
` Ensure your auth credentials are strong and not exposed.`,
...saferRemoteAccessLines,
);
}
}

View File

@@ -8,6 +8,7 @@ import {
loadSessionStore,
resolveMainSessionKey,
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptsDirForAgent,
resolveStorePath,
} from "../config/sessions.js";
@@ -386,6 +387,7 @@ export async function noteStateIntegrity(
}
const store = loadSessionStore(storePath);
const sessionPathOpts = resolveSessionFilePathOptions({ agentId, storePath });
const entries = Object.entries(store).filter(([, entry]) => entry && typeof entry === "object");
if (entries.length > 0) {
const recent = entries
@@ -401,9 +403,7 @@ export async function noteStateIntegrity(
if (!sessionId) {
return false;
}
const transcriptPath = resolveSessionFilePath(sessionId, entry, {
agentId,
});
const transcriptPath = resolveSessionFilePath(sessionId, entry, sessionPathOpts);
return !existsFile(transcriptPath);
});
if (missing.length > 0) {
@@ -415,7 +415,11 @@ export async function noteStateIntegrity(
const mainKey = resolveMainSessionKey(cfg);
const mainEntry = store[mainKey];
if (mainEntry?.sessionId) {
const transcriptPath = resolveSessionFilePath(mainEntry.sessionId, mainEntry, { agentId });
const transcriptPath = resolveSessionFilePath(
mainEntry.sessionId,
mainEntry,
sessionPathOpts,
);
if (!existsFile(transcriptPath)) {
warnings.push(
`- Main session transcript missing (${shortenHomePath(transcriptPath)}). History will appear to reset.`,

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
applyOnboardingLocalWorkspaceConfig,
ONBOARDING_DEFAULT_DM_SCOPE,
} from "./onboard-config.js";
describe("applyOnboardingLocalWorkspaceConfig", () => {
it("sets secure dmScope default when unset", () => {
const baseConfig: OpenClawConfig = {};
const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace");
expect(result.session?.dmScope).toBe(ONBOARDING_DEFAULT_DM_SCOPE);
expect(result.gateway?.mode).toBe("local");
expect(result.agents?.defaults?.workspace).toBe("/tmp/workspace");
});
it("preserves existing dmScope when already configured", () => {
const baseConfig: OpenClawConfig = {
session: {
dmScope: "main",
},
};
const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace");
expect(result.session?.dmScope).toBe("main");
});
it("preserves explicit non-main dmScope values", () => {
const baseConfig: OpenClawConfig = {
session: {
dmScope: "per-account-channel-peer",
},
};
const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace");
expect(result.session?.dmScope).toBe("per-account-channel-peer");
});
});

View File

@@ -1,4 +1,7 @@
import type { OpenClawConfig } from "../config/config.js";
import type { DmScope } from "../config/types.base.js";
export const ONBOARDING_DEFAULT_DM_SCOPE: DmScope = "per-channel-peer";
export function applyOnboardingLocalWorkspaceConfig(
baseConfig: OpenClawConfig,
@@ -17,5 +20,9 @@ export function applyOnboardingLocalWorkspaceConfig(
...baseConfig.gateway,
mode: "local",
},
session: {
...baseConfig.session,
dmScope: baseConfig.session?.dmScope ?? ONBOARDING_DEFAULT_DM_SCOPE,
},
};
}

View File

@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { createWizardPrompter } from "./test-wizard-helpers.js";
const discoverGatewayBeacons = vi.hoisted(() => vi.fn<() => Promise<GatewayBonjourBeacon[]>>());
const resolveWideAreaDiscoveryDomain = vi.hoisted(() => vi.fn(() => undefined));
const detectBinary = vi.hoisted(() => vi.fn<(name: string) => Promise<boolean>>());
vi.mock("../infra/bonjour-discovery.js", () => ({
discoverGatewayBeacons,
}));
vi.mock("../infra/widearea-dns.js", () => ({
resolveWideAreaDiscoveryDomain,
}));
vi.mock("./onboard-helpers.js", () => ({
detectBinary,
}));
const { promptRemoteGatewayConfig } = await import("./onboard-remote.js");
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return createWizardPrompter(overrides, { defaultSelect: "" });
}
describe("promptRemoteGatewayConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
detectBinary.mockResolvedValue(false);
discoverGatewayBeacons.mockResolvedValue([]);
resolveWideAreaDiscoveryDomain.mockReturnValue(undefined);
});
it("defaults discovered direct remote URLs to wss://", async () => {
detectBinary.mockResolvedValue(true);
discoverGatewayBeacons.mockResolvedValue([
{
instanceName: "gateway",
displayName: "Gateway",
host: "gateway.tailnet.ts.net",
port: 18789,
},
]);
const select: WizardPrompter["select"] = vi.fn(async (params) => {
if (params.message === "Select gateway") {
return "0" as never;
}
if (params.message === "Connection method") {
return "direct" as never;
}
if (params.message === "Gateway auth") {
return "token" as never;
}
return (params.options[0]?.value ?? "") as never;
});
const text: WizardPrompter["text"] = vi.fn(async (params) => {
if (params.message === "Gateway WebSocket URL") {
expect(params.initialValue).toBe("wss://gateway.tailnet.ts.net:18789");
expect(params.validate?.(String(params.initialValue))).toBeUndefined();
return String(params.initialValue);
}
if (params.message === "Gateway token") {
return "token-123";
}
return "";
}) as WizardPrompter["text"];
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
confirm: vi.fn(async () => true),
select,
text,
});
const next = await promptRemoteGatewayConfig(cfg, prompter);
expect(next.gateway?.mode).toBe("remote");
expect(next.gateway?.remote?.url).toBe("wss://gateway.tailnet.ts.net:18789");
expect(next.gateway?.remote?.token).toBe("token-123");
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("Direct remote access defaults to TLS."),
"Direct remote",
);
});
it("validates insecure ws:// remote URLs and allows loopback ws://", async () => {
const text: WizardPrompter["text"] = vi.fn(async (params) => {
if (params.message === "Gateway WebSocket URL") {
expect(params.validate?.("ws://10.0.0.8:18789")).toContain("Use wss://");
expect(params.validate?.("ws://127.0.0.1:18789")).toBeUndefined();
expect(params.validate?.("wss://remote.example.com:18789")).toBeUndefined();
return "wss://remote.example.com:18789";
}
return "";
}) as WizardPrompter["text"];
const select: WizardPrompter["select"] = vi.fn(async (params) => {
if (params.message === "Gateway auth") {
return "off" as never;
}
return (params.options[0]?.value ?? "") as never;
});
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
confirm: vi.fn(async () => false),
select,
text,
});
const next = await promptRemoteGatewayConfig(cfg, prompter);
expect(next.gateway?.mode).toBe("remote");
expect(next.gateway?.remote?.url).toBe("wss://remote.example.com:18789");
expect(next.gateway?.remote?.token).toBeUndefined();
});
});

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import { isSecureWebSocketUrl } from "../gateway/net.js";
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
@@ -29,6 +30,17 @@ function ensureWsUrl(value: string): string {
return trimmed;
}
function validateGatewayWebSocketUrl(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) {
return "URL must start with ws:// or wss://";
}
if (!isSecureWebSocketUrl(trimmed)) {
return "Use wss:// for remote hosts, or ws://127.0.0.1/localhost via SSH tunnel.";
}
return undefined;
}
export async function promptRemoteGatewayConfig(
cfg: OpenClawConfig,
prompter: WizardPrompter,
@@ -95,7 +107,15 @@ export async function promptRemoteGatewayConfig(
],
});
if (mode === "direct") {
suggestedUrl = `ws://${host}:${port}`;
suggestedUrl = `wss://${host}:${port}`;
await prompter.note(
[
"Direct remote access defaults to TLS.",
`Using: ${suggestedUrl}`,
"If your gateway is loopback-only, choose SSH tunnel and keep ws://127.0.0.1:18789.",
].join("\n"),
"Direct remote",
);
} else {
suggestedUrl = DEFAULT_GATEWAY_URL;
await prompter.note(
@@ -115,10 +135,7 @@ export async function promptRemoteGatewayConfig(
const urlInput = await prompter.text({
message: "Gateway WebSocket URL",
initialValue: suggestedUrl,
validate: (value) =>
String(value).trim().startsWith("ws://") || String(value).trim().startsWith("wss://")
? undefined
: "URL must start with ws:// or wss://",
validate: (value) => validateGatewayWebSocketUrl(String(value)),
});
const url = ensureWsUrl(String(urlInput));