test(commands): dedupe command and onboarding test cases

This commit is contained in:
Peter Steinberger
2026-03-02 06:40:52 +00:00
parent 7e29d604ba
commit cded1b960a
16 changed files with 1262 additions and 1591 deletions

View File

@@ -129,6 +129,31 @@ function mockAcpManager(params: {
} as unknown as ReturnType<typeof acpManagerModule.getAcpSessionManager>);
}
async function runAcpSessionWithPolicyOverrides(params: {
acpOverrides: Partial<NonNullable<OpenClawConfig["acp"]>>;
resolveSession?: Parameters<typeof mockAcpManager>[0]["resolveSession"];
}) {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
writeAcpSessionStore(storePath);
mockConfigWithAcpOverrides(home, storePath, params.acpOverrides);
const runTurn = vi.fn(async (_params: unknown) => {});
mockAcpManager({
runTurn: (input: unknown) => runTurn(input),
...(params.resolveSession ? { resolveSession: params.resolveSession } : {}),
});
await expect(
agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime),
).rejects.toMatchObject({
code: "ACP_DISPATCH_DISABLED",
});
expect(runTurn).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled();
});
}
describe("agentCommand ACP runtime routing", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -221,50 +246,19 @@ describe("agentCommand ACP runtime routing", () => {
});
});
it("blocks ACP turns when ACP is disabled by policy", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
writeAcpSessionStore(storePath);
mockConfigWithAcpOverrides(home, storePath, {
enabled: false,
});
const runTurn = vi.fn(async (_params: unknown) => {});
mockAcpManager({
runTurn: (params: unknown) => runTurn(params),
});
await expect(
agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime),
).rejects.toMatchObject({
code: "ACP_DISPATCH_DISABLED",
});
expect(runTurn).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled();
});
});
it("blocks ACP turns when ACP dispatch is disabled by policy", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
writeAcpSessionStore(storePath);
mockConfigWithAcpOverrides(home, storePath, {
it.each([
{
name: "blocks ACP turns when ACP is disabled by policy",
acpOverrides: { enabled: false } satisfies Partial<NonNullable<OpenClawConfig["acp"]>>,
},
{
name: "blocks ACP turns when ACP dispatch is disabled by policy",
acpOverrides: {
dispatch: { enabled: false },
});
const runTurn = vi.fn(async (_params: unknown) => {});
mockAcpManager({
runTurn: (params: unknown) => runTurn(params),
});
await expect(
agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime),
).rejects.toMatchObject({
code: "ACP_DISPATCH_DISABLED",
});
expect(runTurn).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled();
});
} satisfies Partial<NonNullable<OpenClawConfig["acp"]>>,
},
])("$name", async ({ acpOverrides }) => {
await runAcpSessionWithPolicyOverrides({ acpOverrides });
});
it("blocks ACP turns when ACP agent is disallowed by policy", async () => {

View File

@@ -93,6 +93,20 @@ async function runWithDefaultAgentConfig(params: {
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
}
async function runEmbeddedWithTempConfig(params: {
args: Parameters<typeof agentCommand>[0];
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>;
telegramOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>>;
agentsList?: Array<{ id: string; default?: boolean }>;
}) {
return withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, params.agentOverrides, params.telegramOverrides, params.agentsList);
await agentCommand(params.args, runtime);
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
});
}
function writeSessionStoreSeed(
storePath: string,
sessions: Record<string, Record<string, unknown>>,
@@ -101,54 +115,149 @@ function writeSessionStoreSeed(
fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2));
}
function createDefaultAgentResult(params?: {
payloads?: Array<Record<string, unknown>>;
durationMs?: number;
}) {
return {
payloads: params?.payloads ?? [{ text: "ok" }],
meta: {
durationMs: params?.durationMs ?? 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
}
function getLastEmbeddedCall() {
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
}
function expectLastRunProviderModel(provider: string, model: string): void {
const callArgs = getLastEmbeddedCall();
expect(callArgs?.provider).toBe(provider);
expect(callArgs?.model).toBe(model);
}
function readSessionStore<T>(storePath: string): Record<string, T> {
return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record<string, T>;
}
async function withCrossAgentResumeFixture(
run: (params: {
home: string;
storePattern: string;
sessionId: string;
sessionKey: string;
}) => Promise<void>,
): Promise<void> {
await withTempHome(async (home) => {
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
const execStore = path.join(home, "sessions", "exec", "sessions.json");
const sessionId = "session-exec-hook";
const sessionKey = "agent:exec:hook:gmail:thread-1";
writeSessionStoreSeed(execStore, {
[sessionKey]: {
sessionId,
updatedAt: Date.now(),
systemSent: true,
},
});
mockConfig(home, storePattern, undefined, undefined, [
{ id: "dev" },
{ id: "exec", default: true },
]);
await agentCommand({ message: "resume me", sessionId }, runtime);
await run({ home, storePattern, sessionId, sessionKey });
});
}
async function expectPersistedSessionFile(params: {
seedKey: string;
sessionId: string;
expectedPathFragment: string;
}) {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
writeSessionStoreSeed(store, {
[params.seedKey]: {
sessionId: params.sessionId,
updatedAt: Date.now(),
},
});
mockConfig(home, store);
await agentCommand({ message: "hi", sessionKey: params.seedKey }, runtime);
const saved = readSessionStore<{ sessionId?: string; sessionFile?: string }>(store);
const entry = saved[params.seedKey];
expect(entry?.sessionId).toBe(params.sessionId);
expect(entry?.sessionFile).toContain(params.expectedPathFragment);
expect(getLastEmbeddedCall()?.sessionFile).toBe(entry?.sessionFile);
});
}
async function runAgentWithSessionKey(sessionKey: string): Promise<void> {
await agentCommand({ message: "hi", sessionKey }, runtime);
}
async function expectDefaultThinkLevel(params: {
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>;
catalogEntry: Record<string, unknown>;
expected: string;
}) {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, params.agentOverrides);
vi.mocked(loadModelCatalog).mockResolvedValueOnce([params.catalogEntry as never]);
await agentCommand({ message: "hi", to: "+1555" }, runtime);
expect(getLastEmbeddedCall()?.thinkLevel).toBe(params.expected);
});
}
function createTelegramOutboundPlugin() {
const sendWithTelegram = async (
ctx: {
deps?: {
sendTelegram?: (
to: string,
text: string,
opts: Record<string, unknown>,
) => Promise<{
messageId: string;
chatId: string;
}>;
};
to: string;
text: string;
accountId?: string;
mediaUrl?: string;
},
mediaUrl?: string,
) => {
const sendTelegram = ctx.deps?.sendTelegram;
if (!sendTelegram) {
throw new Error("sendTelegram dependency missing");
}
const result = await sendTelegram(ctx.to, ctx.text, {
accountId: ctx.accountId ?? undefined,
...(mediaUrl ? { mediaUrl } : {}),
verbose: false,
});
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
};
return createOutboundTestPlugin({
id: "telegram",
outbound: {
deliveryMode: "direct",
sendText: async (ctx) => {
const sendTelegram = ctx.deps?.sendTelegram;
if (!sendTelegram) {
throw new Error("sendTelegram dependency missing");
}
const result = await sendTelegram(ctx.to, ctx.text, {
accountId: ctx.accountId ?? undefined,
verbose: false,
});
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
},
sendMedia: async (ctx) => {
const sendTelegram = ctx.deps?.sendTelegram;
if (!sendTelegram) {
throw new Error("sendTelegram dependency missing");
}
const result = await sendTelegram(ctx.to, ctx.text, {
accountId: ctx.accountId ?? undefined,
mediaUrl: ctx.mediaUrl,
verbose: false,
});
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
},
sendText: async (ctx) => sendWithTelegram(ctx),
sendMedia: async (ctx) => sendWithTelegram(ctx, ctx.mediaUrl),
},
});
}
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: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never);
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult());
vi.mocked(loadModelCatalog).mockResolvedValue([]);
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
});
@@ -191,28 +300,20 @@ describe("agentCommand", () => {
});
});
it("defaults senderIsOwner to true for local agent runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await agentCommand({ message: "hi", to: "+1555" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.senderIsOwner).toBe(true);
});
});
it("honors explicit senderIsOwner override", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await agentCommand({ message: "hi", to: "+1555", senderIsOwner: false }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.senderIsOwner).toBe(false);
});
it.each([
{
name: "defaults senderIsOwner to true for local agent runs",
args: { message: "hi", to: "+1555" },
expected: true,
},
{
name: "honors explicit senderIsOwner override",
args: { message: "hi", to: "+1555", senderIsOwner: false },
expected: false,
},
])("$name", async ({ args, expected }) => {
const callArgs = await runEmbeddedWithTempConfig({ args });
expect(callArgs?.senderIsOwner).toBe(expected);
});
it("resumes when session-id is provided", async () => {
@@ -235,53 +336,21 @@ describe("agentCommand", () => {
});
it("uses the resumed session agent scope when sessionId resolves to another agent store", async () => {
await withTempHome(async (home) => {
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
const execStore = path.join(home, "sessions", "exec", "sessions.json");
writeSessionStoreSeed(execStore, {
"agent:exec:hook:gmail:thread-1": {
sessionId: "session-exec-hook",
updatedAt: Date.now(),
systemSent: true,
},
});
mockConfig(home, storePattern, undefined, undefined, [
{ id: "dev" },
{ id: "exec", default: true },
]);
await agentCommand({ message: "resume me", sessionId: "session-exec-hook" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.sessionKey).toBe("agent:exec:hook:gmail:thread-1");
await withCrossAgentResumeFixture(async ({ sessionKey }) => {
const callArgs = getLastEmbeddedCall();
expect(callArgs?.sessionKey).toBe(sessionKey);
expect(callArgs?.agentId).toBe("exec");
expect(callArgs?.agentDir).toContain(`${path.sep}agents${path.sep}exec${path.sep}agent`);
});
});
it("forwards resolved outbound session context when resuming by sessionId", async () => {
await withTempHome(async (home) => {
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
const execStore = path.join(home, "sessions", "exec", "sessions.json");
writeSessionStoreSeed(execStore, {
"agent:exec:hook:gmail:thread-1": {
sessionId: "session-exec-hook",
updatedAt: Date.now(),
systemSent: true,
},
});
mockConfig(home, storePattern, undefined, undefined, [
{ id: "dev" },
{ id: "exec", default: true },
]);
await agentCommand({ message: "resume me", sessionId: "session-exec-hook" }, runtime);
await withCrossAgentResumeFixture(async ({ sessionKey }) => {
const deliverCall = deliverAgentCommandResultSpy.mock.calls.at(-1)?.[0];
expect(deliverCall?.opts.sessionKey).toBeUndefined();
expect(deliverCall?.outboundSession).toEqual(
expect.objectContaining({
key: "agent:exec:hook:gmail:thread-1",
key: sessionKey,
agentId: "exec",
}),
);
@@ -362,9 +431,7 @@ describe("agentCommand", () => {
await agentCommand({ message: "hi", to: "+1555" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.provider).toBe("openai");
expect(callArgs?.model).toBe("gpt-4.1-mini");
expectLastRunProviderModel("openai", "gpt-4.1-mini");
});
});
@@ -446,13 +513,7 @@ describe("agentCommand", () => {
{ id: "claude-opus-4-5", name: "Opus", provider: "anthropic" },
]);
await agentCommand(
{
message: "hi",
sessionKey: "agent:main:subagent:allow-any",
},
runtime,
);
await runAgentWithSessionKey("agent:main:subagent:allow-any");
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.provider).toBe("openai");
@@ -497,17 +558,9 @@ describe("agentCommand", () => {
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
]);
await agentCommand(
{
message: "hi",
sessionKey: "agent:main:subagent:clear-overrides",
},
runtime,
);
await runAgentWithSessionKey("agent:main:subagent:clear-overrides");
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.provider).toBe("openai");
expect(callArgs?.model).toBe("gpt-4.1-mini");
expectLastRunProviderModel("openai", "gpt-4.1-mini");
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
string,
@@ -566,68 +619,18 @@ describe("agentCommand", () => {
});
it("persists resolved sessionFile for existing session keys", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
writeSessionStoreSeed(store, {
"agent:main:subagent:abc": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
});
mockConfig(home, store);
await agentCommand(
{
message: "hi",
sessionKey: "agent:main:subagent:abc",
},
runtime,
);
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
string,
{ sessionId?: string; sessionFile?: string }
>;
const entry = saved["agent:main:subagent:abc"];
expect(entry?.sessionId).toBe("sess-main");
expect(entry?.sessionFile).toContain(
`${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`,
);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.sessionFile).toBe(entry?.sessionFile);
await expectPersistedSessionFile({
seedKey: "agent:main:subagent:abc",
sessionId: "sess-main",
expectedPathFragment: `${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`,
});
});
it("preserves topic transcript suffix when persisting missing sessionFile", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
writeSessionStoreSeed(store, {
"agent:main:telegram:group:123:topic:456": {
sessionId: "sess-topic",
updatedAt: Date.now(),
},
});
mockConfig(home, store);
await agentCommand(
{
message: "hi",
sessionKey: "agent:main:telegram:group:123:topic:456",
},
runtime,
);
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
string,
{ sessionId?: string; sessionFile?: string }
>;
const entry = saved["agent:main:telegram:group:123:topic:456"];
expect(entry?.sessionId).toBe("sess-topic");
expect(entry?.sessionFile).toContain("sess-topic-topic-456.jsonl");
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.sessionFile).toBe(entry?.sessionFile);
await expectPersistedSessionFile({
seedKey: "agent:main:telegram:group:123:topic:456",
sessionId: "sess-topic",
expectedPathFragment: "sess-topic-topic-456.jsonl",
});
});
@@ -715,76 +718,61 @@ describe("agentCommand", () => {
});
it("defaults thinking to low for reasoning-capable models", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
{
id: "claude-opus-4-5",
name: "Opus 4.5",
provider: "anthropic",
reasoning: true,
},
]);
await agentCommand({ message: "hi", to: "+1555" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.thinkLevel).toBe("low");
await expectDefaultThinkLevel({
catalogEntry: {
id: "claude-opus-4-5",
name: "Opus 4.5",
provider: "anthropic",
reasoning: true,
},
expected: "low",
});
});
it("defaults thinking to adaptive for Anthropic Claude 4.6 models", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, {
await expectDefaultThinkLevel({
agentOverrides: {
model: { primary: "anthropic/claude-opus-4-6" },
models: { "anthropic/claude-opus-4-6": {} },
});
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
{
id: "claude-opus-4-6",
name: "Opus 4.6",
provider: "anthropic",
reasoning: true,
},
]);
await agentCommand({ message: "hi", to: "+1555" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.thinkLevel).toBe("adaptive");
},
catalogEntry: {
id: "claude-opus-4-6",
name: "Opus 4.6",
provider: "anthropic",
reasoning: true,
},
expected: "adaptive",
});
});
it("prefers per-model thinking over global thinkingDefault", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, {
await expectDefaultThinkLevel({
agentOverrides: {
thinkingDefault: "low",
models: {
"anthropic/claude-opus-4-5": {
params: { thinking: "high" },
},
},
});
await agentCommand({ message: "hi", to: "+1555" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.thinkLevel).toBe("high");
},
catalogEntry: {
id: "claude-opus-4-5",
name: "Opus 4.5",
provider: "anthropic",
reasoning: true,
},
expected: "high",
});
});
it("prints JSON payload when requested", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }],
meta: {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(
createDefaultAgentResult({
payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }],
durationMs: 42,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
}),
);
const store = path.join(home, "sessions.json");
mockConfig(home, store);
@@ -802,15 +790,10 @@ describe("agentCommand", () => {
});
it("passes the message through as the agent prompt", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await agentCommand({ message: "ping", to: "+1333" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.prompt).toBe("ping");
const callArgs = await runEmbeddedWithTempConfig({
args: { message: "ping", to: "+1333" },
});
expect(callArgs?.prompt).toBe("ping");
});
it("passes through telegram accountId when delivering", async () => {
@@ -861,48 +844,31 @@ describe("agentCommand", () => {
});
it("uses reply channel as the message channel context", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
await agentCommand({ message: "hi", agentId: "ops", replyChannel: "slack" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.messageChannel).toBe("slack");
const callArgs = await runEmbeddedWithTempConfig({
args: { message: "hi", agentId: "ops", replyChannel: "slack" },
agentsList: [{ id: "ops" }],
});
expect(callArgs?.messageChannel).toBe("slack");
});
it("prefers runContext for embedded routing", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await agentCommand(
{
message: "hi",
to: "+1555",
channel: "whatsapp",
runContext: { messageChannel: "slack", accountId: "acct-2" },
},
runtime,
);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.messageChannel).toBe("slack");
expect(callArgs?.agentAccountId).toBe("acct-2");
const callArgs = await runEmbeddedWithTempConfig({
args: {
message: "hi",
to: "+1555",
channel: "whatsapp",
runContext: { messageChannel: "slack", accountId: "acct-2" },
},
});
expect(callArgs?.messageChannel).toBe("slack");
expect(callArgs?.agentAccountId).toBe("acct-2");
});
it("forwards accountId to embedded runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await agentCommand({ message: "hi", to: "+1555", accountId: "kev" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.agentAccountId).toBe("kev");
const callArgs = await runEmbeddedWithTempConfig({
args: { message: "hi", to: "+1555", accountId: "kev" },
});
expect(callArgs?.agentAccountId).toBe("kev");
});
it("logs output when delivery is disabled", async () => {

View File

@@ -53,6 +53,39 @@ describe("applyAuthChoiceMiniMax", () => {
delete process.env.MINIMAX_OAUTH_TOKEN;
}
async function runMiniMaxChoice(params: {
authChoice: Parameters<typeof applyAuthChoiceMiniMax>[0]["authChoice"];
opts?: Parameters<typeof applyAuthChoiceMiniMax>[0]["opts"];
env?: { apiKey?: string; oauthToken?: string };
prompter?: Parameters<typeof createMinimaxPrompter>[0];
}) {
const agentDir = await setupTempState();
resetMiniMaxEnv();
if (params.env?.apiKey !== undefined) {
process.env.MINIMAX_API_KEY = params.env.apiKey;
}
if (params.env?.oauthToken !== undefined) {
process.env.MINIMAX_OAUTH_TOKEN = params.env.oauthToken;
}
const text = vi.fn(async () => "should-not-be-used");
const confirm = vi.fn(async () => true);
const result = await applyAuthChoiceMiniMax({
authChoice: params.authChoice,
config: {},
prompter: createMinimaxPrompter({
text,
confirm,
...params.prompter,
}),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
...(params.opts ? { opts: params.opts } : {}),
});
return { agentDir, result, text, confirm };
}
afterEach(async () => {
await lifecycle.cleanup();
});
@@ -92,18 +125,8 @@ describe("applyAuthChoiceMiniMax", () => {
])(
"$caseName",
async ({ authChoice, tokenProvider, token, profileId, provider, expectedModel }) => {
const agentDir = await setupTempState();
resetMiniMaxEnv();
const text = vi.fn(async () => "should-not-be-used");
const confirm = vi.fn(async () => true);
const result = await applyAuthChoiceMiniMax({
const { agentDir, result, text, confirm } = await runMiniMaxChoice({
authChoice,
config: {},
prompter: createMinimaxPrompter({ text, confirm }),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
opts: {
tokenProvider,
token,
@@ -126,80 +149,57 @@ describe("applyAuthChoiceMiniMax", () => {
},
);
it("uses env token for minimax-api-key-cn as plaintext by default", 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(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).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");
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toBeUndefined();
});
it("uses env token for minimax-api-key-cn as keyRef in ref mode", 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,
opts: {
secretInputMode: "ref",
it.each([
{
name: "uses env token for minimax-api-key-cn as plaintext by default",
opts: undefined,
expectKey: "mm-env-token",
expectKeyRef: undefined,
expectConfirmCalls: 1,
},
{
name: "uses env token for minimax-api-key-cn as keyRef in ref mode",
opts: { secretInputMode: "ref" as const },
expectKey: undefined,
expectKeyRef: {
source: "env",
provider: "default",
id: "MINIMAX_API_KEY",
},
expectConfirmCalls: 0,
},
])("$name", async ({ opts, expectKey, expectKeyRef, expectConfirmCalls }) => {
const { agentDir, result, text, confirm } = await runMiniMaxChoice({
authChoice: "minimax-api-key-cn",
opts,
env: { apiKey: "mm-env-token" },
});
expect(result).not.toBeNull();
if (!opts) {
expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({
provider: "minimax-cn",
mode: "api_key",
});
expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe(
"minimax-cn/MiniMax-M2.5",
);
}
expect(text).not.toHaveBeenCalled();
expect(confirm).toHaveBeenCalledTimes(expectConfirmCalls);
const parsed = await readAuthProfiles(agentDir);
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toEqual({
source: "env",
provider: "default",
id: "MINIMAX_API_KEY",
});
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBeUndefined();
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe(expectKey);
if (expectKeyRef) {
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toEqual(expectKeyRef);
} else {
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toBeUndefined();
}
});
it("uses minimax-api-lightning default model", async () => {
const agentDir = await setupTempState();
resetMiniMaxEnv();
const text = vi.fn(async () => "should-not-be-used");
const confirm = vi.fn(async () => true);
const result = await applyAuthChoiceMiniMax({
const { agentDir, result, text, confirm } = await runMiniMaxChoice({
authChoice: "minimax-api-lightning",
config: {},
prompter: createMinimaxPrompter({ text, confirm }),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
opts: {
tokenProvider: "minimax",
token: "mm-lightning-token",

View File

@@ -24,163 +24,117 @@ describe("volcengine/byteplus auth choice", () => {
return env.agentDir;
}
function createTestContext(defaultSelect: string, confirmResult = true, textValue = "unused") {
return {
prompter: createWizardPrompter(
{
confirm: vi.fn(async () => confirmResult),
text: vi.fn(async () => textValue),
},
{ defaultSelect },
),
runtime: createExitThrowingRuntime(),
};
}
type ProviderAuthCase = {
provider: "volcengine" | "byteplus";
authChoice: "volcengine-api-key" | "byteplus-api-key";
envVar: "VOLCANO_ENGINE_API_KEY" | "BYTEPLUS_API_KEY";
envValue: string;
profileId: "volcengine:default" | "byteplus:default";
applyAuthChoice: typeof applyAuthChoiceVolcengine | typeof applyAuthChoiceBytePlus;
};
async function runProviderAuthChoice(
testCase: ProviderAuthCase,
options?: {
defaultSelect?: string;
confirmResult?: boolean;
textValue?: string;
secretInputMode?: "ref";
},
) {
const agentDir = await setupTempState();
process.env[testCase.envVar] = testCase.envValue;
const { prompter, runtime } = createTestContext(
options?.defaultSelect ?? "plaintext",
options?.confirmResult ?? true,
options?.textValue ?? "unused",
);
const result = await testCase.applyAuthChoice({
authChoice: testCase.authChoice,
config: {},
prompter,
runtime,
setDefaultModel: true,
...(options?.secretInputMode ? { opts: { secretInputMode: options.secretInputMode } } : {}),
});
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(agentDir);
return { result, parsed };
}
const providerAuthCases: ProviderAuthCase[] = [
{
provider: "volcengine",
authChoice: "volcengine-api-key",
envVar: "VOLCANO_ENGINE_API_KEY",
envValue: "volc-env-key",
profileId: "volcengine:default",
applyAuthChoice: applyAuthChoiceVolcengine,
},
{
provider: "byteplus",
authChoice: "byteplus-api-key",
envVar: "BYTEPLUS_API_KEY",
envValue: "byte-env-key",
profileId: "byteplus:default",
applyAuthChoice: applyAuthChoiceBytePlus,
},
];
afterEach(async () => {
await lifecycle.cleanup();
});
it("stores volcengine env key as plaintext by default", async () => {
const agentDir = await setupTempState();
process.env.VOLCANO_ENGINE_API_KEY = "volc-env-key";
it.each(providerAuthCases)(
"stores $provider env key as plaintext by default",
async (testCase) => {
const { result, parsed } = await runProviderAuthChoice(testCase);
expect(result).not.toBeNull();
expect(result?.config.auth?.profiles?.[testCase.profileId]).toMatchObject({
provider: testCase.provider,
mode: "api_key",
});
expect(parsed.profiles?.[testCase.profileId]?.key).toBe(testCase.envValue);
expect(parsed.profiles?.[testCase.profileId]?.keyRef).toBeUndefined();
},
);
const prompter = createWizardPrompter(
{
confirm: vi.fn(async () => true),
text: vi.fn(async () => "unused"),
},
{ defaultSelect: "plaintext" },
);
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoiceVolcengine({
authChoice: "volcengine-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
it.each(providerAuthCases)("stores $provider env key as keyRef in ref mode", async (testCase) => {
const { result, parsed } = await runProviderAuthChoice(testCase, {
defaultSelect: "ref",
});
expect(result).not.toBeNull();
expect(result?.config.auth?.profiles?.["volcengine:default"]).toMatchObject({
provider: "volcengine",
mode: "api_key",
expect(parsed.profiles?.[testCase.profileId]).toMatchObject({
keyRef: { source: "env", provider: "default", id: testCase.envVar },
});
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(agentDir);
expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-env-key");
expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined();
});
it("stores volcengine env key as keyRef in ref mode", async () => {
const agentDir = await setupTempState();
process.env.VOLCANO_ENGINE_API_KEY = "volc-env-key";
const prompter = createWizardPrompter(
{
confirm: vi.fn(async () => true),
text: vi.fn(async () => "unused"),
},
{ defaultSelect: "ref" },
);
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoiceVolcengine({
authChoice: "volcengine-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(result).not.toBeNull();
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(agentDir);
expect(parsed.profiles?.["volcengine:default"]).toMatchObject({
keyRef: { source: "env", provider: "default", id: "VOLCANO_ENGINE_API_KEY" },
});
expect(parsed.profiles?.["volcengine:default"]?.key).toBeUndefined();
});
it("stores byteplus env key as plaintext by default", async () => {
const agentDir = await setupTempState();
process.env.BYTEPLUS_API_KEY = "byte-env-key";
const prompter = createWizardPrompter(
{
confirm: vi.fn(async () => true),
text: vi.fn(async () => "unused"),
},
{ defaultSelect: "plaintext" },
);
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoiceBytePlus({
authChoice: "byteplus-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(result).not.toBeNull();
expect(result?.config.auth?.profiles?.["byteplus:default"]).toMatchObject({
provider: "byteplus",
mode: "api_key",
});
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(agentDir);
expect(parsed.profiles?.["byteplus:default"]?.key).toBe("byte-env-key");
expect(parsed.profiles?.["byteplus:default"]?.keyRef).toBeUndefined();
});
it("stores byteplus env key as keyRef in ref mode", async () => {
const agentDir = await setupTempState();
process.env.BYTEPLUS_API_KEY = "byte-env-key";
const prompter = createWizardPrompter(
{
confirm: vi.fn(async () => true),
text: vi.fn(async () => "unused"),
},
{ defaultSelect: "ref" },
);
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoiceBytePlus({
authChoice: "byteplus-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(result).not.toBeNull();
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(agentDir);
expect(parsed.profiles?.["byteplus:default"]).toMatchObject({
keyRef: { source: "env", provider: "default", id: "BYTEPLUS_API_KEY" },
});
expect(parsed.profiles?.["byteplus:default"]?.key).toBeUndefined();
expect(parsed.profiles?.[testCase.profileId]?.key).toBeUndefined();
});
it("stores explicit volcengine key when env is not used", async () => {
const agentDir = await setupTempState();
const prompter = createWizardPrompter(
{
confirm: vi.fn(async () => false),
text: vi.fn(async () => "volc-manual-key"),
},
{ defaultSelect: "" },
);
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoiceVolcengine({
authChoice: "volcengine-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
const { result, parsed } = await runProviderAuthChoice(providerAuthCases[0], {
defaultSelect: "",
confirmResult: false,
textValue: "volc-manual-key",
});
expect(result).not.toBeNull();
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(agentDir);
expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-manual-key");
expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined();
});

View File

@@ -25,6 +25,10 @@ import {
const runtime = createTestRuntime();
let clackPrompterModule: typeof import("../wizard/clack-prompter.js");
function formatChannelStatusJoined(channelAccounts: Record<string, unknown>) {
return formatGatewayChannelsStatusLines({ channelAccounts }).join("\n");
}
describe("channels command", () => {
beforeAll(async () => {
clackPrompterModule = await import("../wizard/clack-prompter.js");
@@ -45,23 +49,53 @@ describe("channels command", () => {
setDefaultChannelPluginRegistryForTests();
});
it("adds a non-default telegram account", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
await channelsAddCommand(
{ channel: "telegram", account: "alerts", token: "123:abc" },
runtime,
{ hasFlags: true },
);
function getWrittenConfig<T>(): T {
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
return configMocks.writeConfigFile.mock.calls[0]?.[0] as T;
}
async function runRemoveWithConfirm(
args: Parameters<typeof channelsRemoveCommand>[0],
): Promise<void> {
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
const promptSpy = vi
.spyOn(clackPrompterModule, "createClackPrompter")
.mockReturnValue(prompt as never);
try {
await channelsRemoveCommand(args, runtime, { hasFlags: true });
} finally {
promptSpy.mockRestore();
}
}
async function addTelegramAccount(account: string, token: string): Promise<void> {
await channelsAddCommand({ channel: "telegram", account, token }, runtime, {
hasFlags: true,
});
}
async function addAlertsTelegramAccount(token: string): Promise<{
channels?: {
telegram?: {
enabled?: boolean;
accounts?: Record<string, { botToken?: string }>;
};
};
}> {
await addTelegramAccount("alerts", token);
return getWrittenConfig<{
channels?: {
telegram?: {
enabled?: boolean;
accounts?: Record<string, { botToken?: string }>;
};
};
};
}>();
}
it("adds a non-default telegram account", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
const next = await addAlertsTelegramAccount("123:abc");
expect(next.channels?.telegram?.enabled).toBe(true);
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("123:abc");
});
@@ -83,13 +117,9 @@ describe("channels command", () => {
},
});
await channelsAddCommand(
{ channel: "telegram", account: "alerts", token: "alerts-token" },
runtime,
{ hasFlags: true },
);
await addTelegramAccount("alerts", "alerts-token");
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
const next = getWrittenConfig<{
channels?: {
telegram?: {
botToken?: string;
@@ -109,7 +139,7 @@ describe("channels command", () => {
>;
};
};
};
}>();
expect(next.channels?.telegram?.accounts?.default).toEqual({
botToken: "legacy-token",
dmPolicy: "allowlist",
@@ -137,20 +167,7 @@ describe("channels command", () => {
},
});
await channelsAddCommand(
{ channel: "telegram", account: "alerts", token: "alerts-token" },
runtime,
{ hasFlags: true },
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
channels?: {
telegram?: {
enabled?: boolean;
accounts?: Record<string, { botToken?: string }>;
};
};
};
const next = await addAlertsTelegramAccount("alerts-token");
expect(next.channels?.telegram?.enabled).toBe(true);
expect(next.channels?.telegram?.accounts?.default).toEqual({});
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token");
@@ -169,12 +186,11 @@ describe("channels command", () => {
{ hasFlags: true },
);
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
const next = getWrittenConfig<{
channels?: {
slack?: { enabled?: boolean; botToken?: string; appToken?: string };
};
};
}>();
expect(next.channels?.slack?.enabled).toBe(true);
expect(next.channels?.slack?.botToken).toBe("xoxb-1");
expect(next.channels?.slack?.appToken).toBe("xapp-1");
@@ -199,12 +215,11 @@ describe("channels command", () => {
hasFlags: true,
});
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
const next = getWrittenConfig<{
channels?: {
discord?: { accounts?: Record<string, { token?: string }> };
};
};
}>();
expect(next.channels?.discord?.accounts?.work).toBeUndefined();
expect(next.channels?.discord?.accounts?.default?.token).toBe("d0");
});
@@ -217,11 +232,11 @@ describe("channels command", () => {
{ hasFlags: true },
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
const next = getWrittenConfig<{
channels?: {
whatsapp?: { accounts?: Record<string, { name?: string }> };
};
};
}>();
expect(next.channels?.whatsapp?.accounts?.family?.name).toBe("Family Phone");
});
@@ -250,13 +265,13 @@ describe("channels command", () => {
{ hasFlags: true },
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
const next = getWrittenConfig<{
channels?: {
signal?: {
accounts?: Record<string, { account?: string; name?: string }>;
};
};
};
}>();
expect(next.channels?.signal?.accounts?.lab?.account).toBe("+15555550123");
expect(next.channels?.signal?.accounts?.lab?.name).toBe("Lab");
expect(next.channels?.signal?.accounts?.default?.name).toBe("Primary");
@@ -270,20 +285,12 @@ describe("channels command", () => {
},
});
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
const promptSpy = vi
.spyOn(clackPrompterModule, "createClackPrompter")
.mockReturnValue(prompt as never);
await runRemoveWithConfirm({ channel: "discord", account: "default" });
await channelsRemoveCommand({ channel: "discord", account: "default" }, runtime, {
hasFlags: true,
});
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
const next = getWrittenConfig<{
channels?: { discord?: { enabled?: boolean } };
};
}>();
expect(next.channels?.discord?.enabled).toBe(false);
promptSpy.mockRestore();
});
it("includes external auth profiles in JSON output", async () => {
@@ -348,14 +355,14 @@ describe("channels command", () => {
{ hasFlags: true },
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
const next = getWrittenConfig<{
channels?: {
telegram?: {
name?: string;
accounts?: Record<string, { botToken?: string; name?: string }>;
};
};
};
}>();
expect(next.channels?.telegram?.name).toBeUndefined();
expect(next.channels?.telegram?.accounts?.default?.name).toBe("Primary Bot");
});
@@ -377,14 +384,14 @@ describe("channels command", () => {
hasFlags: true,
});
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
const next = getWrittenConfig<{
channels?: {
discord?: {
name?: string;
accounts?: Record<string, { name?: string; token?: string }>;
};
};
};
}>();
expect(next.channels?.discord?.name).toBeUndefined();
expect(next.channels?.discord?.accounts?.default?.name).toBe("Primary Bot");
expect(next.channels?.discord?.accounts?.work?.token).toBe("d1");
@@ -405,8 +412,9 @@ describe("channels command", () => {
expect(telegramIndex).toBeLessThan(whatsappIndex);
});
it("surfaces Discord privileged intent issues in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({
it.each([
{
name: "surfaces Discord privileged intent issues in channels status output",
channelAccounts: {
discord: [
{
@@ -417,14 +425,14 @@ describe("channels command", () => {
},
],
},
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/Message Content Intent is disabled/i);
expect(lines.join("\n")).toMatch(/Run: (?:openclaw|openclaw)( --profile isolated)? doctor/);
});
it("surfaces Discord permission audit issues in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({
patterns: [
/Warnings:/,
/Message Content Intent is disabled/i,
/Run: (?:openclaw|openclaw)( --profile isolated)? doctor/,
],
},
{
name: "surfaces Discord permission audit issues in channels status output",
channelAccounts: {
discord: [
{
@@ -444,14 +452,10 @@ describe("channels command", () => {
},
],
},
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/permission audit/i);
expect(lines.join("\n")).toMatch(/Channel 111/i);
});
it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => {
const lines = formatGatewayChannelsStatusLines({
patterns: [/Warnings:/, /permission audit/i, /Channel 111/i],
},
{
name: "surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled",
channelAccounts: {
telegram: [
{
@@ -462,54 +466,54 @@ describe("channels command", () => {
},
],
},
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
patterns: [/Warnings:/, /Telegram Bot API privacy mode/i],
},
])("$name", ({ channelAccounts, patterns }) => {
const joined = formatChannelStatusJoined(channelAccounts);
for (const pattern of patterns) {
expect(joined).toMatch(pattern);
}
});
it("includes Telegram bot username from probe data", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
telegram: [
{
accountId: "default",
enabled: true,
configured: true,
probe: { ok: true, bot: { username: "openclaw_bot" } },
},
],
},
const joined = formatChannelStatusJoined({
telegram: [
{
accountId: "default",
enabled: true,
configured: true,
probe: { ok: true, bot: { username: "openclaw_bot" } },
},
],
});
expect(lines.join("\n")).toMatch(/bot:@openclaw_bot/);
expect(joined).toMatch(/bot:@openclaw_bot/);
});
it("surfaces Telegram group membership audit issues in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
telegram: [
{
accountId: "default",
enabled: true,
configured: true,
audit: {
hasWildcardUnmentionedGroups: true,
unresolvedGroups: 1,
groups: [
{
chatId: "-1001",
ok: false,
status: "left",
error: "not in group",
},
],
},
const joined = formatChannelStatusJoined({
telegram: [
{
accountId: "default",
enabled: true,
configured: true,
audit: {
hasWildcardUnmentionedGroups: true,
unresolvedGroups: 1,
groups: [
{
chatId: "-1001",
ok: false,
status: "left",
error: "not in group",
},
],
},
],
},
},
],
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/membership probing is not possible/i);
expect(lines.join("\n")).toMatch(/Group -1001/i);
expect(joined).toMatch(/Warnings:/);
expect(joined).toMatch(/membership probing is not possible/i);
expect(joined).toMatch(/Group -1001/i);
});
it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => {
@@ -591,16 +595,8 @@ describe("channels command", () => {
},
});
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
const promptSpy = vi
.spyOn(clackPrompterModule, "createClackPrompter")
.mockReturnValue(prompt as never);
await channelsRemoveCommand({ channel: "telegram", account: "default" }, runtime, {
hasFlags: true,
});
await runRemoveWithConfirm({ channel: "telegram", account: "default" });
expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled();
promptSpy.mockRestore();
});
});

View File

@@ -51,35 +51,56 @@ function makeRuntime(): RuntimeEnv {
const noopPrompter = {} as WizardPrompter;
describe("promptAuthConfig", () => {
it("keeps Kilo provider models while applying allowlist defaults", async () => {
mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key");
mocks.applyAuthChoice.mockResolvedValue({
config: {
agents: {
defaults: {
model: { primary: "kilocode/anthropic/claude-opus-4.6" },
},
},
models: {
providers: {
kilocode: {
baseUrl: "https://api.kilo.ai/api/gateway/",
api: "openai-completions",
models: [
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
],
},
},
function createKilocodeProvider() {
return {
baseUrl: "https://api.kilo.ai/api/gateway/",
api: "openai-completions",
models: [
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
],
};
}
function createApplyAuthChoiceConfig(includeMinimaxProvider = false) {
return {
config: {
agents: {
defaults: {
model: { primary: "kilocode/anthropic/claude-opus-4.6" },
},
},
});
mocks.promptModelAllowlist.mockResolvedValue({
models: ["kilocode/anthropic/claude-opus-4.6"],
});
models: {
providers: {
kilocode: createKilocodeProvider(),
...(includeMinimaxProvider
? {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }],
},
}
: {}),
},
},
},
};
}
const result = await promptAuthConfig({}, makeRuntime(), noopPrompter);
async function runPromptAuthConfigWithAllowlist(includeMinimaxProvider = false) {
mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key");
mocks.applyAuthChoice.mockResolvedValue(createApplyAuthChoiceConfig(includeMinimaxProvider));
mocks.promptModelAllowlist.mockResolvedValue({
models: ["kilocode/anthropic/claude-opus-4.6"],
});
return promptAuthConfig({}, makeRuntime(), noopPrompter);
}
describe("promptAuthConfig", () => {
it("keeps Kilo provider models while applying allowlist defaults", async () => {
const result = await runPromptAuthConfigWithAllowlist();
expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([
"anthropic/claude-opus-4.6",
"minimax/minimax-m2.5:free",
@@ -90,38 +111,7 @@ describe("promptAuthConfig", () => {
});
it("does not mutate provider model catalogs when allowlist is set", async () => {
mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key");
mocks.applyAuthChoice.mockResolvedValue({
config: {
agents: {
defaults: {
model: { primary: "kilocode/anthropic/claude-opus-4.6" },
},
},
models: {
providers: {
kilocode: {
baseUrl: "https://api.kilo.ai/api/gateway/",
api: "openai-completions",
models: [
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
],
},
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }],
},
},
},
},
});
mocks.promptModelAllowlist.mockResolvedValue({
models: ["kilocode/anthropic/claude-opus-4.6"],
});
const result = await promptAuthConfig({}, makeRuntime(), noopPrompter);
const result = await runPromptAuthConfigWithAllowlist(true);
expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([
"anthropic/claude-opus-4.6",
"minimax/minimax-m2.5:free",

View File

@@ -28,67 +28,109 @@ describe("onboard auth credentials secret refs", () => {
await lifecycle.cleanup();
});
it("keeps env-backed moonshot key as plaintext by default", async () => {
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-");
type AuthProfileEntry = { key?: string; keyRef?: unknown; metadata?: unknown };
async function withAuthEnv(
prefix: string,
run: (env: Awaited<ReturnType<typeof setupAuthTestEnv>>) => Promise<void>,
) {
const env = await setupAuthTestEnv(prefix);
lifecycle.setStateDir(env.stateDir);
process.env.MOONSHOT_API_KEY = "sk-moonshot-env";
await setMoonshotApiKey("sk-moonshot-env");
await run(env);
}
async function readProfile(
agentDir: string,
profileId: string,
): Promise<AuthProfileEntry | undefined> {
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(env.agentDir);
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
key: "sk-moonshot-env",
profiles?: Record<string, AuthProfileEntry>;
}>(agentDir);
return parsed.profiles?.[profileId];
}
async function expectStoredAuthKey(params: {
prefix: string;
envVar?: string;
envValue?: string;
profileId: string;
apply: (agentDir: string) => Promise<void>;
expected: AuthProfileEntry;
absent?: Array<keyof AuthProfileEntry>;
}) {
await withAuthEnv(params.prefix, async (env) => {
if (params.envVar && params.envValue !== undefined) {
process.env[params.envVar] = params.envValue;
}
await params.apply(env.agentDir);
const profile = await readProfile(env.agentDir, params.profileId);
expect(profile).toMatchObject(params.expected);
for (const key of params.absent ?? []) {
expect(profile?.[key]).toBeUndefined();
}
});
}
it("keeps env-backed moonshot key as plaintext by default", async () => {
await expectStoredAuthKey({
prefix: "openclaw-onboard-auth-credentials-",
envVar: "MOONSHOT_API_KEY",
envValue: "sk-moonshot-env",
profileId: "moonshot:default",
apply: async () => {
await setMoonshotApiKey("sk-moonshot-env");
},
expected: {
key: "sk-moonshot-env",
},
absent: ["keyRef"],
});
expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined();
});
it("stores env-backed moonshot key as keyRef when secret-input-mode=ref", async () => {
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-ref-");
lifecycle.setStateDir(env.stateDir);
process.env.MOONSHOT_API_KEY = "sk-moonshot-env";
await setMoonshotApiKey("sk-moonshot-env", env.agentDir, { secretInputMode: "ref" });
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(env.agentDir);
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
await expectStoredAuthKey({
prefix: "openclaw-onboard-auth-credentials-ref-",
envVar: "MOONSHOT_API_KEY",
envValue: "sk-moonshot-env",
profileId: "moonshot:default",
apply: async (agentDir) => {
await setMoonshotApiKey("sk-moonshot-env", agentDir, { secretInputMode: "ref" });
},
expected: {
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
},
absent: ["key"],
});
expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined();
});
it("stores ${ENV} moonshot input as keyRef even when env value is unset", async () => {
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-inline-ref-");
lifecycle.setStateDir(env.stateDir);
await setMoonshotApiKey("${MOONSHOT_API_KEY}");
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(env.agentDir);
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
await expectStoredAuthKey({
prefix: "openclaw-onboard-auth-credentials-inline-ref-",
profileId: "moonshot:default",
apply: async () => {
await setMoonshotApiKey("${MOONSHOT_API_KEY}");
},
expected: {
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
},
absent: ["key"],
});
expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined();
});
it("keeps plaintext moonshot key when no env ref applies", async () => {
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-plaintext-");
lifecycle.setStateDir(env.stateDir);
process.env.MOONSHOT_API_KEY = "sk-moonshot-other";
await setMoonshotApiKey("sk-moonshot-plaintext");
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(env.agentDir);
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
key: "sk-moonshot-plaintext",
await expectStoredAuthKey({
prefix: "openclaw-onboard-auth-credentials-plaintext-",
envVar: "MOONSHOT_API_KEY",
envValue: "sk-moonshot-other",
profileId: "moonshot:default",
apply: async () => {
await setMoonshotApiKey("sk-moonshot-plaintext");
},
expected: {
key: "sk-moonshot-plaintext",
},
absent: ["keyRef"],
});
expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined();
});
it("preserves cloudflare metadata when storing keyRef", async () => {
@@ -111,35 +153,35 @@ describe("onboard auth credentials secret refs", () => {
});
it("keeps env-backed openai key as plaintext by default", async () => {
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-openai-");
lifecycle.setStateDir(env.stateDir);
process.env.OPENAI_API_KEY = "sk-openai-env";
await setOpenaiApiKey("sk-openai-env");
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(env.agentDir);
expect(parsed.profiles?.["openai:default"]).toMatchObject({
key: "sk-openai-env",
await expectStoredAuthKey({
prefix: "openclaw-onboard-auth-credentials-openai-",
envVar: "OPENAI_API_KEY",
envValue: "sk-openai-env",
profileId: "openai:default",
apply: async () => {
await setOpenaiApiKey("sk-openai-env");
},
expected: {
key: "sk-openai-env",
},
absent: ["keyRef"],
});
expect(parsed.profiles?.["openai:default"]?.keyRef).toBeUndefined();
});
it("stores env-backed openai key as keyRef in ref mode", async () => {
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-openai-ref-");
lifecycle.setStateDir(env.stateDir);
process.env.OPENAI_API_KEY = "sk-openai-env";
await setOpenaiApiKey("sk-openai-env", env.agentDir, { secretInputMode: "ref" });
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(env.agentDir);
expect(parsed.profiles?.["openai:default"]).toMatchObject({
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
await expectStoredAuthKey({
prefix: "openclaw-onboard-auth-credentials-openai-ref-",
envVar: "OPENAI_API_KEY",
envValue: "sk-openai-env",
profileId: "openai:default",
apply: async (agentDir) => {
await setOpenaiApiKey("sk-openai-env", agentDir, { secretInputMode: "ref" });
},
expected: {
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
absent: ["key"],
});
expect(parsed.profiles?.["openai:default"]?.key).toBeUndefined();
});
it("stores env-backed volcengine and byteplus keys as keyRef in ref mode", async () => {

View File

@@ -31,6 +31,68 @@ function createUnexpectedPromptGuards() {
};
}
type SetupChannelsOptions = Parameters<typeof setupChannels>[3];
function runSetupChannels(
cfg: OpenClawConfig,
prompter: WizardPrompter,
options?: SetupChannelsOptions,
) {
return setupChannels(cfg, createExitThrowingRuntime(), prompter, {
skipConfirm: true,
...options,
});
}
function createQuickstartTelegramSelect(options?: {
configuredAction?: "skip";
strictUnexpected?: boolean;
}) {
return vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
if (options?.configuredAction && message.includes("already configured")) {
return options.configuredAction;
}
if (options?.strictUnexpected) {
throw new Error(`unexpected select prompt: ${message}`);
}
return "__done__";
});
}
function createUnexpectedQuickstartPrompter(select: WizardPrompter["select"]) {
const { multiselect, text } = createUnexpectedPromptGuards();
return {
prompter: createPrompter({ select, multiselect, text }),
multiselect,
text,
};
}
function createTelegramCfg(botToken: string, enabled?: boolean): OpenClawConfig {
return {
channels: {
telegram: {
botToken,
...(typeof enabled === "boolean" ? { enabled } : {}),
},
},
} as OpenClawConfig;
}
function patchTelegramAdapter(overrides: Parameters<typeof patchChannelOnboardingAdapter>[1]) {
return patchChannelOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
...overrides,
});
}
vi.mock("node:fs/promises", () => ({
default: {
access: vi.fn(async () => {
@@ -81,10 +143,7 @@ describe("setupChannels", () => {
text: text as unknown as WizardPrompter["text"],
});
const runtime = createExitThrowingRuntime();
await setupChannels({} as OpenClawConfig, runtime, prompter, {
skipConfirm: true,
await runSetupChannels({} as OpenClawConfig, prompter, {
quickstartDefaults: true,
forceAllowFromChannels: ["whatsapp"],
});
@@ -116,10 +175,7 @@ describe("setupChannels", () => {
text: text as unknown as WizardPrompter["text"],
});
const runtime = createExitThrowingRuntime();
await setupChannels({} as OpenClawConfig, runtime, prompter, {
skipConfirm: true,
await runSetupChannels({} as OpenClawConfig, prompter, {
quickstartDefaults: true,
});
@@ -146,11 +202,7 @@ describe("setupChannels", () => {
text,
});
const runtime = createExitThrowingRuntime();
await setupChannels({} as OpenClawConfig, runtime, prompter, {
skipConfirm: true,
});
await runSetupChannels({} as OpenClawConfig, prompter);
const sawPrimer = note.mock.calls.some(
([message, title]) =>
@@ -162,41 +214,18 @@ describe("setupChannels", () => {
});
it("prompts for configured channel action and skips configuration when told to skip", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
if (message.includes("already configured")) {
return "skip";
}
throw new Error(`unexpected select prompt: ${message}`);
const select = createQuickstartTelegramSelect({
configuredAction: "skip",
strictUnexpected: true,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
await setupChannels(
{
channels: {
telegram: {
botToken: "token",
},
},
} as OpenClawConfig,
runtime,
prompter,
{
skipConfirm: true,
quickstartDefaults: true,
},
const { prompter, multiselect, text } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
await runSetupChannels(createTelegramCfg("token"), prompter, {
quickstartDefaults: true,
});
expect(select).toHaveBeenCalledWith(
expect.objectContaining({ message: "Select channel (QuickStart)" }),
);
@@ -231,58 +260,26 @@ describe("setupChannels", () => {
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
});
const runtime = createExitThrowingRuntime();
await setupChannels(
{
channels: {
telegram: {
botToken: "token",
enabled: false,
},
},
} as OpenClawConfig,
runtime,
prompter,
{
skipConfirm: true,
},
);
await runSetupChannels(createTelegramCfg("token", false), prompter);
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
expect(multiselect).not.toHaveBeenCalled();
});
it("uses configureInteractive skip without mutating selection/account state", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
return "__done__";
});
const select = createQuickstartTelegramSelect();
const selection = vi.fn();
const onAccountId = vi.fn();
const configureInteractive = vi.fn(async () => "skip" as const);
const restore = patchChannelOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
const restore = patchTelegramAdapter({
configureInteractive,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
skipConfirm: true,
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
quickstartDefaults: true,
onSelection: selection,
onAccountId,
@@ -300,12 +297,7 @@ describe("setupChannels", () => {
});
it("applies configureInteractive result cfg/account updates", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
return "__done__";
});
const select = createQuickstartTelegramSelect();
const selection = vi.fn();
const onAccountId = vi.fn();
const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
@@ -321,27 +313,16 @@ describe("setupChannels", () => {
const configure = vi.fn(async () => {
throw new Error("configure should not be called when configureInteractive is present");
});
const restore = patchChannelOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
const restore = patchTelegramAdapter({
configureInteractive,
configure,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
skipConfirm: true,
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
quickstartDefaults: true,
onSelection: selection,
onAccountId,
@@ -358,12 +339,7 @@ describe("setupChannels", () => {
});
it("uses configureWhenConfigured when channel is already configured", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
return "__done__";
});
const select = createQuickstartTelegramSelect();
const selection = vi.fn();
const onAccountId = vi.fn();
const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
@@ -381,43 +357,21 @@ describe("setupChannels", () => {
"configure should not be called when configureWhenConfigured handles updates",
);
});
const restore = patchChannelOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
const restore = patchTelegramAdapter({
configureInteractive: undefined,
configureWhenConfigured,
configure,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
const cfg = await setupChannels(
{
channels: {
telegram: {
botToken: "old-token",
},
},
} as OpenClawConfig,
runtime,
prompter,
{
skipConfirm: true,
quickstartDefaults: true,
onSelection: selection,
onAccountId,
},
);
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, {
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
expect(configureWhenConfigured).toHaveBeenCalledTimes(1);
expect(configureWhenConfigured).toHaveBeenCalledWith(
@@ -433,55 +387,28 @@ describe("setupChannels", () => {
});
it("respects configureWhenConfigured skip without mutating selection or account state", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
throw new Error(`unexpected select prompt: ${message}`);
});
const select = createQuickstartTelegramSelect({ strictUnexpected: true });
const selection = vi.fn();
const onAccountId = vi.fn();
const configureWhenConfigured = vi.fn(async () => "skip" as const);
const configure = vi.fn(async () => {
throw new Error("configure should not run when configureWhenConfigured handles skip");
});
const restore = patchChannelOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
const restore = patchTelegramAdapter({
configureInteractive: undefined,
configureWhenConfigured,
configure,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
const cfg = await setupChannels(
{
channels: {
telegram: {
botToken: "old-token",
},
},
} as OpenClawConfig,
runtime,
prompter,
{
skipConfirm: true,
quickstartDefaults: true,
onSelection: selection,
onAccountId,
},
);
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, {
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
expect(configureWhenConfigured).toHaveBeenCalledWith(
expect.objectContaining({ configured: true, label: expect.any(String) }),
@@ -496,54 +423,27 @@ describe("setupChannels", () => {
});
it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
throw new Error(`unexpected select prompt: ${message}`);
});
const select = createQuickstartTelegramSelect({ strictUnexpected: true });
const selection = vi.fn();
const onAccountId = vi.fn();
const configureInteractive = vi.fn(async () => "skip" as const);
const configureWhenConfigured = vi.fn(async () => {
throw new Error("configureWhenConfigured should not run when configureInteractive exists");
});
const restore = patchChannelOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
const restore = patchTelegramAdapter({
configureInteractive,
configureWhenConfigured,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const { prompter } = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
await setupChannels(
{
channels: {
telegram: {
botToken: "old-token",
},
},
} as OpenClawConfig,
runtime,
prompter,
{
skipConfirm: true,
quickstartDefaults: true,
onSelection: selection,
onAccountId,
},
);
await runSetupChannels(createTelegramCfg("old-token"), prompter, {
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
expect(configureInteractive).toHaveBeenCalledWith(
expect.objectContaining({ configured: true, label: expect.any(String) }),

View File

@@ -76,6 +76,43 @@ function expectOpenAiCompatResult(params: {
expect(params.result.config.models?.providers?.custom?.api).toBe("openai-completions");
}
function buildCustomProviderConfig(contextWindow?: number) {
if (contextWindow === undefined) {
return {};
}
return {
models: {
providers: {
custom: {
api: "openai-completions",
baseUrl: "https://llm.example.com/v1",
models: [
{
id: "foo-large",
name: "foo-large",
contextWindow,
maxTokens: contextWindow > CONTEXT_WINDOW_HARD_MIN_TOKENS ? 4096 : 1024,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
reasoning: false,
},
],
},
},
},
};
}
function applyCustomModelConfigWithContextWindow(contextWindow?: number) {
return applyCustomApiConfig({
config: buildCustomProviderConfig(contextWindow),
baseUrl: "https://llm.example.com/v1",
modelId: "foo-large",
compatibility: "openai",
providerId: "custom",
});
}
describe("promptCustomApiConfig", () => {
afterEach(() => {
vi.unstubAllGlobals();
@@ -327,89 +364,28 @@ describe("promptCustomApiConfig", () => {
});
describe("applyCustomApiConfig", () => {
it("uses hard-min context window for newly added custom models", () => {
const result = applyCustomApiConfig({
config: {},
baseUrl: "https://llm.example.com/v1",
modelId: "foo-large",
compatibility: "openai",
providerId: "custom",
});
it.each([
{
name: "uses hard-min context window for newly added custom models",
existingContextWindow: undefined,
expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS,
},
{
name: "upgrades existing custom model context window when below hard minimum",
existingContextWindow: 4096,
expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS,
},
{
name: "preserves existing custom model context window when already above minimum",
existingContextWindow: 131072,
expectedContextWindow: 131072,
},
])("$name", ({ existingContextWindow, expectedContextWindow }) => {
const result = applyCustomModelConfigWithContextWindow(existingContextWindow);
const model = result.config.models?.providers?.custom?.models?.find(
(entry) => entry.id === "foo-large",
);
expect(model?.contextWindow).toBe(CONTEXT_WINDOW_HARD_MIN_TOKENS);
});
it("upgrades existing custom model context window when below hard minimum", () => {
const result = applyCustomApiConfig({
config: {
models: {
providers: {
custom: {
api: "openai-completions",
baseUrl: "https://llm.example.com/v1",
models: [
{
id: "foo-large",
name: "foo-large",
contextWindow: 4096,
maxTokens: 1024,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
reasoning: false,
},
],
},
},
},
},
baseUrl: "https://llm.example.com/v1",
modelId: "foo-large",
compatibility: "openai",
providerId: "custom",
});
const model = result.config.models?.providers?.custom?.models?.find(
(entry) => entry.id === "foo-large",
);
expect(model?.contextWindow).toBe(CONTEXT_WINDOW_HARD_MIN_TOKENS);
});
it("preserves existing custom model context window when already above minimum", () => {
const result = applyCustomApiConfig({
config: {
models: {
providers: {
custom: {
api: "openai-completions",
baseUrl: "https://llm.example.com/v1",
models: [
{
id: "foo-large",
name: "foo-large",
contextWindow: 131072,
maxTokens: 4096,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
reasoning: false,
},
],
},
},
},
},
baseUrl: "https://llm.example.com/v1",
modelId: "foo-large",
compatibility: "openai",
providerId: "custom",
});
const model = result.config.models?.providers?.custom?.models?.find(
(entry) => entry.id === "foo-large",
);
expect(model?.contextWindow).toBe(131072);
expect(model?.contextWindow).toBe(expectedContextWindow);
});
it.each([

View File

@@ -27,6 +27,18 @@ function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return createWizardPrompter(overrides, { defaultSelect: "" });
}
function createSelectPrompter(
responses: Partial<Record<string, string>>,
): WizardPrompter["select"] {
return vi.fn(async (params) => {
const value = responses[params.message];
if (value !== undefined) {
return value as never;
}
return (params.options[0]?.value ?? "") as never;
});
}
describe("promptRemoteGatewayConfig", () => {
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
@@ -49,17 +61,10 @@ describe("promptRemoteGatewayConfig", () => {
},
]);
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 select = createSelectPrompter({
"Select gateway": "0",
"Connection method": "direct",
"Gateway auth": "token",
});
const text: WizardPrompter["text"] = vi.fn(async (params) => {
@@ -106,12 +111,7 @@ describe("promptRemoteGatewayConfig", () => {
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 select = createSelectPrompter({ "Gateway auth": "off" });
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({
@@ -138,12 +138,7 @@ describe("promptRemoteGatewayConfig", () => {
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 select = createSelectPrompter({ "Gateway auth": "off" });
const cfg = {} as OpenClawConfig;
const prompter = createPrompter({

View File

@@ -85,6 +85,66 @@ async function withUnknownUsageStore(run: () => Promise<void>) {
}
}
function getRuntimeLogs() {
return runtimeLogMock.mock.calls.map((call: unknown[]) => String(call[0]));
}
function getJoinedRuntimeLogs() {
return getRuntimeLogs().join("\n");
}
async function runStatusAndGetLogs(args: Parameters<typeof statusCommand>[0] = {}) {
runtimeLogMock.mockClear();
await statusCommand(args, runtime as never);
return getRuntimeLogs();
}
async function runStatusAndGetJoinedLogs(args: Parameters<typeof statusCommand>[0] = {}) {
await runStatusAndGetLogs(args);
return getJoinedRuntimeLogs();
}
type ProbeGatewayResult = {
ok: boolean;
url: string;
connectLatencyMs: number | null;
error: string | null;
close: { code: number; reason: string } | null;
health: unknown;
status: unknown;
presence: unknown;
configSnapshot: unknown;
};
function mockProbeGatewayResult(overrides: Partial<ProbeGatewayResult>) {
mocks.probeGateway.mockResolvedValueOnce({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
error: "timeout",
close: null,
health: null,
status: null,
presence: null,
configSnapshot: null,
...overrides,
});
}
async function withEnvVar<T>(key: string, value: string, run: () => Promise<T>): Promise<T> {
const prevValue = process.env[key];
process.env[key] = value;
try {
return await run();
} finally {
if (prevValue === undefined) {
delete process.env[key];
} else {
process.env[key] = prevValue;
}
}
}
const mocks = vi.hoisted(() => ({
loadSessionStore: vi.fn().mockReturnValue({
"+1000": createDefaultSessionStoreEntry(),
@@ -367,86 +427,68 @@ describe("statusCommand", () => {
it("prints unknown usage in formatted output when totalTokens is missing", async () => {
await withUnknownUsageStore(async () => {
runtimeLogMock.mockClear();
await statusCommand({}, runtime as never);
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
const logs = await runStatusAndGetLogs();
expect(logs.some((line) => line.includes("unknown/") && line.includes("(?%)"))).toBe(true);
});
});
it("prints formatted lines otherwise", async () => {
runtimeLogMock.mockClear();
await statusCommand({}, runtime as never);
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
expect(logs.some((l: string) => l.includes("OpenClaw status"))).toBe(true);
expect(logs.some((l: string) => l.includes("Overview"))).toBe(true);
expect(logs.some((l: string) => l.includes("Security audit"))).toBe(true);
expect(logs.some((l: string) => l.includes("Summary:"))).toBe(true);
expect(logs.some((l: string) => l.includes("CRITICAL"))).toBe(true);
expect(logs.some((l: string) => l.includes("Dashboard"))).toBe(true);
expect(logs.some((l: string) => l.includes("macos 14.0 (arm64)"))).toBe(true);
expect(logs.some((l: string) => l.includes("Memory"))).toBe(true);
expect(logs.some((l: string) => l.includes("Channels"))).toBe(true);
expect(logs.some((l: string) => l.includes("WhatsApp"))).toBe(true);
expect(logs.some((l: string) => l.includes("bootstrap files"))).toBe(true);
expect(logs.some((l: string) => l.includes("Sessions"))).toBe(true);
expect(logs.some((l: string) => l.includes("+1000"))).toBe(true);
expect(logs.some((l: string) => l.includes("50%"))).toBe(true);
expect(logs.some((l: string) => l.includes("40% cached"))).toBe(true);
expect(logs.some((l: string) => l.includes("LaunchAgent"))).toBe(true);
expect(logs.some((l: string) => l.includes("FAQ:"))).toBe(true);
expect(logs.some((l: string) => l.includes("Troubleshooting:"))).toBe(true);
expect(logs.some((l: string) => l.includes("Next steps:"))).toBe(true);
const logs = await runStatusAndGetLogs();
for (const token of [
"OpenClaw status",
"Overview",
"Security audit",
"Summary:",
"CRITICAL",
"Dashboard",
"macos 14.0 (arm64)",
"Memory",
"Channels",
"WhatsApp",
"bootstrap files",
"Sessions",
"+1000",
"50%",
"40% cached",
"LaunchAgent",
"FAQ:",
"Troubleshooting:",
"Next steps:",
]) {
expect(logs.some((line) => line.includes(token))).toBe(true);
}
expect(
logs.some(
(l: string) =>
l.includes("openclaw status --all") ||
l.includes("openclaw --profile isolated status --all") ||
l.includes("openclaw status --all") ||
l.includes("openclaw --profile isolated status --all"),
(line) =>
line.includes("openclaw status --all") ||
line.includes("openclaw --profile isolated status --all"),
),
).toBe(true);
});
it("shows gateway auth when reachable", async () => {
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = "abcd1234";
try {
mocks.probeGateway.mockResolvedValueOnce({
await withEnvVar("OPENCLAW_GATEWAY_TOKEN", "abcd1234", async () => {
mockProbeGatewayResult({
ok: true,
url: "ws://127.0.0.1:18789",
connectLatencyMs: 123,
error: null,
close: null,
health: {},
status: {},
presence: [],
configSnapshot: null,
});
runtimeLogMock.mockClear();
await statusCommand({}, runtime as never);
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
const logs = await runStatusAndGetLogs();
expect(logs.some((l: string) => l.includes("auth token"))).toBe(true);
} finally {
if (prevToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
}
}
});
});
it("surfaces channel runtime errors from the gateway", async () => {
mocks.probeGateway.mockResolvedValueOnce({
mockProbeGatewayResult({
ok: true,
url: "ws://127.0.0.1:18789",
connectLatencyMs: 10,
error: null,
close: null,
health: {},
status: {},
presence: [],
configSnapshot: null,
});
mocks.callGateway.mockResolvedValueOnce({
channelAccounts: {
@@ -471,98 +513,58 @@ describe("statusCommand", () => {
},
});
runtimeLogMock.mockClear();
await statusCommand({}, runtime as never);
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
expect(logs.join("\n")).toMatch(/Signal/i);
expect(logs.join("\n")).toMatch(/iMessage/i);
expect(logs.join("\n")).toMatch(/gateway:/i);
expect(logs.join("\n")).toMatch(/WARN/);
const joined = await runStatusAndGetJoinedLogs();
expect(joined).toMatch(/Signal/i);
expect(joined).toMatch(/iMessage/i);
expect(joined).toMatch(/gateway:/i);
expect(joined).toMatch(/WARN/);
});
it("prints requestId-aware recovery guidance when gateway pairing is required", async () => {
mocks.probeGateway.mockResolvedValueOnce({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
it.each([
{
name: "prints requestId-aware recovery guidance when gateway pairing is required",
error: "connect failed: pairing required (requestId: req-123)",
close: { code: 1008, reason: "pairing required (requestId: req-123)" },
health: null,
status: null,
presence: null,
configSnapshot: null,
});
runtimeLogMock.mockClear();
await statusCommand({}, runtime as never);
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
const joined = logs.join("\n");
expect(joined).toContain("Gateway pairing approval required.");
expect(joined).toContain("devices approve req-123");
expect(joined).toContain("devices approve --latest");
expect(joined).toContain("devices list");
});
it("prints fallback recovery guidance when pairing requestId is unavailable", async () => {
mocks.probeGateway.mockResolvedValueOnce({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
closeReason: "pairing required (requestId: req-123)",
includes: ["devices approve req-123"],
excludes: [],
},
{
name: "prints fallback recovery guidance when pairing requestId is unavailable",
error: "connect failed: pairing required",
close: { code: 1008, reason: "connect failed" },
health: null,
status: null,
presence: null,
configSnapshot: null,
closeReason: "connect failed",
includes: [],
excludes: ["devices approve req-"],
},
{
name: "does not render unsafe requestId content into approval command hints",
error: "connect failed: pairing required (requestId: req-123;rm -rf /)",
closeReason: "pairing required (requestId: req-123;rm -rf /)",
includes: [],
excludes: ["devices approve req-123;rm -rf /"],
},
])("$name", async ({ error, closeReason, includes, excludes }) => {
mockProbeGatewayResult({
error,
close: { code: 1008, reason: closeReason },
});
runtimeLogMock.mockClear();
await statusCommand({}, runtime as never);
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
const joined = logs.join("\n");
const joined = await runStatusAndGetJoinedLogs();
expect(joined).toContain("Gateway pairing approval required.");
expect(joined).not.toContain("devices approve req-");
expect(joined).toContain("devices approve --latest");
expect(joined).toContain("devices list");
});
it("does not render unsafe requestId content into approval command hints", async () => {
mocks.probeGateway.mockResolvedValueOnce({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
error: "connect failed: pairing required (requestId: req-123;rm -rf /)",
close: { code: 1008, reason: "pairing required (requestId: req-123;rm -rf /)" },
health: null,
status: null,
presence: null,
configSnapshot: null,
});
runtimeLogMock.mockClear();
await statusCommand({}, runtime as never);
const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
expect(joined).toContain("Gateway pairing approval required.");
expect(joined).not.toContain("devices approve req-123;rm -rf /");
expect(joined).toContain("devices approve --latest");
for (const expected of includes) {
expect(joined).toContain(expected);
}
for (const blocked of excludes) {
expect(joined).not.toContain(blocked);
}
});
it("extracts requestId from close reason when error text omits it", async () => {
mocks.probeGateway.mockResolvedValueOnce({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
mockProbeGatewayResult({
error: "connect failed: pairing required",
close: { code: 1008, reason: "pairing required (requestId: req-close-456)" },
health: null,
status: null,
presence: null,
configSnapshot: null,
});
runtimeLogMock.mockClear();
await statusCommand({}, runtime as never);
const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
const joined = await runStatusAndGetJoinedLogs();
expect(joined).toContain("devices approve req-close-456");
});