Gateway: simplify startup and stabilize mock responses tests

This commit is contained in:
Gustavo Madeira Santana
2026-03-16 14:31:08 +00:00
parent f8bcfb9d73
commit 771fbeae79
10 changed files with 435 additions and 190 deletions

View File

@@ -47,7 +47,10 @@ describe("auth profiles read-only external CLI sync", () => {
const loaded = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true });
expect(mocks.syncExternalCliCredentials).toHaveBeenCalled();
expect(mocks.syncExternalCliCredentials).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ log: false }),
);
expect(loaded.profiles["qwen-portal:default"]).toMatchObject({
type: "oauth",
provider: "qwen-portal",

View File

@@ -14,6 +14,10 @@ import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from ".
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
type ExternalCliSyncOptions = {
log?: boolean;
};
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
if (!a) {
return false;
@@ -60,6 +64,7 @@ function syncExternalCliCredentialsForProvider(
provider: string,
readCredentials: () => OAuthCredential | null,
now: number,
options: ExternalCliSyncOptions,
): boolean {
const existing = store.profiles[profileId];
const shouldSync =
@@ -78,10 +83,12 @@ function syncExternalCliCredentialsForProvider(
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) {
store.profiles[profileId] = creds;
log.info(`synced ${provider} credentials from external cli`, {
profileId,
expires: new Date(creds.expires).toISOString(),
});
if (options.log !== false) {
log.info(`synced ${provider} credentials from external cli`, {
profileId,
expires: new Date(creds.expires).toISOString(),
});
}
return true;
}
@@ -94,7 +101,10 @@ function syncExternalCliCredentialsForProvider(
*
* Returns true if any credentials were updated.
*/
export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
export function syncExternalCliCredentials(
store: AuthProfileStore,
options: ExternalCliSyncOptions = {},
): boolean {
let mutated = false;
const now = Date.now();
@@ -119,10 +129,12 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) {
store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds;
mutated = true;
log.info("synced qwen credentials from qwen cli", {
profileId: QWEN_CLI_PROFILE_ID,
expires: new Date(qwenCreds.expires).toISOString(),
});
if (options.log !== false) {
log.info("synced qwen credentials from qwen cli", {
profileId: QWEN_CLI_PROFILE_ID,
expires: new Date(qwenCreds.expires).toISOString(),
});
}
}
}
@@ -134,6 +146,7 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
"minimax-portal",
() => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
now,
options,
)
) {
mutated = true;
@@ -145,6 +158,7 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
"openai-codex",
() => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
now,
options,
)
) {
mutated = true;

View File

@@ -381,7 +381,7 @@ function loadAuthProfileStoreForAgent(
if (asStore) {
// Runtime secret activation must remain read-only:
// sync external CLI credentials in-memory, but never persist while readOnly.
const synced = syncExternalCliCredentials(asStore);
const synced = syncExternalCliCredentials(asStore, { log: !readOnly });
if (synced && !readOnly) {
saveJsonFile(authPath, asStore);
}
@@ -413,7 +413,7 @@ function loadAuthProfileStoreForAgent(
const mergedOAuth = mergeOAuthFileIntoStore(store);
// Keep external CLI credentials visible in runtime even during read-only loads.
const syncedCli = syncExternalCliCredentials(store);
const syncedCli = syncExternalCliCredentials(store, { log: !readOnly });
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli);
if (shouldWrite) {

View File

@@ -25,6 +25,7 @@ class MockWebSocket {
readonly sent: string[] = [];
closeCalls = 0;
terminateCalls = 0;
autoCloseOnClose = true;
constructor(_url: string, _options?: unknown) {
wsInstances.push(this);
@@ -55,7 +56,9 @@ class MockWebSocket {
close(code?: number, reason?: string): void {
this.closeCalls += 1;
this.emitClose(code ?? 1000, reason ?? "");
if (this.autoCloseOnClose) {
this.emitClose(code ?? 1000, reason ?? "");
}
}
terminate(): void {
@@ -327,6 +330,39 @@ describe("GatewayClient close handling", () => {
}
});
it("waits for a lingering socket to terminate in stopAndWait", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
});
client.start();
const ws = getLatestWs();
ws.autoCloseOnClose = false;
let settled = false;
const stopPromise = client.stopAndWait().then(() => {
settled = true;
});
expect(ws.closeCalls).toBe(1);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(249);
expect(ws.terminateCalls).toBe(0);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await stopPromise;
expect(ws.terminateCalls).toBe(1);
expect(settled).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("does not clear persisted device auth when explicit shared token is provided", () => {
const onClose = vi.fn();
const identity: DeviceIdentity = {

View File

@@ -120,6 +120,13 @@ export function describeGatewayCloseCode(code: number): string | undefined {
}
const FORCE_STOP_TERMINATE_GRACE_MS = 250;
const STOP_AND_WAIT_TIMEOUT_MS = 1_000;
type PendingStop = {
ws: WebSocket;
promise: Promise<void>;
resolve: () => void;
};
export class GatewayClient {
private ws: WebSocket | null = null;
@@ -139,6 +146,7 @@ export class GatewayClient {
private tickIntervalMs = 30_000;
private tickTimer: NodeJS.Timeout | null = null;
private readonly requestTimeoutMs: number;
private pendingStop: PendingStop | null = null;
constructor(opts: GatewayClientOptions) {
this.opts = {
@@ -217,9 +225,10 @@ export class GatewayClient {
// oxlint-disable-next-line typescript/no-explicit-any
}) as any;
}
this.ws = new WebSocket(url, wsOptions);
const ws = new WebSocket(url, wsOptions);
this.ws = ws;
this.ws.on("open", () => {
ws.on("open", () => {
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
const tlsError = this.validateTlsFingerprint();
if (tlsError) {
@@ -230,12 +239,15 @@ export class GatewayClient {
}
this.queueConnect();
});
this.ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
this.ws.on("close", (code, reason) => {
ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
ws.on("close", (code, reason) => {
const reasonText = rawDataToString(reason);
const connectErrorDetailCode = this.pendingConnectErrorDetailCode;
this.pendingConnectErrorDetailCode = null;
this.ws = null;
if (this.ws === ws) {
this.ws = null;
}
this.resolvePendingStop(ws);
// Clear persisted device auth state only when device-token auth was active.
// Shared token/password failures can return the same close reason but should
// not erase a valid cached device token.
@@ -265,7 +277,7 @@ export class GatewayClient {
this.scheduleReconnect();
this.opts.onClose?.(code, reasonText);
});
this.ws.on("error", (err) => {
ws.on("error", (err) => {
logDebug(`gateway client error: ${String(err)}`);
if (!this.connectSent) {
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
@@ -274,6 +286,39 @@ export class GatewayClient {
}
stop() {
void this.beginStop();
}
async stopAndWait(opts?: { timeoutMs?: number }): Promise<void> {
// Some callers need teardown ordering, not just "close requested". Wait for
// the socket to close or the terminate fallback to fire.
const stopPromise = this.beginStop();
if (!stopPromise) {
return;
}
const timeoutMs =
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
? Math.max(1, Math.floor(opts.timeoutMs))
: STOP_AND_WAIT_TIMEOUT_MS;
let timeout: NodeJS.Timeout | null = null;
try {
await Promise.race([
stopPromise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`gateway client stop timed out after ${timeoutMs}ms`));
}, timeoutMs);
timeout.unref?.();
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
private beginStop(): Promise<void> | null {
this.closed = true;
this.pendingDeviceTokenRetry = false;
this.deviceTokenRetryBudgetUsed = false;
@@ -282,18 +327,52 @@ export class GatewayClient {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
if (this.connectTimer) {
clearTimeout(this.connectTimer);
this.connectTimer = null;
}
if (this.pendingStop) {
this.flushPendingErrors(new Error("gateway client stopped"));
return this.pendingStop.promise;
}
const ws = this.ws;
this.ws = null;
if (ws) {
const stopPromise = this.createPendingStop(ws);
ws.close();
const forceTerminateTimer = setTimeout(() => {
try {
ws.terminate();
} catch {}
this.resolvePendingStop(ws);
}, FORCE_STOP_TERMINATE_GRACE_MS);
forceTerminateTimer.unref?.();
this.flushPendingErrors(new Error("gateway client stopped"));
return stopPromise;
}
this.flushPendingErrors(new Error("gateway client stopped"));
return null;
}
private createPendingStop(ws: WebSocket): Promise<void> {
if (this.pendingStop?.ws === ws) {
return this.pendingStop.promise;
}
let resolve!: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
this.pendingStop = { ws, promise, resolve };
return promise;
}
private resolvePendingStop(ws: WebSocket): void {
if (this.pendingStop?.ws !== ws) {
return;
}
const { resolve } = this.pendingStop;
this.pendingStop = null;
resolve();
}
private sendConnect() {

View File

@@ -18,6 +18,9 @@ let writeConfigFile: typeof import("../config/config.js").writeConfigFile;
let resolveConfigPath: typeof import("../config/config.js").resolveConfigPath;
const GATEWAY_E2E_TIMEOUT_MS = 30_000;
let gatewayTestSeq = 0;
// Keep this off the real "openai" provider id so the runtime stays on the
// mocked HTTP Responses path instead of upgrading to the OpenAI WS transport.
const MOCK_OPENAI_PROVIDER_ID = "mock-openai";
function nextGatewayId(prefix: string): string {
return `${prefix}-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}-${gatewayTestSeq++}`;
@@ -73,7 +76,7 @@ describe("gateway e2e", () => {
models: {
mode: "replace",
providers: {
openai: buildOpenAiResponsesProviderConfig(openaiBaseUrl),
[MOCK_OPENAI_PROVIDER_ID]: buildOpenAiResponsesProviderConfig(openaiBaseUrl),
},
},
gateway: { auth: { token } },
@@ -91,7 +94,7 @@ describe("gateway e2e", () => {
await client.request("sessions.patch", {
key: sessionKey,
model: "openai/gpt-5.2",
model: `${MOCK_OPENAI_PROVIDER_ID}/gpt-5.2`,
});
const runId = nextGatewayId("run");
@@ -116,7 +119,7 @@ describe("gateway e2e", () => {
expect(text).toContain(nonceA);
expect(text).toContain(nonceB);
} finally {
client.stop();
await client.stopAndWait();
await server.close({ reason: "mock openai test complete" });
await fs.rm(tempHome, { recursive: true, force: true });
restore();
@@ -216,7 +219,7 @@ describe("gateway e2e", () => {
| undefined;
expect((token?.auth as { token?: string } | undefined)?.token).toBe(wizardToken);
} finally {
client.stop();
await client.stopAndWait();
await server.close({ reason: "wizard e2e complete" });
}

View File

@@ -10,8 +10,9 @@ import { formatCliCommand } from "../cli/command-format.js";
import { createDefaultDeps } from "../cli/deps.js";
import { isRestartEnabled } from "../config/commands.js";
import {
CONFIG_PATH,
type ConfigFileSnapshot,
type OpenClawConfig,
applyConfigOverrides,
isNixMode,
loadConfig,
migrateLegacyConfig,
@@ -217,6 +218,73 @@ function applyGatewayAuthOverridesForStartupPreflight(
};
}
function assertValidGatewayStartupConfigSnapshot(
snapshot: ConfigFileSnapshot,
options: { includeDoctorHint?: boolean } = {},
): void {
if (snapshot.valid) {
return;
}
const issues =
snapshot.issues.length > 0
? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n")
: "Unknown validation issue.";
const doctorHint = options.includeDoctorHint
? `\nRun "${formatCliCommand("openclaw doctor")}" to repair, then retry.`
: "";
throw new Error(`Invalid config at ${snapshot.path}.\n${issues}${doctorHint}`);
}
async function prepareGatewayStartupConfig(params: {
configSnapshot: ConfigFileSnapshot;
// Keep startup auth/runtime behavior aligned with loadConfig(), which applies
// runtime overrides beyond the raw on-disk snapshot.
runtimeConfig: OpenClawConfig;
authOverride?: GatewayServerOptions["auth"];
tailscaleOverride?: GatewayServerOptions["tailscale"];
activateRuntimeSecrets: (
config: OpenClawConfig,
options: { reason: "startup"; activate: boolean },
) => Promise<{ config: OpenClawConfig }>;
}): Promise<Awaited<ReturnType<typeof ensureGatewayStartupAuth>>> {
assertValidGatewayStartupConfigSnapshot(params.configSnapshot);
// Fail fast before startup auth persists anything if required refs are unresolved.
const startupPreflightConfig = applyGatewayAuthOverridesForStartupPreflight(
params.runtimeConfig,
{
auth: params.authOverride,
tailscale: params.tailscaleOverride,
},
);
await params.activateRuntimeSecrets(startupPreflightConfig, {
reason: "startup",
activate: false,
});
const authBootstrap = await ensureGatewayStartupAuth({
cfg: params.runtimeConfig,
env: process.env,
authOverride: params.authOverride,
tailscaleOverride: params.tailscaleOverride,
persist: true,
});
const runtimeStartupConfig = applyGatewayAuthOverridesForStartupPreflight(authBootstrap.cfg, {
auth: params.authOverride,
tailscale: params.tailscaleOverride,
});
const activatedConfig = (
await params.activateRuntimeSecrets(runtimeStartupConfig, {
reason: "startup",
activate: true,
})
).config;
return {
...authBootstrap,
cfg: activatedConfig,
};
}
export type GatewayServer = {
close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise<void>;
};
@@ -315,20 +383,16 @@ export async function startGatewayServer(
}
configSnapshot = await readConfigFileSnapshot();
if (configSnapshot.exists && !configSnapshot.valid) {
const issues =
configSnapshot.issues.length > 0
? formatConfigIssueLines(configSnapshot.issues, "", { normalizeRoot: true }).join("\n")
: "Unknown validation issue.";
throw new Error(
`Invalid config at ${configSnapshot.path}.\n${issues}\nRun "${formatCliCommand("openclaw doctor")}" to repair, then retry.`,
);
if (configSnapshot.exists) {
assertValidGatewayStartupConfigSnapshot(configSnapshot, { includeDoctorHint: true });
}
const autoEnable = applyPluginAutoEnable({ config: configSnapshot.config, env: process.env });
if (autoEnable.changes.length > 0) {
try {
await writeConfigFile(autoEnable.config);
configSnapshot = await readConfigFileSnapshot();
assertValidGatewayStartupConfigSnapshot(configSnapshot);
log.info(
`gateway: auto-enabled plugins:\n${autoEnable.changes
.map((entry) => `- ${entry}`)
@@ -405,37 +469,14 @@ export async function startGatewayServer(
}
});
// Fail fast before startup if required refs are unresolved.
let cfgAtStart: OpenClawConfig;
{
const freshSnapshot = await readConfigFileSnapshot();
if (!freshSnapshot.valid) {
const issues =
freshSnapshot.issues.length > 0
? formatConfigIssueLines(freshSnapshot.issues, "", { normalizeRoot: true }).join("\n")
: "Unknown validation issue.";
throw new Error(`Invalid config at ${freshSnapshot.path}.\n${issues}`);
}
const startupPreflightConfig = applyGatewayAuthOverridesForStartupPreflight(
freshSnapshot.config,
{
auth: opts.auth,
tailscale: opts.tailscale,
},
);
await activateRuntimeSecrets(startupPreflightConfig, {
reason: "startup",
activate: false,
});
}
cfgAtStart = loadConfig();
const authBootstrap = await ensureGatewayStartupAuth({
cfg: cfgAtStart,
env: process.env,
const startupRuntimeConfig = applyConfigOverrides(configSnapshot.config);
const authBootstrap = await prepareGatewayStartupConfig({
configSnapshot,
runtimeConfig: startupRuntimeConfig,
authOverride: opts.auth,
tailscaleOverride: opts.tailscale,
persist: true,
activateRuntimeSecrets,
});
cfgAtStart = authBootstrap.cfg;
if (authBootstrap.generatedToken) {
@@ -449,12 +490,6 @@ export async function startGatewayServer(
);
}
}
cfgAtStart = (
await activateRuntimeSecrets(cfgAtStart, {
reason: "startup",
activate: true,
})
).config;
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
if (diagnosticsEnabled) {
startDiagnosticHeartbeat();
@@ -1061,7 +1096,7 @@ export async function startGatewayServer(
warn: (msg) => logReload.warn(msg),
error: (msg) => logReload.error(msg),
},
watchPath: CONFIG_PATH,
watchPath: configSnapshot.path,
});
})();

View File

@@ -367,6 +367,130 @@ vi.mock("../config/config.js", async () => {
}
};
const composeTestConfig = (baseConfig: Record<string, unknown>) => {
const fileAgents =
baseConfig.agents &&
typeof baseConfig.agents === "object" &&
!Array.isArray(baseConfig.agents)
? (baseConfig.agents as Record<string, unknown>)
: {};
const fileDefaults =
fileAgents.defaults &&
typeof fileAgents.defaults === "object" &&
!Array.isArray(fileAgents.defaults)
? (fileAgents.defaults as Record<string, unknown>)
: {};
const defaults = {
model: { primary: "anthropic/claude-opus-4-6" },
workspace: path.join(os.tmpdir(), "openclaw-gateway-test"),
...fileDefaults,
...testState.agentConfig,
};
const agents = testState.agentsConfig
? { ...fileAgents, ...testState.agentsConfig, defaults }
: { ...fileAgents, defaults };
const fileBindings = Array.isArray(baseConfig.bindings)
? (baseConfig.bindings as AgentBinding[])
: undefined;
const fileChannels =
baseConfig.channels &&
typeof baseConfig.channels === "object" &&
!Array.isArray(baseConfig.channels)
? ({ ...(baseConfig.channels as Record<string, unknown>) } as Record<string, unknown>)
: {};
const overrideChannels =
testState.channelsConfig && typeof testState.channelsConfig === "object"
? { ...testState.channelsConfig }
: {};
const mergedChannels = { ...fileChannels, ...overrideChannels };
if (testState.allowFrom !== undefined) {
const existing =
mergedChannels.whatsapp &&
typeof mergedChannels.whatsapp === "object" &&
!Array.isArray(mergedChannels.whatsapp)
? (mergedChannels.whatsapp as Record<string, unknown>)
: {};
mergedChannels.whatsapp = {
...existing,
allowFrom: testState.allowFrom,
};
}
const channels = Object.keys(mergedChannels).length > 0 ? mergedChannels : undefined;
const fileSession =
baseConfig.session &&
typeof baseConfig.session === "object" &&
!Array.isArray(baseConfig.session)
? (baseConfig.session as Record<string, unknown>)
: {};
const session: Record<string, unknown> = {
...fileSession,
mainKey: fileSession.mainKey ?? "main",
};
if (typeof testState.sessionStorePath === "string") {
session.store = testState.sessionStorePath;
}
if (testState.sessionConfig) {
Object.assign(session, testState.sessionConfig);
}
const fileGateway =
baseConfig.gateway &&
typeof baseConfig.gateway === "object" &&
!Array.isArray(baseConfig.gateway)
? ({ ...(baseConfig.gateway as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (testState.gatewayBind) {
fileGateway.bind = testState.gatewayBind;
}
if (testState.gatewayAuth) {
fileGateway.auth = testState.gatewayAuth;
}
if (testState.gatewayControlUi) {
fileGateway.controlUi = testState.gatewayControlUi;
}
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
const fileCanvasHost =
baseConfig.canvasHost &&
typeof baseConfig.canvasHost === "object" &&
!Array.isArray(baseConfig.canvasHost)
? ({ ...(baseConfig.canvasHost as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (typeof testState.canvasHostPort === "number") {
fileCanvasHost.port = testState.canvasHostPort;
}
const canvasHost = Object.keys(fileCanvasHost).length > 0 ? fileCanvasHost : undefined;
const hooks = testState.hooksConfig ?? (baseConfig.hooks as HooksConfig | undefined);
const fileCron =
baseConfig.cron && typeof baseConfig.cron === "object" && !Array.isArray(baseConfig.cron)
? ({ ...(baseConfig.cron as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (typeof testState.cronEnabled === "boolean") {
fileCron.enabled = testState.cronEnabled;
}
if (typeof testState.cronStorePath === "string") {
fileCron.store = testState.cronStorePath;
}
const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined;
return {
...baseConfig,
agents,
bindings: testState.bindingsConfig ?? fileBindings,
channels,
session,
gateway,
canvasHost,
hooks,
cron,
} as OpenClawConfig;
};
const writeConfigFile = vi.fn(async (cfg: Record<string, unknown>) => {
const configPath = resolveConfigPath();
await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -389,6 +513,8 @@ vi.mock("../config/config.js", async () => {
config: testState.migrationConfig ?? (raw as Record<string, unknown>),
changes: testState.migrationChanges,
}),
applyConfigOverrides: (cfg: OpenClawConfig) =>
composeTestConfig(cfg as Record<string, unknown>),
loadConfig: () => {
const configPath = resolveConfigPath();
let fileConfig: Record<string, unknown> = {};
@@ -400,129 +526,8 @@ vi.mock("../config/config.js", async () => {
} catch {
fileConfig = {};
}
const fileAgents =
fileConfig.agents &&
typeof fileConfig.agents === "object" &&
!Array.isArray(fileConfig.agents)
? (fileConfig.agents as Record<string, unknown>)
: {};
const fileDefaults =
fileAgents.defaults &&
typeof fileAgents.defaults === "object" &&
!Array.isArray(fileAgents.defaults)
? (fileAgents.defaults as Record<string, unknown>)
: {};
const defaults = {
model: { primary: "anthropic/claude-opus-4-6" },
workspace: path.join(os.tmpdir(), "openclaw-gateway-test"),
...fileDefaults,
...testState.agentConfig,
};
const agents = testState.agentsConfig
? { ...fileAgents, ...testState.agentsConfig, defaults }
: { ...fileAgents, defaults };
const fileBindings = Array.isArray(fileConfig.bindings)
? (fileConfig.bindings as AgentBinding[])
: undefined;
const fileChannels =
fileConfig.channels &&
typeof fileConfig.channels === "object" &&
!Array.isArray(fileConfig.channels)
? ({ ...(fileConfig.channels as Record<string, unknown>) } as Record<string, unknown>)
: {};
const overrideChannels =
testState.channelsConfig && typeof testState.channelsConfig === "object"
? { ...testState.channelsConfig }
: {};
const mergedChannels = { ...fileChannels, ...overrideChannels };
if (testState.allowFrom !== undefined) {
const existing =
mergedChannels.whatsapp &&
typeof mergedChannels.whatsapp === "object" &&
!Array.isArray(mergedChannels.whatsapp)
? (mergedChannels.whatsapp as Record<string, unknown>)
: {};
mergedChannels.whatsapp = {
...existing,
allowFrom: testState.allowFrom,
};
}
const channels = Object.keys(mergedChannels).length > 0 ? mergedChannels : undefined;
const fileSession =
fileConfig.session &&
typeof fileConfig.session === "object" &&
!Array.isArray(fileConfig.session)
? (fileConfig.session as Record<string, unknown>)
: {};
const session: Record<string, unknown> = {
...fileSession,
mainKey: fileSession.mainKey ?? "main",
};
if (typeof testState.sessionStorePath === "string") {
session.store = testState.sessionStorePath;
}
if (testState.sessionConfig) {
Object.assign(session, testState.sessionConfig);
}
const fileGateway =
fileConfig.gateway &&
typeof fileConfig.gateway === "object" &&
!Array.isArray(fileConfig.gateway)
? ({ ...(fileConfig.gateway as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (testState.gatewayBind) {
fileGateway.bind = testState.gatewayBind;
}
if (testState.gatewayAuth) {
fileGateway.auth = testState.gatewayAuth;
}
if (testState.gatewayControlUi) {
fileGateway.controlUi = testState.gatewayControlUi;
}
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
const fileCanvasHost =
fileConfig.canvasHost &&
typeof fileConfig.canvasHost === "object" &&
!Array.isArray(fileConfig.canvasHost)
? ({ ...(fileConfig.canvasHost as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (typeof testState.canvasHostPort === "number") {
fileCanvasHost.port = testState.canvasHostPort;
}
const canvasHost = Object.keys(fileCanvasHost).length > 0 ? fileCanvasHost : undefined;
const hooks = testState.hooksConfig ?? (fileConfig.hooks as HooksConfig | undefined);
const fileCron =
fileConfig.cron && typeof fileConfig.cron === "object" && !Array.isArray(fileConfig.cron)
? ({ ...(fileConfig.cron as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (typeof testState.cronEnabled === "boolean") {
fileCron.enabled = testState.cronEnabled;
}
if (typeof testState.cronStorePath === "string") {
fileCron.store = testState.cronStorePath;
}
const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined;
const config = {
...fileConfig,
agents,
bindings: testState.bindingsConfig ?? fileBindings,
channels,
session,
gateway,
canvasHost,
hooks,
cron,
};
return applyPluginAutoEnable({ config, env: process.env }).config;
return applyPluginAutoEnable({ config: composeTestConfig(fileConfig), env: process.env })
.config;
},
parseConfigJson5: (raw: string) => {
try {

View File

@@ -0,0 +1,53 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
const tempDirs: string[] = [];
const originalCwd = process.cwd();
const originalBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const originalWatchMode = process.env.OPENCLAW_WATCH_MODE;
function makeRepoRoot(prefix: string): string {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(repoRoot);
return repoRoot;
}
afterEach(() => {
process.chdir(originalCwd);
if (originalBundledDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledDir;
}
if (originalWatchMode === undefined) {
delete process.env.OPENCLAW_WATCH_MODE;
} else {
process.env.OPENCLAW_WATCH_MODE = originalWatchMode;
}
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("resolveBundledPluginsDir", () => {
it("prefers source extensions from the package root in watch mode", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-dir-watch-");
fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true });
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
fs.writeFileSync(
path.join(repoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
process.chdir(repoRoot);
process.env.OPENCLAW_WATCH_MODE = "1";
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(repoRoot, "extensions")),
);
});
});

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { resolveUserPath } from "../utils.js";
export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
@@ -9,6 +10,22 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env):
return resolveUserPath(override, env);
}
if (env.OPENCLAW_WATCH_MODE === "1") {
try {
const packageRoot = resolveOpenClawPackageRootSync({ cwd: process.cwd() });
if (packageRoot) {
// In watch mode, prefer source plugin roots so plugin-local runtime deps
// resolve from extensions/<id>/node_modules instead of stripped dist copies.
const sourceExtensionsDir = path.join(packageRoot, "extensions");
if (fs.existsSync(sourceExtensionsDir)) {
return sourceExtensionsDir;
}
}
} catch {
// ignore
}
}
// bun --compile: ship a sibling `extensions/` next to the executable.
try {
const execDir = path.dirname(process.execPath);