mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 19:50:22 +00:00
chore: merge origin/main into main
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
208
src/commands/auth-choice.apply-helpers.test.ts
Normal file
208
src/commands/auth-choice.apply-helpers.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
160
src/commands/auth-choice.apply.minimax.test.ts
Normal file
160
src/commands/auth-choice.apply.minimax.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
108
src/commands/doctor-config-flow.safe-bins.test.ts
Normal file
108
src/commands/doctor-config-flow.safe-bins.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
39
src/commands/onboard-config.test.ts
Normal file
39
src/commands/onboard-config.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
122
src/commands/onboard-remote.test.ts
Normal file
122
src/commands/onboard-remote.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user