mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 09:51:22 +00:00
refactor(agent): dedupe harness and command workflows
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }] } },
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }]),
|
||||
);
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
20
src/commands/config-validation.ts
Normal file
20
src/commands/config-validation.ts
Normal 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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
src/commands/test-runtime-config-helpers.ts
Normal file
20
src/commands/test-runtime-config-helpers.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
33
src/commands/test-wizard-helpers.ts
Normal file
33
src/commands/test-wizard-helpers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user