mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 14:45:46 +00:00
Gateway: simplify startup and stabilize mock responses tests
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
53
src/plugins/bundled-dir.test.ts
Normal file
53
src/plugins/bundled-dir.test.ts
Normal 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")),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user