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

@ -6,11 +6,36 @@ const flushMicrotasks = async () => {
await Promise.resolve();
};
async function withFakeTimers(run: () => Promise<void>) {
vi.useFakeTimers();
try {
await run();
} finally {
vi.useRealTimers();
}
}
function createTypingHarness(overrides: Partial<Parameters<typeof createTypingCallbacks>[0]> = {}) {
const start = overrides.start ?? vi.fn().mockResolvedValue(undefined);
const stop = overrides.stop ?? vi.fn().mockResolvedValue(undefined);
const onStartError = overrides.onStartError ?? vi.fn();
const onStopError = overrides.onStopError ?? vi.fn();
const callbacks = createTypingCallbacks({
start,
stop,
onStartError,
...(onStopError ? { onStopError } : {}),
...(overrides.maxConsecutiveFailures !== undefined
? { maxConsecutiveFailures: overrides.maxConsecutiveFailures }
: {}),
...(overrides.maxDurationMs !== undefined ? { maxDurationMs: overrides.maxDurationMs } : {}),
});
return { start, stop, onStartError, onStopError, callbacks };
}
describe("createTypingCallbacks", () => {
it("invokes start on reply start", async () => {
const start = vi.fn().mockResolvedValue(undefined);
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({ start, onStartError });
const { start, onStartError, callbacks } = createTypingHarness();
await callbacks.onReplyStart();
@ -19,9 +44,9 @@ describe("createTypingCallbacks", () => {
});
it("reports start errors", async () => {
const start = vi.fn().mockRejectedValue(new Error("fail"));
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({ start, onStartError });
const { onStartError, callbacks } = createTypingHarness({
start: vi.fn().mockRejectedValue(new Error("fail")),
});
await callbacks.onReplyStart();
@ -29,11 +54,9 @@ describe("createTypingCallbacks", () => {
});
it("invokes stop on idle and reports stop errors", async () => {
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockRejectedValue(new Error("stop"));
const onStartError = vi.fn();
const onStopError = vi.fn();
const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError });
const { stop, onStopError, callbacks } = createTypingHarness({
stop: vi.fn().mockRejectedValue(new Error("stop")),
});
callbacks.onIdle?.();
await flushMicrotasks();
@ -43,13 +66,8 @@ describe("createTypingCallbacks", () => {
});
it("sends typing keepalive pings until idle cleanup", async () => {
vi.useFakeTimers();
try {
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({ start, stop, onStartError });
await withFakeTimers(async () => {
const { start, stop, callbacks } = createTypingHarness();
await callbacks.onReplyStart();
expect(start).toHaveBeenCalledTimes(1);
@ -68,18 +86,14 @@ describe("createTypingCallbacks", () => {
await vi.advanceTimersByTimeAsync(9_000);
expect(start).toHaveBeenCalledTimes(3);
} finally {
vi.useRealTimers();
}
});
});
it("stops keepalive after consecutive start failures", async () => {
vi.useFakeTimers();
try {
const start = vi.fn().mockRejectedValue(new Error("gone"));
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({ start, onStartError });
await withFakeTimers(async () => {
const { start, onStartError, callbacks } = createTypingHarness({
start: vi.fn().mockRejectedValue(new Error("gone")),
});
await callbacks.onReplyStart();
expect(start).toHaveBeenCalledTimes(1);
expect(onStartError).toHaveBeenCalledTimes(1);
@ -90,19 +104,13 @@ describe("createTypingCallbacks", () => {
await vi.advanceTimersByTimeAsync(9_000);
expect(start).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});
it("does not restart keepalive when breaker trips on initial start", async () => {
vi.useFakeTimers();
try {
const start = vi.fn().mockRejectedValue(new Error("gone"));
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({
start,
onStartError,
await withFakeTimers(async () => {
const { start, onStartError, callbacks } = createTypingHarness({
start: vi.fn().mockRejectedValue(new Error("gone")),
maxConsecutiveFailures: 1,
});
@ -112,28 +120,21 @@ describe("createTypingCallbacks", () => {
await vi.advanceTimersByTimeAsync(9_000);
expect(start).toHaveBeenCalledTimes(1);
expect(onStartError).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
it("resets failure counter after a successful keepalive tick", async () => {
vi.useFakeTimers();
try {
await withFakeTimers(async () => {
let callCount = 0;
const start = vi.fn().mockImplementation(async () => {
callCount += 1;
if (callCount % 2 === 1) {
throw new Error("flaky");
}
});
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({
start,
onStartError,
const { start, onStartError, callbacks } = createTypingHarness({
start: vi.fn().mockImplementation(async () => {
callCount += 1;
if (callCount % 2 === 1) {
throw new Error("flaky");
}
}),
maxConsecutiveFailures: 2,
});
await callbacks.onReplyStart(); // fail
await vi.advanceTimersByTimeAsync(3_000); // success
await vi.advanceTimersByTimeAsync(3_000); // fail
@ -142,16 +143,11 @@ describe("createTypingCallbacks", () => {
expect(start).toHaveBeenCalledTimes(5);
expect(onStartError).toHaveBeenCalledTimes(3);
} finally {
vi.useRealTimers();
}
});
});
it("deduplicates stop across idle and cleanup", async () => {
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({ start, stop, onStartError });
const { stop, callbacks } = createTypingHarness();
callbacks.onIdle?.();
callbacks.onCleanup?.();
@ -161,12 +157,8 @@ describe("createTypingCallbacks", () => {
});
it("does not restart keepalive after idle cleanup", async () => {
vi.useFakeTimers();
try {
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({ start, stop, onStartError });
await withFakeTimers(async () => {
const { start, stop, callbacks } = createTypingHarness();
await callbacks.onReplyStart();
expect(start).toHaveBeenCalledTimes(1);
@ -179,26 +171,15 @@ describe("createTypingCallbacks", () => {
expect(start).toHaveBeenCalledTimes(1);
expect(stop).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
// ========== TTL Safety Tests ==========
describe("TTL safety", () => {
it("auto-stops typing after maxDurationMs", async () => {
vi.useFakeTimers();
try {
await withFakeTimers(async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({
start,
stop,
onStartError,
maxDurationMs: 10_000,
});
const { start, stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 });
await callbacks.onReplyStart();
expect(start).toHaveBeenCalledTimes(1);
@ -212,24 +193,13 @@ describe("createTypingCallbacks", () => {
expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining("TTL exceeded"));
consoleWarn.mockRestore();
} finally {
vi.useRealTimers();
}
});
});
it("does not auto-stop if idle is called before TTL", async () => {
vi.useFakeTimers();
try {
await withFakeTimers(async () => {
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({
start,
stop,
onStartError,
maxDurationMs: 10_000,
});
const { stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 });
await callbacks.onReplyStart();
@ -249,18 +219,12 @@ describe("createTypingCallbacks", () => {
expect(stop).toHaveBeenCalledTimes(1);
consoleWarn.mockRestore();
} finally {
vi.useRealTimers();
}
});
});
it("uses default 60s TTL when not specified", async () => {
vi.useFakeTimers();
try {
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({ start, stop, onStartError });
await withFakeTimers(async () => {
const { stop, callbacks } = createTypingHarness();
await callbacks.onReplyStart();
@ -271,46 +235,24 @@ describe("createTypingCallbacks", () => {
// Should stop at 60s
await vi.advanceTimersByTimeAsync(1_000);
expect(stop).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
it("disables TTL when maxDurationMs is 0", async () => {
vi.useFakeTimers();
try {
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({
start,
stop,
onStartError,
maxDurationMs: 0,
});
await withFakeTimers(async () => {
const { stop, callbacks } = createTypingHarness({ maxDurationMs: 0 });
await callbacks.onReplyStart();
// Should not auto-stop even after long time
await vi.advanceTimersByTimeAsync(300_000);
expect(stop).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
it("resets TTL timer on restart after idle", async () => {
vi.useFakeTimers();
try {
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({
start,
stop,
onStartError,
maxDurationMs: 10_000,
});
await withFakeTimers(async () => {
const { stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 });
// First start
await callbacks.onReplyStart();
@ -330,9 +272,7 @@ describe("createTypingCallbacks", () => {
// Should not trigger stop again since it's closed
expect(stop).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
});
});

View File

@ -156,61 +156,49 @@ async function expectCronEditWithScheduleLookupExit(
).rejects.toThrow("__exit__:1");
}
async function runCronRunAndCaptureExit(params: { ran: boolean }) {
resetGatewayMock();
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, callParams?: unknown) => {
if (method === "cron.status") {
return { enabled: true };
}
if (method === "cron.run") {
return { ok: true, params: callParams, ran: params.ran };
}
return { ok: true, params: callParams };
},
);
const runtimeModule = await import("../runtime.js");
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
const originalExit = runtime.exit;
const exitSpy = vi.fn();
runtime.exit = exitSpy;
try {
const program = buildProgram();
await program.parseAsync(["cron", "run", "job-1"], { from: "user" });
} finally {
runtime.exit = originalExit;
}
return exitSpy;
}
describe("cron cli", () => {
it("exits 0 for cron run when job executes successfully", async () => {
resetGatewayMock();
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "cron.status") {
return { enabled: true };
}
if (method === "cron.run") {
return { ok: true, params, ran: true };
}
return { ok: true, params };
},
);
const runtimeModule = await import("../runtime.js");
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
const originalExit = runtime.exit;
const exitSpy = vi.fn();
runtime.exit = exitSpy;
try {
const program = buildProgram();
await program.parseAsync(["cron", "run", "job-1"], { from: "user" });
expect(exitSpy).toHaveBeenCalledWith(0);
} finally {
runtime.exit = originalExit;
}
});
it("exits 1 for cron run when job does not execute", async () => {
resetGatewayMock();
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "cron.status") {
return { enabled: true };
}
if (method === "cron.run") {
return { ok: true, params, ran: false };
}
return { ok: true, params };
},
);
const runtimeModule = await import("../runtime.js");
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
const originalExit = runtime.exit;
const exitSpy = vi.fn();
runtime.exit = exitSpy;
try {
const program = buildProgram();
await program.parseAsync(["cron", "run", "job-1"], { from: "user" });
expect(exitSpy).toHaveBeenCalledWith(1);
} finally {
runtime.exit = originalExit;
}
it.each([
{
name: "exits 0 for cron run when job executes successfully",
ran: true,
expectedExitCode: 0,
},
{
name: "exits 1 for cron run when job does not execute",
ran: false,
expectedExitCode: 1,
},
])("$name", async ({ ran, expectedExitCode }) => {
const exitSpy = await runCronRunAndCaptureExit({ ran });
expect(exitSpy).toHaveBeenCalledWith(expectedExitCode);
});
it("trims model and thinking on cron add", { timeout: CRON_CLI_TEST_TIMEOUT_MS }, async () => {

View File

@ -28,6 +28,20 @@ function makeRuntime() {
};
}
async function withCapturedStdout(run: () => Promise<void>): Promise<string> {
const writes: string[] = [];
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
writes.push(String(chunk));
return true;
}) as typeof process.stdout.write);
try {
await run();
return writes.join("");
} finally {
writeSpy.mockRestore();
}
}
describe("ensureConfigReady", () => {
async function loadEnsureConfigReady() {
vi.resetModules();
@ -107,36 +121,22 @@ describe("ensureConfigReady", () => {
});
it("prevents preflight stdout noise when suppression is enabled", async () => {
const stdoutWrites: string[] = [];
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
}) as typeof process.stdout.write);
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
process.stdout.write("Doctor warnings\n");
});
try {
const output = await withCapturedStdout(async () => {
await runEnsureConfigReady(["message"], true);
expect(stdoutWrites.join("")).not.toContain("Doctor warnings");
} finally {
writeSpy.mockRestore();
}
});
expect(output).not.toContain("Doctor warnings");
});
it("allows preflight stdout noise when suppression is not enabled", async () => {
const stdoutWrites: string[] = [];
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
}) as typeof process.stdout.write);
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
process.stdout.write("Doctor warnings\n");
});
try {
const output = await withCapturedStdout(async () => {
await runEnsureConfigReady(["message"], false);
expect(stdoutWrites.join("")).toContain("Doctor warnings");
} finally {
writeSpy.mockRestore();
}
});
expect(output).toContain("Doctor warnings");
});
});

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");
});

View File

@ -2,35 +2,57 @@ import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { waitForDiscordGatewayStop } from "./monitor.gateway.js";
function createGatewayWaitHarness() {
const emitter = new EventEmitter();
const disconnect = vi.fn();
const abort = new AbortController();
return { emitter, disconnect, abort };
}
function startGatewayWait(params?: {
onGatewayError?: (error: unknown) => void;
shouldStopOnError?: (error: unknown) => boolean;
registerForceStop?: (fn: (error: unknown) => void) => void;
}) {
const harness = createGatewayWaitHarness();
const promise = waitForDiscordGatewayStop({
gateway: { emitter: harness.emitter, disconnect: harness.disconnect },
abortSignal: harness.abort.signal,
...(params?.onGatewayError ? { onGatewayError: params.onGatewayError } : {}),
...(params?.shouldStopOnError ? { shouldStopOnError: params.shouldStopOnError } : {}),
...(params?.registerForceStop ? { registerForceStop: params.registerForceStop } : {}),
});
return { ...harness, promise };
}
async function expectAbortToResolve(params: {
emitter: EventEmitter;
disconnect: ReturnType<typeof vi.fn>;
abort: AbortController;
promise: Promise<void>;
expectedDisconnectBeforeAbort?: number;
}) {
if (params.expectedDisconnectBeforeAbort !== undefined) {
expect(params.disconnect).toHaveBeenCalledTimes(params.expectedDisconnectBeforeAbort);
}
expect(params.emitter.listenerCount("error")).toBe(1);
params.abort.abort();
await expect(params.promise).resolves.toBeUndefined();
expect(params.disconnect).toHaveBeenCalledTimes(1);
expect(params.emitter.listenerCount("error")).toBe(0);
}
describe("waitForDiscordGatewayStop", () => {
it("resolves on abort and disconnects gateway", async () => {
const emitter = new EventEmitter();
const disconnect = vi.fn();
const abort = new AbortController();
const promise = waitForDiscordGatewayStop({
gateway: { emitter, disconnect },
abortSignal: abort.signal,
});
expect(emitter.listenerCount("error")).toBe(1);
abort.abort();
await expect(promise).resolves.toBeUndefined();
expect(disconnect).toHaveBeenCalledTimes(1);
expect(emitter.listenerCount("error")).toBe(0);
const { emitter, disconnect, abort, promise } = startGatewayWait();
await expectAbortToResolve({ emitter, disconnect, abort, promise });
});
it("rejects on gateway error and disconnects", async () => {
const emitter = new EventEmitter();
const disconnect = vi.fn();
const onGatewayError = vi.fn();
const abort = new AbortController();
const err = new Error("boom");
const promise = waitForDiscordGatewayStop({
gateway: { emitter, disconnect },
abortSignal: abort.signal,
const { emitter, disconnect, abort, promise } = startGatewayWait({
onGatewayError,
});
@ -46,28 +68,23 @@ describe("waitForDiscordGatewayStop", () => {
});
it("ignores gateway errors when instructed", async () => {
const emitter = new EventEmitter();
const disconnect = vi.fn();
const onGatewayError = vi.fn();
const abort = new AbortController();
const err = new Error("transient");
const promise = waitForDiscordGatewayStop({
gateway: { emitter, disconnect },
abortSignal: abort.signal,
const { emitter, disconnect, abort, promise } = startGatewayWait({
onGatewayError,
shouldStopOnError: () => false,
});
emitter.emit("error", err);
expect(onGatewayError).toHaveBeenCalledWith(err);
expect(disconnect).toHaveBeenCalledTimes(0);
expect(emitter.listenerCount("error")).toBe(1);
abort.abort();
await expect(promise).resolves.toBeUndefined();
expect(disconnect).toHaveBeenCalledTimes(1);
expect(emitter.listenerCount("error")).toBe(0);
await expectAbortToResolve({
emitter,
disconnect,
abort,
promise,
expectedDisconnectBeforeAbort: 0,
});
});
it("resolves on abort without a gateway", async () => {
@ -83,14 +100,9 @@ describe("waitForDiscordGatewayStop", () => {
});
it("rejects via registerForceStop and disconnects gateway", async () => {
const emitter = new EventEmitter();
const disconnect = vi.fn();
const abort = new AbortController();
let forceStop: ((err: unknown) => void) | undefined;
const promise = waitForDiscordGatewayStop({
gateway: { emitter, disconnect },
abortSignal: abort.signal,
const { emitter, disconnect, promise } = startGatewayWait({
registerForceStop: (fn) => {
forceStop = fn;
},
@ -106,14 +118,9 @@ describe("waitForDiscordGatewayStop", () => {
});
it("ignores forceStop after promise already settled", async () => {
const emitter = new EventEmitter();
const disconnect = vi.fn();
const abort = new AbortController();
let forceStop: ((err: unknown) => void) | undefined;
const promise = waitForDiscordGatewayStop({
gateway: { emitter, disconnect },
abortSignal: abort.signal,
const { abort, disconnect, promise } = startGatewayWait({
registerForceStop: (fn) => {
forceStop = fn;
},

View File

@ -59,24 +59,34 @@ describe("configureGatewayForOnboarding", () => {
};
}
it("generates a token when the prompt returns undefined", async () => {
mocks.randomToken.mockReturnValue("generated-token");
async function runGatewayConfig(params?: {
flow?: "advanced" | "quickstart";
bindChoice?: string;
authChoice?: "token" | "password";
tailscaleChoice?: "off" | "serve";
textQueue?: Array<string | undefined>;
nextConfig?: Record<string, unknown>;
}) {
const authChoice = params?.authChoice ?? "token";
const prompter = createPrompter({
selectQueue: ["loopback", "token", "off"],
textQueue: ["18789", undefined],
selectQueue: [params?.bindChoice ?? "loopback", authChoice, params?.tailscaleChoice ?? "off"],
textQueue: params?.textQueue ?? ["18789", undefined],
});
const runtime = createRuntime();
const result = await configureGatewayForOnboarding({
flow: "advanced",
return configureGatewayForOnboarding({
flow: params?.flow ?? "advanced",
baseConfig: {},
nextConfig: {},
nextConfig: params?.nextConfig ?? {},
localPort: 18789,
quickstartGateway: createQuickstartGateway("token"),
quickstartGateway: createQuickstartGateway(authChoice),
prompter,
runtime,
});
}
it("generates a token when the prompt returns undefined", async () => {
mocks.randomToken.mockReturnValue("generated-token");
const result = await runGatewayConfig();
expect(result.settings.gatewayToken).toBe("generated-token");
expect(result.nextConfig.gateway?.nodes?.denyCommands).toEqual([
@ -95,21 +105,10 @@ describe("configureGatewayForOnboarding", () => {
mocks.randomToken.mockReturnValue("generated-token");
mocks.randomToken.mockClear();
const prompter = createPrompter({
selectQueue: ["loopback", "token", "off"],
textQueue: [],
});
const runtime = createRuntime();
try {
const result = await configureGatewayForOnboarding({
const result = await runGatewayConfig({
flow: "quickstart",
baseConfig: {},
nextConfig: {},
localPort: 18789,
quickstartGateway: createQuickstartGateway("token"),
prompter,
runtime,
textQueue: [],
});
expect(result.settings.gatewayToken).toBe("token-from-env");
@ -124,22 +123,8 @@ describe("configureGatewayForOnboarding", () => {
it("does not set password to literal 'undefined' when prompt returns undefined", async () => {
mocks.randomToken.mockReturnValue("unused");
// Flow: loopback bind → password auth → tailscale off
const prompter = createPrompter({
selectQueue: ["loopback", "password", "off"],
textQueue: ["18789", undefined],
});
const runtime = createRuntime();
const result = await configureGatewayForOnboarding({
flow: "advanced",
baseConfig: {},
nextConfig: {},
localPort: 18789,
quickstartGateway: createQuickstartGateway("password"),
prompter,
runtime,
const result = await runGatewayConfig({
authChoice: "password",
});
const authConfig = result.nextConfig.gateway?.auth as { mode?: string; password?: string };
@ -150,21 +135,8 @@ describe("configureGatewayForOnboarding", () => {
it("seeds control UI allowed origins for non-loopback binds", async () => {
mocks.randomToken.mockReturnValue("generated-token");
const prompter = createPrompter({
selectQueue: ["lan", "token", "off"],
textQueue: ["18789", undefined],
});
const runtime = createRuntime();
const result = await configureGatewayForOnboarding({
flow: "advanced",
baseConfig: {},
nextConfig: {},
localPort: 18789,
quickstartGateway: createQuickstartGateway("token"),
prompter,
runtime,
const result = await runGatewayConfig({
bindChoice: "lan",
});
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toEqual([
@ -176,21 +148,8 @@ describe("configureGatewayForOnboarding", () => {
it("adds Tailscale origin to controlUi.allowedOrigins when tailscale serve is enabled", async () => {
mocks.randomToken.mockReturnValue("generated-token");
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
const prompter = createPrompter({
selectQueue: ["loopback", "token", "serve"],
textQueue: ["18789", undefined],
});
const runtime = createRuntime();
const result = await configureGatewayForOnboarding({
flow: "advanced",
baseConfig: {},
nextConfig: {},
localPort: 18789,
quickstartGateway: createQuickstartGateway("token"),
prompter,
runtime,
const result = await runGatewayConfig({
tailscaleChoice: "serve",
});
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain(
@ -201,21 +160,8 @@ describe("configureGatewayForOnboarding", () => {
it("does not add Tailscale origin when getTailnetHostname fails", async () => {
mocks.randomToken.mockReturnValue("generated-token");
mocks.getTailnetHostname.mockRejectedValue(new Error("not found"));
const prompter = createPrompter({
selectQueue: ["loopback", "token", "serve"],
textQueue: ["18789", undefined],
});
const runtime = createRuntime();
const result = await configureGatewayForOnboarding({
flow: "advanced",
baseConfig: {},
nextConfig: {},
localPort: 18789,
quickstartGateway: createQuickstartGateway("token"),
prompter,
runtime,
const result = await runGatewayConfig({
tailscaleChoice: "serve",
});
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toBeUndefined();
@ -224,21 +170,8 @@ describe("configureGatewayForOnboarding", () => {
it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => {
mocks.randomToken.mockReturnValue("generated-token");
mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::99");
const prompter = createPrompter({
selectQueue: ["loopback", "token", "serve"],
textQueue: ["18789", undefined],
});
const runtime = createRuntime();
const result = await configureGatewayForOnboarding({
flow: "advanced",
baseConfig: {},
nextConfig: {},
localPort: 18789,
quickstartGateway: createQuickstartGateway("token"),
prompter,
runtime,
const result = await runGatewayConfig({
tailscaleChoice: "serve",
});
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain(
@ -249,16 +182,8 @@ describe("configureGatewayForOnboarding", () => {
it("does not duplicate Tailscale origin when allowlist already contains case variants", async () => {
mocks.randomToken.mockReturnValue("generated-token");
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
const prompter = createPrompter({
selectQueue: ["loopback", "token", "serve"],
textQueue: ["18789", undefined],
});
const runtime = createRuntime();
const result = await configureGatewayForOnboarding({
flow: "advanced",
baseConfig: {},
const result = await runGatewayConfig({
tailscaleChoice: "serve",
nextConfig: {
gateway: {
controlUi: {
@ -266,10 +191,6 @@ describe("configureGatewayForOnboarding", () => {
},
},
},
localPort: 18789,
quickstartGateway: createQuickstartGateway("token"),
prompter,
runtime,
});
const origins = result.nextConfig.gateway?.controlUi?.allowedOrigins ?? [];