diff --git a/src/agents/auth-profiles.readonly-sync.test.ts b/src/agents/auth-profiles.readonly-sync.test.ts index 2ef1c40d2f8..30a7b501a95 100644 --- a/src/agents/auth-profiles.readonly-sync.test.ts +++ b/src/agents/auth-profiles.readonly-sync.test.ts @@ -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", diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 2627845ed40..7e490c97c94 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -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; diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 0fa050e55ec..b1362310b7f 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -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) { diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index d3fdc89c86a..4108ed30e46 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -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 = { diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 0e30cef34e8..f4e49df1a10 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -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; + 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 { + // 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((_, 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 | 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 { + if (this.pendingStop?.ws === ws) { + return this.pendingStop.promise; + } + let resolve!: () => void; + const promise = new Promise((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() { diff --git a/src/gateway/gateway.test.ts b/src/gateway/gateway.test.ts index aea5a816fa7..3625a3c481e 100644 --- a/src/gateway/gateway.test.ts +++ b/src/gateway/gateway.test.ts @@ -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" }); } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 350172bcee4..cec8f2cb42a 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -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>> { + 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; }; @@ -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, }); })(); diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 59ad8a9cedc..4bfb7ef4e4d 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -367,6 +367,130 @@ vi.mock("../config/config.js", async () => { } }; + const composeTestConfig = (baseConfig: Record) => { + const fileAgents = + baseConfig.agents && + typeof baseConfig.agents === "object" && + !Array.isArray(baseConfig.agents) + ? (baseConfig.agents as Record) + : {}; + const fileDefaults = + fileAgents.defaults && + typeof fileAgents.defaults === "object" && + !Array.isArray(fileAgents.defaults) + ? (fileAgents.defaults as Record) + : {}; + 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) } as Record) + : {}; + 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) + : {}; + 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) + : {}; + const session: Record = { + ...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) } as Record) + : {}; + 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) } as Record) + : {}; + 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) } as Record) + : {}; + 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) => { 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), changes: testState.migrationChanges, }), + applyConfigOverrides: (cfg: OpenClawConfig) => + composeTestConfig(cfg as Record), loadConfig: () => { const configPath = resolveConfigPath(); let fileConfig: Record = {}; @@ -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) - : {}; - const fileDefaults = - fileAgents.defaults && - typeof fileAgents.defaults === "object" && - !Array.isArray(fileAgents.defaults) - ? (fileAgents.defaults as Record) - : {}; - 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) } as Record) - : {}; - 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) - : {}; - 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) - : {}; - const session: Record = { - ...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) } as Record) - : {}; - 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) } as Record) - : {}; - 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) } as Record) - : {}; - 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 { diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts new file mode 100644 index 00000000000..8bc1fc9cf76 --- /dev/null +++ b/src/plugins/bundled-dir.test.ts @@ -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")), + ); + }); +}); diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 89d43444640..7fa25092f42 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -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//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);