mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
perf: reduce command and gateway test imports
This commit is contained in:
@@ -4,15 +4,13 @@ import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { agentCliCommand as AgentCliCommand } from "./agent-via-gateway.js";
|
||||
import { agentCliCommand } from "./agent-via-gateway.js";
|
||||
import type { agentCommand as AgentCommand } from "./agent.js";
|
||||
|
||||
const loadConfig = vi.hoisted(() => vi.fn());
|
||||
const callGateway = vi.hoisted(() => vi.fn());
|
||||
const agentCommand = vi.hoisted(() => vi.fn());
|
||||
|
||||
let agentCliCommand: typeof AgentCliCommand;
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@@ -71,16 +69,15 @@ function mockLocalAgentReply(text = "local") {
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.mock("../config/config.js", () => ({ loadConfig }));
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway,
|
||||
randomIdempotencyKey: () => "idem-1",
|
||||
}));
|
||||
vi.mock("./agent.js", () => ({ agentCommand }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doMock("../config/config.js", () => ({ loadConfig }));
|
||||
vi.doMock("../gateway/call.js", () => ({
|
||||
callGateway,
|
||||
randomIdempotencyKey: () => "idem-1",
|
||||
}));
|
||||
vi.doMock("./agent.js", () => ({ agentCommand }));
|
||||
({ agentCliCommand } = await import("./agent-via-gateway.js"));
|
||||
});
|
||||
|
||||
describe("agentCliCommand", () => {
|
||||
|
||||
@@ -22,16 +22,28 @@ import {
|
||||
} from "./agent-command.test-support.js";
|
||||
import { agentCommand } from "./agent.js";
|
||||
|
||||
vi.mock("../agents/auth-profiles/store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agents/auth-profiles/store.js")>(
|
||||
"../agents/auth-profiles/store.js",
|
||||
);
|
||||
vi.mock("../agents/auth-profiles.js", () => {
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agents/auth-profiles/store.js", () => {
|
||||
const createEmptyStore = () => ({ version: 1, profiles: {} });
|
||||
return {
|
||||
clearRuntimeAuthProfileStoreSnapshots: vi.fn(),
|
||||
ensureAuthProfileStore: vi.fn(createEmptyStore),
|
||||
ensureAuthProfileStoreForLocalUpdate: vi.fn(createEmptyStore),
|
||||
hasAnyAuthProfileStoreSource: vi.fn(() => false),
|
||||
loadAuthProfileStore: vi.fn(createEmptyStore),
|
||||
loadAuthProfileStoreForRuntime: vi.fn(createEmptyStore),
|
||||
loadAuthProfileStoreForSecretsRuntime: vi.fn(createEmptyStore),
|
||||
replaceRuntimeAuthProfileStoreSnapshots: vi.fn(),
|
||||
saveAuthProfileStore: vi.fn(),
|
||||
updateAuthProfileStoreWithLock: vi.fn(async () => createEmptyStore()),
|
||||
};
|
||||
});
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
|
||||
@@ -21,22 +21,30 @@ import {
|
||||
withAgentCommandTempHome,
|
||||
} from "./agent-command.test-support.js";
|
||||
|
||||
vi.mock("../agents/auth-profiles.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agents/auth-profiles.js")>(
|
||||
"../agents/auth-profiles.js",
|
||||
);
|
||||
vi.mock("../agents/auth-profiles.js", () => {
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agents/command/session-store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agents/command/session-store.js")>(
|
||||
"../agents/command/session-store.js",
|
||||
);
|
||||
vi.mock("../agents/auth-profiles/store.js", () => {
|
||||
const createEmptyStore = () => ({ version: 1, profiles: {} });
|
||||
return {
|
||||
clearRuntimeAuthProfileStoreSnapshots: vi.fn(),
|
||||
ensureAuthProfileStore: vi.fn(createEmptyStore),
|
||||
ensureAuthProfileStoreForLocalUpdate: vi.fn(createEmptyStore),
|
||||
hasAnyAuthProfileStoreSource: vi.fn(() => false),
|
||||
loadAuthProfileStore: vi.fn(createEmptyStore),
|
||||
loadAuthProfileStoreForRuntime: vi.fn(createEmptyStore),
|
||||
loadAuthProfileStoreForSecretsRuntime: vi.fn(createEmptyStore),
|
||||
replaceRuntimeAuthProfileStoreSnapshots: vi.fn(),
|
||||
saveAuthProfileStore: vi.fn(),
|
||||
updateAuthProfileStoreWithLock: vi.fn(async () => createEmptyStore()),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agents/command/session-store.js", () => {
|
||||
return {
|
||||
...actual,
|
||||
updateSessionStoreAfterAgentRun: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import "./agent-command.test-mocks.js";
|
||||
import "../cron/isolated-agent.mocks.js";
|
||||
import { __testing as acpManagerTesting } from "../acp/control-plane/manager.js";
|
||||
import { resolveAgentDir, resolveSessionAgentId } from "../agents/agent-scope.js";
|
||||
@@ -27,74 +28,34 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { agentCommand, agentCommandFromIngress } from "./agent.js";
|
||||
|
||||
vi.mock("../logging/subsystem.js", () => {
|
||||
const createMockLogger = () => ({
|
||||
subsystem: "test",
|
||||
isEnabled: vi.fn(() => true),
|
||||
trace: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
raw: vi.fn(),
|
||||
child: vi.fn(() => createMockLogger()),
|
||||
});
|
||||
vi.mock("../agents/auth-profiles.js", () => {
|
||||
return {
|
||||
createSubsystemLogger: vi.fn(() => createMockLogger()),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agents/auth-profiles.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agents/auth-profiles.js")>(
|
||||
"../agents/auth-profiles.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agents/auth-profiles/store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agents/auth-profiles/store.js")>(
|
||||
"../agents/auth-profiles/store.js",
|
||||
);
|
||||
vi.mock("../agents/auth-profiles/store.js", () => {
|
||||
const createEmptyStore = () => ({ version: 1, profiles: {} });
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
|
||||
clearRuntimeAuthProfileStoreSnapshots: vi.fn(),
|
||||
ensureAuthProfileStore: vi.fn(createEmptyStore),
|
||||
ensureAuthProfileStoreForLocalUpdate: vi.fn(createEmptyStore),
|
||||
hasAnyAuthProfileStoreSource: vi.fn(() => false),
|
||||
loadAuthProfileStore: vi.fn(createEmptyStore),
|
||||
loadAuthProfileStoreForRuntime: vi.fn(createEmptyStore),
|
||||
loadAuthProfileStoreForSecretsRuntime: vi.fn(createEmptyStore),
|
||||
replaceRuntimeAuthProfileStoreSnapshots: vi.fn(),
|
||||
saveAuthProfileStore: vi.fn(),
|
||||
updateAuthProfileStoreWithLock: vi.fn(async () => createEmptyStore()),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agents/workspace.js", () => {
|
||||
const resolveDefaultAgentWorkspaceDir = () => "/tmp/openclaw-workspace";
|
||||
vi.mock("../agents/command/session-store.js", () => {
|
||||
return {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace",
|
||||
DEFAULT_AGENTS_FILENAME: "AGENTS.md",
|
||||
DEFAULT_IDENTITY_FILENAME: "IDENTITY.md",
|
||||
resolveDefaultAgentWorkspaceDir,
|
||||
ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => ({ dir })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agents/command/session-store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agents/command/session-store.js")>(
|
||||
"../agents/command/session-store.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
updateSessionStoreAfterAgentRun: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agents/skills.js", () => ({
|
||||
buildWorkspaceSkillSnapshot: vi.fn(() => undefined),
|
||||
loadWorkspaceSkillEntries: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/skills/refresh.js", () => ({
|
||||
getSkillsSnapshotVersion: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
|
||||
@@ -37,13 +37,40 @@ vi.mock("../plugins/provider-openai-codex-oauth.js", () => ({
|
||||
|
||||
const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => []));
|
||||
const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {}));
|
||||
vi.mock("../plugins/provider-auth-choice.runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../plugins/provider-auth-choice.runtime.js")>(
|
||||
"../plugins/provider-auth-choice.runtime.js",
|
||||
);
|
||||
vi.mock("../plugins/provider-auth-choice.runtime.js", () => {
|
||||
const normalizeProviderId = (value: string) => value.trim().toLowerCase();
|
||||
return {
|
||||
...actual,
|
||||
resolvePluginProviders,
|
||||
resolveProviderPluginChoice: (params: { providers: ProviderPlugin[]; choice: string }) => {
|
||||
const choice = params.choice.trim();
|
||||
if (!choice) {
|
||||
return null;
|
||||
}
|
||||
if (choice.startsWith("provider-plugin:")) {
|
||||
const payload = choice.slice("provider-plugin:".length);
|
||||
const separator = payload.indexOf(":");
|
||||
const providerId = separator >= 0 ? payload.slice(0, separator) : payload;
|
||||
const methodId = separator >= 0 ? payload.slice(separator + 1) : undefined;
|
||||
const provider = params.providers.find(
|
||||
(entry) => normalizeProviderId(entry.id) === normalizeProviderId(providerId),
|
||||
);
|
||||
const method = methodId
|
||||
? provider?.auth.find((entry) => entry.id === methodId)
|
||||
: provider?.auth[0];
|
||||
return provider && method ? { provider, method } : null;
|
||||
}
|
||||
for (const provider of params.providers) {
|
||||
for (const method of provider.auth) {
|
||||
if (method.wizard?.choiceId === choice) {
|
||||
return { provider, method, wizard: method.wizard };
|
||||
}
|
||||
}
|
||||
if (normalizeProviderId(provider.id) === normalizeProviderId(choice) && provider.auth[0]) {
|
||||
return { provider, method: provider.auth[0] };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
runProviderModelSelectedHook,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
listBundledChannelPlugins,
|
||||
getBundledChannelPlugin,
|
||||
listBundledChannelPluginIds,
|
||||
setBundledChannelRuntime,
|
||||
} from "../channels/plugins/bundled.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
@@ -7,8 +8,11 @@ import type { PluginRuntime } from "../plugins/runtime/index.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
|
||||
function resolveChannelPluginsForTests(onlyPluginIds?: readonly string[]) {
|
||||
const scopedIds = onlyPluginIds ? new Set(onlyPluginIds) : null;
|
||||
return listBundledChannelPlugins().filter((plugin) => !scopedIds || scopedIds.has(plugin.id));
|
||||
const ids = onlyPluginIds ?? listBundledChannelPluginIds();
|
||||
return ids.flatMap((id) => {
|
||||
const plugin = getBundledChannelPlugin(id);
|
||||
return plugin ? [plugin] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function createChannelTestRuntime(): PluginRuntime {
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { noteChromeMcpBrowserReadiness } from "./doctor-browser.js";
|
||||
|
||||
describe("doctor browser readiness", () => {
|
||||
it("does nothing when Chrome MCP is not configured", async () => {
|
||||
const noteFn = vi.fn();
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
profiles: {
|
||||
openclaw: { color: "#FF4500" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
noteFn,
|
||||
},
|
||||
);
|
||||
expect(noteFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns when Chrome MCP is configured but Chrome is missing", async () => {
|
||||
const noteFn = vi.fn();
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
defaultProfile: "user",
|
||||
},
|
||||
},
|
||||
{
|
||||
noteFn,
|
||||
platform: "darwin",
|
||||
resolveChromeExecutable: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(noteFn).toHaveBeenCalledTimes(1);
|
||||
expect(String(noteFn.mock.calls[0]?.[0])).toContain("Google Chrome was not found");
|
||||
expect(String(noteFn.mock.calls[0]?.[0])).toContain("brave://inspect/#remote-debugging");
|
||||
});
|
||||
|
||||
it("warns when detected Chrome is too old for Chrome MCP", async () => {
|
||||
const noteFn = vi.fn();
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
profiles: {
|
||||
chromeLive: {
|
||||
driver: "existing-session",
|
||||
color: "#00AA00",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
noteFn,
|
||||
platform: "linux",
|
||||
resolveChromeExecutable: () => ({ path: "/usr/bin/google-chrome" }),
|
||||
readVersion: () => "Google Chrome 143.0.7499.4",
|
||||
},
|
||||
);
|
||||
|
||||
expect(noteFn).toHaveBeenCalledTimes(1);
|
||||
expect(String(noteFn.mock.calls[0]?.[0])).toContain("too old");
|
||||
expect(String(noteFn.mock.calls[0]?.[0])).toContain("Chrome 144+");
|
||||
});
|
||||
|
||||
it("reports the detected Chrome version for existing-session profiles", async () => {
|
||||
const noteFn = vi.fn();
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
profiles: {
|
||||
chromeLive: {
|
||||
driver: "existing-session",
|
||||
color: "#00AA00",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
noteFn,
|
||||
platform: "win32",
|
||||
resolveChromeExecutable: () => ({
|
||||
path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
}),
|
||||
readVersion: () => "Google Chrome 144.0.7534.0",
|
||||
},
|
||||
);
|
||||
|
||||
expect(noteFn).toHaveBeenCalledTimes(1);
|
||||
expect(String(noteFn.mock.calls[0]?.[0])).toContain(
|
||||
"Detected Chrome Google Chrome 144.0.7534.0",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips Chrome auto-detection when profiles use explicit userDataDir", async () => {
|
||||
const noteFn = vi.fn();
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
profiles: {
|
||||
braveLive: {
|
||||
driver: "existing-session",
|
||||
userDataDir: "/Users/test/Library/Application Support/BraveSoftware/Brave-Browser",
|
||||
color: "#FB542B",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
noteFn,
|
||||
resolveChromeExecutable: () => {
|
||||
throw new Error("should not look up Chrome");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(noteFn).toHaveBeenCalledTimes(1);
|
||||
expect(String(noteFn.mock.calls[0]?.[0])).toContain("explicit Chromium user data directory");
|
||||
expect(String(noteFn.mock.calls[0]?.[0])).toContain("brave://inspect/#remote-debugging");
|
||||
});
|
||||
});
|
||||
@@ -10,17 +10,22 @@ export async function runDoctorConfigWithInput<T>(params: {
|
||||
confirm: () => Promise<boolean>;
|
||||
}) => Promise<T>;
|
||||
}) {
|
||||
return withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "openclaw.json"),
|
||||
JSON.stringify(params.config, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
return params.run({
|
||||
options: { nonInteractive: true, repair: params.repair },
|
||||
confirm: async () => false,
|
||||
});
|
||||
});
|
||||
return withTempHome(
|
||||
async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "openclaw.json"),
|
||||
JSON.stringify(params.config, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
return params.run({
|
||||
options: { nonInteractive: true, repair: params.repair },
|
||||
confirm: async () => false,
|
||||
});
|
||||
},
|
||||
{
|
||||
skipSessionCleanup: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,12 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { CONFIG_PATH } from "../config/config.js";
|
||||
import { findLegacyConfigIssues } from "../config/legacy.js";
|
||||
import { CONFIG_PATH } from "../config/paths.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { listPluginDoctorLegacyConfigRules } from "../plugins/doctor-contract-registry.js";
|
||||
import {
|
||||
collectRelevantDoctorPluginIds,
|
||||
listPluginDoctorLegacyConfigRules,
|
||||
} from "../plugins/doctor-contract-registry.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { noteOpencodeProviderOverrides } from "./doctor-config-analysis.js";
|
||||
import { runDoctorConfigPreflight } from "./doctor-config-preflight.js";
|
||||
@@ -58,7 +61,9 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
const pluginLegacyIssues = findLegacyConfigIssues(
|
||||
snapshot.parsed,
|
||||
snapshot.parsed,
|
||||
listPluginDoctorLegacyConfigRules(),
|
||||
listPluginDoctorLegacyConfigRules({
|
||||
pluginIds: collectRelevantDoctorPluginIds(snapshot.parsed),
|
||||
}),
|
||||
);
|
||||
const seenLegacyIssues = new Set(
|
||||
snapshot.legacyIssues.map((issue) => `${issue.path}:${issue.message}`),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readConfigFileSnapshot } from "../config/config.js";
|
||||
import { readConfigFileSnapshot } from "../config/io.js";
|
||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
|
||||
@@ -3,9 +3,16 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import * as noteModule from "../terminal/note.js";
|
||||
import { maybeRepairLegacyCronStore } from "./doctor-cron.js";
|
||||
|
||||
type TerminalNote = (message: string, title?: string) => void;
|
||||
|
||||
const noteMock = vi.hoisted(() => vi.fn<TerminalNote>());
|
||||
|
||||
vi.mock("../terminal/note.js", () => ({
|
||||
note: noteMock,
|
||||
}));
|
||||
|
||||
let tempRoot: string | null = null;
|
||||
|
||||
async function makeTempStorePath() {
|
||||
@@ -14,7 +21,7 @@ async function makeTempStorePath() {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
noteMock.mockClear();
|
||||
if (tempRoot) {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
tempRoot = null;
|
||||
@@ -74,7 +81,7 @@ describe("maybeRepairLegacyCronStore", () => {
|
||||
const storePath = await makeTempStorePath();
|
||||
await writeCronStore(storePath, [createLegacyCronJob()]);
|
||||
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
const noteSpy = noteMock;
|
||||
const cfg = createCronConfig(storePath);
|
||||
|
||||
await maybeRepairLegacyCronStore({
|
||||
@@ -144,7 +151,7 @@ describe("maybeRepairLegacyCronStore", () => {
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
const noteSpy = noteMock;
|
||||
|
||||
await maybeRepairLegacyCronStore({
|
||||
cfg: {
|
||||
@@ -171,7 +178,7 @@ describe("maybeRepairLegacyCronStore", () => {
|
||||
const storePath = await makeTempStorePath();
|
||||
await writeCronStore(storePath, [createLegacyCronJob()]);
|
||||
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
const noteSpy = noteMock;
|
||||
const prompter = makePrompter(false);
|
||||
|
||||
await maybeRepairLegacyCronStore({
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { clearPluginSetupRegistryCache } from "../plugins/setup-registry.js";
|
||||
import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js";
|
||||
|
||||
vi.mock("../plugins/setup-registry.js", () => ({
|
||||
runPluginSetupConfigMigrations: ({ config }: { config: OpenClawConfig }) => ({
|
||||
config,
|
||||
changes: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
function asLegacyConfig(value: unknown): OpenClawConfig {
|
||||
return value as OpenClawConfig;
|
||||
}
|
||||
@@ -38,7 +44,6 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
previousOauthDir = process.env.OPENCLAW_OAUTH_DIR;
|
||||
tempOauthDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-oauth-"));
|
||||
process.env.OPENCLAW_OAUTH_DIR = tempOauthDir;
|
||||
clearPluginSetupRegistryCache();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -53,7 +58,6 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
process.env.OPENCLAW_OAUTH_DIR = previousOauthDir;
|
||||
}
|
||||
fs.rmSync(tempOauthDir, { recursive: true, force: true });
|
||||
clearPluginSetupRegistryCache();
|
||||
});
|
||||
|
||||
it("does not add whatsapp config when missing and no auth exists", () => {
|
||||
|
||||
@@ -6,6 +6,14 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStorePath, resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
|
||||
import { noteStateIntegrity } from "./doctor-state-integrity.js";
|
||||
|
||||
vi.mock("../channels/plugins/bundled-ids.js", () => ({
|
||||
listBundledChannelPluginIds: () => ["matrix", "whatsapp"],
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/persisted-auth-state.js", () => ({
|
||||
hasBundledChannelPersistedAuthState: () => false,
|
||||
}));
|
||||
|
||||
const noteMock = vi.fn();
|
||||
|
||||
type EnvSnapshot = {
|
||||
|
||||
@@ -72,6 +72,7 @@ export const resolveOpenClawPackageRoot = vi.fn().mockResolvedValue(null) as unk
|
||||
export const runGatewayUpdate = vi
|
||||
.fn()
|
||||
.mockResolvedValue(createGatewayUpdateResult()) as unknown as MockFn;
|
||||
export const collectRelevantDoctorPluginIds = vi.fn(() => []) as unknown as MockFn;
|
||||
export const listPluginDoctorLegacyConfigRules = vi.fn(() => []) as unknown as MockFn;
|
||||
export const runDoctorHealthContributions = vi.fn(
|
||||
defaultRunDoctorHealthContributions,
|
||||
@@ -264,6 +265,16 @@ vi.mock("../config/config.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/io.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/io.js")>("../config/io.js");
|
||||
return {
|
||||
...actual,
|
||||
createConfigIO,
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../daemon/legacy.js", () => ({
|
||||
findLegacyGatewayServices,
|
||||
uninstallLegacyGatewayServices,
|
||||
@@ -351,6 +362,7 @@ vi.mock("./doctor-memory-search.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/doctor-contract-registry.js", () => ({
|
||||
collectRelevantDoctorPluginIds,
|
||||
listPluginDoctorLegacyConfigRules,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { collectChannelDoctorCompatibilityMutations } from "./channel-doctor.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getChannelPlugin: vi.fn(),
|
||||
@@ -21,12 +22,8 @@ vi.mock("../../../channels/plugins/bundled.js", () => ({
|
||||
mocks.listBundledChannelPlugins(...args),
|
||||
}));
|
||||
|
||||
let collectChannelDoctorCompatibilityMutations: typeof import("./channel-doctor.js").collectChannelDoctorCompatibilityMutations;
|
||||
|
||||
describe("channel doctor compatibility mutations", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ collectChannelDoctorCompatibilityMutations } = await import("./channel-doctor.js"));
|
||||
beforeEach(() => {
|
||||
mocks.getChannelPlugin.mockReset();
|
||||
mocks.getBundledChannelPlugin.mockReset();
|
||||
mocks.listChannelPlugins.mockReset();
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function runChannelDoctorConfigSequences(params: {
|
||||
}): Promise<ChannelDoctorSequenceResult> {
|
||||
const changeNotes: string[] = [];
|
||||
const warningNotes: string[] = [];
|
||||
for (const entry of listChannelDoctorEntries()) {
|
||||
for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg))) {
|
||||
const result = await entry.doctor.runConfigSequence?.(params);
|
||||
if (!result) {
|
||||
continue;
|
||||
@@ -118,7 +118,7 @@ export async function collectChannelDoctorStaleConfigMutations(
|
||||
): Promise<ChannelDoctorConfigMutation[]> {
|
||||
const mutations: ChannelDoctorConfigMutation[] = [];
|
||||
let nextCfg = cfg;
|
||||
for (const entry of listChannelDoctorEntries()) {
|
||||
for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(cfg))) {
|
||||
const mutation = await entry.doctor.cleanStaleConfig?.({ cfg: nextCfg });
|
||||
if (!mutation || mutation.changes.length === 0) {
|
||||
continue;
|
||||
@@ -134,7 +134,7 @@ export async function collectChannelDoctorPreviewWarnings(params: {
|
||||
doctorFixCommand: string;
|
||||
}): Promise<string[]> {
|
||||
const warnings: string[] = [];
|
||||
for (const entry of listChannelDoctorEntries()) {
|
||||
for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg))) {
|
||||
const lines = await entry.doctor.collectPreviewWarnings?.(params);
|
||||
if (lines?.length) {
|
||||
warnings.push(...lines);
|
||||
@@ -147,7 +147,7 @@ export async function collectChannelDoctorMutableAllowlistWarnings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
}): Promise<string[]> {
|
||||
const warnings: string[] = [];
|
||||
for (const entry of listChannelDoctorEntries()) {
|
||||
for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg))) {
|
||||
const lines = await entry.doctor.collectMutableAllowlistWarnings?.(params);
|
||||
if (lines?.length) {
|
||||
warnings.push(...lines);
|
||||
@@ -162,7 +162,7 @@ export async function collectChannelDoctorRepairMutations(params: {
|
||||
}): Promise<ChannelDoctorConfigMutation[]> {
|
||||
const mutations: ChannelDoctorConfigMutation[] = [];
|
||||
let nextCfg = params.cfg;
|
||||
for (const entry of listChannelDoctorEntries()) {
|
||||
for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg))) {
|
||||
const mutation = await entry.doctor.repairConfig?.({
|
||||
cfg: nextCfg,
|
||||
doctorFixCommand: params.doctorFixCommand,
|
||||
@@ -183,7 +183,7 @@ export function collectChannelDoctorEmptyAllowlistExtraWarnings(
|
||||
params: ChannelDoctorEmptyAllowlistAccountContext,
|
||||
): string[] {
|
||||
const warnings: string[] = [];
|
||||
for (const entry of listChannelDoctorEntries()) {
|
||||
for (const entry of listChannelDoctorEntries([params.channelName])) {
|
||||
const lines = entry.doctor.collectEmptyAllowlistExtraWarnings?.(params);
|
||||
if (lines?.length) {
|
||||
warnings.push(...lines);
|
||||
@@ -195,7 +195,7 @@ export function collectChannelDoctorEmptyAllowlistExtraWarnings(
|
||||
export function shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning(
|
||||
params: ChannelDoctorEmptyAllowlistAccountContext,
|
||||
): boolean {
|
||||
return listChannelDoctorEntries().some(
|
||||
return listChannelDoctorEntries([params.channelName]).some(
|
||||
(entry) => entry.doctor.shouldSkipDefaultEmptyGroupAllowlistWarning?.(params) === true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { isDeepStrictEqual } from "node:util";
|
||||
import { normalizeProviderId } from "../../../agents/model-selection-normalize.js";
|
||||
import { resolveSingleAccountKeysToMove } from "../../../channels/plugins/setup-helpers.js";
|
||||
import { resolveNormalizedProviderModelMaxTokens } from "../../../config/defaults.js";
|
||||
import { normalizeTalkSection } from "../../../config/talk.js";
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import { DEFAULT_GOOGLE_API_BASE_URL } from "../../../infra/google-api-base-url.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
|
||||
@@ -11,18 +10,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
} from "../../../shared/string-coerce.js";
|
||||
import { isRecord } from "./legacy-config-record-shared.js";
|
||||
|
||||
function buildLegacyTalkProviderCompat(
|
||||
talk: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const compat: Record<string, unknown> = {};
|
||||
for (const key of ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"] as const) {
|
||||
if (talk[key] !== undefined) {
|
||||
compat[key] = talk[key];
|
||||
}
|
||||
}
|
||||
return Object.keys(compat).length > 0 ? compat : undefined;
|
||||
}
|
||||
export { normalizeLegacyTalkConfig } from "./legacy-talk-config-normalizer.js";
|
||||
|
||||
export function normalizeLegacyBrowserConfig(
|
||||
cfg: OpenClawConfig,
|
||||
@@ -320,36 +308,6 @@ export function normalizeLegacyNanoBananaSkill(
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLegacyTalkConfig(cfg: OpenClawConfig, changes: string[]): OpenClawConfig {
|
||||
const rawTalk = cfg.talk;
|
||||
if (!isRecord(rawTalk)) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const normalizedTalk = normalizeTalkSection(rawTalk as OpenClawConfig["talk"]) ?? {};
|
||||
const legacyProviderCompat = buildLegacyTalkProviderCompat(rawTalk);
|
||||
if (legacyProviderCompat) {
|
||||
normalizedTalk.providers = {
|
||||
...normalizedTalk.providers,
|
||||
elevenlabs: {
|
||||
...legacyProviderCompat,
|
||||
...normalizedTalk.providers?.elevenlabs,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (Object.keys(normalizedTalk).length === 0 || isDeepStrictEqual(normalizedTalk, rawTalk)) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
changes.push(
|
||||
"Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).",
|
||||
);
|
||||
return {
|
||||
...cfg,
|
||||
talk: normalizedTalk,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLegacyCrossContextMessageConfig(
|
||||
cfg: OpenClawConfig,
|
||||
changes: string[],
|
||||
|
||||
49
src/commands/doctor/shared/legacy-talk-config-normalizer.ts
Normal file
49
src/commands/doctor/shared/legacy-talk-config-normalizer.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { normalizeTalkSection } from "../../../config/talk.js";
|
||||
import type { OpenClawConfig } from "../../../config/types.js";
|
||||
|
||||
function buildLegacyTalkProviderCompat(
|
||||
talk: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const compat: Record<string, unknown> = {};
|
||||
for (const key of ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"] as const) {
|
||||
if (talk[key] !== undefined) {
|
||||
compat[key] = talk[key];
|
||||
}
|
||||
}
|
||||
return Object.keys(compat).length > 0 ? compat : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
export function normalizeLegacyTalkConfig(cfg: OpenClawConfig, changes: string[]): OpenClawConfig {
|
||||
const rawTalk = cfg.talk;
|
||||
if (!isRecord(rawTalk)) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const normalizedTalk = normalizeTalkSection(rawTalk as OpenClawConfig["talk"]) ?? {};
|
||||
const legacyProviderCompat = buildLegacyTalkProviderCompat(rawTalk);
|
||||
if (legacyProviderCompat) {
|
||||
normalizedTalk.providers = {
|
||||
...normalizedTalk.providers,
|
||||
elevenlabs: {
|
||||
...legacyProviderCompat,
|
||||
...normalizedTalk.providers?.elevenlabs,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (Object.keys(normalizedTalk).length === 0 || isDeepStrictEqual(normalizedTalk, rawTalk)) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
changes.push(
|
||||
"Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).",
|
||||
);
|
||||
return {
|
||||
...cfg,
|
||||
talk: normalizedTalk,
|
||||
};
|
||||
}
|
||||
@@ -12,13 +12,9 @@ import {
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import { flowsCancelCommand, flowsListCommand, flowsShowCommand } from "./flows.js";
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
};
|
||||
});
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
|
||||
import type { GatewayTlsRuntime } from "../infra/tls/gateway.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
|
||||
let gatewayStatusCommand: typeof import("./gateway-status.js").gatewayStatusCommand;
|
||||
import { gatewayStatusCommand } from "./gateway-status.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const sshStop = vi.fn(async () => {});
|
||||
@@ -240,10 +239,8 @@ function findUnresolvedSecretRefWarning(runtimeLogs: string[]) {
|
||||
}
|
||||
|
||||
describe("gateway-status command", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
({ gatewayStatusCommand } = await import("./gateway-status.js"));
|
||||
});
|
||||
|
||||
it("prints human output by default", async () => {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { stripAnsi } from "../terminal/ansi.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import type { HealthSummary } from "./health.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
|
||||
@@ -25,6 +23,32 @@ vi.mock("../gateway/call.js", () => ({
|
||||
Reflect.apply(buildGatewayConnectionDetailsMock, undefined, args),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => {
|
||||
const whatsappPlugin = {
|
||||
id: "whatsapp",
|
||||
meta: {
|
||||
id: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
selectionLabel: "WhatsApp",
|
||||
docsPath: "/channels/whatsapp",
|
||||
blurb: "WhatsApp test stub.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
status: {
|
||||
logSelfId: () => logWebSelfIdMock(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
getChannelPlugin: (channelId: string) => (channelId === "whatsapp" ? whatsappPlugin : null),
|
||||
listChannelPlugins: () => [whatsappPlugin],
|
||||
};
|
||||
});
|
||||
|
||||
describe("healthCommand (coverage)", () => {
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
@@ -37,32 +61,6 @@ describe("healthCommand (coverage)", () => {
|
||||
buildGatewayConnectionDetailsMock.mockReturnValue({
|
||||
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
source: "test",
|
||||
plugin: {
|
||||
id: "whatsapp",
|
||||
meta: {
|
||||
id: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
selectionLabel: "WhatsApp",
|
||||
docsPath: "/channels/whatsapp",
|
||||
blurb: "WhatsApp test stub.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
status: {
|
||||
logSelfId: () => logWebSelfIdMock(),
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("prints the rich text summary when linked and configured", async () => {
|
||||
|
||||
@@ -42,6 +42,12 @@ async function loadFreshHealthModulesForTest() {
|
||||
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||
updateLastRoute: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.doMock("../config/sessions/paths.js", () => ({
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
}));
|
||||
vi.doMock("../config/sessions/store.js", () => ({
|
||||
loadSessionStore: () => testStore,
|
||||
}));
|
||||
vi.doMock("../plugins/runtime/runtime-web-channel-plugin.js", () => ({
|
||||
webAuthExists: vi.fn(async () => true),
|
||||
getWebAuthAgeMs: vi.fn(() => 1234),
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("healthCommand", () => {
|
||||
});
|
||||
callGatewayMock.mockResolvedValueOnce(snapshot);
|
||||
|
||||
await healthCommand({ json: true, timeoutMs: 5000 }, runtime as never);
|
||||
await healthCommand({ json: true, timeoutMs: 5000, config: {} }, runtime as never);
|
||||
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
const logged = runtime.log.mock.calls[0]?.[0] as string;
|
||||
@@ -114,7 +114,7 @@ describe("healthCommand", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await healthCommand({ json: false }, runtime as never);
|
||||
await healthCommand({ json: false, config: {} }, runtime as never);
|
||||
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalled();
|
||||
|
||||
@@ -5,9 +5,8 @@ import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { loadConfig, readBestEffortConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { info } from "../globals.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
@@ -105,7 +104,8 @@ const resolveAgentOrder = (cfg: OpenClawConfig) => {
|
||||
return { defaultAgentId, ordered };
|
||||
};
|
||||
|
||||
const buildSessionSummary = (storePath: string) => {
|
||||
const buildSessionSummary = async (storePath: string) => {
|
||||
const { loadSessionStore } = await import("../config/sessions/store.js");
|
||||
const store = loadSessionStore(storePath);
|
||||
const sessions = Object.entries(store)
|
||||
.filter(([key]) => key !== "global" && key !== "unknown")
|
||||
@@ -379,29 +379,31 @@ export async function getHealthSnapshot(params?: {
|
||||
probe?: boolean;
|
||||
}): Promise<HealthSummary> {
|
||||
const timeoutMs = params?.timeoutMs;
|
||||
const { loadConfig } = await import("../config/config.js");
|
||||
const cfg = loadConfig();
|
||||
const { defaultAgentId, ordered } = resolveAgentOrder(cfg);
|
||||
const channelBindings = buildChannelAccountBindings(cfg);
|
||||
const sessionCache = new Map<string, HealthSummary["sessions"]>();
|
||||
const agents: AgentHealthSummary[] = ordered.map((entry) => {
|
||||
const agents: AgentHealthSummary[] = [];
|
||||
for (const entry of ordered) {
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId: entry.id });
|
||||
const sessions = sessionCache.get(storePath) ?? buildSessionSummary(storePath);
|
||||
const sessions = sessionCache.get(storePath) ?? (await buildSessionSummary(storePath));
|
||||
sessionCache.set(storePath, sessions);
|
||||
return {
|
||||
agents.push({
|
||||
agentId: entry.id,
|
||||
name: entry.name,
|
||||
isDefault: entry.id === defaultAgentId,
|
||||
heartbeat: resolveHeartbeatSummary(cfg, entry.id),
|
||||
sessions,
|
||||
} satisfies AgentHealthSummary;
|
||||
});
|
||||
});
|
||||
}
|
||||
const defaultAgent = agents.find((agent) => agent.isDefault) ?? agents[0];
|
||||
const heartbeatSeconds = defaultAgent?.heartbeat.everyMs
|
||||
? Math.round(defaultAgent.heartbeat.everyMs / 1000)
|
||||
: 0;
|
||||
const sessions =
|
||||
defaultAgent?.sessions ??
|
||||
buildSessionSummary(resolveStorePath(cfg.session?.store, { agentId: defaultAgentId }));
|
||||
(await buildSessionSummary(resolveStorePath(cfg.session?.store, { agentId: defaultAgentId })));
|
||||
|
||||
const start = Date.now();
|
||||
const cappedTimeout = timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : Math.max(50, timeoutMs);
|
||||
@@ -556,7 +558,7 @@ export async function healthCommand(
|
||||
opts: { json?: boolean; timeoutMs?: number; verbose?: boolean; config?: OpenClawConfig },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const cfg = opts.config ?? (await readBestEffortConfig());
|
||||
const cfg = opts.config ?? (await readBestEffortHealthConfig());
|
||||
// Always query the running gateway; do not open a direct Baileys socket here.
|
||||
const summary = await withProgress(
|
||||
{
|
||||
@@ -591,16 +593,17 @@ export async function healthCommand(
|
||||
const localAgents = resolveAgentOrder(cfg);
|
||||
const defaultAgentId = summary.defaultAgentId ?? localAgents.defaultAgentId;
|
||||
const agents = Array.isArray(summary.agents) ? summary.agents : [];
|
||||
const fallbackAgents = localAgents.ordered.map((entry) => {
|
||||
const fallbackAgents: AgentHealthSummary[] = [];
|
||||
for (const entry of localAgents.ordered) {
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId: entry.id });
|
||||
return {
|
||||
fallbackAgents.push({
|
||||
agentId: entry.id,
|
||||
name: entry.name,
|
||||
isDefault: entry.id === localAgents.defaultAgentId,
|
||||
heartbeat: resolveHeartbeatSummary(cfg, entry.id),
|
||||
sessions: buildSessionSummary(storePath),
|
||||
} satisfies AgentHealthSummary;
|
||||
});
|
||||
sessions: await buildSessionSummary(storePath),
|
||||
});
|
||||
}
|
||||
const resolvedAgents = agents.length > 0 ? agents : fallbackAgents;
|
||||
const displayAgents = opts.verbose
|
||||
? resolvedAgents
|
||||
@@ -802,3 +805,8 @@ export async function healthCommand(
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBestEffortHealthConfig(): Promise<OpenClawConfig> {
|
||||
const { readBestEffortConfig } = await import("../config/config.js");
|
||||
return await readBestEffortConfig();
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import path from "node:path";
|
||||
import { inspect } from "node:util";
|
||||
import { cancel, isCancel } from "@clack/prompts";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
|
||||
import { CONFIG_PATH } from "../config/config.js";
|
||||
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
|
||||
import { CONFIG_PATH } from "../config/paths.js";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveControlUiLinks } from "../gateway/control-ui-links.js";
|
||||
import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
|
||||
|
||||
@@ -73,16 +73,31 @@ vi.mock("../gateway/client.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-helpers.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./onboard-helpers.js")>("./onboard-helpers.js");
|
||||
vi.mock("./onboard-helpers.js", () => {
|
||||
const normalizeGatewayTokenInput = (value: unknown): string => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "undefined" || trimmed === "null" ? "" : trimmed;
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
DEFAULT_WORKSPACE: "/tmp/openclaw-workspace",
|
||||
applyWizardMetadata: (cfg: unknown) => cfg,
|
||||
ensureWorkspaceAndSessions: ensureWorkspaceAndSessionsMock,
|
||||
waitForGatewayReachable: (...args: Parameters<typeof actual.waitForGatewayReachable>) =>
|
||||
waitForGatewayReachableMock
|
||||
? waitForGatewayReachableMock(args[0])
|
||||
: actual.waitForGatewayReachable(...args),
|
||||
normalizeGatewayTokenInput,
|
||||
randomToken: () => "tok_generated_gateway_test_token",
|
||||
resolveControlUiLinks: ({ port }: { port: number }) => ({
|
||||
httpUrl: `http://127.0.0.1:${port}`,
|
||||
wsUrl: `ws://127.0.0.1:${port}`,
|
||||
}),
|
||||
waitForGatewayReachable: (params: {
|
||||
url: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
deadlineMs?: number;
|
||||
probeTimeoutMs?: number;
|
||||
}) => waitForGatewayReachableMock?.(params) ?? Promise.resolve({ ok: true }),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -104,15 +119,17 @@ vi.mock("../daemon/diagnostics.js", () => ({
|
||||
|
||||
let runNonInteractiveSetup: typeof import("./onboard-non-interactive.js").runNonInteractiveSetup;
|
||||
let resolveStateConfigPath: typeof import("../config/paths.js").resolveConfigPath;
|
||||
let resolveConfigPath: typeof import("../config/config.js").resolveConfigPath;
|
||||
let callGateway: typeof import("../gateway/call.js").callGateway;
|
||||
let callGateway: typeof import("../gateway/call.js").callGateway | undefined;
|
||||
|
||||
async function loadGatewayOnboardModules(): Promise<void> {
|
||||
vi.resetModules();
|
||||
({ runNonInteractiveSetup } = await import("./onboard-non-interactive.js"));
|
||||
({ resolveConfigPath: resolveStateConfigPath } = await import("../config/paths.js"));
|
||||
({ resolveConfigPath } = await import("../config/config.js"));
|
||||
({ callGateway } = await import("../gateway/call.js"));
|
||||
}
|
||||
|
||||
async function loadCallGateway(): Promise<typeof import("../gateway/call.js").callGateway> {
|
||||
callGateway ??= (await import("../gateway/call.js")).callGateway;
|
||||
return callGateway;
|
||||
}
|
||||
|
||||
function getPseudoPort(base: number): number {
|
||||
@@ -429,7 +446,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
}, 60_000);
|
||||
|
||||
it("writes gateway.remote url/token and callGateway uses them", async () => {
|
||||
await withStateDir("state-remote-", async () => {
|
||||
await withStateDir("state-remote-", async (stateDir) => {
|
||||
const port = getPseudoPort(30_000);
|
||||
const token = "tok_remote_123";
|
||||
await runNonInteractiveSetup(
|
||||
@@ -446,14 +463,14 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
|
||||
const cfg = await readJsonFile<{
|
||||
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
|
||||
}>(resolveConfigPath());
|
||||
}>(resolveStateConfigPath(process.env, stateDir));
|
||||
|
||||
expect(cfg.gateway?.mode).toBe("remote");
|
||||
expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`);
|
||||
expect(cfg.gateway?.remote?.token).toBe(token);
|
||||
|
||||
gatewayClientCalls.length = 0;
|
||||
const health = await callGateway({ method: "health" });
|
||||
const health = await (await loadCallGateway())({ method: "health" });
|
||||
expect(health?.ok).toBe(true);
|
||||
const lastCall = gatewayClientCalls[gatewayClientCalls.length - 1];
|
||||
expect(lastCall?.url).toBe(`ws://127.0.0.1:${port}`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
@@ -28,11 +29,6 @@ const TEST_MAIN_AUTH_STORE_KEY = "__main__";
|
||||
const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => {}));
|
||||
const readConfigFileSnapshotMock = vi.hoisted(() =>
|
||||
vi.fn(async () => {
|
||||
const [{ default: fs }, { default: path }, { default: crypto }] = await Promise.all([
|
||||
import("node:fs/promises"),
|
||||
import("node:path"),
|
||||
import("node:crypto"),
|
||||
]);
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
throw new Error("OPENCLAW_CONFIG_PATH must be set for provider auth onboarding tests");
|
||||
@@ -61,10 +57,6 @@ const readConfigFileSnapshotMock = vi.hoisted(() =>
|
||||
);
|
||||
const replaceConfigFileMock = vi.hoisted(() =>
|
||||
vi.fn(async (params: { nextConfig: unknown }) => {
|
||||
const [{ default: fs }, { default: path }] = await Promise.all([
|
||||
import("node:fs/promises"),
|
||||
import("node:path"),
|
||||
]);
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
throw new Error("OPENCLAW_CONFIG_PATH must be set for provider auth onboarding tests");
|
||||
@@ -141,14 +133,12 @@ function upsertAuthProfile(params: {
|
||||
writeRuntimeAuthSnapshots();
|
||||
}
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
replaceConfigFile: replaceConfigFileMock,
|
||||
};
|
||||
});
|
||||
vi.mock("../config/config.js", () => ({
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
replaceConfigFile: replaceConfigFileMock,
|
||||
resolveGatewayPort: (cfg?: { gateway?: { port?: unknown } }) =>
|
||||
typeof cfg?.gateway?.port === "number" ? cfg.gateway.port : 18789,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async () => {
|
||||
const [
|
||||
@@ -826,12 +816,25 @@ vi.mock("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./onboard-helpers.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./onboard-helpers.js")>("./onboard-helpers.js");
|
||||
vi.mock("./onboard-helpers.js", () => {
|
||||
const normalizeGatewayTokenInput = (value: unknown): string => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "undefined" || trimmed === "null" ? "" : trimmed;
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
DEFAULT_WORKSPACE: "/tmp/openclaw-workspace",
|
||||
applyWizardMetadata: (cfg: unknown) => cfg,
|
||||
ensureWorkspaceAndSessions: ensureWorkspaceAndSessionsMock,
|
||||
normalizeGatewayTokenInput,
|
||||
randomToken: () => "tok_generated_provider_auth_test_token",
|
||||
resolveControlUiLinks: ({ port }: { port: number }) => ({
|
||||
httpUrl: `http://127.0.0.1:${port}`,
|
||||
wsUrl: `ws://127.0.0.1:${port}`,
|
||||
}),
|
||||
waitForGatewayReachable: async () => ({ ok: true }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { readConfigFileSnapshot } from "../config/config.js";
|
||||
import { readConfigFileSnapshot } from "../config/io.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
|
||||
@@ -7,9 +7,17 @@ import { setupCommand } from "./setup.js";
|
||||
function createSetupDeps(home: string) {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
return {
|
||||
createConfigIO: () => ({ configPath }),
|
||||
ensureAgentWorkspace: vi.fn(async (params?: { dir?: string }) => ({
|
||||
dir: params?.dir ?? path.join(home, ".openclaw", "workspace"),
|
||||
})),
|
||||
formatConfigPath: (value: string) => value,
|
||||
logConfigUpdated: vi.fn(
|
||||
(runtime: { log: (message: string) => void }, opts: { path?: string; suffix?: string }) => {
|
||||
const suffix = opts.suffix ? ` ${opts.suffix}` : "";
|
||||
runtime.log(`Updated ${opts.path}${suffix}`);
|
||||
},
|
||||
),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
resolveSessionTranscriptsDir: vi.fn(() => path.join(home, ".openclaw", "sessions")),
|
||||
writeConfigFile: vi.fn(async (config: unknown) => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import JSON5 from "json5";
|
||||
import { z } from "zod";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
|
||||
import { type OpenClawConfig, createConfigIO, writeConfigFile } from "../config/config.js";
|
||||
import { formatConfigPath, logConfigUpdated } from "../config/logging.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
@@ -11,13 +9,71 @@ import { safeParseWithSchema } from "../utils/zod-parse.js";
|
||||
|
||||
const JsonRecordSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
type ConfigIO = {
|
||||
configPath: string;
|
||||
};
|
||||
|
||||
type EnsureAgentWorkspace = (params: {
|
||||
dir: string;
|
||||
ensureBootstrapFiles?: boolean;
|
||||
}) => Promise<{ dir: string }>;
|
||||
|
||||
type SetupCommandDeps = {
|
||||
ensureAgentWorkspace?: typeof ensureAgentWorkspace;
|
||||
createConfigIO?: () => ConfigIO;
|
||||
defaultAgentWorkspaceDir?: string | (() => string | Promise<string>);
|
||||
ensureAgentWorkspace?: EnsureAgentWorkspace;
|
||||
formatConfigPath?: (path: string) => string;
|
||||
logConfigUpdated?: (
|
||||
runtime: RuntimeEnv,
|
||||
opts: { path?: string; suffix?: string },
|
||||
) => void | Promise<void>;
|
||||
mkdir?: (dir: string, options: { recursive: true }) => Promise<unknown>;
|
||||
resolveSessionTranscriptsDir?: () => string | Promise<string>;
|
||||
writeConfigFile?: typeof writeConfigFile;
|
||||
writeConfigFile?: (config: OpenClawConfig) => Promise<void>;
|
||||
};
|
||||
|
||||
async function createDefaultConfigIO(): Promise<ConfigIO> {
|
||||
const { createConfigIO } = await import("../config/io.js");
|
||||
return createConfigIO();
|
||||
}
|
||||
|
||||
async function resolveDefaultAgentWorkspaceDir(deps: SetupCommandDeps): Promise<string> {
|
||||
const override = deps.defaultAgentWorkspaceDir;
|
||||
if (typeof override === "string") {
|
||||
return override;
|
||||
}
|
||||
if (typeof override === "function") {
|
||||
return await override();
|
||||
}
|
||||
const { DEFAULT_AGENT_WORKSPACE_DIR } = await import("../agents/workspace.js");
|
||||
return DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
}
|
||||
|
||||
async function ensureDefaultAgentWorkspace(
|
||||
params: Parameters<EnsureAgentWorkspace>[0],
|
||||
): ReturnType<EnsureAgentWorkspace> {
|
||||
const { ensureAgentWorkspace } = await import("../agents/workspace.js");
|
||||
return ensureAgentWorkspace(params);
|
||||
}
|
||||
|
||||
async function writeDefaultConfigFile(config: OpenClawConfig): Promise<void> {
|
||||
const { writeConfigFile } = await import("../config/io.js");
|
||||
await writeConfigFile(config);
|
||||
}
|
||||
|
||||
async function formatDefaultConfigPath(configPath: string): Promise<string> {
|
||||
const { formatConfigPath } = await import("../config/logging.js");
|
||||
return formatConfigPath(configPath);
|
||||
}
|
||||
|
||||
async function logDefaultConfigUpdated(
|
||||
runtime: RuntimeEnv,
|
||||
opts: { path?: string; suffix?: string },
|
||||
): Promise<void> {
|
||||
const { logConfigUpdated } = await import("../config/logging.js");
|
||||
logConfigUpdated(runtime, opts);
|
||||
}
|
||||
|
||||
async function resolveDefaultSessionTranscriptsDir(): Promise<string> {
|
||||
const { resolveSessionTranscriptsDir } = await import("../config/sessions.js");
|
||||
return resolveSessionTranscriptsDir();
|
||||
@@ -46,13 +102,14 @@ export async function setupCommand(
|
||||
? opts.workspace.trim()
|
||||
: undefined;
|
||||
|
||||
const io = createConfigIO();
|
||||
const io = deps.createConfigIO?.() ?? (await createDefaultConfigIO());
|
||||
const configPath = io.configPath;
|
||||
const existingRaw = await readConfigFileRaw(configPath);
|
||||
const cfg = existingRaw.parsed;
|
||||
const defaults = cfg.agents?.defaults ?? {};
|
||||
|
||||
const workspace = desiredWorkspace ?? defaults.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
const workspace =
|
||||
desiredWorkspace ?? defaults.workspace ?? (await resolveDefaultAgentWorkspaceDir(deps));
|
||||
|
||||
const next: OpenClawConfig = {
|
||||
...cfg,
|
||||
@@ -74,9 +131,10 @@ export async function setupCommand(
|
||||
defaults.workspace !== workspace ||
|
||||
cfg.gateway?.mode !== next.gateway?.mode
|
||||
) {
|
||||
await (deps.writeConfigFile ?? writeConfigFile)(next);
|
||||
await (deps.writeConfigFile ?? writeDefaultConfigFile)(next);
|
||||
if (!existingRaw.exists) {
|
||||
runtime.log(`Wrote ${formatConfigPath(configPath)}`);
|
||||
const formatConfigPath = deps.formatConfigPath ?? formatDefaultConfigPath;
|
||||
runtime.log(`Wrote ${await formatConfigPath(configPath)}`);
|
||||
} else {
|
||||
const updates: string[] = [];
|
||||
if (defaults.workspace !== workspace) {
|
||||
@@ -86,13 +144,17 @@ export async function setupCommand(
|
||||
updates.push("set gateway.mode");
|
||||
}
|
||||
const suffix = updates.length > 0 ? `(${updates.join(", ")})` : undefined;
|
||||
logConfigUpdated(runtime, { path: configPath, suffix });
|
||||
await (deps.logConfigUpdated ?? logDefaultConfigUpdated)(runtime, {
|
||||
path: configPath,
|
||||
suffix,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
runtime.log(`Config OK: ${formatConfigPath(configPath)}`);
|
||||
const formatConfigPath = deps.formatConfigPath ?? formatDefaultConfigPath;
|
||||
runtime.log(`Config OK: ${await formatConfigPath(configPath)}`);
|
||||
}
|
||||
|
||||
const ws = await (deps.ensureAgentWorkspace ?? ensureAgentWorkspace)({
|
||||
const ws = await (deps.ensureAgentWorkspace ?? ensureDefaultAgentWorkspace)({
|
||||
dir: workspace,
|
||||
ensureBootstrapFiles: !next.agents?.defaults?.skipBootstrap,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { executeStatusScanFromOverview } from "./status.scan-execute.ts";
|
||||
import type { StatusScanOverviewResult } from "./status.scan-overview.ts";
|
||||
import type { MemoryStatusSnapshot } from "./status.scan.shared.js";
|
||||
|
||||
@@ -11,21 +12,20 @@ const { resolveStatusSummaryFromOverview, resolveMemoryPluginStatus } = vi.hoist
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./status.scan-overview.ts", () => ({
|
||||
resolveStatusSummaryFromOverview,
|
||||
}));
|
||||
|
||||
vi.mock("./status.scan.shared.js", () => ({
|
||||
resolveMemoryPluginStatus,
|
||||
}));
|
||||
|
||||
describe("executeStatusScanFromOverview", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.doMock("./status.scan-overview.ts", () => ({
|
||||
resolveStatusSummaryFromOverview,
|
||||
}));
|
||||
vi.doMock("./status.scan.shared.js", () => ({
|
||||
resolveMemoryPluginStatus,
|
||||
}));
|
||||
});
|
||||
|
||||
it("resolves memory and summary, then builds the final scan result", async () => {
|
||||
const { executeStatusScanFromOverview } = await import("./status.scan-execute.ts");
|
||||
|
||||
const overview = {
|
||||
cfg: { channels: {} },
|
||||
sourceConfig: { channels: {} },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { collectStatusScanOverview } from "./status.scan-overview.ts";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
hasPotentialConfiguredChannels: vi.fn(),
|
||||
@@ -49,7 +50,6 @@ vi.mock("./status.scan.runtime.js", () => ({
|
||||
|
||||
describe("collectStatusScanOverview", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
|
||||
mocks.hasPotentialConfiguredChannels.mockReturnValue(true);
|
||||
@@ -96,8 +96,6 @@ describe("collectStatusScanOverview", () => {
|
||||
});
|
||||
|
||||
it("uses gateway fallback overrides for channels.status when requested", async () => {
|
||||
const { collectStatusScanOverview } = await import("./status.scan-overview.ts");
|
||||
|
||||
const result = await collectStatusScanOverview({
|
||||
commandName: "status --all",
|
||||
opts: { timeoutMs: 1234 },
|
||||
@@ -149,8 +147,6 @@ describe("collectStatusScanOverview", () => {
|
||||
resolveTailscaleHttpsUrl: vi.fn(async () => null),
|
||||
skipColdStartNetworkChecks: false,
|
||||
});
|
||||
const { collectStatusScanOverview } = await import("./status.scan-overview.ts");
|
||||
|
||||
const result = await collectStatusScanOverview({
|
||||
commandName: "status",
|
||||
opts: {},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveGatewayProbeSnapshot } from "./status.scan.shared.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildGatewayConnectionDetailsWithResolvers: vi.fn(),
|
||||
@@ -30,7 +31,6 @@ vi.mock("./gateway-presence.js", () => ({
|
||||
|
||||
describe("resolveGatewayProbeSnapshot", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mocks.buildGatewayConnectionDetailsWithResolvers.mockReturnValue({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
@@ -50,8 +50,6 @@ describe("resolveGatewayProbeSnapshot", () => {
|
||||
});
|
||||
|
||||
it("skips auth resolution and probe for missing remote urls by default", async () => {
|
||||
const { resolveGatewayProbeSnapshot } = await import("./status.scan.shared.js");
|
||||
|
||||
const result = await resolveGatewayProbeSnapshot({
|
||||
cfg: {},
|
||||
opts: {},
|
||||
@@ -88,8 +86,6 @@ describe("resolveGatewayProbeSnapshot", () => {
|
||||
presence: [{ host: "box" }],
|
||||
configSnapshot: null,
|
||||
});
|
||||
const { resolveGatewayProbeSnapshot } = await import("./status.scan.shared.js");
|
||||
|
||||
const result = await resolveGatewayProbeSnapshot({
|
||||
cfg: {},
|
||||
opts: {
|
||||
@@ -135,8 +131,6 @@ describe("resolveGatewayProbeSnapshot", () => {
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
});
|
||||
const { resolveGatewayProbeSnapshot } = await import("./status.scan.shared.js");
|
||||
|
||||
const result = await resolveGatewayProbeSnapshot({
|
||||
cfg: {},
|
||||
opts: {},
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
loadConfig,
|
||||
resolveConfigPath,
|
||||
resolveGatewayPort,
|
||||
resolveStateDir,
|
||||
} from "../config/config.js";
|
||||
import { loadConfig as loadConfigFromIo } from "../config/io.js";
|
||||
import { loadConfig } from "../config/io.js";
|
||||
import {
|
||||
resolveConfigPath as resolveConfigPathFromPaths,
|
||||
resolveGatewayPort as resolveGatewayPortFromPaths,
|
||||
@@ -99,9 +93,9 @@ const defaultGatewayCallDeps = {
|
||||
createGatewayClient: defaultCreateGatewayClient,
|
||||
loadConfig,
|
||||
loadOrCreateDeviceIdentity,
|
||||
resolveGatewayPort,
|
||||
resolveConfigPath,
|
||||
resolveStateDir,
|
||||
resolveGatewayPort: resolveGatewayPortFromPaths,
|
||||
resolveConfigPath: resolveConfigPathFromPaths,
|
||||
resolveStateDir: resolveStateDirFromPaths,
|
||||
loadGatewayTlsRuntime,
|
||||
};
|
||||
const gatewayCallDeps = {
|
||||
@@ -127,7 +121,7 @@ function loadGatewayConfig(): OpenClawConfig {
|
||||
? gatewayCallDeps.loadConfig
|
||||
: typeof defaultGatewayCallDeps.loadConfig === "function"
|
||||
? defaultGatewayCallDeps.loadConfig
|
||||
: loadConfigFromIo;
|
||||
: loadConfig;
|
||||
return loadConfigFn();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export type GatewayHttpServer = ReturnType<typeof createGatewayHttpServer>;
|
||||
export type GatewayServerOptions = Partial<Parameters<typeof createGatewayHttpServer>[0]>;
|
||||
type HooksHandlerDeps = Parameters<typeof createHooksRequestHandler>[0];
|
||||
|
||||
const responseEndPromises = new WeakMap<ServerResponse, Promise<void>>();
|
||||
export const AUTH_NONE: ResolvedGatewayAuth = {
|
||||
mode: "none",
|
||||
token: undefined,
|
||||
@@ -67,16 +68,23 @@ export function createResponse(): {
|
||||
} {
|
||||
const setHeader = vi.fn();
|
||||
let body = "";
|
||||
let resolveEnd!: () => void;
|
||||
const ended = new Promise<void>((resolve) => {
|
||||
resolveEnd = resolve;
|
||||
});
|
||||
const end = vi.fn((chunk?: unknown) => {
|
||||
if (typeof chunk === "string") {
|
||||
body = chunk;
|
||||
resolveEnd();
|
||||
return;
|
||||
}
|
||||
if (chunk == null) {
|
||||
body = "";
|
||||
resolveEnd();
|
||||
return;
|
||||
}
|
||||
body = JSON.stringify(chunk);
|
||||
resolveEnd();
|
||||
});
|
||||
const res = {
|
||||
headersSent: false,
|
||||
@@ -84,6 +92,7 @@ export function createResponse(): {
|
||||
setHeader,
|
||||
end,
|
||||
} as unknown as ServerResponse;
|
||||
responseEndPromises.set(res, ended);
|
||||
return {
|
||||
res,
|
||||
setHeader,
|
||||
@@ -98,7 +107,10 @@ export async function dispatchRequest(
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
server.emit("request", req, res);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await Promise.race([
|
||||
responseEndPromises.get(res) ?? new Promise((resolve) => setImmediate(resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function withGatewayTempConfig(
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
import { createServer as createHttpsServer } from "node:https";
|
||||
import type { TlsOptions } from "node:tls";
|
||||
import type { WebSocketServer } from "ws";
|
||||
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
|
||||
import { CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
|
||||
import { A2UI_PATH, CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
|
||||
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
||||
import { listBundledChannelPlugins } from "../channels/plugins/bundled.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
@@ -31,13 +29,7 @@ import {
|
||||
type ResolvedGatewayAuth,
|
||||
} from "./auth.js";
|
||||
import { normalizeCanvasScopedUrl } from "./canvas-capability.js";
|
||||
import {
|
||||
handleControlUiAssistantMediaRequest,
|
||||
handleControlUiAvatarRequest,
|
||||
handleControlUiHttpRequest,
|
||||
type ControlUiRootState,
|
||||
} from "./control-ui.js";
|
||||
import { handleOpenAiEmbeddingsHttpRequest } from "./embeddings-http.js";
|
||||
import type { ControlUiRootState } from "./control-ui.js";
|
||||
import { applyHookMappings } from "./hooks-mapping.js";
|
||||
import {
|
||||
extractHookToken,
|
||||
@@ -66,10 +58,7 @@ import {
|
||||
getBearerToken,
|
||||
resolveHttpBrowserOriginPolicy,
|
||||
} from "./http-utils.js";
|
||||
import { handleOpenAiModelsHttpRequest } from "./models-http.js";
|
||||
import { resolveRequestClientIp } from "./net.js";
|
||||
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
||||
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
|
||||
import { DEDUPE_MAX, DEDUPE_TTL_MS } from "./server-constants.js";
|
||||
import { authorizeCanvasRequest, isCanvasPath } from "./server/http-auth.js";
|
||||
import { resolvePluginRouteRuntimeOperatorScopes } from "./server/plugin-route-runtime-scopes.js";
|
||||
@@ -82,15 +71,77 @@ import {
|
||||
import type { PreauthConnectionBudget } from "./server/preauth-connection-budget.js";
|
||||
import type { ReadinessChecker } from "./server/readiness.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
import { handleSessionKillHttpRequest } from "./session-kill-http.js";
|
||||
import { handleSessionHistoryHttpRequest } from "./sessions-history-http.js";
|
||||
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
|
||||
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
const HOOK_AUTH_FAILURE_LIMIT = 20;
|
||||
const HOOK_AUTH_FAILURE_WINDOW_MS = 60_000;
|
||||
|
||||
let bundledChannelsModulePromise:
|
||||
| Promise<typeof import("../channels/plugins/bundled.js")>
|
||||
| undefined;
|
||||
let identityAvatarModulePromise: Promise<typeof import("../agents/identity-avatar.js")> | undefined;
|
||||
let controlUiModulePromise: Promise<typeof import("./control-ui.js")> | undefined;
|
||||
let embeddingsHttpModulePromise: Promise<typeof import("./embeddings-http.js")> | undefined;
|
||||
let modelsHttpModulePromise: Promise<typeof import("./models-http.js")> | undefined;
|
||||
let openAiHttpModulePromise: Promise<typeof import("./openai-http.js")> | undefined;
|
||||
let openResponsesHttpModulePromise: Promise<typeof import("./openresponses-http.js")> | undefined;
|
||||
let sessionHistoryHttpModulePromise:
|
||||
| Promise<typeof import("./sessions-history-http.js")>
|
||||
| undefined;
|
||||
let sessionKillHttpModulePromise: Promise<typeof import("./session-kill-http.js")> | undefined;
|
||||
let toolsInvokeHttpModulePromise: Promise<typeof import("./tools-invoke-http.js")> | undefined;
|
||||
|
||||
function getBundledChannelsModule() {
|
||||
bundledChannelsModulePromise ??= import("../channels/plugins/bundled.js");
|
||||
return bundledChannelsModulePromise;
|
||||
}
|
||||
|
||||
function getIdentityAvatarModule() {
|
||||
identityAvatarModulePromise ??= import("../agents/identity-avatar.js");
|
||||
return identityAvatarModulePromise;
|
||||
}
|
||||
|
||||
function getControlUiModule() {
|
||||
controlUiModulePromise ??= import("./control-ui.js");
|
||||
return controlUiModulePromise;
|
||||
}
|
||||
|
||||
function getEmbeddingsHttpModule() {
|
||||
embeddingsHttpModulePromise ??= import("./embeddings-http.js");
|
||||
return embeddingsHttpModulePromise;
|
||||
}
|
||||
|
||||
function getModelsHttpModule() {
|
||||
modelsHttpModulePromise ??= import("./models-http.js");
|
||||
return modelsHttpModulePromise;
|
||||
}
|
||||
|
||||
function getOpenAiHttpModule() {
|
||||
openAiHttpModulePromise ??= import("./openai-http.js");
|
||||
return openAiHttpModulePromise;
|
||||
}
|
||||
|
||||
function getOpenResponsesHttpModule() {
|
||||
openResponsesHttpModulePromise ??= import("./openresponses-http.js");
|
||||
return openResponsesHttpModulePromise;
|
||||
}
|
||||
|
||||
function getSessionHistoryHttpModule() {
|
||||
sessionHistoryHttpModulePromise ??= import("./sessions-history-http.js");
|
||||
return sessionHistoryHttpModulePromise;
|
||||
}
|
||||
|
||||
function getSessionKillHttpModule() {
|
||||
sessionKillHttpModulePromise ??= import("./session-kill-http.js");
|
||||
return sessionKillHttpModulePromise;
|
||||
}
|
||||
|
||||
function getToolsInvokeHttpModule() {
|
||||
toolsInvokeHttpModulePromise ??= import("./tools-invoke-http.js");
|
||||
return toolsInvokeHttpModulePromise;
|
||||
}
|
||||
|
||||
type HookDispatchers = {
|
||||
dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void;
|
||||
dispatchAgentHook: (value: HookAgentDispatchPayload) => string;
|
||||
@@ -138,8 +189,11 @@ const GATEWAY_PROBE_STATUS_BY_PATH = new Map<string, "live" | "ready">([
|
||||
["/ready", "ready"],
|
||||
["/readyz", "ready"],
|
||||
]);
|
||||
function resolvePluginGatewayAuthBypassPaths(configSnapshot: OpenClawConfig): Set<string> {
|
||||
async function resolvePluginGatewayAuthBypassPaths(
|
||||
configSnapshot: OpenClawConfig,
|
||||
): Promise<Set<string>> {
|
||||
const paths = new Set<string>();
|
||||
const { listBundledChannelPlugins } = await getBundledChannelsModule();
|
||||
for (const plugin of listBundledChannelPlugins()) {
|
||||
for (const path of plugin.gateway?.resolveGatewayAuthBypassPaths?.({ cfg: configSnapshot }) ??
|
||||
[]) {
|
||||
@@ -151,6 +205,38 @@ function resolvePluginGatewayAuthBypassPaths(configSnapshot: OpenClawConfig): Se
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isOpenAiModelsPath(pathname: string): boolean {
|
||||
return pathname === "/v1/models" || pathname.startsWith("/v1/models/");
|
||||
}
|
||||
|
||||
function isEmbeddingsPath(pathname: string): boolean {
|
||||
return pathname === "/v1/embeddings";
|
||||
}
|
||||
|
||||
function isOpenAiChatCompletionsPath(pathname: string): boolean {
|
||||
return pathname === "/v1/chat/completions";
|
||||
}
|
||||
|
||||
function isOpenResponsesPath(pathname: string): boolean {
|
||||
return pathname === "/v1/responses";
|
||||
}
|
||||
|
||||
function isToolsInvokePath(pathname: string): boolean {
|
||||
return pathname === "/tools/invoke";
|
||||
}
|
||||
|
||||
function isSessionKillPath(pathname: string): boolean {
|
||||
return /^\/sessions\/[^/]+\/kill$/.test(pathname);
|
||||
}
|
||||
|
||||
function isSessionHistoryPath(pathname: string): boolean {
|
||||
return /^\/sessions\/[^/]+\/history$/.test(pathname);
|
||||
}
|
||||
|
||||
function isA2uiPath(pathname: string): boolean {
|
||||
return pathname === A2UI_PATH || pathname.startsWith(`${A2UI_PATH}/`);
|
||||
}
|
||||
|
||||
function shouldEnforceDefaultPluginGatewayAuth(pathContext: PluginRoutePathContext): boolean {
|
||||
return (
|
||||
pathContext.malformedEncoding ||
|
||||
@@ -300,7 +386,7 @@ function buildPluginRequestStages(params: {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
requestPath: string;
|
||||
gatewayAuthBypassPaths: ReadonlySet<string>;
|
||||
getGatewayAuthBypassPaths: () => Promise<ReadonlySet<string>>;
|
||||
pluginPathContext: PluginRoutePathContext | null;
|
||||
handlePluginRequest?: PluginHttpRequestHandler;
|
||||
shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean;
|
||||
@@ -319,9 +405,6 @@ function buildPluginRequestStages(params: {
|
||||
{
|
||||
name: "plugin-auth",
|
||||
run: async () => {
|
||||
if (params.gatewayAuthBypassPaths.has(params.requestPath)) {
|
||||
return false;
|
||||
}
|
||||
const pathContext =
|
||||
params.pluginPathContext ?? resolvePluginRoutePathContext(params.requestPath);
|
||||
if (
|
||||
@@ -331,6 +414,9 @@ function buildPluginRequestStages(params: {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ((await params.getGatewayAuthBypassPaths()).has(params.requestPath)) {
|
||||
return false;
|
||||
}
|
||||
const requestAuth = await authorizeGatewayHttpRequestOrReply({
|
||||
req: params.req,
|
||||
res: params.res,
|
||||
@@ -807,7 +893,6 @@ export function createGatewayHttpServer(opts: {
|
||||
req.url = scopedCanvas.rewrittenUrl;
|
||||
}
|
||||
const requestPath = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
const gatewayAuthBypassPaths = resolvePluginGatewayAuthBypassPaths(configSnapshot);
|
||||
const pluginPathContext = handlePluginRequest
|
||||
? resolvePluginRoutePathContext(requestPath)
|
||||
: null;
|
||||
@@ -816,66 +901,72 @@ export function createGatewayHttpServer(opts: {
|
||||
name: "hooks",
|
||||
run: () => handleHooksRequest(req, res),
|
||||
},
|
||||
{
|
||||
name: "models",
|
||||
run: () =>
|
||||
openAiCompatEnabled
|
||||
? handleOpenAiModelsHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
})
|
||||
: false,
|
||||
},
|
||||
{
|
||||
name: "embeddings",
|
||||
run: () =>
|
||||
openAiCompatEnabled
|
||||
? handleOpenAiEmbeddingsHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
})
|
||||
: false,
|
||||
},
|
||||
{
|
||||
name: "tools-invoke",
|
||||
run: () =>
|
||||
handleToolsInvokeHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "sessions-kill",
|
||||
run: () =>
|
||||
handleSessionKillHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "sessions-history",
|
||||
run: () =>
|
||||
handleSessionHistoryHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
},
|
||||
];
|
||||
if (openResponsesEnabled) {
|
||||
if (openAiCompatEnabled && isOpenAiModelsPath(requestPath)) {
|
||||
requestStages.push({
|
||||
name: "models",
|
||||
run: async () =>
|
||||
(await getModelsHttpModule()).handleOpenAiModelsHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (openAiCompatEnabled && isEmbeddingsPath(requestPath)) {
|
||||
requestStages.push({
|
||||
name: "embeddings",
|
||||
run: async () =>
|
||||
(await getEmbeddingsHttpModule()).handleOpenAiEmbeddingsHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (isToolsInvokePath(requestPath)) {
|
||||
requestStages.push({
|
||||
name: "tools-invoke",
|
||||
run: async () =>
|
||||
(await getToolsInvokeHttpModule()).handleToolsInvokeHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (isSessionKillPath(requestPath)) {
|
||||
requestStages.push({
|
||||
name: "sessions-kill",
|
||||
run: async () =>
|
||||
(await getSessionKillHttpModule()).handleSessionKillHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (isSessionHistoryPath(requestPath)) {
|
||||
requestStages.push({
|
||||
name: "sessions-history",
|
||||
run: async () =>
|
||||
(await getSessionHistoryHttpModule()).handleSessionHistoryHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (openResponsesEnabled && isOpenResponsesPath(requestPath)) {
|
||||
requestStages.push({
|
||||
name: "openresponses",
|
||||
run: () =>
|
||||
handleOpenResponsesHttpRequest(req, res, {
|
||||
run: async () =>
|
||||
(await getOpenResponsesHttpModule()).handleOpenResponsesHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
config: openResponsesConfig,
|
||||
trustedProxies,
|
||||
@@ -884,11 +975,11 @@ export function createGatewayHttpServer(opts: {
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (openAiChatCompletionsEnabled) {
|
||||
if (openAiChatCompletionsEnabled && isOpenAiChatCompletionsPath(requestPath)) {
|
||||
requestStages.push({
|
||||
name: "openai",
|
||||
run: () =>
|
||||
handleOpenAiHttpRequest(req, res, {
|
||||
run: async () =>
|
||||
(await getOpenAiHttpModule()).handleOpenAiHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
config: openAiChatCompletionsConfig,
|
||||
trustedProxies,
|
||||
@@ -923,7 +1014,7 @@ export function createGatewayHttpServer(opts: {
|
||||
});
|
||||
requestStages.push({
|
||||
name: "a2ui",
|
||||
run: () => handleA2uiHttpRequest(req, res),
|
||||
run: () => (isA2uiPath(requestPath) ? handleA2uiHttpRequest(req, res) : false),
|
||||
});
|
||||
requestStages.push({
|
||||
name: "canvas-http",
|
||||
@@ -938,7 +1029,7 @@ export function createGatewayHttpServer(opts: {
|
||||
req,
|
||||
res,
|
||||
requestPath,
|
||||
gatewayAuthBypassPaths,
|
||||
getGatewayAuthBypassPaths: () => resolvePluginGatewayAuthBypassPaths(configSnapshot),
|
||||
pluginPathContext,
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth,
|
||||
@@ -952,8 +1043,8 @@ export function createGatewayHttpServer(opts: {
|
||||
if (controlUiEnabled) {
|
||||
requestStages.push({
|
||||
name: "control-ui-assistant-media",
|
||||
run: () =>
|
||||
handleControlUiAssistantMediaRequest(req, res, {
|
||||
run: async () =>
|
||||
(await getControlUiModule()).handleControlUiAssistantMediaRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
config: configSnapshot,
|
||||
agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,
|
||||
@@ -965,17 +1056,20 @@ export function createGatewayHttpServer(opts: {
|
||||
});
|
||||
requestStages.push({
|
||||
name: "control-ui-avatar",
|
||||
run: () =>
|
||||
handleControlUiAvatarRequest(req, res, {
|
||||
run: async () => {
|
||||
const { handleControlUiAvatarRequest } = await getControlUiModule();
|
||||
const { resolveAgentAvatar } = await getIdentityAvatarModule();
|
||||
return handleControlUiAvatarRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
resolveAvatar: (agentId) =>
|
||||
resolveAgentAvatar(configSnapshot, agentId, { includeUiOverride: true }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
requestStages.push({
|
||||
name: "control-ui-http",
|
||||
run: () =>
|
||||
handleControlUiHttpRequest(req, res, {
|
||||
run: async () =>
|
||||
(await getControlUiModule()).handleControlUiHttpRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
config: configSnapshot,
|
||||
agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("loadGatewayStartupPlugins browser plugin integration", () => {
|
||||
log: createTestLog(),
|
||||
coreGatewayHandlers,
|
||||
baseMethods: listGatewayMethods(),
|
||||
pluginIds: ["browser"],
|
||||
logDiagnostics: false,
|
||||
});
|
||||
|
||||
@@ -79,6 +80,7 @@ describe("loadGatewayStartupPlugins browser plugin integration", () => {
|
||||
log: createTestLog(),
|
||||
coreGatewayHandlers,
|
||||
baseMethods: listGatewayMethods(),
|
||||
pluginIds: ["browser"],
|
||||
logDiagnostics: false,
|
||||
});
|
||||
|
||||
|
||||
296
src/gateway/server-startup-config.secrets.test.ts
Normal file
296
src/gateway/server-startup-config.secrets.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js";
|
||||
import type { PreparedSecretsRuntimeSnapshot, SecretResolverWarning } from "../secrets/runtime.js";
|
||||
import {
|
||||
createRuntimeSecretsActivator,
|
||||
prepareGatewayStartupConfig,
|
||||
} from "./server-startup-config.js";
|
||||
import { buildTestConfigSnapshot } from "./test-helpers.config-snapshots.js";
|
||||
|
||||
function gatewayTokenConfig(config: OpenClawConfig): OpenClawConfig {
|
||||
return {
|
||||
...config,
|
||||
gateway: {
|
||||
...config.gateway,
|
||||
auth: {
|
||||
...config.gateway?.auth,
|
||||
mode: config.gateway?.auth?.mode ?? "token",
|
||||
token: config.gateway?.auth?.token ?? "startup-test-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function asConfig(value: unknown): OpenClawConfig {
|
||||
return value as OpenClawConfig;
|
||||
}
|
||||
|
||||
function buildSnapshot(config: OpenClawConfig): ConfigFileSnapshot {
|
||||
const raw = `${JSON.stringify(config, null, 2)}\n`;
|
||||
return buildTestConfigSnapshot({
|
||||
path: "/tmp/openclaw-startup-secrets-test.json",
|
||||
exists: true,
|
||||
raw,
|
||||
parsed: config,
|
||||
valid: true,
|
||||
config,
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
});
|
||||
}
|
||||
|
||||
function preparedSnapshot(config: OpenClawConfig): PreparedSecretsRuntimeSnapshot {
|
||||
return {
|
||||
sourceConfig: config,
|
||||
config,
|
||||
authStores: [],
|
||||
warnings: [],
|
||||
webTools: {
|
||||
search: {
|
||||
providerSource: "none",
|
||||
diagnostics: [],
|
||||
},
|
||||
fetch: {
|
||||
providerSource: "none",
|
||||
diagnostics: [],
|
||||
},
|
||||
diagnostics: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("gateway startup config secret preflight", () => {
|
||||
const previousSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS;
|
||||
const previousSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
|
||||
|
||||
afterEach(() => {
|
||||
if (previousSkipChannels === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_CHANNELS;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = previousSkipChannels;
|
||||
}
|
||||
if (previousSkipProviders === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_PROVIDERS;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_PROVIDERS = previousSkipProviders;
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps startup secret activation failures without emitting reload state events", async () => {
|
||||
const error = new Error('Environment variable "OPENAI_API_KEY" is missing or empty.');
|
||||
const prepareRuntimeSecretsSnapshot = vi.fn(async () => {
|
||||
throw error;
|
||||
});
|
||||
const emitStateEvent = vi.fn();
|
||||
const activateRuntimeSecrets = createRuntimeSecretsActivator({
|
||||
logSecrets: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
emitStateEvent,
|
||||
prepareRuntimeSecretsSnapshot,
|
||||
activateRuntimeSecretsSnapshot: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
activateRuntimeSecrets(gatewayTokenConfig({}), {
|
||||
reason: "startup",
|
||||
activate: false,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Startup failed: required secrets are unavailable. Error: Environment variable "OPENAI_API_KEY" is missing or empty.',
|
||||
);
|
||||
expect(emitStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit degraded or recovered events for warning-only secret reloads", async () => {
|
||||
const warning: SecretResolverWarning = {
|
||||
code: "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED",
|
||||
path: "plugins.entries.google.config.webSearch.apiKey",
|
||||
message: "web search provider fell back to environment credentials",
|
||||
};
|
||||
const prepareRuntimeSecretsSnapshot = vi.fn(async ({ config }) => ({
|
||||
...preparedSnapshot(config),
|
||||
warnings: [warning],
|
||||
}));
|
||||
const emitStateEvent = vi.fn();
|
||||
const logSecrets = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const activateRuntimeSecrets = createRuntimeSecretsActivator({
|
||||
logSecrets,
|
||||
emitStateEvent,
|
||||
prepareRuntimeSecretsSnapshot,
|
||||
activateRuntimeSecretsSnapshot: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
activateRuntimeSecrets(
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: { source: "env", provider: "default", id: "MISSING_GEMINI_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
reason: "reload",
|
||||
activate: true,
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
warnings: [warning],
|
||||
});
|
||||
expect(logSecrets.warn).toHaveBeenCalledWith(
|
||||
"[WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED] web search provider fell back to environment credentials",
|
||||
);
|
||||
expect(emitStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prunes channel refs from startup secret preflight when channels are skipped", async () => {
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
||||
const prepareRuntimeSecretsSnapshot = vi.fn(async ({ config }) => preparedSnapshot(config));
|
||||
const activateRuntimeSecrets = createRuntimeSecretsActivator({
|
||||
logSecrets: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
emitStateEvent: vi.fn(),
|
||||
prepareRuntimeSecretsSnapshot,
|
||||
activateRuntimeSecretsSnapshot: vi.fn(),
|
||||
});
|
||||
const config = gatewayTokenConfig(
|
||||
asConfig({
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
activateRuntimeSecrets(config, {
|
||||
reason: "startup",
|
||||
activate: false,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
config: expect.objectContaining({
|
||||
gateway: expect.any(Object),
|
||||
}),
|
||||
});
|
||||
expect(prepareRuntimeSecretsSnapshot).toHaveBeenCalledWith({
|
||||
config: expect.not.objectContaining({
|
||||
channels: expect.anything(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("honors startup auth overrides before secret preflight gating", async () => {
|
||||
const prepareRuntimeSecretsSnapshot = vi.fn(async ({ config }) => preparedSnapshot(config));
|
||||
const activateRuntimeSecretsSnapshot = vi.fn();
|
||||
const result = await prepareGatewayStartupConfig({
|
||||
configSnapshot: buildSnapshot({
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: { source: "env", provider: "default", id: "MISSING_STARTUP_GW_TOKEN" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
authOverride: {
|
||||
mode: "password",
|
||||
password: "override-password", // pragma: allowlist secret
|
||||
},
|
||||
activateRuntimeSecrets: createRuntimeSecretsActivator({
|
||||
logSecrets: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
emitStateEvent: vi.fn(),
|
||||
prepareRuntimeSecretsSnapshot,
|
||||
activateRuntimeSecretsSnapshot,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.auth).toMatchObject({
|
||||
mode: "password",
|
||||
password: "override-password",
|
||||
});
|
||||
expect(prepareRuntimeSecretsSnapshot).toHaveBeenNthCalledWith(1, {
|
||||
config: expect.objectContaining({
|
||||
gateway: expect.objectContaining({
|
||||
auth: expect.objectContaining({
|
||||
mode: "password",
|
||||
password: "override-password",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(activateRuntimeSecretsSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses gateway auth strings resolved during startup preflight for bootstrap auth", async () => {
|
||||
const prepareRuntimeSecretsSnapshot = vi.fn(async ({ config }) =>
|
||||
preparedSnapshot({
|
||||
...config,
|
||||
gateway: {
|
||||
...config.gateway,
|
||||
auth: {
|
||||
...config.gateway?.auth,
|
||||
token: "resolved-gateway-token",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await prepareGatewayStartupConfig({
|
||||
configSnapshot: buildSnapshot({
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: { source: "env", provider: "default", id: "GATEWAY_TOKEN_REF" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
activateRuntimeSecrets: createRuntimeSecretsActivator({
|
||||
logSecrets: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
emitStateEvent: vi.fn(),
|
||||
prepareRuntimeSecretsSnapshot,
|
||||
activateRuntimeSecretsSnapshot: vi.fn(),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.auth).toMatchObject({
|
||||
mode: "token",
|
||||
token: "resolved-gateway-token",
|
||||
});
|
||||
expect(prepareRuntimeSecretsSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,9 @@ export type ActivateRuntimeSecrets = (
|
||||
params: { reason: "startup" | "reload" | "restart-check"; activate: boolean },
|
||||
) => Promise<Awaited<ReturnType<typeof prepareSecretsRuntimeSnapshot>>>;
|
||||
|
||||
type PrepareRuntimeSecretsSnapshot = typeof prepareSecretsRuntimeSnapshot;
|
||||
type ActivateRuntimeSecretsSnapshot = typeof activateSecretsRuntimeSnapshot;
|
||||
|
||||
type GatewayStartupConfigOverrides = {
|
||||
auth?: GatewayAuthConfig;
|
||||
tailscale?: GatewayTailscaleConfig;
|
||||
@@ -86,9 +89,15 @@ export function createRuntimeSecretsActivator(params: {
|
||||
message: string,
|
||||
cfg: OpenClawConfig,
|
||||
) => void;
|
||||
prepareRuntimeSecretsSnapshot?: PrepareRuntimeSecretsSnapshot;
|
||||
activateRuntimeSecretsSnapshot?: ActivateRuntimeSecretsSnapshot;
|
||||
}): ActivateRuntimeSecrets {
|
||||
let secretsDegraded = false;
|
||||
let secretsActivationTail: Promise<void> = Promise.resolve();
|
||||
const prepareRuntimeSecretsSnapshot =
|
||||
params.prepareRuntimeSecretsSnapshot ?? prepareSecretsRuntimeSnapshot;
|
||||
const activateRuntimeSecretsSnapshot =
|
||||
params.activateRuntimeSecretsSnapshot ?? activateSecretsRuntimeSnapshot;
|
||||
|
||||
const runWithSecretsActivationLock = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const run = secretsActivationTail.then(operation, operation);
|
||||
@@ -102,11 +111,11 @@ export function createRuntimeSecretsActivator(params: {
|
||||
return async (config, activationParams) =>
|
||||
await runWithSecretsActivationLock(async () => {
|
||||
try {
|
||||
const prepared = await prepareSecretsRuntimeSnapshot({
|
||||
const prepared = await prepareRuntimeSecretsSnapshot({
|
||||
config: pruneSkippedStartupSecretSurfaces(config),
|
||||
});
|
||||
if (activationParams.activate) {
|
||||
activateSecretsRuntimeSnapshot(prepared);
|
||||
activateRuntimeSecretsSnapshot(prepared);
|
||||
logGatewayAuthSurfaceDiagnostics(prepared, params.logSecrets);
|
||||
}
|
||||
for (const warning of prepared.warnings) {
|
||||
|
||||
@@ -45,10 +45,16 @@ vi.mock("../agents/subagent-registry.js", () => ({
|
||||
scheduleSubagentOrphanRecovery: hoisted.scheduleSubagentOrphanRecovery,
|
||||
}));
|
||||
|
||||
vi.mock("../config/paths.js", () => ({
|
||||
STATE_DIR: "/tmp/openclaw-state",
|
||||
resolveStateDir: vi.fn(() => "/tmp/openclaw-state"),
|
||||
}));
|
||||
vi.mock("../config/paths.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/paths.js")>("../config/paths.js");
|
||||
return {
|
||||
...actual,
|
||||
STATE_DIR: "/tmp/openclaw-state",
|
||||
resolveConfigPath: vi.fn(() => "/tmp/openclaw-state/openclaw.json"),
|
||||
resolveGatewayPort: vi.fn(() => 18789),
|
||||
resolveStateDir: vi.fn(() => "/tmp/openclaw-state"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../hooks/gmail-watcher-lifecycle.js", () => ({
|
||||
startGmailWatcherWithLogs: hoisted.startGmailWatcherWithLogs,
|
||||
|
||||
@@ -211,49 +211,50 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
return { identityPath, identity: { deviceId: identity.deviceId } };
|
||||
};
|
||||
|
||||
for (const tc of trustedProxyControlUiCases) {
|
||||
test(tc.name, async () => {
|
||||
await configureTrustedProxyControlUiAuth();
|
||||
await withControlUiGatewayServer(async ({ port }) => {
|
||||
test("rejects untrusted trusted-proxy control ui device identity states", async () => {
|
||||
await configureTrustedProxyControlUiAuth();
|
||||
await withControlUiGatewayServer(async ({ port }) => {
|
||||
for (const tc of trustedProxyControlUiCases) {
|
||||
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
|
||||
const scopes = tc.withUnpairedNodeDevice ? [] : undefined;
|
||||
let device: Awaited<ReturnType<typeof createSignedDevice>>["device"] | null = null;
|
||||
if (tc.withUnpairedNodeDevice) {
|
||||
const challengeNonce = await readConnectChallengeNonce(ws);
|
||||
expect(challengeNonce).toBeTruthy();
|
||||
({ device } = await createSignedDevice({
|
||||
token: null,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
nonce: challengeNonce,
|
||||
}));
|
||||
}
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
role: tc.role,
|
||||
scopes,
|
||||
device,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
});
|
||||
expect(res.ok).toBe(tc.expectedOk);
|
||||
if (!tc.expectedOk) {
|
||||
if (tc.expectedErrorSubstring) {
|
||||
expect(res.error?.message ?? "").toContain(tc.expectedErrorSubstring);
|
||||
try {
|
||||
const scopes = tc.withUnpairedNodeDevice ? [] : undefined;
|
||||
let device: Awaited<ReturnType<typeof createSignedDevice>>["device"] | null = null;
|
||||
if (tc.withUnpairedNodeDevice) {
|
||||
const challengeNonce = await readConnectChallengeNonce(ws);
|
||||
expect(challengeNonce, tc.name).toBeTruthy();
|
||||
({ device } = await createSignedDevice({
|
||||
token: null,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
nonce: challengeNonce,
|
||||
}));
|
||||
}
|
||||
if (tc.expectedErrorCode) {
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
tc.expectedErrorCode,
|
||||
);
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
role: tc.role,
|
||||
scopes,
|
||||
device,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
});
|
||||
expect(res.ok, tc.name).toBe(tc.expectedOk);
|
||||
if (!tc.expectedOk) {
|
||||
if (tc.expectedErrorSubstring) {
|
||||
expect(res.error?.message ?? "", tc.name).toContain(tc.expectedErrorSubstring);
|
||||
}
|
||||
if (tc.expectedErrorCode) {
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code, tc.name).toBe(
|
||||
tc.expectedErrorCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
ws.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects trusted-proxy control ui without device identity even with self-declared scopes", async () => {
|
||||
await configureTrustedProxyControlUiAuth();
|
||||
@@ -394,16 +395,16 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
}
|
||||
});
|
||||
|
||||
test("allows control ui with stale device identity when device auth is disabled", async () => {
|
||||
test("allows control ui auth bypasses when device auth is disabled", async () => {
|
||||
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
|
||||
testState.gatewayAuth = { mode: "token", token: "secret" };
|
||||
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "secret";
|
||||
try {
|
||||
await withControlUiGatewayServer(async ({ port }) => {
|
||||
const ws = await openWs(port, { origin: originForPort(port) });
|
||||
const challengeNonce = await readConnectChallengeNonce(ws);
|
||||
expect(challengeNonce).toBeTruthy();
|
||||
const staleDeviceWs = await openWs(port, { origin: originForPort(port) });
|
||||
const challengeNonce = await readConnectChallengeNonce(staleDeviceWs);
|
||||
expect(challengeNonce, "stale device challenge").toBeTruthy();
|
||||
const { device } = await createSignedDevice({
|
||||
token: "secret",
|
||||
scopes: [],
|
||||
@@ -412,7 +413,7 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
signedAtMs: Date.now() - 60 * 60 * 1000,
|
||||
nonce: challengeNonce,
|
||||
});
|
||||
const res = await connectReq(ws, {
|
||||
const res = await connectReq(staleDeviceWs, {
|
||||
token: "secret",
|
||||
scopes: ["operator.read"],
|
||||
device,
|
||||
@@ -422,38 +423,26 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined();
|
||||
const health = await rpcReq(ws, "health");
|
||||
const health = await rpcReq(staleDeviceWs, "health");
|
||||
expect(health.ok).toBe(true);
|
||||
ws.close();
|
||||
});
|
||||
} finally {
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
staleDeviceWs.close();
|
||||
|
||||
test("preserves requested control ui scopes when dangerouslyDisableDeviceAuth bypasses device identity", async () => {
|
||||
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
|
||||
testState.gatewayAuth = { mode: "token", token: "secret" };
|
||||
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "secret";
|
||||
try {
|
||||
await withControlUiGatewayServer(async ({ port }) => {
|
||||
const ws = await openWs(port, { origin: originForPort(port) });
|
||||
const res = await connectReq(ws, {
|
||||
const scopedWs = await openWs(port, { origin: originForPort(port) });
|
||||
const scopedRes = await connectReq(scopedWs, {
|
||||
token: "secret",
|
||||
scopes: ["operator.read"],
|
||||
client: {
|
||||
...CONTROL_UI_CLIENT,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(scopedRes.ok, "requested scope bypass").toBe(true);
|
||||
|
||||
const health = await rpcReq(ws, "health");
|
||||
expect(health.ok).toBe(true);
|
||||
const scopedHealth = await rpcReq(scopedWs, "health");
|
||||
expect(scopedHealth.ok).toBe(true);
|
||||
|
||||
const talk = await rpcReq(ws, "chat.history", { sessionKey: "main", limit: 1 });
|
||||
const talk = await rpcReq(scopedWs, "chat.history", { sessionKey: "main", limit: 1 });
|
||||
expect(talk.ok).toBe(true);
|
||||
ws.close();
|
||||
scopedWs.close();
|
||||
});
|
||||
} finally {
|
||||
restoreGatewayToken(prevToken);
|
||||
|
||||
@@ -87,12 +87,11 @@ describe("gateway config methods", () => {
|
||||
expect(current.payload?.config).toBeTruthy();
|
||||
|
||||
const nextConfig = structuredClone(current.payload?.config ?? {});
|
||||
const channels = (nextConfig.channels ??= {}) as Record<string, unknown>;
|
||||
const telegram = (channels.telegram ??= {}) as Record<string, unknown>;
|
||||
telegram.botToken = { source: "env", provider: "default", id: missingEnvVar };
|
||||
const telegramAccounts = (telegram.accounts ??= {}) as Record<string, unknown>;
|
||||
const defaultTelegramAccount = (telegramAccounts.default ??= {}) as Record<string, unknown>;
|
||||
defaultTelegramAccount.enabled = true;
|
||||
const gateway = (nextConfig.gateway ??= {}) as Record<string, unknown>;
|
||||
gateway.auth = {
|
||||
mode: "token",
|
||||
token: { source: "env", provider: "default", id: missingEnvVar },
|
||||
};
|
||||
|
||||
const res = await rpcReq<{ ok?: boolean; error?: { message?: string } }>(
|
||||
requireWs(),
|
||||
@@ -306,18 +305,14 @@ describe("gateway config methods", () => {
|
||||
"config.patch",
|
||||
{
|
||||
raw: JSON.stringify({
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: missingEnvVar,
|
||||
},
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -344,12 +339,11 @@ describe("gateway config.apply", () => {
|
||||
expect(current.ok).toBe(true);
|
||||
expect(typeof current.payload?.hash).toBe("string");
|
||||
const nextConfig = structuredClone(current.payload?.config ?? {});
|
||||
const channels = (nextConfig.channels ??= {}) as Record<string, unknown>;
|
||||
const telegram = (channels.telegram ??= {}) as Record<string, unknown>;
|
||||
telegram.botToken = { source: "env", provider: "default", id: missingEnvVar };
|
||||
const telegramAccounts = (telegram.accounts ??= {}) as Record<string, unknown>;
|
||||
const defaultTelegramAccount = (telegramAccounts.default ??= {}) as Record<string, unknown>;
|
||||
defaultTelegramAccount.enabled = true;
|
||||
const gateway = (nextConfig.gateway ??= {}) as Record<string, unknown>;
|
||||
gateway.auth = {
|
||||
mode: "token",
|
||||
token: { source: "env", provider: "default", id: missingEnvVar },
|
||||
};
|
||||
|
||||
const res = await sendConfigApply(
|
||||
{
|
||||
|
||||
@@ -240,14 +240,12 @@ describe("gateway hot reload", () => {
|
||||
let prevSkipGmail: string | undefined;
|
||||
let prevSkipProviders: string | undefined;
|
||||
let prevOpenAiApiKey: string | undefined;
|
||||
let prevGeminiApiKey: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
prevSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS;
|
||||
prevSkipGmail = process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
|
||||
prevSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
|
||||
prevOpenAiApiKey = process.env.OPENAI_API_KEY;
|
||||
prevGeminiApiKey = process.env.GEMINI_API_KEY;
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "0";
|
||||
delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
|
||||
delete process.env.OPENCLAW_SKIP_PROVIDERS;
|
||||
@@ -278,11 +276,6 @@ describe("gateway hot reload", () => {
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = prevOpenAiApiKey;
|
||||
}
|
||||
if (prevGeminiApiKey === undefined) {
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
} else {
|
||||
process.env.GEMINI_API_KEY = prevGeminiApiKey;
|
||||
}
|
||||
});
|
||||
|
||||
async function writeEnvRefConfig() {
|
||||
@@ -299,16 +292,6 @@ describe("gateway hot reload", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function writeChannelEnvRefConfig() {
|
||||
await writeConfigFile({
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function writeConfigFile(config: unknown) {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
@@ -336,22 +319,6 @@ describe("gateway hot reload", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function writeGatewayTraversalExecRefConfig() {
|
||||
await writeConfigFile({
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: { source: "exec", provider: "vault", id: "a/../b" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
vault: testNodeExecProvider,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function writeGatewayTokenExecRefConfig(params: {
|
||||
resolverScriptPath: string;
|
||||
modePath: string;
|
||||
@@ -376,145 +343,6 @@ describe("gateway hot reload", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function writeDisabledSurfaceRefConfig() {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
throw new Error("OPENCLAW_CONFIG_PATH is not set");
|
||||
}
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: false,
|
||||
botToken: { source: "env", provider: "default", id: "DISABLED_TELEGRAM_STARTUP_REF" },
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: false,
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISABLED_WEB_SEARCH_STARTUP_REF",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function writeGatewayTokenRefConfig() {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
throw new Error("OPENCLAW_CONFIG_PATH is not set");
|
||||
}
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: { source: "env", provider: "default", id: "MISSING_STARTUP_GW_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function writeAuthProfileEnvRefStore() {
|
||||
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
if (!stateDir) {
|
||||
throw new Error("OPENCLAW_STATE_DIR is not set");
|
||||
}
|
||||
const authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
|
||||
await fs.mkdir(path.dirname(authStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
authStorePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
missing: {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "env", provider: "default", id: "MISSING_OPENCLAW_AUTH_REF" },
|
||||
},
|
||||
},
|
||||
selectedProfileId: "missing",
|
||||
lastUsedProfileByModel: {},
|
||||
usageStats: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function writeWebSearchGeminiRefConfig() {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
throw new Error("OPENCLAW_CONFIG_PATH is not set");
|
||||
}
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "gemini-startup-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "gemini",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function removeMainAuthProfileStore() {
|
||||
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
if (!stateDir) {
|
||||
return;
|
||||
}
|
||||
const authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
|
||||
await fs.rm(authStorePath, { force: true });
|
||||
}
|
||||
|
||||
async function expectOneShotSecretReloadEvents(params: {
|
||||
applyReload: () => Promise<unknown> | undefined;
|
||||
sessionKey: string;
|
||||
@@ -641,75 +469,6 @@ describe("gateway hot reload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails startup when required secret refs are unresolved", async () => {
|
||||
await writeEnvRefConfig();
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
await expect(withGatewayServer(async () => {})).rejects.toThrow(
|
||||
"Startup failed: required secrets are unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows startup when unresolved channel refs exist but channels are skipped", async () => {
|
||||
await writeChannelEnvRefConfig();
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
||||
await expect(withGatewayServer(async () => {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails startup when an active exec ref id contains traversal segments", async () => {
|
||||
await writeGatewayTraversalExecRefConfig();
|
||||
const previousGatewayAuth = testState.gatewayAuth;
|
||||
const previousGatewayTokenEnv = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
testState.gatewayAuth = undefined;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
try {
|
||||
await expect(withGatewayServer(async () => {})).rejects.toThrow(
|
||||
/must not include "\." or "\.\." path segments/i,
|
||||
);
|
||||
} finally {
|
||||
testState.gatewayAuth = previousGatewayAuth;
|
||||
if (previousGatewayTokenEnv === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayTokenEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("allows startup when unresolved refs exist only on disabled surfaces", async () => {
|
||||
await writeDisabledSurfaceRefConfig();
|
||||
delete process.env.DISABLED_TELEGRAM_STARTUP_REF;
|
||||
delete process.env.DISABLED_WEB_SEARCH_STARTUP_REF;
|
||||
await expect(withGatewayServer(async () => {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("honors startup auth overrides before secret preflight gating", async () => {
|
||||
await writeGatewayTokenRefConfig();
|
||||
delete process.env.MISSING_STARTUP_GW_TOKEN;
|
||||
await expect(
|
||||
withGatewayServer(async () => {}, {
|
||||
serverOptions: {
|
||||
auth: {
|
||||
mode: "password",
|
||||
password: "override-password", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails startup when auth-profile secret refs are unresolved", async () => {
|
||||
await writeAuthProfileEnvRefStore();
|
||||
delete process.env.MISSING_OPENCLAW_AUTH_REF;
|
||||
try {
|
||||
await expect(withGatewayServer(async () => {})).rejects.toThrow(
|
||||
'Environment variable "MISSING_OPENCLAW_AUTH_REF" is missing or empty.',
|
||||
);
|
||||
} finally {
|
||||
await removeMainAuthProfileStore();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits one-shot degraded and recovered system events during secret reload transitions", async () => {
|
||||
await writeEnvRefConfig();
|
||||
process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret
|
||||
@@ -757,78 +516,6 @@ describe("gateway hot reload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit secrets reloader events for web search secret reload transitions", async () => {
|
||||
await writeWebSearchGeminiRefConfig();
|
||||
|
||||
await withGatewayServer(async () => {
|
||||
const onHotReload = hoisted.getOnHotReload();
|
||||
expect(onHotReload).toBeTypeOf("function");
|
||||
const sessionKey = resolveMainSessionKeyFromConfig();
|
||||
const plan = {
|
||||
changedPaths: ["plugins.entries.google.config.webSearch.apiKey"],
|
||||
restartGateway: false,
|
||||
restartReasons: [],
|
||||
hotReasons: ["plugins.entries.google.config.webSearch.apiKey"],
|
||||
reloadHooks: false,
|
||||
restartGmailWatcher: false,
|
||||
restartCron: false,
|
||||
restartHeartbeat: false,
|
||||
restartChannels: new Set(),
|
||||
noopPaths: [],
|
||||
};
|
||||
const degradedConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "gemini",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENCLAW_TEST_MISSING_GEMINI_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const recoveredConfig = {
|
||||
tools: degradedConfig.tools,
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "gemini-recovered-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
delete process.env.OPENCLAW_TEST_MISSING_GEMINI_API_KEY;
|
||||
expect(drainSystemEvents(sessionKey)).toEqual([]);
|
||||
await expect(onHotReload?.(plan, degradedConfig)).resolves.toBeUndefined();
|
||||
expect(drainSystemEvents(sessionKey)).toEqual([]);
|
||||
|
||||
await expect(onHotReload?.(plan, recoveredConfig)).resolves.toBeUndefined();
|
||||
expect(drainSystemEvents(sessionKey)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("serves secrets.reload immediately after startup without race failures", async () => {
|
||||
await writeEnvRefConfig();
|
||||
process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret
|
||||
|
||||
@@ -24,20 +24,46 @@ type SpeechProvider = Parameters<typeof withSpeechProviders>[0][number]["provide
|
||||
|
||||
const ALIAS_STUB_VOICE_ID = "VoiceAlias1234567890";
|
||||
|
||||
async function writeAcmeTalkConfig() {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
talk: {
|
||||
provider: "acme",
|
||||
providers: {
|
||||
acme: {
|
||||
voiceId: "plugin-voice",
|
||||
async function setTalkConfig(talk: Record<string, unknown>) {
|
||||
const { setRuntimeConfigSnapshot } = await import("../config/config.js");
|
||||
const config = {
|
||||
commands: {
|
||||
ownerDisplaySecret: "openclaw-test-owner-display-secret",
|
||||
},
|
||||
talk,
|
||||
};
|
||||
setRuntimeConfigSnapshot(config, config);
|
||||
}
|
||||
|
||||
async function setAcmeTalkConfig() {
|
||||
await setTalkConfig({
|
||||
provider: "acme",
|
||||
providers: {
|
||||
acme: {
|
||||
voiceId: "plugin-voice",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function setElevenLabsTalkConfig() {
|
||||
await setTalkConfig({
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
voiceId: "stub-default-voice",
|
||||
voiceAliases: {
|
||||
Clawd: ALIAS_STUB_VOICE_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function setEmptyTalkConfig() {
|
||||
await setTalkConfig({});
|
||||
}
|
||||
|
||||
async function withAcmeSpeechProvider(
|
||||
synthesize: SpeechProvider["synthesize"],
|
||||
run: () => Promise<void>,
|
||||
@@ -73,17 +99,7 @@ describe("gateway talk runtime", () => {
|
||||
});
|
||||
|
||||
it("allows extension speech providers through the talk setup", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
talk: {
|
||||
provider: "acme",
|
||||
providers: {
|
||||
acme: {
|
||||
voiceId: "plugin-voice",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await setAcmeTalkConfig();
|
||||
|
||||
await withSpeechProviders(
|
||||
[
|
||||
@@ -134,7 +150,7 @@ describe("gateway talk runtime", () => {
|
||||
});
|
||||
|
||||
it("allows extension speech providers through talk.speak", async () => {
|
||||
await writeAcmeTalkConfig();
|
||||
await setAcmeTalkConfig();
|
||||
|
||||
await withAcmeSpeechProvider(
|
||||
async () => ({
|
||||
@@ -157,20 +173,7 @@ describe("gateway talk runtime", () => {
|
||||
});
|
||||
|
||||
it("resolves talk voice aliases case-insensitively and forwards provider overrides", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
voiceId: "stub-default-voice",
|
||||
voiceAliases: {
|
||||
Clawd: ALIAS_STUB_VOICE_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await setElevenLabsTalkConfig();
|
||||
|
||||
await withSpeechProviders(
|
||||
[
|
||||
@@ -242,8 +245,7 @@ describe("gateway talk runtime", () => {
|
||||
});
|
||||
|
||||
it("returns fallback-eligible details when talk provider is not configured", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({ talk: {} });
|
||||
await setEmptyTalkConfig();
|
||||
|
||||
const res = await invokeTalkSpeakDirect({ text: "Hello from talk mode." });
|
||||
expect(res?.ok).toBe(false);
|
||||
@@ -255,7 +257,7 @@ describe("gateway talk runtime", () => {
|
||||
});
|
||||
|
||||
it("returns synthesis_failed details when the provider rejects synthesis", async () => {
|
||||
await writeAcmeTalkConfig();
|
||||
await setAcmeTalkConfig();
|
||||
|
||||
await withAcmeSpeechProvider(
|
||||
async () => ({}) as never,
|
||||
@@ -275,7 +277,7 @@ describe("gateway talk runtime", () => {
|
||||
});
|
||||
|
||||
it("rejects empty audio results as invalid_audio_result", async () => {
|
||||
await writeAcmeTalkConfig();
|
||||
await setAcmeTalkConfig();
|
||||
|
||||
await withAcmeSpeechProvider(
|
||||
async () => ({}) as never,
|
||||
|
||||
@@ -192,6 +192,29 @@ vi.mock("../config/config.js", async () => {
|
||||
return createGatewayConfigModuleMock(actual);
|
||||
});
|
||||
|
||||
vi.mock("../config/io.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/io.js")>("../config/io.js");
|
||||
const configActual =
|
||||
await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
const configMock = createGatewayConfigModuleMock(configActual);
|
||||
const createConfigIO = vi.fn(() => ({
|
||||
...actual.createConfigIO(),
|
||||
loadConfig: configMock.loadConfig,
|
||||
readConfigFileSnapshot: configMock.readConfigFileSnapshot,
|
||||
readConfigFileSnapshotForWrite: configMock.readConfigFileSnapshotForWrite,
|
||||
writeConfigFile: configMock.writeConfigFile,
|
||||
}));
|
||||
return {
|
||||
...actual,
|
||||
createConfigIO,
|
||||
getRuntimeConfig: configMock.getRuntimeConfig,
|
||||
loadConfig: configMock.loadConfig,
|
||||
readConfigFileSnapshot: configMock.readConfigFileSnapshot,
|
||||
readConfigFileSnapshotForWrite: configMock.readConfigFileSnapshotForWrite,
|
||||
writeConfigFile: configMock.writeConfigFile,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", async () => {
|
||||
return await importEmbeddedRunMockModule<typeof import("../agents/pi-embedded.js")>(
|
||||
"../agents/pi-embedded.js",
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { clearConfigCache, resetConfigRuntimeState } from "../config/config.js";
|
||||
import {
|
||||
clearConfigCache,
|
||||
resetConfigRuntimeState,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { clearSecretsRuntimeSnapshot } from "../secrets/runtime.js";
|
||||
|
||||
function withStableOwnerDisplaySecretForTest(cfg: unknown): unknown {
|
||||
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) {
|
||||
return cfg;
|
||||
}
|
||||
const record = cfg as Record<string, unknown>;
|
||||
const commands =
|
||||
record.commands && typeof record.commands === "object" && !Array.isArray(record.commands)
|
||||
? (record.commands as Record<string, unknown>)
|
||||
: {};
|
||||
if (typeof commands.ownerDisplaySecret === "string" && commands.ownerDisplaySecret.length > 0) {
|
||||
return cfg;
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
commands: {
|
||||
...commands,
|
||||
ownerDisplaySecret: "openclaw-test-owner-display-secret",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function withTempConfig(params: {
|
||||
cfg: unknown;
|
||||
run: () => Promise<void>;
|
||||
@@ -11,16 +37,18 @@ export async function withTempConfig(params: {
|
||||
}): Promise<void> {
|
||||
const prevConfigPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
|
||||
const testConfig = withStableOwnerDisplaySecretForTest(params.cfg) as OpenClawConfig;
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), params.prefix ?? "openclaw-test-config-"));
|
||||
const configPath = path.join(dir, "openclaw.json");
|
||||
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
|
||||
try {
|
||||
await writeFile(configPath, JSON.stringify(params.cfg, null, 2), "utf-8");
|
||||
await writeFile(configPath, JSON.stringify(testConfig, null, 2), "utf-8");
|
||||
clearConfigCache();
|
||||
resetConfigRuntimeState();
|
||||
clearSecretsRuntimeSnapshot();
|
||||
setRuntimeConfigSnapshot(testConfig, testConfig);
|
||||
await params.run();
|
||||
} finally {
|
||||
if (prevConfigPath === undefined) {
|
||||
|
||||
@@ -9,6 +9,11 @@ const nonCoreGatewayTestExclude = [
|
||||
"src/gateway/**/*gateway-cli-backend*.test.ts",
|
||||
"src/gateway/**/*server*.test.ts",
|
||||
"src/gateway/gateway.test.ts",
|
||||
"src/gateway/embeddings-http.test.ts",
|
||||
"src/gateway/models-http.test.ts",
|
||||
"src/gateway/openai-http.test.ts",
|
||||
"src/gateway/openresponses-http.test.ts",
|
||||
"src/gateway/probe.auth.integration.test.ts",
|
||||
"src/gateway/server.startup-matrix-migration.integration.test.ts",
|
||||
"src/gateway/sessions-history-http.test.ts",
|
||||
];
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
|
||||
|
||||
const gatewayServerBackedHttpTests = [
|
||||
"src/gateway/embeddings-http.test.ts",
|
||||
"src/gateway/models-http.test.ts",
|
||||
"src/gateway/openai-http.test.ts",
|
||||
"src/gateway/openresponses-http.test.ts",
|
||||
"src/gateway/probe.auth.integration.test.ts",
|
||||
];
|
||||
|
||||
export function createGatewayServerVitestConfig(env?: Record<string, string | undefined>) {
|
||||
return createScopedVitestConfig(["src/gateway/**/*server*.test.ts"], {
|
||||
dir: "src/gateway",
|
||||
env,
|
||||
exclude: [
|
||||
"src/gateway/server-methods/**/*.test.ts",
|
||||
"src/gateway/gateway.test.ts",
|
||||
"src/gateway/server.startup-matrix-migration.integration.test.ts",
|
||||
"src/gateway/sessions-history-http.test.ts",
|
||||
],
|
||||
name: "gateway-server",
|
||||
});
|
||||
return createScopedVitestConfig(
|
||||
["src/gateway/**/*server*.test.ts", ...gatewayServerBackedHttpTests],
|
||||
{
|
||||
dir: "src/gateway",
|
||||
env,
|
||||
exclude: [
|
||||
"src/gateway/server-methods/**/*.test.ts",
|
||||
"src/gateway/gateway.test.ts",
|
||||
"src/gateway/server.startup-matrix-migration.integration.test.ts",
|
||||
"src/gateway/sessions-history-http.test.ts",
|
||||
],
|
||||
name: "gateway-server",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export default createGatewayServerVitestConfig();
|
||||
|
||||
Reference in New Issue
Block a user