perf: reduce command and gateway test imports

This commit is contained in:
Peter Steinberger
2026-04-11 13:17:10 +01:00
parent 8ddd9b8aac
commit ff7a842509
49 changed files with 1914 additions and 1699 deletions

View File

@@ -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", () => {

View File

@@ -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(),

View File

@@ -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),
};
});

View File

@@ -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(),

View File

@@ -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,
};
});

View File

@@ -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 {

View File

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

View File

@@ -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

View File

@@ -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}`),

View File

@@ -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";

View File

@@ -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({

View File

@@ -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", () => {

View File

@@ -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 = {

View File

@@ -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,
}));

View File

@@ -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();

View File

@@ -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,
);
}

View File

@@ -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[],

View 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,
};
}

View File

@@ -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;

View File

@@ -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 () => {

View File

@@ -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 () => {

View File

@@ -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),

View File

@@ -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();

View File

@@ -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();
}

View File

@@ -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";

View File

@@ -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}`);

View File

@@ -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 }),
};
});

View File

@@ -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";

View File

@@ -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) => {

View File

@@ -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,
});

View File

@@ -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: {} },

View File

@@ -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: {},

View File

@@ -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: {},

View File

@@ -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();
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,
});

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

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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(
{

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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",
];

View File

@@ -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();