refactor(agent): dedupe harness and command workflows

This commit is contained in:
Peter Steinberger
2026-02-16 14:52:09 +00:00
parent 04892ee230
commit f717a13039
204 changed files with 7366 additions and 11540 deletions

View File

@@ -43,94 +43,86 @@ function mockConfig(storePath: string, overrides?: Partial<OpenClawConfig>) {
});
}
async function withTempStore(
fn: (ctx: { dir: string; store: string }) => Promise<void>,
overrides?: Partial<OpenClawConfig>,
) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-"));
const store = path.join(dir, "sessions.json");
mockConfig(store, overrides);
try {
await fn({ dir, store });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("agentCliCommand", () => {
it("uses a timer-safe max gateway timeout when --timeout is 0", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-"));
const store = path.join(dir, "sessions.json");
mockConfig(store);
await withTempStore(async () => {
vi.mocked(callGateway).mockResolvedValue({
runId: "idem-1",
status: "ok",
result: {
payloads: [{ text: "hello" }],
meta: { stub: true },
},
});
vi.mocked(callGateway).mockResolvedValue({
runId: "idem-1",
status: "ok",
result: {
payloads: [{ text: "hello" }],
meta: { stub: true },
},
});
try {
await agentCliCommand({ message: "hi", to: "+1555", timeout: "0" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
const request = vi.mocked(callGateway).mock.calls[0]?.[0] as { timeoutMs?: number };
expect(request.timeoutMs).toBe(2_147_000_000);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
it("uses gateway by default", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-"));
const store = path.join(dir, "sessions.json");
mockConfig(store);
await withTempStore(async () => {
vi.mocked(callGateway).mockResolvedValue({
runId: "idem-1",
status: "ok",
result: {
payloads: [{ text: "hello" }],
meta: { stub: true },
},
});
vi.mocked(callGateway).mockResolvedValue({
runId: "idem-1",
status: "ok",
result: {
payloads: [{ text: "hello" }],
meta: { stub: true },
},
});
try {
await agentCliCommand({ message: "hi", to: "+1555" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
expect(agentCommand).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith("hello");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
it("falls back to embedded agent when gateway fails", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-"));
const store = path.join(dir, "sessions.json");
mockConfig(store);
await withTempStore(async () => {
vi.mocked(callGateway).mockRejectedValue(new Error("gateway not connected"));
vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => {
rt.log?.("local");
return { payloads: [{ text: "local" }], meta: { stub: true } };
});
vi.mocked(callGateway).mockRejectedValue(new Error("gateway not connected"));
vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => {
rt.log?.("local");
return { payloads: [{ text: "local" }], meta: { stub: true } };
});
try {
await agentCliCommand({ message: "hi", to: "+1555" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
expect(agentCommand).toHaveBeenCalledTimes(1);
expect(runtime.log).toHaveBeenCalledWith("local");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
it("skips gateway when --local is set", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-cli-"));
const store = path.join(dir, "sessions.json");
mockConfig(store);
await withTempStore(async () => {
vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => {
rt.log?.("local");
return { payloads: [{ text: "local" }], meta: { stub: true } };
});
vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => {
rt.log?.("local");
return { payloads: [{ text: "local" }], meta: { stub: true } };
});
try {
await agentCliCommand(
{
message: "hi",
@@ -143,8 +135,6 @@ describe("agentCliCommand", () => {
expect(callGateway).not.toHaveBeenCalled();
expect(agentCommand).toHaveBeenCalledTimes(1);
expect(runtime.log).toHaveBeenCalledWith("local");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
});

View File

@@ -30,31 +30,52 @@ vi.mock("../infra/outbound/targets.js", async () => {
});
describe("deliverAgentCommandResult", () => {
function createRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
}
function createResult(text = "hi") {
return {
payloads: [{ text }],
meta: {},
};
}
async function runDelivery(params: {
opts: Record<string, unknown>;
sessionEntry?: SessionEntry;
runtime?: RuntimeEnv;
resultText?: string;
}) {
const cfg = {} as OpenClawConfig;
const deps = {} as CliDeps;
const runtime = params.runtime ?? createRuntime();
const result = createResult(params.resultText);
const { deliverAgentCommandResult } = await import("./agent/delivery.js");
await deliverAgentCommandResult({
cfg,
deps,
runtime,
opts: params.opts as never,
sessionEntry: params.sessionEntry,
result,
payloads: result.payloads,
});
return { runtime };
}
beforeEach(() => {
mocks.deliverOutboundPayloads.mockClear();
mocks.resolveOutboundTarget.mockClear();
});
it("prefers explicit accountId for outbound delivery", async () => {
const cfg = {} as OpenClawConfig;
const deps = {} as CliDeps;
const runtime = {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
const sessionEntry = {
lastAccountId: "default",
} as SessionEntry;
const result = {
payloads: [{ text: "hi" }],
meta: {},
};
const { deliverAgentCommandResult } = await import("./agent/delivery.js");
await deliverAgentCommandResult({
cfg,
deps,
runtime,
await runDelivery({
opts: {
message: "hello",
deliver: true,
@@ -62,9 +83,9 @@ describe("deliverAgentCommandResult", () => {
accountId: "kev",
to: "+15551234567",
},
sessionEntry,
result,
payloads: result.payloads,
sessionEntry: {
lastAccountId: "default",
} as SessionEntry,
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
@@ -73,34 +94,16 @@ describe("deliverAgentCommandResult", () => {
});
it("falls back to session accountId for implicit delivery", async () => {
const cfg = {} as OpenClawConfig;
const deps = {} as CliDeps;
const runtime = {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
const sessionEntry = {
lastAccountId: "legacy",
lastChannel: "whatsapp",
} as SessionEntry;
const result = {
payloads: [{ text: "hi" }],
meta: {},
};
const { deliverAgentCommandResult } = await import("./agent/delivery.js");
await deliverAgentCommandResult({
cfg,
deps,
runtime,
await runDelivery({
opts: {
message: "hello",
deliver: true,
channel: "whatsapp",
},
sessionEntry,
result,
payloads: result.payloads,
sessionEntry: {
lastAccountId: "legacy",
lastChannel: "whatsapp",
} as SessionEntry,
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
@@ -109,25 +112,7 @@ describe("deliverAgentCommandResult", () => {
});
it("does not infer accountId for explicit delivery targets", async () => {
const cfg = {} as OpenClawConfig;
const deps = {} as CliDeps;
const runtime = {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
const sessionEntry = {
lastAccountId: "legacy",
} as SessionEntry;
const result = {
payloads: [{ text: "hi" }],
meta: {},
};
const { deliverAgentCommandResult } = await import("./agent/delivery.js");
await deliverAgentCommandResult({
cfg,
deps,
runtime,
await runDelivery({
opts: {
message: "hello",
deliver: true,
@@ -135,9 +120,9 @@ describe("deliverAgentCommandResult", () => {
to: "+15551234567",
deliveryTargetMode: "explicit",
},
sessionEntry,
result,
payloads: result.payloads,
sessionEntry: {
lastAccountId: "legacy",
} as SessionEntry,
});
expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith(
@@ -149,34 +134,16 @@ describe("deliverAgentCommandResult", () => {
});
it("skips session accountId when channel differs", async () => {
const cfg = {} as OpenClawConfig;
const deps = {} as CliDeps;
const runtime = {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
const sessionEntry = {
lastAccountId: "legacy",
lastChannel: "telegram",
} as SessionEntry;
const result = {
payloads: [{ text: "hi" }],
meta: {},
};
const { deliverAgentCommandResult } = await import("./agent/delivery.js");
await deliverAgentCommandResult({
cfg,
deps,
runtime,
await runDelivery({
opts: {
message: "hello",
deliver: true,
channel: "whatsapp",
},
sessionEntry,
result,
payloads: result.payloads,
sessionEntry: {
lastAccountId: "legacy",
lastChannel: "telegram",
} as SessionEntry,
});
expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith(
@@ -185,33 +152,15 @@ describe("deliverAgentCommandResult", () => {
});
it("uses session last channel when none is provided", async () => {
const cfg = {} as OpenClawConfig;
const deps = {} as CliDeps;
const runtime = {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
const sessionEntry = {
lastChannel: "telegram",
lastTo: "123",
} as SessionEntry;
const result = {
payloads: [{ text: "hi" }],
meta: {},
};
const { deliverAgentCommandResult } = await import("./agent/delivery.js");
await deliverAgentCommandResult({
cfg,
deps,
runtime,
await runDelivery({
opts: {
message: "hello",
deliver: true,
},
sessionEntry,
result,
payloads: result.payloads,
sessionEntry: {
lastChannel: "telegram",
lastTo: "123",
} as SessionEntry,
});
expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith(
@@ -220,27 +169,7 @@ describe("deliverAgentCommandResult", () => {
});
it("uses reply overrides for delivery routing", async () => {
const cfg = {} as OpenClawConfig;
const deps = {} as CliDeps;
const runtime = {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
const sessionEntry = {
lastChannel: "telegram",
lastTo: "123",
lastAccountId: "legacy",
} as SessionEntry;
const result = {
payloads: [{ text: "hi" }],
meta: {},
};
const { deliverAgentCommandResult } = await import("./agent/delivery.js");
await deliverAgentCommandResult({
cfg,
deps,
runtime,
await runDelivery({
opts: {
message: "hello",
deliver: true,
@@ -249,9 +178,11 @@ describe("deliverAgentCommandResult", () => {
replyChannel: "slack",
replyAccountId: "ops",
},
sessionEntry,
result,
payloads: result.payloads,
sessionEntry: {
lastChannel: "telegram",
lastTo: "123",
lastAccountId: "legacy",
} as SessionEntry,
});
expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith(
@@ -260,22 +191,10 @@ describe("deliverAgentCommandResult", () => {
});
it("prefixes nested agent outputs with context", async () => {
const cfg = {} as OpenClawConfig;
const deps = {} as CliDeps;
const runtime = {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
const result = {
payloads: [{ text: "ANNOUNCE_SKIP" }],
meta: {},
};
const { deliverAgentCommandResult } = await import("./agent/delivery.js");
await deliverAgentCommandResult({
cfg,
deps,
const runtime = createRuntime();
await runDelivery({
runtime,
resultText: "ANNOUNCE_SKIP",
opts: {
message: "hello",
deliver: false,
@@ -285,8 +204,6 @@ describe("deliverAgentCommandResult", () => {
messageChannel: "webchat",
},
sessionEntry: undefined,
result,
payloads: result.payloads,
});
expect(runtime.log).toHaveBeenCalledTimes(1);

View File

@@ -25,6 +25,27 @@ vi.mock("../../agents/agent-scope.js", () => ({
const { resolveSessionKeyForRequest } = await import("./session.js");
describe("resolveSessionKeyForRequest", () => {
const MAIN_STORE_PATH = "/tmp/main-store.json";
const MYBOT_STORE_PATH = "/tmp/mybot-store.json";
type SessionStoreEntry = { sessionId: string; updatedAt: number };
type SessionStoreMap = Record<string, SessionStoreEntry>;
const setupMainAndMybotStorePaths = () => {
mocks.listAgentIds.mockReturnValue(["main", "mybot"]);
mocks.resolveStorePath.mockImplementation(
(_store: string | undefined, opts?: { agentId?: string }) => {
if (opts?.agentId === "mybot") {
return MYBOT_STORE_PATH;
}
return MAIN_STORE_PATH;
},
);
};
const mockStoresByPath = (stores: Partial<Record<string, SessionStoreMap>>) => {
mocks.loadSessionStore.mockImplementation((storePath: string) => stores[storePath] ?? {});
};
beforeEach(() => {
vi.clearAllMocks();
mocks.listAgentIds.mockReturnValue(["main"]);
@@ -33,7 +54,7 @@ describe("resolveSessionKeyForRequest", () => {
const baseCfg: OpenClawConfig = {};
it("returns sessionKey when --to resolves a session key via context", async () => {
mocks.resolveStorePath.mockReturnValue("/tmp/main-store.json");
mocks.resolveStorePath.mockReturnValue(MAIN_STORE_PATH);
mocks.loadSessionStore.mockReturnValue({
"agent:main:main": { sessionId: "sess-1", updatedAt: 0 },
});
@@ -46,7 +67,7 @@ describe("resolveSessionKeyForRequest", () => {
});
it("finds session by sessionId via reverse lookup in primary store", async () => {
mocks.resolveStorePath.mockReturnValue("/tmp/main-store.json");
mocks.resolveStorePath.mockReturnValue(MAIN_STORE_PATH);
mocks.loadSessionStore.mockReturnValue({
"agent:main:main": { sessionId: "target-session-id", updatedAt: 0 },
});
@@ -59,22 +80,11 @@ describe("resolveSessionKeyForRequest", () => {
});
it("finds session by sessionId in non-primary agent store", async () => {
mocks.listAgentIds.mockReturnValue(["main", "mybot"]);
mocks.resolveStorePath.mockImplementation(
(_store: string | undefined, opts?: { agentId?: string }) => {
if (opts?.agentId === "mybot") {
return "/tmp/mybot-store.json";
}
return "/tmp/main-store.json";
setupMainAndMybotStorePaths();
mockStoresByPath({
[MYBOT_STORE_PATH]: {
"agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 },
},
);
mocks.loadSessionStore.mockImplementation((storePath: string) => {
if (storePath === "/tmp/mybot-store.json") {
return {
"agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 },
};
}
return {};
});
const result = resolveSessionKeyForRequest({
@@ -82,27 +92,16 @@ describe("resolveSessionKeyForRequest", () => {
sessionId: "target-session-id",
});
expect(result.sessionKey).toBe("agent:mybot:main");
expect(result.storePath).toBe("/tmp/mybot-store.json");
expect(result.storePath).toBe(MYBOT_STORE_PATH);
});
it("returns correct sessionStore when session found in non-primary agent store", async () => {
const mybotStore = {
"agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 },
};
mocks.listAgentIds.mockReturnValue(["main", "mybot"]);
mocks.resolveStorePath.mockImplementation(
(_store: string | undefined, opts?: { agentId?: string }) => {
if (opts?.agentId === "mybot") {
return "/tmp/mybot-store.json";
}
return "/tmp/main-store.json";
},
);
mocks.loadSessionStore.mockImplementation((storePath: string) => {
if (storePath === "/tmp/mybot-store.json") {
return { ...mybotStore };
}
return {};
setupMainAndMybotStorePaths();
mockStoresByPath({
[MYBOT_STORE_PATH]: { ...mybotStore },
});
const result = resolveSessionKeyForRequest({
@@ -113,15 +112,7 @@ describe("resolveSessionKeyForRequest", () => {
});
it("returns undefined sessionKey when sessionId not found in any store", async () => {
mocks.listAgentIds.mockReturnValue(["main", "mybot"]);
mocks.resolveStorePath.mockImplementation(
(_store: string | undefined, opts?: { agentId?: string }) => {
if (opts?.agentId === "mybot") {
return "/tmp/mybot-store.json";
}
return "/tmp/main-store.json";
},
);
setupMainAndMybotStorePaths();
mocks.loadSessionStore.mockReturnValue({});
const result = resolveSessionKeyForRequest({
@@ -133,7 +124,7 @@ describe("resolveSessionKeyForRequest", () => {
it("does not search other stores when explicitSessionKey is set", async () => {
mocks.listAgentIds.mockReturnValue(["main", "mybot"]);
mocks.resolveStorePath.mockReturnValue("/tmp/main-store.json");
mocks.resolveStorePath.mockReturnValue(MAIN_STORE_PATH);
mocks.loadSessionStore.mockReturnValue({
"agent:main:main": { sessionId: "other-id", updatedAt: 0 },
});
@@ -148,27 +139,14 @@ describe("resolveSessionKeyForRequest", () => {
});
it("searches other stores when --to derives a key that does not match --session-id", async () => {
mocks.listAgentIds.mockReturnValue(["main", "mybot"]);
mocks.resolveStorePath.mockImplementation(
(_store: string | undefined, opts?: { agentId?: string }) => {
if (opts?.agentId === "mybot") {
return "/tmp/mybot-store.json";
}
return "/tmp/main-store.json";
setupMainAndMybotStorePaths();
mockStoresByPath({
[MAIN_STORE_PATH]: {
"agent:main:main": { sessionId: "other-session-id", updatedAt: 0 },
},
[MYBOT_STORE_PATH]: {
"agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 },
},
);
mocks.loadSessionStore.mockImplementation((storePath: string) => {
if (storePath === "/tmp/main-store.json") {
return {
"agent:main:main": { sessionId: "other-session-id", updatedAt: 0 },
};
}
if (storePath === "/tmp/mybot-store.json") {
return {
"agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 },
};
}
return {};
});
const result = resolveSessionKeyForRequest({
@@ -179,19 +157,11 @@ describe("resolveSessionKeyForRequest", () => {
// --to derives agent:main:main, but its sessionId doesn't match target-session-id,
// so the cross-store search finds it in the mybot store
expect(result.sessionKey).toBe("agent:mybot:main");
expect(result.storePath).toBe("/tmp/mybot-store.json");
expect(result.storePath).toBe(MYBOT_STORE_PATH);
});
it("skips already-searched primary store when iterating agents", async () => {
mocks.listAgentIds.mockReturnValue(["main", "mybot"]);
mocks.resolveStorePath.mockImplementation(
(_store: string | undefined, opts?: { agentId?: string }) => {
if (opts?.agentId === "mybot") {
return "/tmp/mybot-store.json";
}
return "/tmp/main-store.json";
},
);
setupMainAndMybotStorePaths();
mocks.loadSessionStore.mockReturnValue({});
resolveSessionKeyForRequest({
@@ -203,7 +173,7 @@ describe("resolveSessionKeyForRequest", () => {
// (not twice for main)
const storePaths = mocks.loadSessionStore.mock.calls.map((call: [string]) => call[0]);
expect(storePaths).toHaveLength(2);
expect(storePaths).toContain("/tmp/main-store.json");
expect(storePaths).toContain("/tmp/mybot-store.json");
expect(storePaths).toContain(MAIN_STORE_PATH);
expect(storePaths).toContain(MYBOT_STORE_PATH);
});
});

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
const configMocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(),
@@ -26,22 +26,7 @@ vi.mock("../wizard/clack-prompter.js", () => ({
import { WizardCancelledError } from "../wizard/prompts.js";
import { agentsAddCommand } from "./agents.js";
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const baseSnapshot = {
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {},
issues: [],
legacyIssues: [],
};
const runtime = createTestRuntime();
describe("agents add command", () => {
beforeEach(() => {
@@ -54,7 +39,7 @@ describe("agents add command", () => {
});
it("requires --workspace when flags are present", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot });
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
await agentsAddCommand({ name: "Work" }, runtime, { hasFlags: true });
@@ -64,7 +49,7 @@ describe("agents add command", () => {
});
it("requires --workspace in non-interactive mode", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot });
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
await agentsAddCommand({ name: "Work", nonInteractive: true }, runtime, {
hasFlags: false,
@@ -76,7 +61,7 @@ describe("agents add command", () => {
});
it("exits with code 1 when the interactive wizard is cancelled", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot });
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
wizardMocks.createClackPrompter.mockReturnValue({
intro: vi.fn().mockRejectedValue(new WizardCancelledError()),
text: vi.fn(),

View File

@@ -1,23 +1,11 @@
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { formatCliCommand } from "../cli/command-format.js";
import { readConfigFileSnapshot } from "../config/config.js";
import { requireValidConfigSnapshot } from "./config-validation.js";
export function createQuietRuntime(runtime: RuntimeEnv): RuntimeEnv {
return { ...runtime, log: () => {} };
}
export async function requireValidConfig(runtime: RuntimeEnv): Promise<OpenClawConfig | null> {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
const issues =
snapshot.issues.length > 0
? snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")
: "Unknown validation issue.";
runtime.error(`Config invalid:\n${issues}`);
runtime.error(`Fix the config or run ${formatCliCommand("openclaw doctor")}.`);
runtime.exit(1);
return null;
}
return snapshot.config;
return await requireValidConfigSnapshot(runtime);
}

View File

@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
const configMocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(),
@@ -20,22 +20,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
import { agentsSetIdentityCommand } from "./agents.js";
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const baseSnapshot = {
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {},
issues: [],
legacyIssues: [],
};
const runtime = createTestRuntime();
describe("agents set-identity command", () => {
beforeEach(() => {
@@ -63,7 +48,7 @@ describe("agents set-identity command", () => {
);
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: {
agents: {
list: [
@@ -96,7 +81,7 @@ describe("agents set-identity command", () => {
await fs.writeFile(path.join(workspace, "IDENTITY.md"), "- Name: Echo\n", "utf-8");
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: {
agents: {
list: [
@@ -131,7 +116,7 @@ describe("agents set-identity command", () => {
);
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: { agents: { list: [{ id: "main", workspace }] } },
});
@@ -176,7 +161,7 @@ describe("agents set-identity command", () => {
);
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: { agents: { list: [{ id: "main" }] } },
});
@@ -205,7 +190,7 @@ describe("agents set-identity command", () => {
);
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: { agents: { list: [{ id: "main", workspace }] } },
});
@@ -222,7 +207,7 @@ describe("agents set-identity command", () => {
it("accepts avatar-only updates via flags", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: { agents: { list: [{ id: "main" }] } },
});
@@ -246,7 +231,7 @@ describe("agents set-identity command", () => {
await fs.mkdir(workspace, { recursive: true });
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: { agents: { list: [{ id: "main", workspace }] } },
});

View File

@@ -2,15 +2,28 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { captureEnv } from "../test-utils/env.js";
import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js";
import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js";
const noopAsync = async () => {};
const noop = () => {};
const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json");
function createHuggingfacePrompter(params: {
text: WizardPrompter["text"];
select: WizardPrompter["select"];
confirm?: WizardPrompter["confirm"];
}): WizardPrompter {
const overrides: Partial<WizardPrompter> = {
text: params.text,
select: params.select,
};
if (params.confirm) {
overrides.confirm = params.confirm;
}
return createWizardPrompter(overrides, { defaultSelect: "" });
}
describe("applyAuthChoiceHuggingface", () => {
const envSnapshot = captureEnv(["OPENCLAW_AGENT_DIR", "HF_TOKEN", "HUGGINGFACE_HUB_TOKEN"]);
let tempStateDir: string | null = null;
@@ -44,23 +57,8 @@ describe("applyAuthChoiceHuggingface", () => {
const select: WizardPrompter["select"] = vi.fn(
async (params) => params.options?.[0]?.value as never,
);
const prompter: WizardPrompter = {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select,
multiselect: vi.fn(async () => []),
text,
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: noop, stop: noop })),
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
const prompter = createHuggingfacePrompter({ text, select });
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoiceHuggingface({
authChoice: "huggingface-api-key",
@@ -104,23 +102,8 @@ describe("applyAuthChoiceHuggingface", () => {
async (params) => params.options?.[0]?.value as never,
);
const confirm = vi.fn(async () => true);
const prompter: WizardPrompter = {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select,
multiselect: vi.fn(async () => []),
text,
confirm,
progress: vi.fn(() => ({ update: noop, stop: noop })),
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
const prompter = createHuggingfacePrompter({ text, select, confirm });
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoiceHuggingface({
authChoice: "huggingface-api-key",

View File

@@ -2,13 +2,11 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { captureEnv } from "../test-utils/env.js";
import { applyAuthChoice } from "./auth-choice.js";
import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js";
const noopAsync = async () => {};
const noop = () => {};
const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json");
const requireAgentDir = () => {
const agentDir = process.env.OPENCLAW_AGENT_DIR;
@@ -18,28 +16,8 @@ const requireAgentDir = () => {
return agentDir;
};
function createRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
}
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select: vi.fn(async () => "" as never),
multiselect: vi.fn(async () => []),
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: noop, stop: noop })),
...overrides,
};
return createWizardPrompter(overrides, { defaultSelect: "" });
}
describe("applyAuthChoice (moonshot)", () => {
@@ -72,7 +50,7 @@ describe("applyAuthChoice (moonshot)", () => {
const text = vi.fn().mockResolvedValue("sk-moonshot-cn-test");
const prompter = createPrompter({ text: text as unknown as WizardPrompter["text"] });
const runtime = createRuntime();
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoice({
authChoice: "moonshot-api-key-cn",
@@ -108,7 +86,7 @@ describe("applyAuthChoice (moonshot)", () => {
const text = vi.fn().mockResolvedValue("sk-moonshot-cn-test");
const prompter = createPrompter({ text: text as unknown as WizardPrompter["text"] });
const runtime = createRuntime();
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoice({
authChoice: "moonshot-api-key-cn",

View File

@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import { discordPlugin } from "../../extensions/discord/src/channel.js";
import { imessagePlugin } from "../../extensions/imessage/src/channel.js";
import { signalPlugin } from "../../extensions/signal/src/channel.js";
@@ -8,6 +7,7 @@ import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
const configMocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(),
@@ -42,22 +42,7 @@ import {
formatGatewayChannelsStatusLines,
} from "./channels.js";
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const baseSnapshot = {
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {},
issues: [],
legacyIssues: [],
};
const runtime = createTestRuntime();
describe("channels command", () => {
beforeEach(() => {
@@ -84,7 +69,7 @@ describe("channels command", () => {
});
it("adds a non-default telegram account", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot });
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
await channelsAddCommand(
{ channel: "telegram", account: "alerts", token: "123:abc" },
runtime,
@@ -105,7 +90,7 @@ describe("channels command", () => {
});
it("adds a default slack account with tokens", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot });
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
await channelsAddCommand(
{
channel: "slack",
@@ -130,7 +115,7 @@ describe("channels command", () => {
it("deletes a non-default discord account", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: {
channels: {
discord: {
@@ -158,7 +143,7 @@ describe("channels command", () => {
});
it("adds a named WhatsApp account", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot });
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
await channelsAddCommand(
{ channel: "whatsapp", account: "family", name: "Family Phone" },
runtime,
@@ -175,7 +160,7 @@ describe("channels command", () => {
it("adds a second signal account with a distinct name", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: {
channels: {
signal: {
@@ -212,7 +197,7 @@ describe("channels command", () => {
it("disables a default provider account when remove has no delete flag", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: {
channels: { discord: { token: "d0", enabled: true } },
},
@@ -237,7 +222,7 @@ describe("channels command", () => {
it("includes external auth profiles in JSON output", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: {},
});
authMocks.loadAuthProfileStore.mockReturnValue({
@@ -273,7 +258,7 @@ describe("channels command", () => {
it("stores default account names in accounts when multiple accounts exist", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: {
channels: {
telegram: {
@@ -311,7 +296,7 @@ describe("channels command", () => {
it("migrates base names when adding non-default accounts", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
...baseConfigSnapshot,
config: {
channels: {
discord: {

View File

@@ -1,67 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { signalPlugin } from "../../extensions/signal/src/channel.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js";
const configMocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(),
writeConfigFile: vi.fn().mockResolvedValue(undefined),
}));
const authMocks = vi.hoisted(() => ({
loadAuthProfileStore: vi.fn(),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
readConfigFileSnapshot: configMocks.readConfigFileSnapshot,
writeConfigFile: configMocks.writeConfigFile,
};
});
vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../agents/auth-profiles.js")>();
return {
...actual,
loadAuthProfileStore: authMocks.loadAuthProfileStore,
};
});
import { formatGatewayChannelsStatusLines } from "./channels.js";
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const _baseSnapshot = {
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {},
issues: [],
legacyIssues: [],
};
import { formatGatewayChannelsStatusLines } from "./channels/status.js";
describe("channels command", () => {
beforeEach(() => {
configMocks.readConfigFileSnapshot.mockReset();
configMocks.writeConfigFile.mockClear();
authMocks.loadAuthProfileStore.mockReset();
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
authMocks.loadAuthProfileStore.mockReturnValue({
version: 1,
profiles: {},
});
setActivePluginRegistry(
createTestRegistry([{ pluginId: "signal", source: "test", plugin: signalPlugin }]),
);

View File

@@ -1,26 +1,15 @@
import type { OpenClawConfig } from "../../config/config.js";
import { type ChannelId, getChannelPlugin } from "../../channels/plugins/index.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { requireValidConfigSnapshot } from "../config-validation.js";
export type ChatChannel = ChannelId;
export async function requireValidConfig(
runtime: RuntimeEnv = defaultRuntime,
): Promise<OpenClawConfig | null> {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
const issues =
snapshot.issues.length > 0
? snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")
: "Unknown validation issue.";
runtime.error(`Config invalid:\n${issues}`);
runtime.error(`Fix the config or run ${formatCliCommand("openclaw doctor")}.`);
runtime.exit(1);
return null;
}
return snapshot.config;
return await requireValidConfigSnapshot(runtime);
}
export function formatAccountLabel(params: { accountId: string; name?: string }) {

View File

@@ -54,6 +54,21 @@ function appendBaseUrlBit(bits: string[], account: Record<string, unknown>) {
}
}
function buildChannelAccountLine(
provider: ChatChannel,
account: Record<string, unknown>,
bits: string[],
): string {
const accountId = typeof account.accountId === "string" ? account.accountId : "default";
const name = typeof account.name === "string" ? account.name.trim() : "";
const labelText = formatChannelAccountLabel({
channel: provider,
accountId,
name: name || undefined,
});
return `- ${labelText}: ${bits.join(", ")}`;
}
export function formatGatewayChannelsStatusLines(payload: Record<string, unknown>): string[] {
const lines: string[] = [];
lines.push(theme.success("Gateway reachable."));
@@ -131,14 +146,7 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
if (typeof account.lastError === "string" && account.lastError) {
bits.push(`error:${account.lastError}`);
}
const accountId = typeof account.accountId === "string" ? account.accountId : "default";
const name = typeof account.name === "string" ? account.name.trim() : "";
const labelText = formatChannelAccountLabel({
channel: provider,
accountId,
name: name || undefined,
});
return `- ${labelText}: ${bits.join(", ")}`;
return buildChannelAccountLine(provider, account, bits);
});
const plugins = listChannelPlugins();
@@ -199,14 +207,7 @@ async function formatConfigChannelsStatusLines(
appendModeBit(bits, account);
appendTokenSourceBits(bits, account);
appendBaseUrlBit(bits, account);
const accountId = typeof account.accountId === "string" ? account.accountId : "default";
const name = typeof account.name === "string" ? account.name.trim() : "";
const labelText = formatChannelAccountLabel({
channel: provider,
accountId,
name: name || undefined,
});
return `- ${labelText}: ${bits.join(", ")}`;
return buildChannelAccountLine(provider, account, bits);
});
const plugins = listChannelPlugins();

View File

@@ -26,31 +26,48 @@ const urlToString = (url: Request | URL | string): string => {
return "url" in url ? url.url : String(url);
};
function createOAuthFetchFn(params: {
accessToken: string;
refreshToken: string;
username: string;
passthrough?: boolean;
}): typeof fetch {
return async (input, init) => {
const url = urlToString(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
return new Response(
JSON.stringify({
access_token: params.accessToken,
refresh_token: params.refreshToken,
expires_in: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === CHUTES_USERINFO_ENDPOINT) {
return new Response(JSON.stringify({ username: params.username }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (params.passthrough) {
return fetch(input, init);
}
return new Response("not found", { status: 404 });
};
}
describe("loginChutes", () => {
it("captures local redirect and exchanges code for tokens", async () => {
const port = await getFreePort();
const redirectUri = `http://127.0.0.1:${port}/oauth-callback`;
const fetchFn: typeof fetch = async (input, init) => {
const url = urlToString(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
return new Response(
JSON.stringify({
access_token: "at_local",
refresh_token: "rt_local",
expires_in: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === CHUTES_USERINFO_ENDPOINT) {
return new Response(JSON.stringify({ username: "local-user" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return fetch(input, init);
};
const fetchFn = createOAuthFetchFn({
accessToken: "at_local",
refreshToken: "rt_local",
username: "local-user",
passthrough: true,
});
const onPrompt = vi.fn(async () => {
throw new Error("onPrompt should not be called for local callback");
@@ -74,26 +91,11 @@ describe("loginChutes", () => {
});
it("supports manual flow with pasted redirect URL", async () => {
const fetchFn: typeof fetch = async (input) => {
const url = urlToString(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
return new Response(
JSON.stringify({
access_token: "at_manual",
refresh_token: "rt_manual",
expires_in: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === CHUTES_USERINFO_ENDPOINT) {
return new Response(JSON.stringify({ username: "manual-user" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("not found", { status: 404 });
};
const fetchFn = createOAuthFetchFn({
accessToken: "at_manual",
refreshToken: "rt_manual",
username: "manual-user",
});
let capturedState: string | null = null;
const creds = await loginChutes({
@@ -121,26 +123,11 @@ describe("loginChutes", () => {
});
it("does not reuse code_verifier as state", async () => {
const fetchFn: typeof fetch = async (input) => {
const url = urlToString(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
return new Response(
JSON.stringify({
access_token: "at_manual",
refresh_token: "rt_manual",
expires_in: 3600,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url === CHUTES_USERINFO_ENDPOINT) {
return new Response(JSON.stringify({ username: "manual-user" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("not found", { status: 404 });
};
const fetchFn = createOAuthFetchFn({
accessToken: "at_manual",
refreshToken: "rt_manual",
username: "manual-user",
});
const createPkce = () => ({
verifier: "verifier_123",

View File

@@ -0,0 +1,20 @@
import type { RuntimeEnv } from "../runtime.js";
import { formatCliCommand } from "../cli/command-format.js";
import { type OpenClawConfig, readConfigFileSnapshot } from "../config/config.js";
export async function requireValidConfigSnapshot(
runtime: RuntimeEnv,
): Promise<OpenClawConfig | null> {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
const issues =
snapshot.issues.length > 0
? snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")
: "Unknown validation issue.";
runtime.error(`Config invalid:\n${issues}`);
runtime.error(`Fix the config or run ${formatCliCommand("openclaw doctor")}.`);
runtime.exit(1);
return null;
}
return snapshot.config;
}

View File

@@ -47,6 +47,30 @@ vi.mock("./onboard-helpers.js", async (importActual) => {
import { promptGatewayConfig } from "./configure.gateway.js";
function makeRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
}
async function runTrustedProxyPrompt(textQueue: Array<string | undefined>) {
vi.clearAllMocks();
mocks.resolveGatewayPort.mockReturnValue(18789);
const selectQueue = ["loopback", "trusted-proxy", "off"];
mocks.select.mockImplementation(async () => selectQueue.shift());
mocks.text.mockImplementation(async () => textQueue.shift());
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
mode,
trustedProxy,
}));
const result = await promptGatewayConfig({}, makeRuntime());
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
return { result, call };
}
describe("promptGatewayConfig", () => {
it("generates a token when the prompt returns undefined", async () => {
mocks.resolveGatewayPort.mockReturnValue(18789);
@@ -99,33 +123,13 @@ describe("promptGatewayConfig", () => {
});
it("prompts for trusted-proxy configuration when trusted-proxy mode selected", async () => {
vi.clearAllMocks();
mocks.resolveGatewayPort.mockReturnValue(18789);
// Flow: loopback bind → trusted-proxy auth → tailscale off
const selectQueue = ["loopback", "trusted-proxy", "off"];
mocks.select.mockImplementation(async () => selectQueue.shift());
// Port prompt, userHeader, requiredHeaders, allowUsers, trustedProxies
const textQueue = [
const { result, call } = await runTrustedProxyPrompt([
"18789",
"x-forwarded-user",
"x-forwarded-proto,x-forwarded-host",
"nick@example.com",
"10.0.1.10,192.168.1.5",
];
mocks.text.mockImplementation(async () => textQueue.shift());
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
mode,
trustedProxy,
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await promptGatewayConfig({}, runtime);
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
]);
expect(call?.mode).toBe("trusted-proxy");
expect(call?.trustedProxy).toEqual({
@@ -138,26 +142,13 @@ describe("promptGatewayConfig", () => {
});
it("handles trusted-proxy with no optional fields", async () => {
vi.clearAllMocks();
mocks.resolveGatewayPort.mockReturnValue(18789);
const selectQueue = ["loopback", "trusted-proxy", "off"];
mocks.select.mockImplementation(async () => selectQueue.shift());
// Port prompt, userHeader (only required), empty requiredHeaders, empty allowUsers, trustedProxies
const textQueue = ["18789", "x-remote-user", "", "", "10.0.0.1"];
mocks.text.mockImplementation(async () => textQueue.shift());
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
mode,
trustedProxy,
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await promptGatewayConfig({}, runtime);
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
const { result, call } = await runTrustedProxyPrompt([
"18789",
"x-remote-user",
"",
"",
"10.0.0.1",
]);
expect(call?.mode).toBe("trusted-proxy");
expect(call?.trustedProxy).toEqual({

View File

@@ -149,12 +149,88 @@ function noteOpencodeProviderOverrides(cfg: OpenClawConfig) {
type TelegramAllowFromUsernameHit = { path: string; entry: string };
type TelegramAllowFromListRef = {
pathLabel: string;
holder: Record<string, unknown>;
key: "allowFrom" | "groupAllowFrom";
};
function asObjectRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function collectTelegramAccountScopes(
cfg: OpenClawConfig,
): Array<{ prefix: string; account: Record<string, unknown> }> {
const scopes: Array<{ prefix: string; account: Record<string, unknown> }> = [];
const telegram = asObjectRecord(cfg.channels?.telegram);
if (!telegram) {
return scopes;
}
scopes.push({ prefix: "channels.telegram", account: telegram });
const accounts = asObjectRecord(telegram.accounts);
if (!accounts) {
return scopes;
}
for (const key of Object.keys(accounts)) {
const account = asObjectRecord(accounts[key]);
if (!account) {
continue;
}
scopes.push({ prefix: `channels.telegram.accounts.${key}`, account });
}
return scopes;
}
function collectTelegramAllowFromLists(
prefix: string,
account: Record<string, unknown>,
): TelegramAllowFromListRef[] {
const refs: TelegramAllowFromListRef[] = [
{ pathLabel: `${prefix}.allowFrom`, holder: account, key: "allowFrom" },
{ pathLabel: `${prefix}.groupAllowFrom`, holder: account, key: "groupAllowFrom" },
];
const groups = asObjectRecord(account.groups);
if (!groups) {
return refs;
}
for (const groupId of Object.keys(groups)) {
const group = asObjectRecord(groups[groupId]);
if (!group) {
continue;
}
refs.push({
pathLabel: `${prefix}.groups.${groupId}.allowFrom`,
holder: group,
key: "allowFrom",
});
const topics = asObjectRecord(group.topics);
if (!topics) {
continue;
}
for (const topicId of Object.keys(topics)) {
const topic = asObjectRecord(topics[topicId]);
if (!topic) {
continue;
}
refs.push({
pathLabel: `${prefix}.groups.${groupId}.topics.${topicId}.allowFrom`,
holder: topic,
key: "allowFrom",
});
}
}
return refs;
}
function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllowFromUsernameHit[] {
const hits: TelegramAllowFromUsernameHit[] = [];
const telegram = cfg.channels?.telegram;
if (!telegram) {
return hits;
}
const scanList = (pathLabel: string, list: unknown) => {
if (!Array.isArray(list)) {
@@ -172,51 +248,10 @@ function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllo
}
};
const scanAccount = (prefix: string, account: Record<string, unknown>) => {
scanList(`${prefix}.allowFrom`, account.allowFrom);
scanList(`${prefix}.groupAllowFrom`, account.groupAllowFrom);
const groups = account.groups;
if (!groups || typeof groups !== "object" || Array.isArray(groups)) {
return;
for (const scope of collectTelegramAccountScopes(cfg)) {
for (const ref of collectTelegramAllowFromLists(scope.prefix, scope.account)) {
scanList(ref.pathLabel, ref.holder[ref.key]);
}
const groupsRecord = groups as Record<string, unknown>;
for (const groupId of Object.keys(groupsRecord)) {
const group = groupsRecord[groupId];
if (!group || typeof group !== "object" || Array.isArray(group)) {
continue;
}
const groupRec = group as Record<string, unknown>;
scanList(`${prefix}.groups.${groupId}.allowFrom`, groupRec.allowFrom);
const topics = groupRec.topics;
if (!topics || typeof topics !== "object" || Array.isArray(topics)) {
continue;
}
const topicsRecord = topics as Record<string, unknown>;
for (const topicId of Object.keys(topicsRecord)) {
const topic = topicsRecord[topicId];
if (!topic || typeof topic !== "object" || Array.isArray(topic)) {
continue;
}
scanList(
`${prefix}.groups.${groupId}.topics.${topicId}.allowFrom`,
(topic as Record<string, unknown>).allowFrom,
);
}
}
};
scanAccount("channels.telegram", telegram as unknown as Record<string, unknown>);
const accounts = telegram.accounts;
if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) {
return hits;
}
for (const key of Object.keys(accounts)) {
const account = accounts[key];
if (!account || typeof account !== "object" || Array.isArray(account)) {
continue;
}
scanAccount(`channels.telegram.accounts.${key}`, account as Record<string, unknown>);
}
return hits;
@@ -345,55 +380,13 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
};
const repairAccount = async (prefix: string, account: Record<string, unknown>) => {
await repairList(`${prefix}.allowFrom`, account, "allowFrom");
await repairList(`${prefix}.groupAllowFrom`, account, "groupAllowFrom");
const groups = account.groups;
if (!groups || typeof groups !== "object" || Array.isArray(groups)) {
return;
}
const groupsRecord = groups as Record<string, unknown>;
for (const groupId of Object.keys(groupsRecord)) {
const group = groupsRecord[groupId];
if (!group || typeof group !== "object" || Array.isArray(group)) {
continue;
}
const groupRec = group as Record<string, unknown>;
await repairList(`${prefix}.groups.${groupId}.allowFrom`, groupRec, "allowFrom");
const topics = groupRec.topics;
if (!topics || typeof topics !== "object" || Array.isArray(topics)) {
continue;
}
const topicsRecord = topics as Record<string, unknown>;
for (const topicId of Object.keys(topicsRecord)) {
const topic = topicsRecord[topicId];
if (!topic || typeof topic !== "object" || Array.isArray(topic)) {
continue;
}
await repairList(
`${prefix}.groups.${groupId}.topics.${topicId}.allowFrom`,
topic as Record<string, unknown>,
"allowFrom",
);
}
for (const ref of collectTelegramAllowFromLists(prefix, account)) {
await repairList(ref.pathLabel, ref.holder, ref.key);
}
};
const telegram = next.channels?.telegram;
if (telegram && typeof telegram === "object" && !Array.isArray(telegram)) {
await repairAccount("channels.telegram", telegram as unknown as Record<string, unknown>);
const accounts = (telegram as Record<string, unknown>).accounts;
if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
for (const key of Object.keys(accounts as Record<string, unknown>)) {
const account = (accounts as Record<string, unknown>)[key];
if (!account || typeof account !== "object" || Array.isArray(account)) {
continue;
}
await repairAccount(
`channels.telegram.accounts.${key}`,
account as Record<string, unknown>,
);
}
}
for (const scope of collectTelegramAccountScopes(next)) {
await repairAccount(scope.prefix, scope.account);
}
if (changes.length === 0) {

View File

@@ -35,6 +35,39 @@ function writeJson5(filePath: string, value: unknown) {
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8");
}
async function detectAndRunMigrations(params: {
root: string;
cfg: OpenClawConfig;
now?: () => number;
}) {
const detected = await detectLegacyStateMigrations({
cfg: params.cfg,
env: { OPENCLAW_STATE_DIR: params.root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: params.now });
}
function readSessionsStore(targetDir: string) {
return JSON.parse(fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8")) as Record<
string,
{ sessionId: string }
>;
}
async function runAndReadSessionsStore(params: {
root: string;
cfg: OpenClawConfig;
targetDir: string;
now?: () => number;
}) {
await detectAndRunMigrations({
root: params.root,
cfg: params.cfg,
now: params.now,
});
return readSessionsStore(params.targetDir);
}
describe("doctor legacy state migrations", () => {
it("migrates legacy sessions into agents/<id>/sessions", async () => {
const root = await makeTempRoot();
@@ -236,16 +269,13 @@ describe("doctor legacy state migrations", () => {
"+1555": { sessionId: "a", updatedAt: 10 },
});
const detected = await detectLegacyStateMigrations({
cfg,
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const targetDir = path.join(root, "agents", "alpha", "sessions");
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
const store = await runAndReadSessionsStore({
root,
cfg,
targetDir,
now: () => 123,
});
expect(store["agent:alpha:main"]?.sessionId).toBe("a");
});
@@ -259,16 +289,13 @@ describe("doctor legacy state migrations", () => {
"+1666": { sessionId: "b", updatedAt: 20 },
});
const detected = await detectLegacyStateMigrations({
cfg,
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const targetDir = path.join(root, "agents", "main", "sessions");
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
const store = await runAndReadSessionsStore({
root,
cfg,
targetDir,
now: () => 123,
});
expect(store["agent:main:work"]?.sessionId).toBe("b");
expect(store["agent:main:main"]).toBeUndefined();
});
@@ -282,15 +309,12 @@ describe("doctor legacy state migrations", () => {
"agent:main:main": { sessionId: "fresh", updatedAt: 20 },
});
const detected = await detectLegacyStateMigrations({
const store = await runAndReadSessionsStore({
root,
cfg,
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
targetDir,
now: () => 123,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["main"]).toBeUndefined();
expect(store["agent:main:main"]?.sessionId).toBe("fresh");
});
@@ -304,15 +328,12 @@ describe("doctor legacy state migrations", () => {
"agent:main:work": { sessionId: "canonical", updatedAt: 10 },
});
const detected = await detectLegacyStateMigrations({
const store = await runAndReadSessionsStore({
root,
cfg,
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
targetDir,
now: () => 123,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["agent:main:work"]?.sessionId).toBe("legacy");
expect(store["agent:main:main"]).toBeUndefined();
});
@@ -325,15 +346,12 @@ describe("doctor legacy state migrations", () => {
"agent:main:slack:channel:C123": { sessionId: "legacy", updatedAt: 10 },
});
const detected = await detectLegacyStateMigrations({
const store = await runAndReadSessionsStore({
root,
cfg,
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
targetDir,
now: () => 123,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["agent:main:slack:channel:c123"]?.sessionId).toBe("legacy");
expect(store["agent:main:slack:channel:C123"]).toBeUndefined();
});

View File

@@ -108,17 +108,32 @@ vi.mock("../gateway/probe.js", () => ({
probeGateway: (opts: unknown) => probeGateway(opts),
}));
function createRuntimeCapture() {
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
return { runtime, runtimeLogs, runtimeErrors };
}
async function withUserEnv(user: string, fn: () => Promise<void>) {
const originalUser = process.env.USER;
try {
process.env.USER = user;
await fn();
} finally {
process.env.USER = originalUser;
}
}
describe("gateway-status command", () => {
it("prints human output by default", async () => {
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
@@ -133,15 +148,7 @@ describe("gateway-status command", () => {
});
it("prints a structured JSON envelope when --json is set", async () => {
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
const { gatewayStatusCommand } = await import("./gateway-status.js");
await gatewayStatusCommand(
@@ -160,14 +167,7 @@ describe("gateway-status command", () => {
});
it("supports SSH tunnel targets", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const { runtime, runtimeLogs } = createRuntimeCapture();
startSshPortForward.mockClear();
sshStop.mockClear();
@@ -193,18 +193,8 @@ describe("gateway-status command", () => {
});
it("skips invalid ssh-auto discovery targets", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const originalUser = process.env.USER;
try {
process.env.USER = "steipete";
const { runtime } = createRuntimeCapture();
await withUserEnv("steipete", async () => {
loadConfig.mockReturnValueOnce({
gateway: {
mode: "remote",
@@ -226,24 +216,12 @@ describe("gateway-status command", () => {
expect(startSshPortForward).toHaveBeenCalledTimes(1);
const call = startSshPortForward.mock.calls[0]?.[0] as { target: string };
expect(call.target).toBe("steipete@goodhost");
} finally {
process.env.USER = originalUser;
}
});
});
it("infers SSH target from gateway.remote.url and ssh config", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const originalUser = process.env.USER;
try {
process.env.USER = "steipete";
const { runtime } = createRuntimeCapture();
await withUserEnv("steipete", async () => {
loadConfig.mockReturnValueOnce({
gateway: {
mode: "remote",
@@ -271,24 +249,12 @@ describe("gateway-status command", () => {
};
expect(call.target).toBe("steipete@peters-mac-studio-1.sheep-coho.ts.net:2222");
expect(call.identity).toBe("/tmp/id_ed25519");
} finally {
process.env.USER = originalUser;
}
});
});
it("falls back to host-only when USER is missing and ssh config is unavailable", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const originalUser = process.env.USER;
try {
process.env.USER = "";
const { runtime } = createRuntimeCapture();
await withUserEnv("", async () => {
loadConfig.mockReturnValueOnce({
gateway: {
mode: "remote",
@@ -308,20 +274,11 @@ describe("gateway-status command", () => {
target: string;
};
expect(call.target).toBe("studio.example");
} finally {
process.env.USER = originalUser;
}
});
});
it("keeps explicit SSH identity even when ssh config provides one", async () => {
const runtimeLogs: string[] = [];
const runtime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (_msg: string) => {},
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
const { runtime } = createRuntimeCapture();
loadConfig.mockReturnValueOnce({
gateway: {

View File

@@ -10,6 +10,43 @@ const runtime = {
exit: vi.fn(),
};
const defaultSessions = { path: "/tmp/sessions.json", count: 0, recent: [] };
const createMainAgentSummary = (sessions = defaultSessions) => ({
agentId: "main",
isDefault: true,
heartbeat: {
enabled: true,
every: "1m",
everyMs: 60_000,
prompt: "hi",
target: "last",
ackMaxChars: 160,
},
sessions,
});
const createHealthSummary = (params: {
channels: HealthSummary["channels"];
channelOrder: string[];
channelLabels: HealthSummary["channelLabels"];
sessions?: HealthSummary["sessions"];
}): HealthSummary => {
const sessions = params.sessions ?? defaultSessions;
return {
ok: true,
ts: Date.now(),
durationMs: 5,
channels: params.channels,
channelOrder: params.channelOrder,
channelLabels: params.channelLabels,
heartbeatSeconds: 60,
defaultAgentId: "main",
agents: [createMainAgentSummary(sessions)],
sessions,
};
};
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
@@ -26,10 +63,7 @@ describe("healthCommand", () => {
count: 1,
recent: [{ key: "+1555", updatedAt: Date.now(), age: 0 }],
};
const snapshot: HealthSummary = {
ok: true,
ts: Date.now(),
durationMs: 5,
const snapshot = createHealthSummary({
channels: {
whatsapp: { accountId: "default", linked: true, authAgeMs: 5000 },
telegram: {
@@ -45,25 +79,8 @@ describe("healthCommand", () => {
telegram: "Telegram",
discord: "Discord",
},
heartbeatSeconds: 60,
defaultAgentId: "main",
agents: [
{
agentId: "main",
isDefault: true,
heartbeat: {
enabled: true,
every: "1m",
everyMs: 60_000,
prompt: "hi",
target: "last",
ackMaxChars: 160,
},
sessions: agentSessions,
},
],
sessions: agentSessions,
};
});
callGatewayMock.mockResolvedValueOnce(snapshot);
await healthCommand({ json: true, timeoutMs: 5000 }, runtime as never);
@@ -77,40 +94,21 @@ describe("healthCommand", () => {
});
it("prints text summary when not json", async () => {
callGatewayMock.mockResolvedValueOnce({
ok: true,
ts: Date.now(),
durationMs: 5,
channels: {
whatsapp: { accountId: "default", linked: false, authAgeMs: null },
telegram: { accountId: "default", configured: false },
discord: { accountId: "default", configured: false },
},
channelOrder: ["whatsapp", "telegram", "discord"],
channelLabels: {
whatsapp: "WhatsApp",
telegram: "Telegram",
discord: "Discord",
},
heartbeatSeconds: 60,
defaultAgentId: "main",
agents: [
{
agentId: "main",
isDefault: true,
heartbeat: {
enabled: true,
every: "1m",
everyMs: 60_000,
prompt: "hi",
target: "last",
ackMaxChars: 160,
},
sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
callGatewayMock.mockResolvedValueOnce(
createHealthSummary({
channels: {
whatsapp: { accountId: "default", linked: false, authAgeMs: null },
telegram: { accountId: "default", configured: false },
discord: { accountId: "default", configured: false },
},
],
sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
} satisfies HealthSummary);
channelOrder: ["whatsapp", "telegram", "discord"],
channelLabels: {
whatsapp: "WhatsApp",
telegram: "Telegram",
discord: "Discord",
},
}),
);
await healthCommand({ json: false }, runtime as never);
@@ -119,10 +117,7 @@ describe("healthCommand", () => {
});
it("formats per-account probe timings", () => {
const summary: HealthSummary = {
ok: true,
ts: Date.now(),
durationMs: 5,
const summary = createHealthSummary({
channels: {
telegram: {
accountId: "main",
@@ -149,25 +144,7 @@ describe("healthCommand", () => {
},
channelOrder: ["telegram"],
channelLabels: { telegram: "Telegram" },
heartbeatSeconds: 60,
defaultAgentId: "main",
agents: [
{
agentId: "main",
isDefault: true,
heartbeat: {
enabled: true,
every: "1m",
everyMs: 60_000,
prompt: "hi",
target: "last",
ackMaxChars: 160,
},
sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
},
],
sessions: { path: "/tmp/sessions.json", count: 0, recent: [] },
};
});
const lines = formatHealthChannelLines(summary, { accountMode: "all" });
expect(lines).toContain(

View File

@@ -117,26 +117,47 @@ const createStubPlugin = (params: {
outbound: params.outbound,
});
const createDiscordPollPluginRegistration = () => ({
pluginId: "discord",
source: "test",
plugin: createStubPlugin({
id: "discord",
label: "Discord",
actions: {
listActions: () => ["poll"],
handleAction: async ({ action, params, cfg, accountId }) =>
await handleDiscordAction(
{ action, to: params.to, accountId: accountId ?? undefined },
cfg,
),
},
}),
});
const createTelegramSendPluginRegistration = () => ({
pluginId: "telegram",
source: "test",
plugin: createStubPlugin({
id: "telegram",
label: "Telegram",
actions: {
listActions: () => ["send"],
handleAction: async ({ action, params, cfg, accountId }) =>
await handleTelegramAction(
{ action, to: params.to, accountId: accountId ?? undefined },
cfg,
),
},
}),
});
describe("messageCommand", () => {
it("defaults channel when only one configured", async () => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
await setRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: createStubPlugin({
id: "telegram",
label: "Telegram",
actions: {
listActions: () => ["send"],
handleAction: async ({ action, params, cfg, accountId }) =>
await handleTelegramAction(
{ action, to: params.to, accountId: accountId ?? undefined },
cfg,
),
},
}),
...createTelegramSendPluginRegistration(),
},
]),
);
@@ -159,36 +180,10 @@ describe("messageCommand", () => {
await setRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: createStubPlugin({
id: "telegram",
label: "Telegram",
actions: {
listActions: () => ["send"],
handleAction: async ({ action, params, cfg, accountId }) =>
await handleTelegramAction(
{ action, to: params.to, accountId: accountId ?? undefined },
cfg,
),
},
}),
...createTelegramSendPluginRegistration(),
},
{
pluginId: "discord",
source: "test",
plugin: createStubPlugin({
id: "discord",
label: "Discord",
actions: {
listActions: () => ["poll"],
handleAction: async ({ action, params, cfg, accountId }) =>
await handleDiscordAction(
{ action, to: params.to, accountId: accountId ?? undefined },
cfg,
),
},
}),
...createDiscordPollPluginRegistration(),
},
]),
);
@@ -242,20 +237,7 @@ describe("messageCommand", () => {
await setRegistry(
createTestRegistry([
{
pluginId: "discord",
source: "test",
plugin: createStubPlugin({
id: "discord",
label: "Discord",
actions: {
listActions: () => ["poll"],
handleAction: async ({ action, params, cfg, accountId }) =>
await handleDiscordAction(
{ action, to: params.to, accountId: accountId ?? undefined },
cfg,
),
},
}),
...createDiscordPollPluginRegistration(),
},
]),
);

View File

@@ -123,6 +123,16 @@ describe("models list/status", () => {
baseUrl: "https://api.openai.com/v1",
contextWindow: 128000,
};
const GOOGLE_ANTIGRAVITY_TEMPLATE_BASE = {
provider: "google-antigravity",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
};
function setDefaultModel(model: string) {
loadConfig.mockReturnValue({
@@ -130,11 +140,68 @@ describe("models list/status", () => {
});
}
function configureModelAsConfigured(model: string) {
loadConfig.mockReturnValue({
agents: {
defaults: {
model,
models: {
[model]: {},
},
},
},
});
}
function configureGoogleAntigravityModel(modelId: string) {
configureModelAsConfigured(`google-antigravity/${modelId}`);
}
function makeGoogleAntigravityTemplate(id: string, name: string) {
return {
...GOOGLE_ANTIGRAVITY_TEMPLATE_BASE,
id,
name,
};
}
function enableGoogleAntigravityAuthProfile() {
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
}
function parseJsonLog(runtime: ReturnType<typeof makeRuntime>) {
expect(runtime.log).toHaveBeenCalledTimes(1);
return JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
}
async function runAvailabilityFallbackCase(params: {
setup?: () => void;
expectedErrorDetail: string;
}) {
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
enableGoogleAntigravityAuthProfile();
const runtime = makeRuntime();
modelRegistryState.models = [
makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"),
];
modelRegistryState.available = [];
params.setup?.();
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics");
expect(runtime.error.mock.calls[0]?.[0]).toContain(params.expectedErrorDetail);
const payload = parseJsonLog(runtime);
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
}
async function expectZaiProviderFilter(provider: string) {
setDefaultModel("z.ai/glm-4.7");
const runtime = makeRuntime();
@@ -153,49 +220,54 @@ describe("models list/status", () => {
({ modelsListCommand } = await import("./models/list.list-command.js"));
});
it("models list outputs canonical zai key for configured z.ai model", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
modelRegistryState.models = [ZAI_MODEL];
modelRegistryState.available = [ZAI_MODEL];
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("zai/glm-4.7");
});
it("models list plain outputs canonical zai key", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const model = {
provider: "zai",
id: "glm-4.7",
name: "GLM-4.7",
input: ["text"],
baseUrl: "https://api.z.ai/v1",
contextWindow: 128000,
};
modelRegistryState.models = [model];
modelRegistryState.available = [model];
modelRegistryState.models = [ZAI_MODEL];
modelRegistryState.available = [ZAI_MODEL];
await modelsListCommand({ plain: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
expect(runtime.log.mock.calls[0]?.[0]).toBe("zai/glm-4.7");
});
it("models list provider filter normalizes z.ai alias", async () => {
await expectZaiProviderFilter("z.ai");
});
it("models list provider filter normalizes Z.AI alias casing", async () => {
await expectZaiProviderFilter("Z.AI");
});
it("models list provider filter normalizes z-ai alias", async () => {
await expectZaiProviderFilter("z-ai");
});
it("models list marks auth as unavailable when ZAI key is missing", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
const runtime = makeRuntime();
const model = {
provider: "zai",
id: "glm-4.7",
name: "GLM-4.7",
input: ["text"],
baseUrl: "https://api.z.ai/v1",
contextWindow: 128000,
};
modelRegistryState.models = [model];
modelRegistryState.models = [ZAI_MODEL];
modelRegistryState.available = [];
await modelsListCommand({ all: true, json: true }, runtime);
@@ -205,31 +277,11 @@ describe("models list/status", () => {
});
it("models list resolves antigravity opus 4.6 thinking from 4.5 template", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"),
];
modelRegistryState.available = [];
await modelsListCommand({ json: true }, runtime);
@@ -243,31 +295,11 @@ describe("models list/status", () => {
});
it("models list resolves antigravity opus 4.6 (non-thinking) from 4.5 template", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6",
models: {
"google-antigravity/claude-opus-4-6": {},
},
},
},
});
configureGoogleAntigravityModel("claude-opus-4-6");
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
makeGoogleAntigravityTemplate("claude-opus-4-5", "Claude Opus 4.5"),
];
modelRegistryState.available = [];
await modelsListCommand({ json: true }, runtime);
@@ -281,30 +313,13 @@ describe("models list/status", () => {
});
it("models list marks synthesized antigravity opus 4.6 thinking as available when template is available", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
const runtime = makeRuntime();
const template = {
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
};
const template = makeGoogleAntigravityTemplate(
"claude-opus-4-5-thinking",
"Claude Opus 4.5 Thinking",
);
modelRegistryState.models = [template];
modelRegistryState.available = [template];
await modelsListCommand({ json: true }, runtime);
@@ -316,36 +331,31 @@ describe("models list/status", () => {
expect(payload.models[0]?.available).toBe(true);
});
it("models list prefers registry availability over provider auth heuristics", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => {
configureGoogleAntigravityModel("claude-opus-4-6");
const runtime = makeRuntime();
const template = {
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
};
const template = makeGoogleAntigravityTemplate("claude-opus-4-5", "Claude Opus 4.5");
modelRegistryState.models = [template];
modelRegistryState.available = [template];
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
});
it("models list prefers registry availability over provider auth heuristics", async () => {
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
enableGoogleAntigravityAuthProfile();
const runtime = makeRuntime();
const template = makeGoogleAntigravityTemplate(
"claude-opus-4-5-thinking",
"Claude Opus 4.5 Thinking",
);
modelRegistryState.models = [template];
modelRegistryState.available = [];
await modelsListCommand({ json: true }, runtime);
@@ -358,112 +368,40 @@ describe("models list/status", () => {
listProfilesForProvider.mockReturnValue([]);
});
it("models list falls back to auth heuristics when getAvailable returns invalid shape", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
it("models list falls back to auth heuristics when registry availability is unavailable", async () => {
await runAvailabilityFallbackCase({
setup: () => {
modelRegistryState.getAvailableError = Object.assign(
new Error("availability unsupported: getAvailable failed"),
{ code: "MODEL_AVAILABILITY_UNAVAILABLE" },
);
},
expectedErrorDetail: "getAvailable failed",
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
modelRegistryState.available = { bad: true } as unknown as Array<Record<string, unknown>>;
const runtime = makeRuntime();
});
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
it("models list falls back to auth heuristics when getAvailable returns invalid shape", async () => {
await runAvailabilityFallbackCase({
setup: () => {
modelRegistryState.available = { bad: true } as unknown as Array<Record<string, unknown>>;
},
];
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics");
expect(runtime.error.mock.calls[0]?.[0]).toContain("non-array value");
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
expectedErrorDetail: "non-array value",
});
});
it("models list falls back to auth heuristics when getAvailable throws", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
await runAvailabilityFallbackCase({
setup: () => {
modelRegistryState.getAvailableError = new Error(
"availability unsupported: getAvailable failed",
);
},
expectedErrorDetail: "availability unsupported: getAvailable failed",
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
modelRegistryState.getAvailableError = new Error(
"availability unsupported: getAvailable failed",
);
const runtime = makeRuntime();
modelRegistryState.models = [
{
provider: "google-antigravity",
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
api: "google-gemini-cli",
input: ["text", "image"],
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
contextWindow: 200000,
maxTokens: 64000,
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
];
modelRegistryState.available = [];
await modelsListCommand({ json: true }, runtime);
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("falling back to auth heuristics");
expect(runtime.error.mock.calls[0]?.[0]).toContain(
"availability unsupported: getAvailable failed",
);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
});
it("models list does not treat availability-unavailable code as discovery fallback", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
modelRegistryState.getAllError = Object.assign(new Error("model discovery failed"), {
code: "MODEL_AVAILABILITY_UNAVAILABLE",
});
@@ -479,21 +417,8 @@ describe("models list/status", () => {
});
it("models list fails fast when registry model discovery is unavailable", async () => {
loadConfig.mockReturnValue({
agents: {
defaults: {
model: "google-antigravity/claude-opus-4-6-thinking",
models: {
"google-antigravity/claude-opus-4-6-thinking": {},
},
},
},
});
listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "google-antigravity"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
enableGoogleAntigravityAuthProfile();
modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), {
code: "MODEL_DISCOVERY_UNAVAILABLE",
});
@@ -510,6 +435,18 @@ describe("models list/status", () => {
expect(process.exitCode).toBe(1);
});
it("loadModelRegistry throws when model discovery is unavailable", async () => {
modelRegistryState.getAllError = Object.assign(new Error("model discovery unavailable"), {
code: "MODEL_DISCOVERY_UNAVAILABLE",
});
modelRegistryState.available = [
makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"),
];
const { loadModelRegistry } = await import("./models/list.registry.js");
await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable");
});
it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => {
const { toModelRow } = await import("./models/list.registry.js");

View File

@@ -11,6 +11,27 @@ vi.mock("../config/config.js", () => ({
loadConfig,
}));
function mockConfigSnapshot(config: Record<string, unknown> = {}) {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config,
issues: [],
legacyIssues: [],
});
}
function makeRuntime() {
return { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
}
function getWrittenConfig() {
return writeConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
}
describe("models set + fallbacks", () => {
beforeEach(() => {
readConfigFileSnapshot.mockReset();
@@ -18,24 +39,14 @@ describe("models set + fallbacks", () => {
});
it("normalizes z.ai provider in models set", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {},
issues: [],
legacyIssues: [],
});
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
mockConfigSnapshot({});
const runtime = makeRuntime();
const { modelsSetCommand } = await import("./models/set.js");
await modelsSetCommand("z.ai/glm-4.7", runtime);
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
const written = getWrittenConfig();
expect(written.agents).toEqual({
defaults: {
model: { primary: "zai/glm-4.7" },
@@ -45,24 +56,14 @@ describe("models set + fallbacks", () => {
});
it("normalizes z-ai provider in models fallbacks add", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: { agents: { defaults: { model: { fallbacks: [] } } } },
issues: [],
legacyIssues: [],
});
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
mockConfigSnapshot({ agents: { defaults: { model: { fallbacks: [] } } } });
const runtime = makeRuntime();
const { modelsFallbacksAddCommand } = await import("./models/fallbacks.js");
await modelsFallbacksAddCommand("z-ai/glm-4.7", runtime);
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
const written = getWrittenConfig();
expect(written.agents).toEqual({
defaults: {
model: { fallbacks: ["zai/glm-4.7"] },
@@ -72,24 +73,14 @@ describe("models set + fallbacks", () => {
});
it("normalizes provider casing in models set", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {},
issues: [],
legacyIssues: [],
});
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
mockConfigSnapshot({});
const runtime = makeRuntime();
const { modelsSetCommand } = await import("./models/set.js");
await modelsSetCommand("Z.AI/glm-4.7", runtime);
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
const written = getWrittenConfig();
expect(written.agents).toEqual({
defaults: {
model: { primary: "zai/glm-4.7" },

View File

@@ -1,6 +1,9 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ModelProviderConfig } from "../config/types.models.js";
import { applyOnboardAuthAgentModelsAndProviders } from "./onboard-auth.config-shared.js";
import {
applyAgentDefaultModelPrimary,
applyOnboardAuthAgentModelsAndProviders,
} from "./onboard-auth.config-shared.js";
import {
buildMinimaxApiModelDefinition,
buildMinimaxModelDefinition,
@@ -82,24 +85,7 @@ export function applyMinimaxHostedProviderConfig(
export function applyMinimaxConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyMinimaxProviderConfig(cfg);
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(next.agents?.defaults?.model &&
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
? {
fallbacks: (next.agents.defaults.model as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: "lmstudio/minimax-m2.1-gs32",
},
},
},
};
return applyAgentDefaultModelPrimary(next, "lmstudio/minimax-m2.1-gs32");
}
export function applyMinimaxHostedConfig(
@@ -223,22 +209,5 @@ function applyMinimaxApiConfigWithBaseUrl(
params: MinimaxApiProviderConfigParams,
): OpenClawConfig {
const next = applyMinimaxApiProviderConfigWithBaseUrl(cfg, params);
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(next.agents?.defaults?.model &&
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
? {
fallbacks: (next.agents.defaults.model as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: `${params.providerId}/${params.modelId}`,
},
},
},
};
return applyAgentDefaultModelPrimary(next, `${params.providerId}/${params.modelId}`);
}

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/config.js";
import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js";
import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js";
export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
// Use the built-in opencode provider from pi-ai; only seed the allowlist alias.
@@ -23,22 +24,5 @@ export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawCon
export function applyOpencodeZenConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyOpencodeZenProviderConfig(cfg);
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(next.agents?.defaults?.model &&
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
? {
fallbacks: (next.agents.defaults.model as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: OPENCODE_ZEN_DEFAULT_MODEL_REF,
},
},
},
};
return applyAgentDefaultModelPrimary(next, OPENCODE_ZEN_DEFAULT_MODEL_REF);
}

View File

@@ -89,20 +89,13 @@ export function applyProviderConfigWithDefaultModels(
? existingModels
: [...existingModels, ...defaultModels]
: defaultModels;
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as {
apiKey?: string;
};
const normalizedApiKey = typeof existingApiKey === "string" ? existingApiKey.trim() : undefined;
providers[params.providerId] = {
...existingProviderRest,
baseUrl: params.baseUrl,
providers[params.providerId] = buildProviderConfig({
existingProvider,
api: params.api,
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : defaultModels,
};
baseUrl: params.baseUrl,
mergedModels,
fallbackModels: defaultModels,
});
return applyOnboardAuthAgentModelsAndProviders(cfg, {
agentModels: params.agentModels,
@@ -157,23 +150,37 @@ export function applyProviderConfigWithModelCatalog(
),
]
: catalogModels;
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as {
apiKey?: string;
};
const normalizedApiKey = typeof existingApiKey === "string" ? existingApiKey.trim() : undefined;
providers[params.providerId] = {
...existingProviderRest,
baseUrl: params.baseUrl,
providers[params.providerId] = buildProviderConfig({
existingProvider,
api: params.api,
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : catalogModels,
};
baseUrl: params.baseUrl,
mergedModels,
fallbackModels: catalogModels,
});
return applyOnboardAuthAgentModelsAndProviders(cfg, {
agentModels: params.agentModels,
providers,
});
}
function buildProviderConfig(params: {
existingProvider: ModelProviderConfig | undefined;
api: ModelApi;
baseUrl: string;
mergedModels: ModelDefinitionConfig[];
fallbackModels: ModelDefinitionConfig[];
}): ModelProviderConfig {
const { apiKey: existingApiKey, ...existingProviderRest } = (params.existingProvider ?? {}) as {
apiKey?: string;
};
const normalizedApiKey = typeof existingApiKey === "string" ? existingApiKey.trim() : undefined;
return {
...existingProviderRest,
baseUrl: params.baseUrl,
api: params.api,
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: params.mergedModels.length > 0 ? params.mergedModels : params.fallbackModels,
};
}

View File

@@ -40,6 +40,36 @@ const requireAgentDir = () => {
return agentDir;
};
function createLegacyProviderConfig(params: {
providerId: string;
api: string;
modelId?: string;
modelName?: string;
}) {
return {
models: {
providers: {
[params.providerId]: {
baseUrl: "https://old.example.com",
apiKey: "old-key",
api: params.api,
models: [
{
id: params.modelId ?? "old-model",
name: params.modelName ?? "Old",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
],
},
},
},
};
}
describe("writeOAuthCredentials", () => {
const envSnapshot = captureEnv([
"OPENCLAW_STATE_DIR",
@@ -209,28 +239,12 @@ describe("applyMinimaxApiConfig", () => {
});
it("merges existing minimax provider models", () => {
const cfg = applyMinimaxApiConfig({
models: {
providers: {
minimax: {
baseUrl: "https://old.example.com",
apiKey: "old-key",
api: "openai-completions",
models: [
{
id: "old-model",
name: "Old",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
],
},
},
},
});
const cfg = applyMinimaxApiConfig(
createLegacyProviderConfig({
providerId: "minimax",
api: "openai-completions",
}),
);
expect(cfg.models?.providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
expect(cfg.models?.providers?.minimax?.api).toBe("anthropic-messages");
expect(cfg.models?.providers?.minimax?.apiKey).toBe("old-key");
@@ -341,28 +355,12 @@ describe("applySyntheticConfig", () => {
});
it("merges existing synthetic provider models", () => {
const cfg = applySyntheticProviderConfig({
models: {
providers: {
synthetic: {
baseUrl: "https://old.example.com",
apiKey: "old-key",
api: "openai-completions",
models: [
{
id: "old-model",
name: "Old",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
],
},
},
},
});
const cfg = applySyntheticProviderConfig(
createLegacyProviderConfig({
providerId: "synthetic",
api: "openai-completions",
}),
);
expect(cfg.models?.providers?.synthetic?.baseUrl).toBe("https://api.synthetic.new/anthropic");
expect(cfg.models?.providers?.synthetic?.api).toBe("anthropic-messages");
expect(cfg.models?.providers?.synthetic?.apiKey).toBe("old-key");
@@ -383,28 +381,14 @@ describe("applyXiaomiConfig", () => {
});
it("merges Xiaomi models and keeps existing provider overrides", () => {
const cfg = applyXiaomiProviderConfig({
models: {
providers: {
xiaomi: {
baseUrl: "https://old.example.com",
apiKey: "old-key",
api: "openai-completions",
models: [
{
id: "custom-model",
name: "Custom",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
],
},
},
},
});
const cfg = applyXiaomiProviderConfig(
createLegacyProviderConfig({
providerId: "xiaomi",
api: "openai-completions",
modelId: "custom-model",
modelName: "Custom",
}),
);
expect(cfg.models?.providers?.xiaomi?.baseUrl).toBe("https://api.xiaomimimo.com/anthropic");
expect(cfg.models?.providers?.xiaomi?.api).toBe("anthropic-messages");
@@ -445,28 +429,14 @@ describe("applyXaiProviderConfig", () => {
});
it("merges xAI models and keeps existing provider overrides", () => {
const cfg = applyXaiProviderConfig({
models: {
providers: {
xai: {
baseUrl: "https://old.example.com",
apiKey: "old-key",
api: "anthropic-messages",
models: [
{
id: "custom-model",
name: "Custom",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
],
},
},
},
});
const cfg = applyXaiProviderConfig(
createLegacyProviderConfig({
providerId: "xai",
api: "anthropic-messages",
modelId: "custom-model",
modelName: "Custom",
}),
);
expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1");
expect(cfg.models?.providers?.xai?.api).toBe("openai-completions");

View File

@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { discordPlugin } from "../../extensions/discord/src/channel.js";
import { imessagePlugin } from "../../extensions/imessage/src/channel.js";
@@ -11,31 +10,16 @@ import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { setupChannels } from "./onboard-channels.js";
const noopAsync = async () => {};
function createRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
}
import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js";
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select: vi.fn(async () => "__done__" as never),
multiselect: vi.fn(async () => []),
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
...overrides,
};
return createWizardPrompter(
{
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
...overrides,
},
{ defaultSelect: "__done__" },
);
}
vi.mock("node:fs/promises", () => ({
@@ -88,7 +72,7 @@ describe("setupChannels", () => {
text: text as unknown as WizardPrompter["text"],
});
const runtime = createRuntime();
const runtime = createExitThrowingRuntime();
await setupChannels({} as OpenClawConfig, runtime, prompter, {
skipConfirm: true,
@@ -119,7 +103,7 @@ describe("setupChannels", () => {
text: text as unknown as WizardPrompter["text"],
});
const runtime = createRuntime();
const runtime = createExitThrowingRuntime();
await setupChannels({} as OpenClawConfig, runtime, prompter, {
skipConfirm: true,
@@ -157,7 +141,7 @@ describe("setupChannels", () => {
text: text as unknown as WizardPrompter["text"],
});
const runtime = createRuntime();
const runtime = createExitThrowingRuntime();
await setupChannels(
{
@@ -209,7 +193,7 @@ describe("setupChannels", () => {
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
});
const runtime = createRuntime();
const runtime = createExitThrowingRuntime();
await setupChannels(
{

View File

@@ -11,6 +11,59 @@ vi.mock("./model-picker.js", () => ({
applyPrimaryModel: vi.fn((cfg) => cfg),
}));
function createTestPrompter(params: { text: string[]; select?: string[] }): {
text: ReturnType<typeof vi.fn>;
select: ReturnType<typeof vi.fn>;
confirm: ReturnType<typeof vi.fn>;
note: ReturnType<typeof vi.fn>;
progress: ReturnType<typeof vi.fn>;
} {
const text = vi.fn();
for (const answer of params.text) {
text.mockResolvedValueOnce(answer);
}
const select = vi.fn();
for (const answer of params.select ?? []) {
select.mockResolvedValueOnce(answer);
}
return {
text,
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select,
confirm: vi.fn(),
note: vi.fn(),
};
}
function stubFetchSequence(
responses: Array<{ ok: boolean; status?: number }>,
): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn();
for (const response of responses) {
fetchMock.mockResolvedValueOnce({
ok: response.ok,
status: response.status,
json: async () => ({}),
});
}
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
async function runPromptCustomApi(
prompter: ReturnType<typeof createTestPrompter>,
config: object = {},
) {
return promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config,
});
}
describe("promptCustomApiConfig", () => {
afterEach(() => {
vi.unstubAllGlobals();
@@ -18,36 +71,12 @@ describe("promptCustomApiConfig", () => {
});
it("handles openai flow and saves alias", async () => {
const prompter = {
text: vi
.fn()
.mockResolvedValueOnce("http://localhost:11434/v1") // Base URL
.mockResolvedValueOnce("") // API Key
.mockResolvedValueOnce("llama3") // Model ID
.mockResolvedValueOnce("custom") // Endpoint ID
.mockResolvedValueOnce("local"), // Alias
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select: vi.fn().mockResolvedValueOnce("openai"), // Compatibility
confirm: vi.fn(),
note: vi.fn(),
};
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({}),
}),
);
const result = await promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config: {},
const prompter = createTestPrompter({
text: ["http://localhost:11434/v1", "", "llama3", "custom", "local"],
select: ["openai"],
});
stubFetchSequence([{ ok: true }]);
const result = await runPromptCustomApi(prompter);
expect(prompter.text).toHaveBeenCalledTimes(5);
expect(prompter.select).toHaveBeenCalledTimes(1);
@@ -56,76 +85,24 @@ describe("promptCustomApiConfig", () => {
});
it("retries when verification fails", async () => {
const prompter = {
text: vi
.fn()
.mockResolvedValueOnce("http://localhost:11434/v1") // Base URL
.mockResolvedValueOnce("") // API Key
.mockResolvedValueOnce("bad-model") // Model ID
.mockResolvedValueOnce("good-model") // Model ID retry
.mockResolvedValueOnce("custom") // Endpoint ID
.mockResolvedValueOnce(""), // Alias
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select: vi
.fn()
.mockResolvedValueOnce("openai") // Compatibility
.mockResolvedValueOnce("model"), // Retry choice
confirm: vi.fn(),
note: vi.fn(),
};
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({}) })
.mockResolvedValueOnce({ ok: true, json: async () => ({}) }),
);
await promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config: {},
const prompter = createTestPrompter({
text: ["http://localhost:11434/v1", "", "bad-model", "good-model", "custom", ""],
select: ["openai", "model"],
});
stubFetchSequence([{ ok: false, status: 400 }, { ok: true }]);
await runPromptCustomApi(prompter);
expect(prompter.text).toHaveBeenCalledTimes(6);
expect(prompter.select).toHaveBeenCalledTimes(2);
});
it("detects openai compatibility when unknown", async () => {
const prompter = {
text: vi
.fn()
.mockResolvedValueOnce("https://example.com/v1") // Base URL
.mockResolvedValueOnce("test-key") // API Key
.mockResolvedValueOnce("detected-model") // Model ID
.mockResolvedValueOnce("custom") // Endpoint ID
.mockResolvedValueOnce("alias"), // Alias
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select: vi.fn().mockResolvedValueOnce("unknown"),
confirm: vi.fn(),
note: vi.fn(),
};
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({}),
}),
);
const result = await promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config: {},
const prompter = createTestPrompter({
text: ["https://example.com/v1", "test-key", "detected-model", "custom", "alias"],
select: ["unknown"],
});
stubFetchSequence([{ ok: true }]);
const result = await runPromptCustomApi(prompter);
expect(prompter.text).toHaveBeenCalledTimes(5);
expect(prompter.select).toHaveBeenCalledTimes(1);
@@ -133,39 +110,20 @@ describe("promptCustomApiConfig", () => {
});
it("re-prompts base url when unknown detection fails", async () => {
const prompter = {
text: vi
.fn()
.mockResolvedValueOnce("https://bad.example.com/v1") // Base URL #1
.mockResolvedValueOnce("bad-key") // API Key #1
.mockResolvedValueOnce("bad-model") // Model ID #1
.mockResolvedValueOnce("https://ok.example.com/v1") // Base URL #2
.mockResolvedValueOnce("ok-key") // API Key #2
.mockResolvedValueOnce("custom") // Endpoint ID
.mockResolvedValueOnce(""), // Alias
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select: vi.fn().mockResolvedValueOnce("unknown").mockResolvedValueOnce("baseUrl"),
confirm: vi.fn(),
note: vi.fn(),
};
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) })
.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) })
.mockResolvedValueOnce({ ok: true, json: async () => ({}) }),
);
await promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config: {},
const prompter = createTestPrompter({
text: [
"https://bad.example.com/v1",
"bad-key",
"bad-model",
"https://ok.example.com/v1",
"ok-key",
"custom",
"",
],
select: ["unknown", "baseUrl"],
});
stubFetchSequence([{ ok: false, status: 404 }, { ok: false, status: 404 }, { ok: true }]);
await runPromptCustomApi(prompter);
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("did not respond"),
@@ -174,52 +132,28 @@ describe("promptCustomApiConfig", () => {
});
it("renames provider id when baseUrl differs", async () => {
const prompter = {
text: vi
.fn()
.mockResolvedValueOnce("http://localhost:11434/v1") // Base URL
.mockResolvedValueOnce("") // API Key
.mockResolvedValueOnce("llama3") // Model ID
.mockResolvedValueOnce("custom") // Endpoint ID
.mockResolvedValueOnce(""), // Alias
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select: vi.fn().mockResolvedValueOnce("openai"),
confirm: vi.fn(),
note: vi.fn(),
};
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({}),
}),
);
const result = await promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config: {
models: {
providers: {
custom: {
baseUrl: "http://old.example.com/v1",
api: "openai-completions",
models: [
{
id: "old-model",
name: "Old",
contextWindow: 1,
maxTokens: 1,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
reasoning: false,
},
],
},
const prompter = createTestPrompter({
text: ["http://localhost:11434/v1", "", "llama3", "custom", ""],
select: ["openai"],
});
stubFetchSequence([{ ok: true }]);
const result = await runPromptCustomApi(prompter, {
models: {
providers: {
custom: {
baseUrl: "http://old.example.com/v1",
api: "openai-completions",
models: [
{
id: "old-model",
name: "Old",
contextWindow: 1,
maxTokens: 1,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
reasoning: false,
},
],
},
},
},
@@ -232,23 +166,10 @@ describe("promptCustomApiConfig", () => {
it("aborts verification after timeout", async () => {
vi.useFakeTimers();
const prompter = {
text: vi
.fn()
.mockResolvedValueOnce("http://localhost:11434/v1") // Base URL
.mockResolvedValueOnce("") // API Key
.mockResolvedValueOnce("slow-model") // Model ID
.mockResolvedValueOnce("fast-model") // Model ID retry
.mockResolvedValueOnce("custom") // Endpoint ID
.mockResolvedValueOnce(""), // Alias
progress: vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
})),
select: vi.fn().mockResolvedValueOnce("openai").mockResolvedValueOnce("model"),
confirm: vi.fn(),
note: vi.fn(),
};
const prompter = createTestPrompter({
text: ["http://localhost:11434/v1", "", "slow-model", "fast-model", "custom", ""],
select: ["openai", "model"],
});
const fetchMock = vi
.fn()
@@ -260,11 +181,7 @@ describe("promptCustomApiConfig", () => {
.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
vi.stubGlobal("fetch", fetchMock);
const promise = promptCustomApiConfig({
prompter: prompter as unknown as Parameters<typeof promptCustomApiConfig>[0]["prompter"],
runtime: { ...defaultRuntime, log: vi.fn() },
config: {},
});
const promise = runPromptCustomApi(prompter);
await vi.advanceTimersByTimeAsync(10000);
await promise;

View File

@@ -40,76 +40,75 @@ describe("onboard-hooks", () => {
exit: vi.fn(),
});
const createMockHook = (
params: {
name: string;
description: string;
filePath: string;
baseDir: string;
handlerPath: string;
hookKey: string;
emoji: string;
events: string[];
},
eligible: boolean,
) => ({
...params,
source: "openclaw-bundled" as const,
pluginId: undefined,
homepage: undefined,
always: false,
disabled: false,
eligible,
managedByPlugin: false,
requirements: {
bins: [],
anyBins: [],
env: [],
config: ["workspace.dir"],
os: [],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: eligible ? [] : ["workspace.dir"],
os: [],
},
configChecks: [],
install: [],
});
const createMockHookReport = (eligible = true): HookStatusReport => ({
workspaceDir: "/mock/workspace",
managedHooksDir: "/mock/.openclaw/hooks",
hooks: [
{
name: "session-memory",
description: "Save session context to memory when /new command is issued",
source: "openclaw-bundled",
pluginId: undefined,
filePath: "/mock/workspace/hooks/session-memory/HOOK.md",
baseDir: "/mock/workspace/hooks/session-memory",
handlerPath: "/mock/workspace/hooks/session-memory/handler.js",
hookKey: "session-memory",
emoji: "💾",
events: ["command:new"],
homepage: undefined,
always: false,
disabled: false,
createMockHook(
{
name: "session-memory",
description: "Save session context to memory when /new command is issued",
filePath: "/mock/workspace/hooks/session-memory/HOOK.md",
baseDir: "/mock/workspace/hooks/session-memory",
handlerPath: "/mock/workspace/hooks/session-memory/handler.js",
hookKey: "session-memory",
emoji: "💾",
events: ["command:new"],
},
eligible,
managedByPlugin: false,
requirements: {
bins: [],
anyBins: [],
env: [],
config: ["workspace.dir"],
os: [],
),
createMockHook(
{
name: "command-logger",
description: "Log all command events to a centralized audit file",
filePath: "/mock/workspace/hooks/command-logger/HOOK.md",
baseDir: "/mock/workspace/hooks/command-logger",
handlerPath: "/mock/workspace/hooks/command-logger/handler.js",
hookKey: "command-logger",
emoji: "📝",
events: ["command"],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: eligible ? [] : ["workspace.dir"],
os: [],
},
configChecks: [],
install: [],
},
{
name: "command-logger",
description: "Log all command events to a centralized audit file",
source: "openclaw-bundled",
pluginId: undefined,
filePath: "/mock/workspace/hooks/command-logger/HOOK.md",
baseDir: "/mock/workspace/hooks/command-logger",
handlerPath: "/mock/workspace/hooks/command-logger/handler.js",
hookKey: "command-logger",
emoji: "📝",
events: ["command"],
homepage: undefined,
always: false,
disabled: false,
eligible,
managedByPlugin: false,
requirements: {
bins: [],
anyBins: [],
env: [],
config: ["workspace.dir"],
os: [],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: eligible ? [] : ["workspace.dir"],
os: [],
},
configChecks: [],
install: [],
},
),
],
});

View File

@@ -1,9 +1,8 @@
import fs from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { getDeterministicFreePortBlock } from "../test-utils/ports.js";
import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js";
const gatewayClientCalls: Array<{
url?: string;
@@ -41,49 +40,17 @@ vi.mock("../gateway/client.js", () => ({
}));
async function getFreePort(): Promise<number> {
try {
return await new Promise((resolve, reject) => {
const srv = createServer();
srv.on("error", (err) => {
srv.close();
reject(err);
});
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
if (!addr || typeof addr === "string") {
srv.close();
reject(new Error("failed to acquire free port"));
return;
}
const port = addr.port;
srv.close((err) => {
if (err) {
reject(err);
} else {
resolve(port);
}
});
});
});
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code === "EPERM" || code === "EACCES") {
return 30_000 + (process.pid % 10_000);
}
throw err;
}
return await getFreePortBlockWithPermissionFallback({
offsets: [0],
fallbackBase: 30_000,
});
}
async function getFreeGatewayPort(): Promise<number> {
try {
return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 4] });
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code === "EPERM" || code === "EACCES") {
return 40_000 + (process.pid % 10_000);
}
throw err;
}
return await getFreePortBlockWithPermissionFallback({
offsets: [0, 1, 2, 4],
fallbackBase: 40_000,
});
}
const runtime = {
@@ -96,6 +63,19 @@ const runtime = {
},
};
async function expectGatewayTokenAuth(params: {
authConfig: unknown;
token: string;
env: NodeJS.ProcessEnv;
}) {
const { authorizeGatewayConnect, resolveGatewayAuth } = await import("../gateway/auth.js");
const auth = resolveGatewayAuth({ authConfig: params.authConfig, env: params.env });
const resNoToken = await authorizeGatewayConnect({ auth, connectAuth: { token: undefined } });
expect(resNoToken.ok).toBe(false);
const resToken = await authorizeGatewayConnect({ auth, connectAuth: { token: params.token } });
expect(resToken.ok).toBe(true);
}
describe("onboard (non-interactive): gateway and remote auth", () => {
const prev = {
home: process.env.HOME,
@@ -183,12 +163,11 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
expect(cfg?.gateway?.auth?.mode).toBe("token");
expect(cfg?.gateway?.auth?.token).toBe(token);
const { authorizeGatewayConnect, resolveGatewayAuth } = await import("../gateway/auth.js");
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
const resNoToken = await authorizeGatewayConnect({ auth, connectAuth: { token: undefined } });
expect(resNoToken.ok).toBe(false);
const resToken = await authorizeGatewayConnect({ auth, connectAuth: { token } });
expect(resToken.ok).toBe(true);
await expectGatewayTokenAuth({
authConfig: cfg.gateway?.auth,
token,
env: process.env,
});
await fs.rm(stateDir, { recursive: true, force: true });
}, 60_000);
@@ -274,12 +253,11 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
const token = cfg.gateway?.auth?.token ?? "";
expect(token.length).toBeGreaterThan(8);
const { authorizeGatewayConnect, resolveGatewayAuth } = await import("../gateway/auth.js");
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
const resNoToken = await authorizeGatewayConnect({ auth, connectAuth: { token: undefined } });
expect(resNoToken.ok).toBe(false);
const resToken = await authorizeGatewayConnect({ auth, connectAuth: { token } });
expect(resToken.ok).toBe(true);
await expectGatewayTokenAuth({
authConfig: cfg.gateway?.auth,
token,
env: process.env,
});
await fs.rm(stateDir, { recursive: true, force: true });
}, 60_000);

View File

@@ -18,6 +18,12 @@ type OnboardEnv = {
runtime: RuntimeMock;
};
type ProviderAuthConfigSnapshot = {
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
agents?: { defaults?: { model?: { primary?: string } } };
models?: { providers?: Record<string, { baseUrl?: string }> };
};
async function removeDirWithRetry(dir: string): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
@@ -93,10 +99,86 @@ async function runNonInteractive(
await runNonInteractiveOnboarding(options, runtime);
}
async function runNonInteractiveWithDefaults(
runtime: RuntimeMock,
options: Record<string, unknown>,
): Promise<void> {
await runNonInteractive(
{
nonInteractive: true,
skipHealth: true,
skipChannels: true,
json: true,
...options,
},
runtime,
);
}
async function readJsonFile<T>(filePath: string): Promise<T> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
}
async function runApiKeyOnboardingAndReadConfig(
env: OnboardEnv,
options: Record<string, unknown>,
): Promise<ProviderAuthConfigSnapshot> {
await runNonInteractiveWithDefaults(env.runtime, {
skipSkills: true,
...options,
});
return readJsonFile<ProviderAuthConfigSnapshot>(env.configPath);
}
async function runInferredApiKeyOnboardingAndReadConfig(
env: OnboardEnv,
options: Record<string, unknown>,
): Promise<ProviderAuthConfigSnapshot> {
await runNonInteractive(
{
nonInteractive: true,
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
...options,
},
env.runtime,
);
return readJsonFile<ProviderAuthConfigSnapshot>(env.configPath);
}
const CUSTOM_LOCAL_BASE_URL = "https://models.custom.local/v1";
const CUSTOM_LOCAL_MODEL_ID = "local-large";
const CUSTOM_LOCAL_PROVIDER_ID = "custom-models-custom-local";
async function runCustomLocalNonInteractive(
runtime: RuntimeMock,
overrides: Record<string, unknown> = {},
): Promise<void> {
await runNonInteractiveWithDefaults(runtime, {
authChoice: "custom-api-key",
customBaseUrl: CUSTOM_LOCAL_BASE_URL,
customModelId: CUSTOM_LOCAL_MODEL_ID,
skipSkills: true,
...overrides,
});
}
async function readCustomLocalProviderApiKey(configPath: string): Promise<string | undefined> {
const cfg = await readJsonFile<{
models?: {
providers?: Record<
string,
{
apiKey?: string;
}
>;
};
}>(configPath);
return cfg.models?.providers?.[CUSTOM_LOCAL_PROVIDER_ID]?.apiKey;
}
async function expectApiKeyProfile(params: {
profileId: string;
provider: string;
@@ -118,25 +200,11 @@ async function expectApiKeyProfile(params: {
describe("onboard (non-interactive): provider auth", () => {
it("stores MiniMax API key and uses global baseUrl by default", async () => {
await withOnboardEnv("openclaw-onboard-minimax-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
nonInteractive: true,
authChoice: "minimax-api",
minimaxApiKey: "sk-minimax-test",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const cfg = await readJsonFile<{
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
agents?: { defaults?: { model?: { primary?: string } } };
models?: { providers?: Record<string, { baseUrl?: string }> };
}>(configPath);
await withOnboardEnv("openclaw-onboard-minimax-", async (env) => {
const cfg = await runApiKeyOnboardingAndReadConfig(env, {
authChoice: "minimax-api",
minimaxApiKey: "sk-minimax-test",
});
expect(cfg.auth?.profiles?.["minimax:default"]?.provider).toBe("minimax");
expect(cfg.auth?.profiles?.["minimax:default"]?.mode).toBe("api_key");
@@ -151,25 +219,11 @@ describe("onboard (non-interactive): provider auth", () => {
}, 60_000);
it("supports MiniMax CN API endpoint auth choice", async () => {
await withOnboardEnv("openclaw-onboard-minimax-cn-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
nonInteractive: true,
authChoice: "minimax-api-key-cn",
minimaxApiKey: "sk-minimax-test",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const cfg = await readJsonFile<{
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
agents?: { defaults?: { model?: { primary?: string } } };
models?: { providers?: Record<string, { baseUrl?: string }> };
}>(configPath);
await withOnboardEnv("openclaw-onboard-minimax-cn-", async (env) => {
const cfg = await runApiKeyOnboardingAndReadConfig(env, {
authChoice: "minimax-api-key-cn",
minimaxApiKey: "sk-minimax-test",
});
expect(cfg.auth?.profiles?.["minimax-cn:default"]?.provider).toBe("minimax-cn");
expect(cfg.auth?.profiles?.["minimax-cn:default"]?.mode).toBe("api_key");
@@ -185,18 +239,11 @@ describe("onboard (non-interactive): provider auth", () => {
it("stores Z.AI API key and uses global baseUrl by default", async () => {
await withOnboardEnv("openclaw-onboard-zai-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
nonInteractive: true,
authChoice: "zai-api-key",
zaiApiKey: "zai-test-key",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
await runNonInteractiveWithDefaults(runtime, {
authChoice: "zai-api-key",
zaiApiKey: "zai-test-key",
skipSkills: true,
});
const cfg = await readJsonFile<{
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
@@ -214,18 +261,11 @@ describe("onboard (non-interactive): provider auth", () => {
it("supports Z.AI CN coding endpoint auth choice", async () => {
await withOnboardEnv("openclaw-onboard-zai-cn-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
nonInteractive: true,
authChoice: "zai-coding-cn",
zaiApiKey: "zai-test-key",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
await runNonInteractiveWithDefaults(runtime, {
authChoice: "zai-coding-cn",
zaiApiKey: "zai-test-key",
skipSkills: true,
});
const cfg = await readJsonFile<{
models?: { providers?: Record<string, { baseUrl?: string }> };
@@ -240,18 +280,11 @@ describe("onboard (non-interactive): provider auth", () => {
it("stores xAI API key and sets default model", async () => {
await withOnboardEnv("openclaw-onboard-xai-", async ({ configPath, runtime }) => {
const rawKey = "xai-test-\r\nkey";
await runNonInteractive(
{
nonInteractive: true,
authChoice: "xai-api-key",
xaiApiKey: rawKey,
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
await runNonInteractiveWithDefaults(runtime, {
authChoice: "xai-api-key",
xaiApiKey: rawKey,
skipSkills: true,
});
const cfg = await readJsonFile<{
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
@@ -267,18 +300,11 @@ describe("onboard (non-interactive): provider auth", () => {
it("stores Vercel AI Gateway API key and sets default model", async () => {
await withOnboardEnv("openclaw-onboard-ai-gateway-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
nonInteractive: true,
authChoice: "ai-gateway-api-key",
aiGatewayApiKey: "gateway-test-key",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
await runNonInteractiveWithDefaults(runtime, {
authChoice: "ai-gateway-api-key",
aiGatewayApiKey: "gateway-test-key",
skipSkills: true,
});
const cfg = await readJsonFile<{
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
@@ -303,19 +329,12 @@ describe("onboard (non-interactive): provider auth", () => {
const cleanToken = `sk-ant-oat01-${"a".repeat(80)}`;
const token = `${cleanToken.slice(0, 30)}\r${cleanToken.slice(30)}`;
await runNonInteractive(
{
nonInteractive: true,
authChoice: "token",
tokenProvider: "anthropic",
token,
tokenProfileId: "anthropic:default",
skipHealth: true,
skipChannels: true,
json: true,
},
runtime,
);
await runNonInteractiveWithDefaults(runtime, {
authChoice: "token",
tokenProvider: "anthropic",
token,
tokenProfileId: "anthropic:default",
});
const cfg = await readJsonFile<{
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
@@ -337,18 +356,11 @@ describe("onboard (non-interactive): provider auth", () => {
it("stores OpenAI API key and sets OpenAI default model", async () => {
await withOnboardEnv("openclaw-onboard-openai-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
nonInteractive: true,
authChoice: "openai-api-key",
openaiApiKey: "sk-openai-test",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
await runNonInteractiveWithDefaults(runtime, {
authChoice: "openai-api-key",
openaiApiKey: "sk-openai-test",
skipSkills: true,
});
const cfg = await readJsonFile<{
agents?: { defaults?: { model?: { primary?: string } } };
@@ -361,35 +373,21 @@ describe("onboard (non-interactive): provider auth", () => {
it("rejects vLLM auth choice in non-interactive mode", async () => {
await withOnboardEnv("openclaw-onboard-vllm-non-interactive-", async ({ runtime }) => {
await expect(
runNonInteractive(
{
nonInteractive: true,
authChoice: "vllm",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
),
runNonInteractiveWithDefaults(runtime, {
authChoice: "vllm",
skipSkills: true,
}),
).rejects.toThrow('Auth choice "vllm" requires interactive mode.');
});
}, 60_000);
it("stores LiteLLM API key and sets default model", async () => {
await withOnboardEnv("openclaw-onboard-litellm-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
nonInteractive: true,
authChoice: "litellm-api-key",
litellmApiKey: "litellm-test-key",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
await runNonInteractiveWithDefaults(runtime, {
authChoice: "litellm-api-key",
litellmApiKey: "litellm-test-key",
skipSkills: true,
});
const cfg = await readJsonFile<{
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
@@ -463,23 +461,10 @@ describe("onboard (non-interactive): provider auth", () => {
);
it("infers Together auth choice from --together-api-key and sets default model", async () => {
await withOnboardEnv("openclaw-onboard-together-infer-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
nonInteractive: true,
togetherApiKey: "together-test-key",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const cfg = await readJsonFile<{
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
agents?: { defaults?: { model?: { primary?: string } } };
}>(configPath);
await withOnboardEnv("openclaw-onboard-together-infer-", async (env) => {
const cfg = await runInferredApiKeyOnboardingAndReadConfig(env, {
togetherApiKey: "together-test-key",
});
expect(cfg.auth?.profiles?.["together:default"]?.provider).toBe("together");
expect(cfg.auth?.profiles?.["together:default"]?.mode).toBe("api_key");
@@ -493,23 +478,10 @@ describe("onboard (non-interactive): provider auth", () => {
}, 60_000);
it("infers QIANFAN auth choice from --qianfan-api-key and sets default model", async () => {
await withOnboardEnv("openclaw-onboard-qianfan-infer-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
nonInteractive: true,
qianfanApiKey: "qianfan-test-key",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const cfg = await readJsonFile<{
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
agents?: { defaults?: { model?: { primary?: string } } };
}>(configPath);
await withOnboardEnv("openclaw-onboard-qianfan-infer-", async (env) => {
const cfg = await runInferredApiKeyOnboardingAndReadConfig(env, {
qianfanApiKey: "qianfan-test-key",
});
expect(cfg.auth?.profiles?.["qianfan:default"]?.provider).toBe("qianfan");
expect(cfg.auth?.profiles?.["qianfan:default"]?.mode).toBe("api_key");
@@ -611,35 +583,8 @@ describe("onboard (non-interactive): provider auth", () => {
"openclaw-onboard-custom-provider-env-fallback-",
async ({ configPath, runtime }) => {
process.env.CUSTOM_API_KEY = "custom-env-key";
await runNonInteractive(
{
nonInteractive: true,
authChoice: "custom-api-key",
customBaseUrl: "https://models.custom.local/v1",
customModelId: "local-large",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const cfg = await readJsonFile<{
models?: {
providers?: Record<
string,
{
apiKey?: string;
}
>;
};
}>(configPath);
expect(cfg.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe(
"custom-env-key",
);
await runCustomLocalNonInteractive(runtime);
expect(await readCustomLocalProviderApiKey(configPath)).toBe("custom-env-key");
},
);
}, 60_000);
@@ -650,42 +595,15 @@ describe("onboard (non-interactive): provider auth", () => {
async ({ configPath, runtime }) => {
const { upsertAuthProfile } = await import("../agents/auth-profiles.js");
upsertAuthProfile({
profileId: "custom-models-custom-local:default",
profileId: `${CUSTOM_LOCAL_PROVIDER_ID}:default`,
credential: {
type: "api_key",
provider: "custom-models-custom-local",
provider: CUSTOM_LOCAL_PROVIDER_ID,
key: "custom-profile-key",
},
});
await runNonInteractive(
{
nonInteractive: true,
authChoice: "custom-api-key",
customBaseUrl: "https://models.custom.local/v1",
customModelId: "local-large",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const cfg = await readJsonFile<{
models?: {
providers?: Record<
string,
{
apiKey?: string;
}
>;
};
}>(configPath);
expect(cfg.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe(
"custom-profile-key",
);
await runCustomLocalNonInteractive(runtime);
expect(await readCustomLocalProviderApiKey(configPath)).toBe("custom-profile-key");
},
);
}, 60_000);

View File

@@ -24,6 +24,70 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { detectBinary } from "./onboard-helpers.js";
import { setupSkills } from "./onboard-skills.js";
function createBundledSkill(params: {
name: string;
description: string;
bins: string[];
os?: string[];
installLabel: string;
}): {
name: string;
description: string;
source: string;
bundled: boolean;
filePath: string;
baseDir: string;
skillKey: string;
always: boolean;
disabled: boolean;
blockedByAllowlist: boolean;
eligible: boolean;
requirements: {
bins: string[];
anyBins: string[];
env: string[];
config: string[];
os: string[];
};
missing: { bins: string[]; anyBins: string[]; env: string[]; config: string[]; os: string[] };
configChecks: [];
install: Array<{ id: string; kind: string; label: string; bins: string[] }>;
} {
return {
name: params.name,
description: params.description,
source: "openclaw-bundled",
bundled: true,
filePath: `/tmp/skills/${params.name}`,
baseDir: `/tmp/skills/${params.name}`,
skillKey: params.name,
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: false,
requirements: { bins: params.bins, anyBins: [], env: [], config: [], os: params.os ?? [] },
missing: { bins: params.bins, anyBins: [], env: [], config: [], os: params.os ?? [] },
configChecks: [],
install: [{ id: "brew", kind: "brew", label: params.installLabel, bins: params.bins }],
};
}
function mockMissingBrewStatus(skills: Array<ReturnType<typeof createBundledSkill>>): void {
vi.mocked(detectBinary).mockResolvedValue(false);
vi.mocked(installSkill).mockResolvedValue({
ok: true,
message: "Installed",
stdout: "",
stderr: "",
code: 0,
});
vi.mocked(buildWorkspaceSkillStatus).mockReturnValue({
workspaceDir: "/tmp/ws",
managedSkillsDir: "/tmp/managed",
skills,
});
}
function createPrompter(params: {
configure?: boolean;
showBrewInstall?: boolean;
@@ -69,56 +133,21 @@ describe("setupSkills", () => {
return;
}
vi.mocked(detectBinary).mockResolvedValue(false);
vi.mocked(installSkill).mockResolvedValue({
ok: true,
message: "Installed",
stdout: "",
stderr: "",
code: 0,
});
vi.mocked(buildWorkspaceSkillStatus).mockReturnValue({
workspaceDir: "/tmp/ws",
managedSkillsDir: "/tmp/managed",
skills: [
{
name: "apple-reminders",
description: "macOS-only",
source: "openclaw-bundled",
bundled: true,
filePath: "/tmp/skills/apple-reminders",
baseDir: "/tmp/skills/apple-reminders",
skillKey: "apple-reminders",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: false,
requirements: { bins: ["remindctl"], anyBins: [], env: [], config: [], os: ["darwin"] },
missing: { bins: ["remindctl"], anyBins: [], env: [], config: [], os: ["darwin"] },
configChecks: [],
install: [
{ id: "brew", kind: "brew", label: "Install remindctl (brew)", bins: ["remindctl"] },
],
},
{
name: "video-frames",
description: "ffmpeg",
source: "openclaw-bundled",
bundled: true,
filePath: "/tmp/skills/video-frames",
baseDir: "/tmp/skills/video-frames",
skillKey: "video-frames",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: false,
requirements: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] },
missing: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] },
configChecks: [],
install: [{ id: "brew", kind: "brew", label: "Install ffmpeg (brew)", bins: ["ffmpeg"] }],
},
],
});
mockMissingBrewStatus([
createBundledSkill({
name: "apple-reminders",
description: "macOS-only",
bins: ["remindctl"],
os: ["darwin"],
installLabel: "Install remindctl (brew)",
}),
createBundledSkill({
name: "video-frames",
description: "ffmpeg",
bins: ["ffmpeg"],
installLabel: "Install ffmpeg (brew)",
}),
]);
const { prompter, notes } = createPrompter({ multiselect: ["__skip__"] });
await setupSkills({} as OpenClawConfig, "/tmp/ws", runtime, prompter);
@@ -136,37 +165,14 @@ describe("setupSkills", () => {
return;
}
vi.mocked(detectBinary).mockResolvedValue(false);
vi.mocked(installSkill).mockResolvedValue({
ok: true,
message: "Installed",
stdout: "",
stderr: "",
code: 0,
});
vi.mocked(buildWorkspaceSkillStatus).mockReturnValue({
workspaceDir: "/tmp/ws",
managedSkillsDir: "/tmp/managed",
skills: [
{
name: "video-frames",
description: "ffmpeg",
source: "openclaw-bundled",
bundled: true,
filePath: "/tmp/skills/video-frames",
baseDir: "/tmp/skills/video-frames",
skillKey: "video-frames",
always: false,
disabled: false,
blockedByAllowlist: false,
eligible: false,
requirements: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] },
missing: { bins: ["ffmpeg"], anyBins: [], env: [], config: [], os: [] },
configChecks: [],
install: [{ id: "brew", kind: "brew", label: "Install ffmpeg (brew)", bins: ["ffmpeg"] }],
},
],
});
mockMissingBrewStatus([
createBundledSkill({
name: "video-frames",
description: "ffmpeg",
bins: ["ffmpeg"],
installLabel: "Install ffmpeg (brew)",
}),
]);
const { prompter, notes } = createPrompter({ multiselect: ["video-frames"] });
await setupSkills({} as OpenClawConfig, "/tmp/ws", runtime, prompter);

View File

@@ -43,6 +43,30 @@ beforeEach(() => {
vi.clearAllMocks();
});
function mockRepoLocalPathExists() {
vi.mocked(fs.existsSync).mockImplementation((value) => {
const raw = String(value);
return raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`);
});
}
async function runInitialValueForChannel(channel: "dev" | "beta") {
const runtime = makeRuntime();
const select = vi.fn(async () => "skip") as WizardPrompter["select"];
const prompter = makePrompter({ select });
const cfg: OpenClawConfig = { update: { channel } };
mockRepoLocalPathExists();
await ensureOnboardingPluginInstalled({
cfg,
entry: baseEntry,
prompter,
runtime,
});
return select.mock.calls[0]?.[0]?.initialValue;
}
describe("ensureOnboardingPluginInstalled", () => {
it("installs from npm and enables the plugin", async () => {
const runtime = makeRuntime();
@@ -82,12 +106,7 @@ describe("ensureOnboardingPluginInstalled", () => {
select: vi.fn(async () => "local") as WizardPrompter["select"],
});
const cfg: OpenClawConfig = {};
vi.mocked(fs.existsSync).mockImplementation((value) => {
const raw = String(value);
return (
raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`)
);
});
mockRepoLocalPathExists();
const result = await ensureOnboardingPluginInstalled({
cfg,
@@ -103,49 +122,11 @@ describe("ensureOnboardingPluginInstalled", () => {
});
it("defaults to local on dev channel when local path exists", async () => {
const runtime = makeRuntime();
const select = vi.fn(async () => "skip") as WizardPrompter["select"];
const prompter = makePrompter({ select });
const cfg: OpenClawConfig = { update: { channel: "dev" } };
vi.mocked(fs.existsSync).mockImplementation((value) => {
const raw = String(value);
return (
raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`)
);
});
await ensureOnboardingPluginInstalled({
cfg,
entry: baseEntry,
prompter,
runtime,
});
const firstCall = select.mock.calls[0]?.[0];
expect(firstCall?.initialValue).toBe("local");
expect(await runInitialValueForChannel("dev")).toBe("local");
});
it("defaults to npm on beta channel even when local path exists", async () => {
const runtime = makeRuntime();
const select = vi.fn(async () => "skip") as WizardPrompter["select"];
const prompter = makePrompter({ select });
const cfg: OpenClawConfig = { update: { channel: "beta" } };
vi.mocked(fs.existsSync).mockImplementation((value) => {
const raw = String(value);
return (
raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`)
);
});
await ensureOnboardingPluginInstalled({
cfg,
entry: baseEntry,
prompter,
runtime,
});
const firstCall = select.mock.calls[0]?.[0];
expect(firstCall?.initialValue).toBe("npm");
expect(await runInitialValueForChannel("beta")).toBe("npm");
});
it("falls back to local path after npm install failure", async () => {
@@ -158,12 +139,7 @@ describe("ensureOnboardingPluginInstalled", () => {
confirm,
});
const cfg: OpenClawConfig = {};
vi.mocked(fs.existsSync).mockImplementation((value) => {
const raw = String(value);
return (
raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`)
);
});
mockRepoLocalPathExists();
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "nope",

View File

@@ -1,5 +1,6 @@
import type { SessionStatus } from "./status.types.js";
import { formatDurationPrecise } from "../infra/format-time/format-duration.ts";
import { formatRuntimeStatusWithDetails } from "../infra/runtime-status.ts";
export const formatKTokens = (value: number) =>
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
@@ -44,19 +45,17 @@ export const formatDaemonRuntimeShort = (runtime?: {
if (!runtime) {
return null;
}
const status = runtime.status ?? "unknown";
const details: string[] = [];
if (runtime.pid) {
details.push(`pid ${runtime.pid}`);
}
if (runtime.state && runtime.state.toLowerCase() !== status) {
details.push(`state ${runtime.state}`);
}
const detail = runtime.detail?.replace(/\s+/g, " ").trim() || "";
const noisyLaunchctlDetail =
runtime.missingUnit === true && detail.toLowerCase().includes("could not find service");
if (detail && !noisyLaunchctlDetail) {
details.push(detail);
}
return details.length > 0 ? `${status} (${details.join(", ")})` : status;
return formatRuntimeStatusWithDetails({
status: runtime.status,
pid: runtime.pid,
state: runtime.state,
details,
});
};

View File

@@ -10,29 +10,13 @@ import {
resolveStorePath,
type SessionEntry,
} from "../config/sessions.js";
import { listAgentsForGateway } from "../gateway/session-utils.js";
import { classifySessionKey, listAgentsForGateway } from "../gateway/session-utils.js";
import { buildChannelSummary } from "../infra/channel-summary.js";
import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-runner.js";
import { peekSystemEvents } from "../infra/system-events.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { resolveLinkChannelContext } from "./status.link-channel.js";
const classifyKey = (key: string, entry?: SessionEntry): SessionStatus["kind"] => {
if (key === "global") {
return "global";
}
if (key === "unknown") {
return "unknown";
}
if (entry?.chatType === "group" || entry?.chatType === "channel") {
return "group";
}
if (key.includes(":group:") || key.includes(":channel:")) {
return "group";
}
return "direct";
};
const buildFlags = (entry?: SessionEntry): string[] => {
if (!entry) {
return [];
@@ -159,7 +143,7 @@ export async function getStatusSummary(
return {
agentId,
key,
kind: classifyKey(key, entry),
kind: classifySessionKey(key, entry),
sessionId: entry?.sessionId,
updatedAt,
age,

View File

@@ -0,0 +1,20 @@
import { vi } from "vitest";
export const baseConfigSnapshot = {
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {},
issues: [],
legacyIssues: [],
};
export function createTestRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
}

View File

@@ -0,0 +1,33 @@
import { vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
export const noopAsync = async () => {};
export const noop = () => {};
export function createExitThrowingRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
}
export function createWizardPrompter(
overrides: Partial<WizardPrompter>,
options?: { defaultSelect?: string },
): WizardPrompter {
return {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select: vi.fn(async () => (options?.defaultSelect ?? "") as never),
multiselect: vi.fn(async () => []),
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: noop, stop: noop })),
...overrides,
};
}