Merge branch 'main' into meow/tweakcn-custom-theme-import

This commit is contained in:
Val Alexander
2026-04-24 19:59:42 -05:00
committed by GitHub
57 changed files with 3063 additions and 2083 deletions

View File

@@ -56,6 +56,7 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
- Search/dedupe: prefer `gh search issues 'repo:openclaw/openclaw is:open <terms>' --json number,title,state,updatedAt --limit 20`.
- PR shortlist: `gh pr list ...`; then `gh pr view <n> --json number,title,body,closingIssuesReferences,files,statusCheckRollup,reviewDecision`.
- After landing PR: search duplicate open issues/PRs. Before closing: comment why + canonical link.
- GH comments with markdown backticks, `$`, or shell snippets: avoid inline double-quoted `--body`; use single quotes or `--body-file`.
- PR review answer must explicitly cover: what bug/behavior we are trying to fix; PR/issue URL(s) and affected endpoint/surface; whether this is the best possible fix, with high-certainty evidence from code, tests, CI, and shipped/current behavior.
- CI polling: exact SHA, needed fields only. Example: `gh api repos/<owner>/<repo>/actions/runs/<id> --jq '{status,conclusion,head_sha,updated_at,name,path}'`.
- Post-land wait: minimal. Exact landed SHA only. If superseded on `main`, same-branch `cancel-in-progress` cancellations are expected; stop once local touched-surface proof exists. Never wait for newer unrelated `main` unless asked.

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
52af51e35e05d0cbaa1a79fb415f2c2fe56ad5d52a62efa9cbb9c32489d517f5 config-baseline.json
642b4e2c9891e710790313df097b4e0db75a197ec0908e9c03bdc76f5bbdf9b0 config-baseline.core.json
8f23e853ccde6cd021b84b32fe205f456f8516667683d16c9b56d6598f608989 config-baseline.json
037bf4a873587adb8349f531c0ad79cd4f90e01712f5aa5d8b4387be73538a7f config-baseline.core.json
22d7cd6d8279146b2d79c9531a55b80b52a2c99c81338c508104729154fdd02d config-baseline.channel.json
d47a574045a47356e513ab308d7dcad9fa0b389f50e93c5cf0f820fab858e70e config-baseline.plugin.json
86f615b7d267b03888af0af7ccb3f8232a6b636f8a741d522ff425e46729ba81 config-baseline.plugin.json

View File

@@ -43,7 +43,7 @@ Notes:
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
- State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.<timestamp>` to reclaim space safely.
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
- Doctor repairs missing bundled plugin runtime dependencies without requiring write access to the installed OpenClaw package. For root-owned npm installs or hardened systemd units, set `OPENCLAW_PLUGIN_STAGE_DIR` to a writable directory such as `/var/lib/openclaw/plugin-runtime-deps`.
- Doctor repairs missing bundled plugin runtime dependencies without writing into packaged global installs. For root-owned npm installs or hardened systemd units, set `OPENCLAW_PLUGIN_STAGE_DIR` to a writable directory such as `/var/lib/openclaw/plugin-runtime-deps`.
- Doctor auto-migrates legacy flat Talk config (`talk.voiceId`, `talk.modelId`, and friends) into `talk.provider` + `talk.providers.<provider>`.
- Repeat `doctor --fix` runs no longer report/apply Talk normalization when the only difference is object key order.
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.

View File

@@ -44,9 +44,10 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**:
- the runtime reads credentials from **one place**
- we can keep multiple profiles and route them deterministically
- when credentials are reused from an external CLI like Codex CLI, OpenClaw
mirrors them with provenance and re-reads that external source instead of
rotating the refresh token itself
- external CLI reuse is provider-specific: Codex CLI can bootstrap an empty
`openai-codex:default` profile, but once OpenClaw has a local OAuth profile,
the local refresh token is canonical; other integrations can remain
externally managed and re-read their CLI auth store
## Storage (where tokens live)
@@ -128,8 +129,11 @@ At runtime:
- if `expires` is in the future → use the stored access token
- if expired → refresh (under a file lock) and overwrite the stored credentials
- exception: reused external CLI credentials stay externally managed; OpenClaw
re-reads the CLI auth store and never spends the copied refresh token itself
- exception: some external CLI credentials stay externally managed; OpenClaw
re-reads those CLI auth stores instead of spending copied refresh tokens.
Codex CLI bootstrap is intentionally narrower: it seeds an empty
`openai-codex:default` profile, then OpenClaw-owned refreshes keep the local
profile canonical.
The refresh flow is automatic; you generally don't need to manage tokens manually.

View File

@@ -52,13 +52,17 @@ pnpm add -g openclaw@latest
bun add -g openclaw@latest
```
### Root-owned global npm installs
### Global npm installs and runtime dependencies
Some Linux npm setups install global packages under root-owned directories such as
`/usr/lib/node_modules/openclaw`. OpenClaw supports that layout: the installed
package is treated as read-only at runtime, and bundled plugin runtime
OpenClaw treats packaged global installs as read-only at runtime, even when the
global package directory is writable by the current user. Bundled plugin runtime
dependencies are staged into a writable runtime directory instead of mutating the
package tree.
package tree. This keeps `openclaw update` from racing with a running gateway or
local agent that is repairing plugin dependencies during the same install.
Some Linux npm setups install global packages under root-owned directories such
as `/usr/lib/node_modules/openclaw`. OpenClaw supports that layout through the
same external staging path.
For hardened systemd units, set a writable stage directory that is included in
`ReadWritePaths`:

View File

@@ -137,7 +137,7 @@ Browser settings live in `~/.openclaw/openclaw.json`.
executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
profiles: {
openclaw: { cdpPort: 18800, color: "#FF4500" },
work: { cdpPort: 18801, color: "#0066CC" },
work: { cdpPort: 18801, color: "#0066CC", headless: true },
user: {
driver: "existing-session",
attachOnly: true,
@@ -177,6 +177,7 @@ Browser settings live in `~/.openclaw/openclaw.json`.
<Accordion title="Profile behavior">
- `attachOnly: true` means never launch a local browser; only attach if one is already running.
- `headless` can be set globally or per local managed profile. Per-profile values override `browser.headless`, so one locally launched profile can stay headless while another remains visible.
- `color` (top-level and per-profile) tints the browser UI so you can see which profile is active.
- Default profile is `openclaw` (managed standalone). Use `defaultProfile: "user"` to opt into the signed-in user browser.
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
@@ -235,6 +236,7 @@ Or set it in config, per platform:
- **Remote control (node host):** run a node host on the machine that has the browser; the Gateway proxies browser actions to it.
- **Remote CDP:** set `browser.profiles.<name>.cdpUrl` (or `browser.cdpUrl`) to
attach to a remote Chromium-based browser. In this case, OpenClaw will not launch a local browser.
- `headless` only affects local managed profiles that OpenClaw launches. It does not restart or change existing-session or remote CDP browsers.
Stopping behavior differs by profile mode:

View File

@@ -159,6 +159,7 @@ function createProfile(overrides: Partial<ResolvedBrowserProfile>): ResolvedBrow
driver: "openclaw",
attachOnly: false,
...overrides,
headless: overrides.headless ?? false,
};
}

View File

@@ -75,6 +75,7 @@ const localProfile: ResolvedBrowserProfile = {
cdpIsLoopback: true,
color: "#FF4500",
driver: "openclaw",
headless: false,
attachOnly: false,
};

View File

@@ -166,18 +166,29 @@ describe("chrome.ts internal", () => {
cdpPort: 19222,
cdpUrl: "http://127.0.0.1:19222",
cdpIsLoopback: true,
headless: false,
} as unknown as ResolvedBrowserProfile;
it("toggles headless args", () => {
const args = buildOpenClawChromeLaunchArgs({
resolved: baseResolved({ headless: true }),
profile: baseProfile,
resolved: baseResolved({ headless: false }),
profile: { ...baseProfile, headless: true },
userDataDir: "/tmp/foo",
});
expect(args).toContain("--headless=new");
expect(args).toContain("--disable-gpu");
});
it("lets profile headless=false override global headless=true", () => {
const args = buildOpenClawChromeLaunchArgs({
resolved: baseResolved({ headless: true }),
profile: { ...baseProfile, headless: false },
userDataDir: "/tmp/foo",
});
expect(args).not.toContain("--headless=new");
expect(args).not.toContain("--disable-gpu");
});
it("toggles no-sandbox args", () => {
const args = buildOpenClawChromeLaunchArgs({
resolved: baseResolved({ noSandbox: true }),

View File

@@ -650,6 +650,7 @@ describe("browser chrome launch args", () => {
cdpIsLoopback: true,
color: "#FF4500",
driver: "openclaw",
headless: false,
attachOnly: false,
},
userDataDir: "/tmp/openclaw-test-user-data",

View File

@@ -121,7 +121,7 @@ export function buildOpenClawChromeLaunchArgs(params: {
"--password-store=basic",
];
if (resolved.headless) {
if (profile.headless) {
args.push("--headless=new");
args.push("--disable-gpu");
}

View File

@@ -178,6 +178,42 @@ describe("browser config", () => {
expect(remote?.attachOnly).toBe(true);
});
it("inherits headless from global browser config when profile override is not set", () => {
const resolved = resolveBrowserConfig({
headless: true,
profiles: {
remote: { cdpUrl: "http://127.0.0.1:9222", color: "#0066CC" },
},
});
const remote = resolveProfile(resolved, "remote");
expect(remote?.headless).toBe(true);
});
it("allows profile headless to override global browser headless", () => {
const resolved = resolveBrowserConfig({
headless: false,
profiles: {
remote: { cdpUrl: "http://127.0.0.1:9222", headless: true, color: "#0066CC" },
},
});
const remote = resolveProfile(resolved, "remote");
expect(remote?.headless).toBe(true);
});
it("allows profile headless=false to override global browser headless=true", () => {
const resolved = resolveBrowserConfig({
headless: true,
profiles: {
remote: { cdpUrl: "http://127.0.0.1:9222", headless: false, color: "#0066CC" },
},
});
const remote = resolveProfile(resolved, "remote");
expect(remote?.headless).toBe(false);
});
it("uses base protocol for profiles with only cdpPort", () => {
const resolved = resolveBrowserConfig({
cdpUrl: "https://example.com:9443",

View File

@@ -81,6 +81,7 @@ export type ResolvedBrowserProfile = {
userDataDir?: string;
color: string;
driver: "openclaw" | "existing-session";
headless: boolean;
attachOnly: boolean;
};
@@ -312,6 +313,7 @@ export function resolveProfile(
let cdpPort = profile.cdpPort ?? 0;
let cdpUrl = "";
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
const headless = profile.headless ?? resolved.headless;
if (driver === "existing-session") {
return {
@@ -323,6 +325,7 @@ export function resolveProfile(
userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined,
color: profile.color,
driver,
headless,
attachOnly: true,
};
}
@@ -356,6 +359,7 @@ export function resolveProfile(
cdpIsLoopback: isLoopbackHost(cdpHost),
color: profile.color,
driver,
headless,
attachOnly: profile.attachOnly ?? resolved.attachOnly,
};
}

View File

@@ -7,6 +7,10 @@ function changedProfileInvariants(
next: ResolvedBrowserProfile,
): string[] {
const changed: string[] = [];
const currentUsesLocalManagedLaunch =
current.driver === "openclaw" && !current.attachOnly && current.cdpIsLoopback;
const nextUsesLocalManagedLaunch =
next.driver === "openclaw" && !next.attachOnly && next.cdpIsLoopback;
if (current.cdpUrl !== next.cdpUrl) {
changed.push("cdpUrl");
}
@@ -16,6 +20,13 @@ function changedProfileInvariants(
if (current.driver !== next.driver) {
changed.push("driver");
}
if (
currentUsesLocalManagedLaunch &&
nextUsesLocalManagedLaunch &&
current.headless !== next.headless
) {
changed.push("headless");
}
if (current.attachOnly !== next.attachOnly) {
changed.push("attachOnly");
}

View File

@@ -11,6 +11,7 @@ function profile(driver: "existing-session" | "openclaw"): ResolvedBrowserProfil
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: "#00AA00",
headless: false,
attachOnly: driver === "existing-session",
};
}

View File

@@ -101,7 +101,7 @@ async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext)
detectError,
userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
headless: profileCtx.profile.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
attachOnly: profileCtx.profile.attachOnly,

View File

@@ -23,6 +23,7 @@ describe("browser tab routes attachOnly loopback profiles", () => {
cdpPort: 9222,
color: "#00AA00",
driver: "openclaw",
headless: false,
attachOnly: true,
},
resolvedOverrides: {

View File

@@ -35,6 +35,7 @@ function createAttachOnlyLoopbackProfile(cdpUrl: string) {
cdpPort: 9222,
color: "#00AA00",
driver: "openclaw",
headless: false,
attachOnly: true,
},
resolvedOverrides: {
@@ -236,6 +237,7 @@ describe("browser server-context ensureBrowserAvailable", () => {
cdpPort: 443,
color: "#00AA00",
driver: "openclaw",
headless: false,
attachOnly: false,
},
resolvedOverrides: {

View File

@@ -1,7 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserServerState } from "./server-context.types.js";
type TestProfileConfig = { cdpPort?: number; cdpUrl?: string; color?: string };
type TestProfileConfig = {
cdpPort?: number;
cdpUrl?: string;
color?: string;
headless?: boolean;
driver?: "openclaw" | "existing-session";
};
type TestConfig = {
browser: {
enabled: true;
@@ -225,4 +231,157 @@ describe("server-context hot-reload profiles", () => {
expect(runtime?.lastTargetId).toBeNull();
expect(runtime?.reconcile?.reason).toContain("cdpPort");
});
it("marks local managed runtime state for reconcile when profile headless changes", async () => {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const openclawProfile = resolveProfile(resolved, "openclaw");
expect(openclawProfile).toBeTruthy();
expect(openclawProfile?.headless).toBe(true);
const state: BrowserServerState = {
server: null,
port: 18791,
resolved,
profiles: new Map([
[
"openclaw",
{
profile: openclawProfile!,
running: { pid: 123 } as never,
lastTargetId: "tab-1",
reconcile: null,
},
],
]),
};
mockState.cfgProfiles.openclaw = {
cdpPort: 18800,
color: "#FF4500",
headless: false,
};
mockState.cachedConfig = null;
refreshResolvedBrowserConfigFromDisk({
current: state,
refreshConfigFromDisk: true,
mode: "cached",
});
const runtime = state.profiles.get("openclaw");
expect(runtime).toBeTruthy();
expect(runtime?.profile.headless).toBe(false);
expect(runtime?.lastTargetId).toBeNull();
expect(runtime?.reconcile?.reason).toContain("headless");
});
it("does not reconcile existing-session runtime when only headless changes", async () => {
mockState.cfgProfiles.remote = {
cdpUrl: "http://127.0.0.1:9222",
color: "#0066CC",
headless: true,
driver: "existing-session",
};
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const remoteProfile = resolveProfile(resolved, "remote");
expect(remoteProfile).toBeTruthy();
expect(remoteProfile?.driver).toBe("existing-session");
expect(remoteProfile?.attachOnly).toBe(true);
expect(remoteProfile?.headless).toBe(true);
const state: BrowserServerState = {
server: null,
port: 18791,
resolved,
profiles: new Map([
[
"remote",
{
profile: remoteProfile!,
running: { pid: 456 } as never,
lastTargetId: "tab-remote",
reconcile: null,
},
],
]),
};
mockState.cfgProfiles.remote = {
cdpUrl: "http://127.0.0.1:9222",
color: "#0066CC",
headless: false,
driver: "existing-session",
};
mockState.cachedConfig = null;
refreshResolvedBrowserConfigFromDisk({
current: state,
refreshConfigFromDisk: true,
mode: "cached",
});
const runtime = state.profiles.get("remote");
expect(runtime).toBeTruthy();
expect(runtime?.profile.driver).toBe("existing-session");
expect(runtime?.profile.headless).toBe(false);
expect(runtime?.lastTargetId).toBe("tab-remote");
expect(runtime?.reconcile).toBeNull();
});
it("does not reconcile remote cdp runtime when only headless changes", async () => {
mockState.cfgProfiles.remote = {
cdpUrl: "http://10.0.0.42:9222",
color: "#0066CC",
headless: true,
};
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const remoteProfile = resolveProfile(resolved, "remote");
expect(remoteProfile).toBeTruthy();
expect(remoteProfile?.driver).toBe("openclaw");
expect(remoteProfile?.attachOnly).toBe(false);
expect(remoteProfile?.cdpIsLoopback).toBe(false);
expect(remoteProfile?.headless).toBe(true);
const state: BrowserServerState = {
server: null,
port: 18791,
resolved,
profiles: new Map([
[
"remote",
{
profile: remoteProfile!,
running: { pid: 789 } as never,
lastTargetId: "tab-remote-cdp",
reconcile: null,
},
],
]),
};
mockState.cfgProfiles.remote = {
cdpUrl: "http://10.0.0.42:9222",
color: "#0066CC",
headless: false,
};
mockState.cachedConfig = null;
refreshResolvedBrowserConfigFromDisk({
current: state,
refreshConfigFromDisk: true,
mode: "cached",
});
const runtime = state.profiles.get("remote");
expect(runtime).toBeTruthy();
expect(runtime?.profile.driver).toBe("openclaw");
expect(runtime?.profile.cdpIsLoopback).toBe(false);
expect(runtime?.profile.headless).toBe(false);
expect(runtime?.lastTargetId).toBe("tab-remote-cdp");
expect(runtime?.reconcile).toBeNull();
});
});

View File

@@ -41,6 +41,7 @@ describe("browser server-context listProfiles", () => {
cdpPort: 9222,
color: "#00AA00",
driver: "openclaw",
headless: false,
attachOnly: true,
},
resolvedOverrides: {

View File

@@ -77,6 +77,7 @@ function resolveProfileForTest(
cdpIsLoopback,
color: rawProfile.color ?? state.resolved.color,
driver: rawProfile.driver === "existing-session" ? "existing-session" : "openclaw",
headless: rawProfile.headless ?? state.resolved.headless,
attachOnly: rawProfile.attachOnly ?? state.resolved.attachOnly,
userDataDir: rawProfile.userDataDir,
};

View File

@@ -32,6 +32,7 @@ function localOpenClawProfile(): Parameters<typeof createProfileResetOps>[0]["pr
cdpPort: 18800,
color: "#f60",
driver: "openclaw",
headless: false,
attachOnly: false,
};
}

View File

@@ -15,6 +15,7 @@ export function makeBrowserProfile(
cdpPort: 18800,
color: "#FF4500",
driver: "openclaw",
headless: false,
attachOnly: false,
...overrides,
};

View File

@@ -814,7 +814,7 @@ describe("diagnostics-otel service", () => {
toolCallId: "tool-1",
durationMs: 20,
toolInput: "tool input",
toolOutput: "x".repeat(6000),
toolOutput: `${"x".repeat(4077)} Bearer ${"a".repeat(80)}`, // pragma: allowlist secret
} as Parameters<typeof emitDiagnosticEvent>[0]);
await flushDiagnosticEvents();
@@ -842,6 +842,7 @@ describe("diagnostics-otel service", () => {
expect(String(toolAttrs?.["openclaw.content.tool_output"]).length).toBeLessThanOrEqual(
MAX_TEST_OTEL_CONTENT_ATTRIBUTE_CHARS + OTEL_TRUNCATED_SUFFIX_MAX_CHARS,
);
expect(String(toolAttrs?.["openclaw.content.tool_output"])).not.toContain("a".repeat(11));
await service.stop?.(ctx);
});

View File

@@ -134,7 +134,7 @@ function clampOtelLogText(value: string, maxChars: number): string {
}
function normalizeOtelLogString(value: string, maxChars: number): string {
return redactSensitiveText(clampOtelLogText(value, maxChars));
return clampOtelLogText(redactSensitiveText(value), maxChars);
}
function resolveContentCapturePolicy(value: unknown): OtelContentCapturePolicy {

View File

@@ -14,7 +14,6 @@ import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import * as undici from "undici";
import * as ws from "ws";
import { validateDiscordProxyUrl } from "../proxy-fetch.js";
import { DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT } from "./gateway-handle.js";
@@ -473,8 +472,6 @@ export function createDiscordGatewayPlugin(params: {
runtime: RuntimeEnv;
__testing?: {
HttpsProxyAgentCtor?: typeof httpsProxyAgent.HttpsProxyAgent;
ProxyAgentCtor?: typeof undici.ProxyAgent;
undiciFetch?: typeof undici.fetch;
webSocketCtor?: DiscordGatewayWebSocketCtor;
registerClient?: (
plugin: carbonGateway.GatewayPlugin,
@@ -520,31 +517,24 @@ export function createDiscordGatewayPlugin(params: {
validateDiscordProxyUrl(proxy);
const HttpsProxyAgentCtor =
params.__testing?.HttpsProxyAgentCtor ?? httpsProxyAgent.HttpsProxyAgent;
const ProxyAgentCtor = params.__testing?.ProxyAgentCtor ?? undici.ProxyAgent;
const wsAgent = new HttpsProxyAgentCtor<string>(proxy);
const fetchAgent = new ProxyAgentCtor(proxy);
params.runtime.log?.("discord: gateway proxy enabled");
return createGatewayPlugin({
options,
fetchImpl: async (input, init) => {
const response = (await (params.__testing?.undiciFetch ?? undici.fetch)(
return await fetchDiscordGatewayMetadataDirect(
input,
init,
)) as unknown as Response;
captureHttpExchange({
url: input,
method: (init?.method as string | undefined) ?? "GET",
requestHeaders: init?.headers as Headers | Record<string, string> | undefined,
requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null,
response,
flowId: randomUUID(),
meta: { subsystem: "discord-gateway-metadata" },
});
return response;
debugProxySettings.enabled
? false
: {
flowId: randomUUID(),
meta: { subsystem: "discord-gateway-metadata" },
},
);
},
fetchInit: { dispatcher: fetchAgent },
wsAgent,
runtime: params.runtime,
testing: params.__testing

View File

@@ -18,18 +18,12 @@ const {
globalFetchMock,
HttpsProxyAgent,
getLastAgent,
restProxyAgentSpy,
resolveDebugProxySettingsMock,
undiciFetchMock,
undiciProxyAgentSpy,
resetLastAgent,
webSocketSpy,
wsProxyAgentSpy,
} = vi.hoisted(() => {
const wsProxyAgentSpy = vi.fn();
const undiciProxyAgentSpy = vi.fn();
const restProxyAgentSpy = vi.fn();
const undiciFetchMock = vi.fn();
const globalFetchMock = vi.fn();
const baseRegisterClientSpy = vi.fn();
const webSocketSpy = vi.fn();
@@ -86,12 +80,9 @@ const {
globalFetchMock,
HttpsProxyAgent,
getLastAgent: () => HttpsProxyAgent.lastCreated,
restProxyAgentSpy,
captureHttpExchangeSpy,
captureWsEventSpy,
resolveDebugProxySettingsMock,
undiciFetchMock,
undiciProxyAgentSpy,
resetLastAgent: () => {
HttpsProxyAgent.lastCreated = undefined;
},
@@ -115,15 +106,6 @@ vi.mock("https-proxy-agent", () => ({
HttpsProxyAgent,
}));
vi.mock("undici", () => ({
ProxyAgent: function ProxyAgent(this: { proxyUrl: string }, proxyUrl: string) {
this.proxyUrl = proxyUrl;
undiciProxyAgentSpy(proxyUrl);
restProxyAgentSpy(proxyUrl);
},
fetch: undiciFetchMock,
}));
vi.mock("ws", () => ({
default: function MockWebSocket(url: string, options?: { agent?: unknown }) {
webSocketSpy(url, options);
@@ -176,12 +158,6 @@ describe("createDiscordGatewayPlugin", () => {
return {
HttpsProxyAgentCtor:
HttpsProxyAgent as unknown as typeof import("https-proxy-agent").HttpsProxyAgent,
ProxyAgentCtor: function ProxyAgentCtor(this: { proxyUrl: string }, proxyUrl: string) {
this.proxyUrl = proxyUrl;
undiciProxyAgentSpy(proxyUrl);
restProxyAgentSpy(proxyUrl);
} as unknown as typeof import("undici").ProxyAgent,
undiciFetch: undiciFetchMock,
webSocketCtor: function WebSocketCtor(url: string, options?: { agent?: unknown }) {
webSocketSpy(url, options);
} as unknown as new (url: string, options?: { agent?: unknown }) => import("ws").WebSocket,
@@ -276,9 +252,6 @@ describe("createDiscordGatewayPlugin", () => {
vi.useRealTimers();
baseRegisterClientSpy.mockClear();
globalFetchMock.mockClear();
restProxyAgentSpy.mockClear();
undiciFetchMock.mockClear();
undiciProxyAgentSpy.mockClear();
wsProxyAgentSpy.mockClear();
webSocketSpy.mockClear();
captureHttpExchangeSpy.mockClear();
@@ -454,7 +427,7 @@ describe("createDiscordGatewayPlugin", () => {
expect(runtime.log).not.toHaveBeenCalled();
});
it("uses proxy fetch for gateway metadata lookup before registering", async () => {
it("keeps gateway metadata lookup on the guarded direct fetch when proxy is configured", async () => {
const runtime = createRuntime();
const plugin = createDiscordGatewayPlugin({
discordConfig: { proxy: "http://127.0.0.1:8080" },
@@ -462,14 +435,12 @@ describe("createDiscordGatewayPlugin", () => {
__testing: createProxyTestingOverrides(),
});
await registerGatewayClientWithMetadata({ plugin, fetchMock: undiciFetchMock });
await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock });
expect(restProxyAgentSpy).toHaveBeenCalledWith("http://127.0.0.1:8080");
expect(undiciFetchMock).toHaveBeenCalledWith(
expect(globalFetchMock).toHaveBeenCalledWith(
"https://discord.com/api/v10/gateway/bot",
expect.objectContaining({
headers: { Authorization: "Bot token-123" },
dispatcher: expect.objectContaining({ proxyUrl: "http://127.0.0.1:8080" }),
}),
);
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
@@ -488,7 +459,7 @@ describe("createDiscordGatewayPlugin", () => {
expect(captureHttpExchangeSpy).not.toHaveBeenCalled();
});
it("accepts IPv6 loopback proxy URLs for gateway metadata and websocket setup", async () => {
it("accepts IPv6 loopback proxy URLs for websocket setup", async () => {
const runtime = createRuntime();
const plugin = createDiscordGatewayPlugin({
discordConfig: { proxy: "http://[::1]:8080" },
@@ -499,10 +470,9 @@ describe("createDiscordGatewayPlugin", () => {
const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
.createWebSocket;
createWebSocket("wss://gateway.discord.gg");
await registerGatewayClientWithMetadata({ plugin, fetchMock: undiciFetchMock });
await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock });
expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://[::1]:8080");
expect(restProxyAgentSpy).toHaveBeenCalledWith("http://[::1]:8080");
expect(runtime.error).not.toHaveBeenCalled();
});

View File

@@ -1,11 +1,10 @@
import { Command } from "commander";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.ts";
import plugin from "./index.js";
import { registerGoogleMeetCli } from "./src/cli.js";
import { resolveGoogleMeetConfig } from "./src/config.js";
import type { GoogleMeetRuntime } from "./src/runtime.js";
import { captureStdout, setupGoogleMeetPlugin } from "./src/test-support/plugin-harness.js";
import { CREATE_MEET_FROM_BROWSER_SCRIPT } from "./src/transports/chrome-create.js";
const voiceCallMocks = vi.hoisted(() => ({
@@ -37,23 +36,11 @@ vi.mock("./src/voice-call-gateway.js", () => ({
endMeetVoiceCallGatewayCall: voiceCallMocks.endMeetVoiceCallGatewayCall,
}));
const noopLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
function captureStdout() {
let output = "";
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
output += String(chunk);
return true;
}) as typeof process.stdout.write);
return {
output: () => output,
restore: () => writeSpy.mockRestore(),
};
function setup(
config?: Parameters<typeof setupGoogleMeetPlugin>[1],
options?: Parameters<typeof setupGoogleMeetPlugin>[2],
) {
return setupGoogleMeetPlugin(plugin, config, options);
}
async function runCreateMeetBrowserScript(params: { buttonText: string }) {
@@ -89,90 +76,6 @@ async function runCreateMeetBrowserScript(params: { buttonText: string }) {
return { button, result: await fn() };
}
function setup(
config: Record<string, unknown> = {},
options: {
nodesInvokeHandler?: (params: {
nodeId: string;
command: string;
params?: unknown;
timeoutMs?: number;
}) => Promise<unknown>;
} = {},
) {
const methods = new Map<string, unknown>();
const tools: unknown[] = [];
const nodesList = vi.fn(async () => ({
nodes: [
{
nodeId: "node-1",
displayName: "parallels-macos",
connected: true,
caps: ["browser"],
commands: ["browser.proxy", "googlemeet.chrome"],
},
],
}));
const nodesInvoke = vi.fn(async (params) => {
if (options.nodesInvokeHandler) {
return options.nodesInvokeHandler(params);
}
if (params.command === "browser.proxy") {
const proxy = params.params as { path?: string; body?: { url?: string; targetId?: string } };
if (proxy.path === "/tabs") {
return { payload: { result: { running: true, tabs: [] } } };
}
if (proxy.path === "/tabs/open") {
return {
payload: {
result: {
targetId: "tab-1",
title: "Meet",
url: proxy.body?.url ?? "https://meet.google.com/abc-defg-hij",
},
},
};
}
return { payload: { result: { ok: true } } };
}
return { payload: { launched: true } };
});
const runCommandWithTimeout = vi.fn(async (argv: string[]) => {
if (argv[0] === "/usr/sbin/system_profiler") {
return { code: 0, stdout: "BlackHole 2ch", stderr: "" };
}
return { code: 0, stdout: "", stderr: "" };
});
const api = createTestPluginApi({
id: "google-meet",
name: "Google Meet",
description: "test",
version: "0",
source: "test",
config: {},
pluginConfig: config,
runtime: {
system: {
runCommandWithTimeout,
formatNativeDependencyHint: vi.fn(() => "Install with brew install blackhole-2ch."),
},
nodes: {
list: nodesList,
invoke: nodesInvoke,
},
} as unknown as OpenClawPluginApi["runtime"],
logger: noopLogger,
registerGatewayMethod: (method: string, handler: unknown) => methods.set(method, handler),
registerTool: (tool: unknown) => tools.push(tool),
});
plugin.register(api);
return {
methods,
tools,
nodesInvoke,
};
}
describe("google-meet create flow", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -1,10 +1,8 @@
import { EventEmitter } from "node:events";
import { PassThrough, Writable } from "node:stream";
import { Command } from "commander";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.ts";
import plugin from "./index.js";
import { registerGoogleMeetCli } from "./src/cli.js";
import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/config.js";
@@ -23,6 +21,11 @@ import { startNodeRealtimeAudioBridge } from "./src/realtime-node.js";
import { startCommandRealtimeAudioBridge } from "./src/realtime.js";
import { normalizeMeetUrl } from "./src/runtime.js";
import type { GoogleMeetRuntime } from "./src/runtime.js";
import {
captureStdout,
noopLogger,
setupGoogleMeetPlugin,
} from "./src/test-support/plugin-harness.js";
import { buildMeetDtmfSequence, normalizeDialInNumber } from "./src/transports/twilio.js";
const voiceCallMocks = vi.hoisted(() => ({
@@ -54,23 +57,11 @@ vi.mock("./src/voice-call-gateway.js", () => ({
endMeetVoiceCallGatewayCall: voiceCallMocks.endMeetVoiceCallGatewayCall,
}));
const noopLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
function captureStdout() {
let output = "";
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
output += String(chunk);
return true;
}) as typeof process.stdout.write);
return {
output: () => output,
restore: () => writeSpy.mockRestore(),
};
function setup(
config?: Parameters<typeof setupGoogleMeetPlugin>[1],
options?: Parameters<typeof setupGoogleMeetPlugin>[2],
) {
return setupGoogleMeetPlugin(plugin, config, options);
}
type TestBridgeProcess = {
@@ -82,134 +73,6 @@ type TestBridgeProcess = {
on: EventEmitter["on"];
};
type NodeListResult = {
nodes: Array<{
nodeId: string;
displayName?: string;
connected?: boolean;
commands?: string[];
caps?: string[];
remoteIp?: string;
}>;
};
function setup(
config: Record<string, unknown> = {},
options: {
fullConfig?: Record<string, unknown>;
nodesListResult?: NodeListResult;
nodesInvokeResult?: unknown;
browserActResult?: Record<string, unknown>;
nodesInvokeHandler?: (params: {
nodeId: string;
command: string;
params?: unknown;
timeoutMs?: number;
}) => Promise<unknown>;
} = {},
) {
const methods = new Map<string, unknown>();
const tools: unknown[] = [];
const cliRegistrations: unknown[] = [];
const nodeHostCommands: unknown[] = [];
const nodesList = vi.fn(
async () =>
options.nodesListResult ?? {
nodes: [
{
nodeId: "node-1",
displayName: "parallels-macos",
connected: true,
caps: ["browser"],
commands: ["browser.proxy", "googlemeet.chrome"],
},
],
},
);
const nodesInvoke = vi.fn(async (params) => {
if (options.nodesInvokeHandler) {
return options.nodesInvokeHandler(params);
}
if (params.command === "browser.proxy") {
const proxy = params.params as { path?: string; body?: { url?: string; targetId?: string } };
if (proxy.path === "/tabs") {
return { payload: { result: { running: true, tabs: [] } } };
}
if (proxy.path === "/tabs/open") {
return {
payload: {
result: {
targetId: "tab-1",
title: "Meet",
url: proxy.body?.url ?? "https://meet.google.com/abc-defg-hij",
},
},
};
}
if (proxy.path === "/act") {
return {
payload: {
result: {
ok: true,
targetId: proxy.body?.targetId ?? "tab-1",
result: JSON.stringify(
options.browserActResult ?? {
inCall: true,
micMuted: false,
title: "Meet call",
url: "https://meet.google.com/abc-defg-hij",
},
),
},
},
};
}
return { payload: { result: { ok: true } } };
}
return options.nodesInvokeResult ?? { launched: true };
});
const runCommandWithTimeout = vi.fn(async (argv: string[]) => {
if (argv[0] === "/usr/sbin/system_profiler") {
return { code: 0, stdout: "BlackHole 2ch", stderr: "" };
}
return { code: 0, stdout: "", stderr: "" };
});
const api = createTestPluginApi({
id: "google-meet",
name: "Google Meet",
description: "test",
version: "0",
source: "test",
config: options.fullConfig ?? {},
pluginConfig: config,
runtime: {
system: {
runCommandWithTimeout,
formatNativeDependencyHint: vi.fn(() => "Install with brew install blackhole-2ch."),
},
nodes: {
list: nodesList,
invoke: nodesInvoke,
},
} as unknown as OpenClawPluginApi["runtime"],
logger: noopLogger,
registerGatewayMethod: (method: string, handler: unknown) => methods.set(method, handler),
registerTool: (tool: unknown) => tools.push(tool),
registerCli: (_registrar: unknown, opts: unknown) => cliRegistrations.push(opts),
registerNodeHostCommand: (command: unknown) => nodeHostCommands.push(command),
});
plugin.register(api);
return {
cliRegistrations,
methods,
tools,
runCommandWithTimeout,
nodesList,
nodesInvoke,
nodeHostCommands,
};
}
describe("google-meet plugin", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -0,0 +1,155 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { vi } from "vitest";
import { createTestPluginApi } from "../../../../test/helpers/plugins/plugin-api.ts";
type GoogleMeetTestPluginEntry = {
register(api: OpenClawPluginApi): void;
};
export const noopLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
export type GoogleMeetTestNodeListResult = {
nodes: Array<{
nodeId: string;
displayName?: string;
connected?: boolean;
commands?: string[];
caps?: string[];
remoteIp?: string;
}>;
};
export function captureStdout() {
let output = "";
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
output += String(chunk);
return true;
}) as typeof process.stdout.write);
return {
output: () => output,
restore: () => writeSpy.mockRestore(),
};
}
export function setupGoogleMeetPlugin(
plugin: GoogleMeetTestPluginEntry,
config: Record<string, unknown> = {},
options: {
fullConfig?: Record<string, unknown>;
nodesListResult?: GoogleMeetTestNodeListResult;
nodesInvokeResult?: unknown;
browserActResult?: Record<string, unknown>;
nodesInvokeHandler?: (params: {
nodeId: string;
command: string;
params?: unknown;
timeoutMs?: number;
}) => Promise<unknown>;
} = {},
) {
const methods = new Map<string, unknown>();
const tools: unknown[] = [];
const cliRegistrations: unknown[] = [];
const nodeHostCommands: unknown[] = [];
const nodesList = vi.fn(
async () =>
options.nodesListResult ?? {
nodes: [
{
nodeId: "node-1",
displayName: "parallels-macos",
connected: true,
caps: ["browser"],
commands: ["browser.proxy", "googlemeet.chrome"],
},
],
},
);
const nodesInvoke = vi.fn(async (params) => {
if (options.nodesInvokeHandler) {
return options.nodesInvokeHandler(params);
}
if (params.command === "browser.proxy") {
const proxy = params.params as { path?: string; body?: { url?: string; targetId?: string } };
if (proxy.path === "/tabs") {
return { payload: { result: { running: true, tabs: [] } } };
}
if (proxy.path === "/tabs/open") {
return {
payload: {
result: {
targetId: "tab-1",
title: "Meet",
url: proxy.body?.url ?? "https://meet.google.com/abc-defg-hij",
},
},
};
}
if (proxy.path === "/act") {
return {
payload: {
result: {
ok: true,
targetId: proxy.body?.targetId ?? "tab-1",
result: JSON.stringify(
options.browserActResult ?? {
inCall: true,
micMuted: false,
title: "Meet call",
url: "https://meet.google.com/abc-defg-hij",
},
),
},
},
};
}
return { payload: { result: { ok: true } } };
}
return options.nodesInvokeResult ?? { launched: true };
});
const runCommandWithTimeout = vi.fn(async (argv: string[]) => {
if (argv[0] === "/usr/sbin/system_profiler") {
return { code: 0, stdout: "BlackHole 2ch", stderr: "" };
}
return { code: 0, stdout: "", stderr: "" };
});
const api = createTestPluginApi({
id: "google-meet",
name: "Google Meet",
description: "test",
version: "0",
source: "test",
config: options.fullConfig ?? {},
pluginConfig: config,
runtime: {
system: {
runCommandWithTimeout,
formatNativeDependencyHint: vi.fn(() => "Install with brew install blackhole-2ch."),
},
nodes: {
list: nodesList,
invoke: nodesInvoke,
},
} as unknown as OpenClawPluginApi["runtime"],
logger: noopLogger,
registerGatewayMethod: (method: string, handler: unknown) => methods.set(method, handler),
registerTool: (tool: unknown) => tools.push(tool),
registerCli: (_registrar: unknown, opts: unknown) => cliRegistrations.push(opts),
registerNodeHostCommand: (command: unknown) => nodeHostCommands.push(command),
});
plugin.register(api);
return {
cliRegistrations,
methods,
tools,
runCommandWithTimeout,
nodesList,
nodesInvoke,
nodeHostCommands,
};
}

View File

@@ -127,6 +127,29 @@ test -d "$package_root/dist/extensions/slack"
test -d "$package_root/dist/extensions/feishu"
test -d "$package_root/dist/extensions/memory-lancedb"
stage_root() {
printf "%s/.openclaw/plugin-runtime-deps" "$HOME"
}
find_external_dep_package() {
local dep_path="$1"
find "$(stage_root)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true
}
assert_package_dep_absent() {
local channel="$1"
local dep_path="$2"
for candidate in \
"$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" \
"$package_root/dist/extensions/node_modules/$dep_path/package.json" \
"$package_root/node_modules/$dep_path/package.json"; do
if [ -f "$candidate" ]; then
echo "packaged install should not mutate package tree for $channel: $candidate" >&2
exit 1
fi
done
}
if [ -d "$package_root/dist/extensions/$CHANNEL/node_modules" ]; then
echo "$CHANNEL runtime deps should not be preinstalled in package" >&2
find "$package_root/dist/extensions/$CHANNEL/node_modules" -maxdepth 2 -type f | head -20 >&2 || true
@@ -357,12 +380,10 @@ assert_installed_once() {
if [ "$count" -eq 1 ]; then
return 0
fi
if [ "$count" -eq 0 ] && [ -f "$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then
return 0
fi
if [ "$count" -ne 1 ]; then
echo "expected exactly one runtime deps install log or installed sentinel for $channel, got $count log lines" >&2
echo "expected exactly one runtime deps install log for $channel, got $count log lines" >&2
cat "$log_file" >&2
find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true
exit 1
fi
}
@@ -380,18 +401,22 @@ assert_not_installed() {
assert_dep_sentinel() {
local channel="$1"
local dep_path="$2"
if [ ! -f "$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then
echo "missing dependency sentinel for $channel: $dep_path" >&2
find "$package_root/dist/extensions/$channel" -maxdepth 3 -type f | sort | head -80 >&2 || true
local sentinel
sentinel="$(find_external_dep_package "$dep_path")"
if [ -z "$sentinel" ]; then
echo "missing external dependency sentinel for $channel: $dep_path" >&2
find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true
exit 1
fi
assert_package_dep_absent "$channel" "$dep_path"
}
assert_no_dep_sentinel() {
local channel="$1"
local dep_path="$2"
if [ -f "$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then
echo "dependency sentinel should be absent before activation for $channel: $dep_path" >&2
assert_package_dep_absent "$channel" "$dep_path"
if [ -n "$(find_external_dep_package "$dep_path")" ]; then
echo "external dependency sentinel should be absent before activation for $channel: $dep_path" >&2
exit 1
fi
}
@@ -1063,6 +1088,15 @@ package_root() {
printf "%s/openclaw" "$(npm root -g)"
}
stage_root() {
printf "%s/.openclaw/plugin-runtime-deps" "$HOME"
}
find_external_dep_package() {
local dep_path="$1"
find "$(stage_root)" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f -print -quit 2>/dev/null || true
}
package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}"
update_target="file:$package_tgz"
candidate_version="$(node - <<'NODE' "$package_tgz"
@@ -1182,12 +1216,15 @@ assert_dep_sentinel() {
local channel="$1"
local dep_path="$2"
local root
local sentinel
root="$(package_root)"
if [ ! -f "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then
echo "missing dependency sentinel for $channel: $dep_path" >&2
find "$root/dist/extensions/$channel" -maxdepth 3 -type f | sort | head -80 >&2 || true
sentinel="$(find_external_dep_package "$dep_path")"
if [ -z "$sentinel" ]; then
echo "missing external dependency sentinel for $channel: $dep_path" >&2
find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true
exit 1
fi
assert_no_package_dep_available "$channel" "$dep_path" "$root"
}
assert_no_dep_sentinel() {
@@ -1195,28 +1232,43 @@ assert_no_dep_sentinel() {
local dep_path="$2"
local root
root="$(package_root)"
if [ -f "$root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then
echo "dependency sentinel should be absent before repair for $channel: $dep_path" >&2
assert_no_package_dep_available "$channel" "$dep_path" "$root"
if [ -n "$(find_external_dep_package "$dep_path")" ]; then
echo "external dependency sentinel should be absent before repair for $channel: $dep_path" >&2
exit 1
fi
}
assert_no_package_dep_available() {
local channel="$1"
local dep_path="$2"
local root="$3"
for candidate in \
"$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \
"$root/dist/extensions/node_modules/$dep_path/package.json" \
"$root/node_modules/$dep_path/package.json"; do
if [ -f "$candidate" ]; then
echo "packaged install should not mutate package tree for $channel: $candidate" >&2
exit 1
fi
done
}
assert_dep_available() {
local channel="$1"
local dep_path="$2"
local root
local sentinel
root="$(package_root)"
for candidate in \
"$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \
"$root/dist/extensions/node_modules/$dep_path/package.json" \
"$root/node_modules/$dep_path/package.json"; do
if [ -f "$candidate" ]; then
return 0
fi
done
sentinel="$(find_external_dep_package "$dep_path")"
if [ -n "$sentinel" ]; then
assert_no_package_dep_available "$channel" "$dep_path" "$root"
return 0
fi
echo "missing dependency sentinel for $channel: $dep_path" >&2
find "$root/dist/extensions/$channel" -maxdepth 3 -type f | sort | head -80 >&2 || true
find "$root/node_modules" -maxdepth 3 -path "*/$dep_path/package.json" -type f -print >&2 || true
find "$(stage_root)" -maxdepth 12 -type f | sort | head -120 >&2 || true
exit 1
}
@@ -1225,15 +1277,11 @@ assert_no_dep_available() {
local dep_path="$2"
local root
root="$(package_root)"
for candidate in \
"$root/dist/extensions/$channel/node_modules/$dep_path/package.json" \
"$root/dist/extensions/node_modules/$dep_path/package.json" \
"$root/node_modules/$dep_path/package.json"; do
if [ -f "$candidate" ]; then
echo "dependency sentinel should be absent before repair for $channel: $dep_path ($candidate)" >&2
exit 1
fi
done
assert_no_package_dep_available "$channel" "$dep_path" "$root"
if [ -n "$(find_external_dep_package "$dep_path")" ]; then
echo "dependency sentinel should be absent before repair for $channel: $dep_path" >&2
exit 1
fi
}
remove_runtime_dep() {
@@ -1244,6 +1292,7 @@ remove_runtime_dep() {
rm -rf "$root/dist/extensions/$channel/node_modules"
rm -rf "$root/dist/extensions/node_modules/$dep_path"
rm -rf "$root/node_modules/$dep_path"
rm -rf "$(stage_root)"
}
assert_update_ok() {

View File

@@ -236,6 +236,59 @@ describe("external cli oauth resolution", () => {
expect(credential).toBeNull();
});
it("bootstraps the default codex profile from Codex CLI credentials when missing locally", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai-codex",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
accountId: "acct-codex",
}),
);
const profiles = resolveExternalCliAuthProfiles(makeStore());
expect(profiles).toEqual([
{
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: expect.objectContaining({
provider: "openai-codex",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
accountId: "acct-codex",
}),
},
]);
});
it("keeps any existing default codex oauth over Codex CLI bootstrap credentials", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai-codex",
access: "codex-cli-fresh-access",
refresh: "codex-cli-fresh-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
accountId: "acct-codex",
}),
);
const profiles = resolveExternalCliAuthProfiles(
makeStore(
OPENAI_CODEX_DEFAULT_PROFILE_ID,
makeOAuthCredential({
provider: "openai-codex",
access: "local-expired-access",
refresh: "local-canonical-refresh",
expires: Date.now() - 5_000,
accountId: "acct-codex",
}),
),
);
expect(profiles).toEqual([]);
});
it("returns null when the profile id/provider do not map to the same external source", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({ provider: "openai-codex" }),

View File

@@ -1,5 +1,12 @@
import { readMiniMaxCliCredentialsCached } from "../cli-credentials.js";
import { EXTERNAL_CLI_SYNC_TTL_MS, MINIMAX_CLI_PROFILE_ID } from "./constants.js";
import {
readCodexCliCredentialsCached,
readMiniMaxCliCredentialsCached,
} from "../cli-credentials.js";
import {
EXTERNAL_CLI_SYNC_TTL_MS,
MINIMAX_CLI_PROFILE_ID,
OPENAI_CODEX_DEFAULT_PROFILE_ID,
} from "./constants.js";
import { log } from "./constants.js";
import {
areOAuthCredentialsEquivalent,
@@ -29,6 +36,12 @@ type ExternalCliSyncProvider = {
profileId: string;
provider: string;
readCredentials: () => OAuthCredential | null;
// bootstrapOnly providers adopt the external CLI credential only to
// seed an empty slot; once a local OAuth credential exists for the
// profile, the local refresh token is treated as canonical and the
// CLI state must not replace or shadow it. Codex requires this to
// avoid clobbering a locally refreshed token with stale CLI state.
bootstrapOnly?: boolean;
};
function normalizeAuthIdentityToken(value: string | undefined): string | undefined {
@@ -72,6 +85,12 @@ export function isSafeToUseExternalCliCredential(
}
const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [
{
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
provider: "openai-codex",
readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
bootstrapOnly: true,
},
{
profileId: MINIMAX_CLI_PROFILE_ID,
provider: "minimax-portal",
@@ -103,6 +122,13 @@ export function readExternalCliBootstrapCredential(params: {
if (!provider) {
return null;
}
// bootstrapOnly providers must not replace an existing local credential
// during runtime refresh. The oauth-manager only calls this hook when a
// local credential is already present, so returning null here keeps the
// locally stored refresh token canonical.
if (provider.bootstrapOnly) {
return null;
}
return provider.readCredentials();
}
@@ -132,6 +158,13 @@ export function resolveExternalCliAuthProfiles(
});
continue;
}
if (providerConfig.bootstrapOnly && existingOAuth) {
log.debug("kept local oauth over external cli bootstrap-only provider", {
profileId: providerConfig.profileId,
provider: providerConfig.provider,
});
continue;
}
if (existingOAuth && !isSafeToUseExternalCliCredential(existingOAuth, creds)) {
log.warn("refused external cli oauth bootstrap: identity mismatch", {
profileId: providerConfig.profileId,

View File

@@ -190,6 +190,173 @@ describe("prepareCliBundleMcpConfig", () => {
await prepared.cleanup?.();
});
it("merges user-configured mcp.servers from OpenClaw config", async () => {
const workspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-user-servers-");
const prepared = await prepareCliBundleMcpConfig({
enabled: true,
mode: "claude-config-file",
backend: {
command: "node",
args: ["./fake-claude.mjs"],
},
workspaceDir,
config: {
plugins: { enabled: false },
mcp: {
servers: {
omi: {
type: "sse",
url: "https://api.omi.me/v1/mcp/sse",
headers: { Authorization: "Bearer test-token" },
},
},
},
},
});
const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1;
expect(configFlagIndex).toBeGreaterThanOrEqual(0);
const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1];
const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as {
mcpServers?: Record<string, { type?: string; url?: string }>;
};
expect(raw.mcpServers?.omi?.type).toBe("sse");
expect(raw.mcpServers?.omi?.url).toBe("https://api.omi.me/v1/mcp/sse");
await prepared.cleanup?.();
});
it("user mcp.servers do not override the loopback additionalConfig", async () => {
const workspaceDir = await tempHarness.createTempDir(
"openclaw-cli-bundle-mcp-user-servers-loopback-",
);
const prepared = await prepareCliBundleMcpConfig({
enabled: true,
mode: "claude-config-file",
backend: {
command: "node",
args: ["./fake-claude.mjs"],
},
workspaceDir,
config: {
plugins: { enabled: false },
mcp: {
servers: {
openclaw: {
type: "http",
url: "https://example.com/malicious",
},
},
},
},
additionalConfig: {
mcpServers: {
openclaw: {
type: "http",
url: "http://127.0.0.1:23119/mcp",
headers: { Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}" },
},
},
},
});
const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1;
expect(configFlagIndex).toBeGreaterThanOrEqual(0);
const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1];
const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as {
mcpServers?: Record<string, { url?: string }>;
};
expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
await prepared.cleanup?.();
});
it("replaces overlapping bundle server entries with user-configured mcp.servers", async () => {
const workspaceDir = await tempHarness.createTempDir(
"openclaw-cli-bundle-mcp-user-servers-replace-",
);
await writeClaudeBundleManifest({
homeDir: bundleProbeHomeDir,
pluginId: "omi",
manifest: { name: "omi" },
});
const pluginDir = path.join(bundleProbeHomeDir, ".openclaw", "extensions", "omi");
await fs.writeFile(
path.join(pluginDir, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
omi: {
command: process.execPath,
args: [bundleProbeServerPath],
env: { BUNDLE_ONLY: "true" },
},
},
},
null,
2,
)}\n`,
"utf-8",
);
const env = captureEnv(["HOME"]);
try {
process.env.HOME = bundleProbeHomeDir;
const prepared = await prepareCliBundleMcpConfig({
enabled: true,
mode: "claude-config-file",
backend: {
command: "node",
args: ["./fake-claude.mjs"],
},
workspaceDir,
config: {
plugins: {
entries: {
omi: { enabled: true },
},
},
mcp: {
servers: {
omi: {
type: "sse",
url: "https://api.omi.me/v1/mcp/sse",
headers: { Authorization: "Bearer test-token" },
},
},
},
},
});
const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1;
expect(configFlagIndex).toBeGreaterThanOrEqual(0);
const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1];
const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as {
mcpServers?: Record<
string,
{
type?: string;
url?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
}
>;
};
expect(raw.mcpServers?.omi?.type).toBe("sse");
expect(raw.mcpServers?.omi?.url).toBe("https://api.omi.me/v1/mcp/sse");
expect(raw.mcpServers?.omi?.command).toBeUndefined();
expect(raw.mcpServers?.omi?.args).toBeUndefined();
expect(raw.mcpServers?.omi?.env).toBeUndefined();
await prepared.cleanup?.();
} finally {
env.restore();
}
});
it("stabilizes the resume hash when only the OpenClaw loopback port changes", async () => {
const first = await prepareBundleProbeCliConfig({
additionalConfig: {

View File

@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { normalizeConfiguredMcpServers } from "../../config/mcp-config.js";
import { applyMergePatch } from "../../config/merge-patch.js";
import type { CliBackendConfig } from "../../config/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -415,6 +416,14 @@ export async function prepareCliBundleMcpConfig(params: {
params.warn?.(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`);
}
mergedConfig = applyMergePatch(mergedConfig, bundleConfig.config) as BundleMcpConfig;
const configuredMcp = normalizeConfiguredMcpServers(params.config?.mcp?.servers);
if (Object.keys(configuredMcp).length > 0) {
const existingMcpServers = mergedConfig.mcpServers;
mergedConfig = {
...mergedConfig,
mcpServers: existingMcpServers ? { ...existingMcpServers, ...configuredMcp } : configuredMcp,
} satisfies BundleMcpConfig;
}
if (params.additionalConfig) {
mergedConfig = applyMergePatch(mergedConfig, params.additionalConfig) as BundleMcpConfig;
}

View File

@@ -5,15 +5,12 @@ import { describe, expect, it } from "vitest";
import {
resolveBundledRuntimeDependencyPackageInstallRoot,
scanBundledPluginRuntimeDeps,
type BundledRuntimeDepsInstallParams,
} from "../plugins/bundled-runtime-deps.js";
import { maybeRepairBundledPluginRuntimeDeps } from "./doctor-bundled-plugin-runtime-deps.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
type InstalledRuntimeDeps = Array<{
installRoot: string;
missingSpecs: string[];
installSpecs: string[];
}>;
type InstalledRuntimeDeps = BundledRuntimeDepsInstallParams[];
function writeJson(filePath: string, value: unknown) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -49,6 +46,14 @@ function createInstalledRuntimeDeps(): InstalledRuntimeDeps {
return [];
}
function readRetainedRuntimeDepsManifest(installRoot: string): string[] {
const manifestPath = path.join(installRoot, ".openclaw-runtime-deps.json");
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { specs?: unknown };
return Array.isArray(parsed.specs)
? parsed.specs.filter((entry): entry is string => typeof entry === "string")
: [];
}
function createNonInteractivePrompter(
options: { updateInProgress?: boolean } = {},
): DoctorPrompter {
@@ -122,7 +127,7 @@ describe("doctor bundled plugin runtime deps", () => {
const result = scanBundledPluginRuntimeDeps({ packageRoot: root });
const missing = result.missing.map((dep) => `${dep.name}@${dep.version}`);
expect(missing).toEqual(["@scope/dep-two@2.0.0", "dep-opt@3.0.0"]);
expect(missing).toEqual(["@scope/dep-two@2.0.0", "dep-one@1.0.0", "dep-opt@3.0.0"]);
expect(result.conflicts).toHaveLength(1);
expect(result.conflicts[0]?.name).toBe("dep-conflict");
expect(result.conflicts[0]?.versions).toEqual(["1.0.0", "2.0.0"]);
@@ -300,13 +305,16 @@ describe("doctor bundled plugin runtime deps", () => {
},
});
const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root);
expect(installed).toEqual([
{
installRoot: root,
installRoot,
missingSpecs: ["grammy@1.37.0"],
installSpecs: ["grammy@1.37.0"],
},
]);
expect(installRoot).not.toBe(root);
expect(readRetainedRuntimeDepsManifest(installRoot)).toEqual(["grammy@1.37.0"]);
});
it("repairs Feishu runtime deps from preserved source config", async () => {
@@ -329,13 +337,15 @@ describe("doctor bundled plugin runtime deps", () => {
},
});
const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root);
expect(installed).toEqual([
{
installRoot: root,
installRoot,
missingSpecs: ["@larksuiteoapi/node-sdk@^1.61.0"],
installSpecs: ["@larksuiteoapi/node-sdk@^1.61.0"],
},
]);
expect(installRoot).not.toBe(root);
});
it("repairs missing deps into an external stage dir when configured", async () => {
@@ -369,16 +379,17 @@ describe("doctor bundled plugin runtime deps", () => {
},
]);
expect(installRoot).toContain(stageDir);
expect(readRetainedRuntimeDepsManifest(installRoot)).toEqual(["@slack/web-api@7.15.1"]);
});
it("retains configured bundled deps when repairing a subset", async () => {
it("retains already staged bundled deps when repairing a subset", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
writeJson(path.join(root, "package.json"), { name: "openclaw" });
writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" });
writeBundledChannelPlugin(root, "slack", { "@slack/web-api": "7.15.1" });
writeJson(path.join(root, "node_modules", "@slack", "web-api", "package.json"), {
name: "@slack/web-api",
version: "7.15.1",
const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root);
writeJson(path.join(installRoot, ".openclaw-runtime-deps.json"), {
specs: ["@slack/web-api@7.15.1"],
});
const installed = createInstalledRuntimeDeps();
@@ -401,10 +412,15 @@ describe("doctor bundled plugin runtime deps", () => {
expect(installed).toEqual([
{
installRoot: root,
installRoot,
missingSpecs: ["grammy@1.37.0"],
installSpecs: ["grammy@1.37.0"],
installSpecs: ["@slack/web-api@7.15.1", "grammy@1.37.0"],
},
]);
expect(installRoot).not.toBe(root);
expect(readRetainedRuntimeDepsManifest(installRoot)).toEqual([
"@slack/web-api@7.15.1",
"grammy@1.37.0",
]);
});
});

View File

@@ -2,9 +2,10 @@ import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import {
installBundledRuntimeDeps,
repairBundledRuntimeDepsInstallRoot,
resolveBundledRuntimeDependencyPackageInstallRoot,
scanBundledPluginRuntimeDeps,
type BundledRuntimeDepsInstallParams,
} from "../plugins/bundled-runtime-deps.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
@@ -17,11 +18,7 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
env?: NodeJS.ProcessEnv;
packageRoot?: string | null;
includeConfiguredChannels?: boolean;
installDeps?: (params: {
installRoot: string;
missingSpecs: string[];
installSpecs: string[];
}) => void;
installDeps?: (params: BundledRuntimeDepsInstallParams) => void;
}): Promise<void> {
const packageRoot =
params.packageRoot ??
@@ -89,16 +86,14 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(packageRoot, {
env: params.env ?? process.env,
});
const install =
params.installDeps ??
((installParams) =>
installBundledRuntimeDeps({
installRoot: installParams.installRoot,
missingSpecs: installParams.installSpecs,
env: params.env ?? process.env,
}));
install({ installRoot, missingSpecs, installSpecs });
note(`Installed bundled plugin deps: ${installSpecs.join(", ")}`, "Bundled plugins");
const result = repairBundledRuntimeDepsInstallRoot({
installRoot,
missingSpecs,
installSpecs,
env: params.env ?? process.env,
installDeps: params.installDeps,
});
note(`Installed bundled plugin deps: ${result.installSpecs.join(", ")}`, "Bundled plugins");
} catch (error) {
params.runtime.error(`Failed to install bundled plugin runtime deps: ${String(error)}`);
}

View File

@@ -744,6 +744,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.',
},
headless: {
type: "boolean",
title: "Browser Profile Headless Mode",
description:
"Per-profile headless override for locally launched browser instances. Use this when one profile should stay headless without forcing browser.headless for every other profile.",
},
attachOnly: {
type: "boolean",
title: "Browser Profile Attach-only Mode",
@@ -23950,6 +23956,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.',
tags: ["storage"],
},
"browser.profiles.*.headless": {
label: "Browser Profile Headless Mode",
help: "Per-profile headless override for locally launched browser instances. Use this when one profile should stay headless without forcing browser.headless for every other profile.",
tags: ["storage"],
},
"browser.profiles.*.attachOnly": {
label: "Browser Profile Attach-only Mode",
help: "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.",

View File

@@ -282,6 +282,8 @@ export const FIELD_HELP: Record<string, string> = {
"Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory on the selected host or browser node.",
"browser.profiles.*.driver":
'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.',
"browser.profiles.*.headless":
"Per-profile headless override for locally launched browser instances. Use this when one profile should stay headless without forcing browser.headless for every other profile.",
"browser.profiles.*.attachOnly":
"Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.",
"browser.profiles.*.color":

View File

@@ -152,6 +152,7 @@ export const FIELD_LABELS: Record<string, string> = {
"browser.profiles.*.cdpUrl": "Browser Profile CDP URL",
"browser.profiles.*.userDataDir": "Browser Profile User Data Dir",
"browser.profiles.*.driver": "Browser Profile Driver",
"browser.profiles.*.headless": "Browser Profile Headless Mode",
"browser.profiles.*.attachOnly": "Browser Profile Attach-only Mode",
"browser.profiles.*.color": "Browser Profile Accent Color",
tools: "Tools",

View File

@@ -7,6 +7,8 @@ export type BrowserProfileConfig = {
userDataDir?: string;
/** Profile driver (default: openclaw). */
driver?: "openclaw" | "clawd" | "existing-session";
/** If true, launch this profile in headless mode. Falls back to browser.headless. */
headless?: boolean;
/** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */
attachOnly?: boolean;
/** Profile color (hex). Auto-assigned at creation. */

View File

@@ -409,6 +409,7 @@ export const OpenClawSchema = z
driver: z
.union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")])
.optional(),
headless: z.boolean().optional(),
attachOnly: z.boolean().optional(),
color: HexColorSchema,
})

View File

@@ -245,6 +245,7 @@ export function createMockCronStateForJobs(params: {
storeFileMtimeMs: null,
op: Promise.resolve(),
warnedDisabled: false,
warnedMissingSessionTargetJobIds: new Set<string>(),
deps: {
storePath: "/mock/path",
cronEnabled: true,

View File

@@ -154,6 +154,11 @@ function resolveEveryAnchorMs(params: {
}
export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "payload">) {
if (typeof job.sessionTarget !== "string") {
throw new Error(
'cron job is missing sessionTarget; expected "main", "isolated", "current", or "session:<id>"',
);
}
const isIsolatedLike =
job.sessionTarget === "isolated" ||
job.sessionTarget === "current" ||

View File

@@ -128,6 +128,12 @@ export type CronServiceState = {
running: boolean;
op: Promise<unknown>;
warnedDisabled: boolean;
/**
* Job ids whose missing `sessionTarget` was defaulted at load and warned
* about. Used to suppress duplicate warns across forceReload ticks so a
* single broken job does not spam the log on every scheduler cycle.
*/
warnedMissingSessionTargetJobIds: Set<string>;
storeLoadedAtMs: number | null;
storeFileMtimeMs: number | null;
};
@@ -140,6 +146,7 @@ export function createCronServiceState(deps: CronServiceDeps): CronServiceState
running: false,
op: Promise.resolve(),
warnedDisabled: false,
warnedMissingSessionTargetJobIds: new Set<string>(),
storeLoadedAtMs: null,
storeFileMtimeMs: null,
};

View File

@@ -0,0 +1,115 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { setupCronServiceSuite } from "../service.test-harness.js";
import { assertSupportedJobSpec, findJobOrThrow } from "./jobs.js";
import { createCronServiceState } from "./state.js";
import { ensureLoaded } from "./store.js";
const { logger, makeStorePath } = setupCronServiceSuite({
prefix: "cron-service-store-missing-session-target-",
});
const STORE_TEST_NOW = Date.parse("2026-03-23T12:00:00.000Z");
async function writeSingleJobStore(storePath: string, job: Record<string, unknown>) {
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify({ version: 1, jobs: [job] }, null, 2), "utf8");
}
function createStoreTestState(storePath: string) {
return createCronServiceState({
storePath,
cronEnabled: true,
log: logger,
nowMs: () => STORE_TEST_NOW,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
});
}
describe("cron service store load: missing sessionTarget", () => {
it('defaults missing sessionTarget to "main" for systemEvent payloads', async () => {
const { storePath } = await makeStorePath();
await writeSingleJobStore(storePath, {
id: "missing-session-target-system-event",
name: "missing session target system event",
enabled: true,
createdAtMs: STORE_TEST_NOW - 60_000,
updatedAtMs: STORE_TEST_NOW - 60_000,
schedule: { kind: "every", everyMs: 60_000 },
wakeMode: "now",
payload: { kind: "systemEvent", text: "tick" },
state: {},
});
const state = createStoreTestState(storePath);
await ensureLoaded(state);
const job = findJobOrThrow(state, "missing-session-target-system-event");
expect(job.sessionTarget).toBe("main");
expect(() => assertSupportedJobSpec(job)).not.toThrow();
});
it('defaults missing sessionTarget to "isolated" for agentTurn payloads', async () => {
const { storePath } = await makeStorePath();
await writeSingleJobStore(storePath, {
id: "missing-session-target-agent-turn",
name: "missing session target agent turn",
enabled: true,
createdAtMs: STORE_TEST_NOW - 60_000,
updatedAtMs: STORE_TEST_NOW - 60_000,
schedule: { kind: "every", everyMs: 60_000 },
wakeMode: "now",
payload: { kind: "agentTurn", message: "ping" },
state: {},
});
const state = createStoreTestState(storePath);
await ensureLoaded(state);
const job = findJobOrThrow(state, "missing-session-target-agent-turn");
expect(job.sessionTarget).toBe("isolated");
expect(() => assertSupportedJobSpec(job)).not.toThrow();
});
it("assertSupportedJobSpec throws a clear error when sessionTarget is missing", () => {
const bogus = {
payload: { kind: "agentTurn" as const, message: "ping" },
} as unknown as Parameters<typeof assertSupportedJobSpec>[0];
expect(() => assertSupportedJobSpec(bogus)).toThrow(/missing sessionTarget/);
});
it("warns once per jobId across repeated forceReload cycles", async () => {
const { storePath } = await makeStorePath();
await writeSingleJobStore(storePath, {
id: "log-dedupe-target",
name: "log dedupe target",
enabled: true,
createdAtMs: STORE_TEST_NOW - 60_000,
updatedAtMs: STORE_TEST_NOW - 60_000,
schedule: { kind: "every", everyMs: 60_000 },
wakeMode: "now",
payload: { kind: "agentTurn", message: "ping" },
state: {},
});
const warnSpy = vi.spyOn(logger, "warn");
const state = createStoreTestState(storePath);
await ensureLoaded(state);
await ensureLoaded(state, { forceReload: true });
await ensureLoaded(state, { forceReload: true });
const missingSessionTargetWarns = warnSpy.mock.calls.filter((call) => {
const msg = typeof call[1] === "string" ? call[1] : "";
return msg.includes("missing sessionTarget");
});
expect(missingSessionTargetWarns).toHaveLength(1);
warnSpy.mockRestore();
});
});

View File

@@ -67,6 +67,43 @@ export async function ensureLoaded(
if (typeof hydrated.enabled !== "boolean") {
hydrated.enabled = true;
}
// Same shape: persisted jobs missing `sessionTarget` crash downstream
// on any code path that dereferences `.startsWith` (e.g.
// `runIsolatedAgentJob` in `src/gateway/server-cron.ts`). Mirror the
// defaulter applied at create time: systemEvent payloads -> "main",
// agentTurn -> "isolated". Use `Object.hasOwn` rather than `in` so a
// poisoned prototype cannot feed a crafted `kind` into the defaulter.
if (typeof hydrated.sessionTarget !== "string") {
const payload = hydrated.payload as unknown;
const payloadKind =
payload &&
typeof payload === "object" &&
!Array.isArray(payload) &&
Object.hasOwn(payload, "kind")
? (payload as { kind?: unknown }).kind
: undefined;
let defaulted: "main" | "isolated" | undefined;
if (payloadKind === "systemEvent") {
defaulted = "main";
} else if (payloadKind === "agentTurn") {
defaulted = "isolated";
}
if (defaulted) {
hydrated.sessionTarget = defaulted;
// `ensureLoaded` is called with `forceReload: true` on every tick;
// warn once per jobId per process to avoid log spam on repeated
// loads of the same still-broken store file.
const jobId = typeof hydrated.id === "string" ? hydrated.id : undefined;
const dedupeKey = jobId ?? "<unknown>";
if (!state.warnedMissingSessionTargetJobIds.has(dedupeKey)) {
state.warnedMissingSessionTargetJobIds.add(dedupeKey);
state.deps.log.warn(
{ storePath: state.deps.storePath, jobId, defaulted },
"cron: job missing sessionTarget; defaulted in memory (edit jobs.json to persist canonical shape)",
);
}
}
}
}
state.store = {
version: 1,

View File

@@ -43,6 +43,7 @@ export type ResolvedBrowserProfile = {
userDataDir?: string;
color: string;
driver: "openclaw" | "existing-session";
headless?: boolean;
attachOnly: boolean;
};

View File

@@ -428,17 +428,18 @@ describe("ensureBundledPluginRuntimeDeps", () => {
});
expect(result).toEqual({
installedSpecs: ["missing@2.0.0"],
installedSpecs: ["already-present@1.0.0", "missing@2.0.0"],
retainSpecs: ["already-present@1.0.0", "missing@2.0.0", "previous@3.0.0"],
});
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"),
missingSpecs: ["missing@2.0.0"],
installRoot,
missingSpecs: ["already-present@1.0.0", "missing@2.0.0"],
installSpecs: ["already-present@1.0.0", "missing@2.0.0", "previous@3.0.0"],
},
]);
expect(installRoot).not.toBe(pluginRoot);
});
it("skips workspace-only runtime deps before npm install", () => {
@@ -471,17 +472,18 @@ describe("ensureBundledPluginRuntimeDeps", () => {
installedSpecs: ["external-runtime@^1.2.3"],
retainSpecs: ["external-runtime@^1.2.3"],
});
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"),
installRoot,
missingSpecs: ["external-runtime@^1.2.3"],
installSpecs: ["external-runtime@^1.2.3"],
},
]);
expect(installRoot).not.toBe(pluginRoot);
});
it("stages plugin-root install when the plugin's own package.json declares workspace:* deps", () => {
it("uses external staging when a packaged plugin declares workspace:* deps", () => {
// Regression guard for packaged/Docker bundled plugins whose `package.json`
// still lists `"@openclaw/plugin-sdk": "workspace:*"` (and similar) alongside
// concrete runtime deps. Without a distinct execution root, `npm install`
@@ -515,19 +517,15 @@ describe("ensureBundledPluginRuntimeDeps", () => {
installedSpecs: ["@anthropic-ai/sdk@^0.50.0"],
retainSpecs: ["@anthropic-ai/sdk@^0.50.0"],
});
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"),
installRoot,
missingSpecs: ["@anthropic-ai/sdk@^0.50.0"],
installSpecs: ["@anthropic-ai/sdk@^0.50.0"],
},
]);
// The stage dir must be distinct from the plugin root so npm does not read
// the plugin's cwd manifest during install.
const installExecutionRoot = calls[0]?.installExecutionRoot;
expect(installExecutionRoot).toBeDefined();
expect(path.resolve(installExecutionRoot ?? "")).not.toEqual(path.resolve(pluginRoot));
expect(installRoot).not.toBe(pluginRoot);
});
it("installs runtime deps into an external stage dir and exposes loader aliases", () => {
@@ -657,6 +655,58 @@ describe("ensureBundledPluginRuntimeDeps", () => {
]);
});
it("retains existing staged deps without a retained manifest before shared installs", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.22" }),
);
const alphaRoot = path.join(packageRoot, "dist", "extensions", "alpha");
const betaRoot = path.join(packageRoot, "dist", "extensions", "beta");
fs.mkdirSync(alphaRoot, { recursive: true });
fs.mkdirSync(betaRoot, { recursive: true });
fs.writeFileSync(
path.join(alphaRoot, "package.json"),
JSON.stringify({ dependencies: { "alpha-runtime": "1.0.0" } }),
);
fs.writeFileSync(
path.join(betaRoot, "package.json"),
JSON.stringify({ dependencies: { "beta-runtime": "2.0.0" } }),
);
const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir };
const installRoot = resolveBundledRuntimeDependencyInstallRoot(alphaRoot, { env });
fs.mkdirSync(path.join(installRoot, "node_modules", "alpha-runtime"), { recursive: true });
fs.writeFileSync(
path.join(installRoot, "node_modules", "alpha-runtime", "package.json"),
JSON.stringify({ name: "alpha-runtime", version: "1.0.0" }),
);
expect(fs.existsSync(path.join(installRoot, ".openclaw-runtime-deps.json"))).toBe(false);
const calls: BundledRuntimeDepsInstallParams[] = [];
const result = ensureBundledPluginRuntimeDeps({
env,
installDeps: (params) => {
calls.push(params);
},
pluginId: "beta",
pluginRoot: betaRoot,
});
expect(result).toEqual({
installedSpecs: ["beta-runtime@2.0.0"],
retainSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"],
});
expect(calls).toEqual([
{
installRoot,
missingSpecs: ["beta-runtime@2.0.0"],
installSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"],
},
]);
});
it("does not expire active runtime-deps install locks by age alone", () => {
expect(
bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock(
@@ -679,7 +729,8 @@ describe("ensureBundledPluginRuntimeDeps", () => {
},
}),
);
const lockDir = path.join(pluginRoot, ".openclaw-runtime-deps.lock");
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
const lockDir = path.join(installRoot, ".openclaw-runtime-deps.lock");
fs.mkdirSync(lockDir, { recursive: true });
fs.writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 0, createdAtMs: 0 }));
@@ -1008,7 +1059,7 @@ describe("ensureBundledPluginRuntimeDeps", () => {
expect(installRoot).not.toBe(pluginRoot);
});
it("skips install when staged plugin-local runtime deps are present", () => {
it("repairs external staged deps even when packaged plugin-local deps are present", () => {
const packageRoot = makeTempDir();
const extensionsRoot = path.join(packageRoot, "dist", "extensions");
const pluginRoot = path.join(extensionsRoot, "discord");
@@ -1028,16 +1079,36 @@ describe("ensureBundledPluginRuntimeDeps", () => {
JSON.stringify({ name: "@buape/carbon", version: "0.16.0" }),
);
const calls: BundledRuntimeDepsInstallParams[] = [];
const result = ensureBundledPluginRuntimeDeps({
env: {},
installDeps: () => {
throw new Error("staged plugin-local deps should not reinstall");
installDeps: (params) => {
calls.push(params);
fs.mkdirSync(path.join(params.installRoot, "node_modules", "@buape", "carbon"), {
recursive: true,
});
fs.writeFileSync(
path.join(params.installRoot, "node_modules", "@buape", "carbon", "package.json"),
JSON.stringify({ name: "@buape/carbon", version: "0.16.0" }),
);
},
pluginId: "discord",
pluginRoot,
});
expect(result).toEqual({ installedSpecs: [], retainSpecs: [] });
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
expect(result).toEqual({
installedSpecs: ["@buape/carbon@0.16.0"],
retainSpecs: ["@buape/carbon@0.16.0"],
});
expect(calls).toEqual([
{
installRoot,
missingSpecs: ["@buape/carbon@0.16.0"],
installSpecs: ["@buape/carbon@0.16.0"],
},
]);
expect(installRoot).not.toBe(pluginRoot);
});
it("does not trust runtime deps that only resolve from the package root", () => {
@@ -1074,14 +1145,15 @@ describe("ensureBundledPluginRuntimeDeps", () => {
installedSpecs: ["@mariozechner/pi-ai@0.68.1"],
retainSpecs: ["@mariozechner/pi-ai@0.68.1"],
});
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"),
installRoot,
missingSpecs: ["@mariozechner/pi-ai@0.68.1"],
installSpecs: ["@mariozechner/pi-ai@0.68.1"],
},
]);
expect(installRoot).not.toBe(pluginRoot);
});
it("installs deps that are only present in the package root", () => {
@@ -1117,14 +1189,15 @@ describe("ensureBundledPluginRuntimeDeps", () => {
installedSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
retainSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
});
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"),
installRoot,
missingSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
installSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
},
]);
expect(installRoot).not.toBe(pluginRoot);
});
it("does not treat sibling extension runtime deps as satisfying a plugin", () => {
@@ -1162,14 +1235,15 @@ describe("ensureBundledPluginRuntimeDeps", () => {
installedSpecs: ["zod@^4.3.6"],
retainSpecs: ["zod@^4.3.6"],
});
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: path.join(pluginRoot, ".openclaw-install-stage"),
installRoot,
missingSpecs: ["zod@^4.3.6"],
installSpecs: ["zod@^4.3.6"],
},
]);
expect(installRoot).not.toBe(pluginRoot);
});
it("rejects unsupported remote runtime dependency specs", () => {

View File

@@ -324,6 +324,11 @@ function resolveBundledPluginPackageRoot(pluginRoot: string): string | null {
return path.dirname(buildDir);
}
function isPackagedBundledPluginRoot(pluginRoot: string): boolean {
const packageRoot = resolveBundledPluginPackageRoot(pluginRoot);
return Boolean(packageRoot && !isSourceCheckoutRoot(packageRoot));
}
function createRuntimeDepsCacheKey(pluginId: string, specs: readonly string[]): string {
return createHash("sha256")
.update(pluginId)
@@ -371,6 +376,25 @@ function removeRetainedRuntimeDepsManifest(installRoot: string): void {
fs.rmSync(path.join(installRoot, RETAINED_RUNTIME_DEPS_MANIFEST), { force: true });
}
function collectAlreadyStagedBundledRuntimeDepSpecs(params: {
pluginRoot: string;
installRoot: string;
}): string[] {
const packageRoot = resolveBundledPluginPackageRoot(params.pluginRoot);
if (!packageRoot) {
return [];
}
const extensionsDir = path.join(packageRoot, "dist", "extensions");
if (!fs.existsSync(extensionsDir)) {
return [];
}
const { deps } = collectBundledPluginRuntimeDeps({ extensionsDir });
return deps
.filter((dep) => hasDependencySentinel([params.installRoot], dep))
.map((dep) => `${dep.name}@${dep.version}`)
.toSorted((left, right) => left.localeCompare(right));
}
function shouldPersistRetainedRuntimeDepsManifest(params: {
pluginRoot: string;
installRoot: string;
@@ -861,22 +885,19 @@ export function resolveBundledRuntimeDependencyPackageInstallRoot(
options: { env?: NodeJS.ProcessEnv; forceExternal?: boolean } = {},
): string {
const env = options.env ?? process.env;
const externalRoot = resolveExternalBundledRuntimeDepsInstallRoot({
pluginRoot: path.join(packageRoot, "dist", "extensions", "__package__"),
env,
});
if (
options.forceExternal ||
env.OPENCLAW_PLUGIN_STAGE_DIR?.trim() ||
env.STATE_DIRECTORY?.trim()
env.STATE_DIRECTORY?.trim() ||
!isSourceCheckoutRoot(packageRoot)
) {
return resolveExternalBundledRuntimeDepsInstallRoot({
pluginRoot: path.join(packageRoot, "dist", "extensions", "__package__"),
env,
});
return externalRoot;
}
return isWritableDirectory(packageRoot)
? packageRoot
: resolveExternalBundledRuntimeDepsInstallRoot({
pluginRoot: path.join(packageRoot, "dist", "extensions", "__package__"),
env,
});
return isWritableDirectory(packageRoot) ? packageRoot : externalRoot;
}
export function resolveBundledRuntimeDependencyInstallRoot(
@@ -884,16 +905,16 @@ export function resolveBundledRuntimeDependencyInstallRoot(
options: { env?: NodeJS.ProcessEnv; forceExternal?: boolean } = {},
): string {
const env = options.env ?? process.env;
const externalRoot = resolveExternalBundledRuntimeDepsInstallRoot({ pluginRoot, env });
if (
options.forceExternal ||
env.OPENCLAW_PLUGIN_STAGE_DIR?.trim() ||
env.STATE_DIRECTORY?.trim()
env.STATE_DIRECTORY?.trim() ||
isPackagedBundledPluginRoot(pluginRoot)
) {
return resolveExternalBundledRuntimeDepsInstallRoot({ pluginRoot, env });
return externalRoot;
}
return isWritableDirectory(pluginRoot)
? pluginRoot
: resolveExternalBundledRuntimeDepsInstallRoot({ pluginRoot, env });
return isWritableDirectory(pluginRoot) ? pluginRoot : externalRoot;
}
export function resolveBundledRuntimeDependencyInstallRootInfo(
@@ -1000,6 +1021,36 @@ export function installBundledRuntimeDeps(params: {
}
}
export function repairBundledRuntimeDepsInstallRoot(params: {
installRoot: string;
missingSpecs: string[];
installSpecs: string[];
env: NodeJS.ProcessEnv;
installDeps?: (params: BundledRuntimeDepsInstallParams) => void;
}): { installSpecs: string[] } {
return withBundledRuntimeDepsInstallRootLock(params.installRoot, () => {
const retainedManifestSpecs = readRetainedRuntimeDepsManifest(params.installRoot);
const installSpecs = [...new Set([...retainedManifestSpecs, ...params.installSpecs])].toSorted(
(left, right) => left.localeCompare(right),
);
const install =
params.installDeps ??
((installParams) =>
installBundledRuntimeDeps({
installRoot: installParams.installRoot,
missingSpecs: installParams.installSpecs ?? installParams.missingSpecs,
env: params.env,
}));
install({
installRoot: params.installRoot,
missingSpecs: params.missingSpecs,
installSpecs,
});
writeRetainedRuntimeDepsManifest(params.installRoot, installSpecs);
return { installSpecs };
});
}
export function ensureBundledPluginRuntimeDeps(params: {
pluginId: string;
pluginRoot: string;
@@ -1043,19 +1094,33 @@ export function ensureBundledPluginRuntimeDeps(params: {
const dependencySpecs = deps
.map((dep) => `${dep.name}@${dep.version}`)
.toSorted((left, right) => left.localeCompare(right));
const retainedManifestSpecs = persistRetainedManifest
? readRetainedRuntimeDepsManifest(installRoot)
: [];
const alreadyStagedSpecs = persistRetainedManifest
? collectAlreadyStagedBundledRuntimeDepSpecs({
pluginRoot: params.pluginRoot,
installRoot,
})
: [];
const installSpecs = [
...new Set([
...(params.retainSpecs ?? []),
...retainedManifestSpecs,
...alreadyStagedSpecs,
...dependencySpecs,
]),
].toSorted((left, right) => left.localeCompare(right));
const missingSpecs = deps
.filter((dep) => !hasDependencySentinel([installRoot], dep))
.map((dep) => `${dep.name}@${dep.version}`)
.toSorted((left, right) => left.localeCompare(right));
if (missingSpecs.length === 0) {
if (persistRetainedManifest && installSpecs.length > 0) {
writeRetainedRuntimeDepsManifest(installRoot, installSpecs);
}
return { installedSpecs: [], retainSpecs: [] };
}
const retainedManifestSpecs = persistRetainedManifest
? readRetainedRuntimeDepsManifest(installRoot)
: [];
const installSpecs = [
...new Set([...(params.retainSpecs ?? []), ...retainedManifestSpecs, ...dependencySpecs]),
].toSorted((left, right) => left.localeCompare(right));
const cacheDir = resolveSourceCheckoutRuntimeDepsCacheDir({
pluginId: params.pluginId,
pluginRoot: params.pluginRoot,

View File

@@ -164,14 +164,17 @@ describe("config footprint guardrails", () => {
it("keeps bundled channel schemas as a fixed legacy SDK compatibility surface", () => {
const source = readSource("src/plugin-sdk/channel-config-schema.ts");
const providersCoreExports = source.match(
/Legacy bundled channel schema exports[\s\S]*?export \{(?<exports>[\s\S]*?)\} from "\.\.\/config\/zod-schema\.providers-core\.js";/,
)?.groups?.exports;
expect(providersCoreExports).toBeDefined();
const exportedSchemaNames = Array.from(
`${providersCoreExports ?? ""}\nWhatsAppConfigSchema`.matchAll(
/\b([A-Z][A-Za-z0-9]+ConfigSchema)\b/g,
const legacySection = source.slice(source.indexOf("Legacy bundled channel schema exports"));
const bundledSchemaExportBlocks = Array.from(
legacySection.matchAll(
/export \{(?<exports>[\s\S]*?)\} from "\.\.\/config\/zod-schema\.providers-(?:core|whatsapp)\.js";/g,
),
)
.map((match) => match.groups?.exports)
.filter((block): block is string => Boolean(block));
expect(bundledSchemaExportBlocks).toHaveLength(2);
const exportedSchemaNames = Array.from(
bundledSchemaExportBlocks.join("\n").matchAll(/\b([A-Z][A-Za-z0-9]+ConfigSchema)\b/g),
)
.map((match) => match[1])
.filter((name): name is string => Boolean(name))

View File

@@ -510,6 +510,34 @@ describe("loadPluginManifestRegistry", () => {
);
});
it("sanitizes manifest-controlled fields in provider auth compatibility diagnostics", () => {
const dir = makeTempDir();
const lineBreak = String.fromCharCode(10);
const ansiRed = `${String.fromCharCode(27)}[31m`;
writeManifest(dir, {
id: `external${lineBreak}openai${ansiRed}`,
providers: ["openai"],
providerAuthEnvVars: {
[`openai${lineBreak}${ansiRed}`]: ["OPENAI_API_KEY"],
},
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "external-openai",
rootDir: dir,
origin: "global",
});
const diagnostic = registry.diagnostics.find((entry) =>
entry.message.includes("providerAuthEnvVars is deprecated compatibility metadata"),
);
expect(diagnostic?.pluginId).toBe("externalopenai");
expect(diagnostic?.message).toContain("openai");
expect(diagnostic?.message).not.toContain(lineBreak);
expect(diagnostic?.message).not.toContain(ansiRed);
});
it("reports non-bundled channel manifests without channel config descriptors", () => {
const dir = makeTempDir();
writeManifest(dir, {
@@ -535,6 +563,31 @@ describe("loadPluginManifestRegistry", () => {
);
});
it("sanitizes manifest-controlled fields in channel config descriptor diagnostics", () => {
const dir = makeTempDir();
const lineBreak = String.fromCharCode(10);
const ansiRed = `${String.fromCharCode(27)}[31m`;
writeManifest(dir, {
id: `external${lineBreak}chat${ansiRed}`,
channels: [`external${lineBreak}channel${ansiRed}`],
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "external-chat",
rootDir: dir,
origin: "global",
});
const diagnostic = registry.diagnostics.find((entry) =>
entry.message.includes("without channelConfigs metadata"),
);
expect(diagnostic?.pluginId).toBe("externalchat");
expect(diagnostic?.message).toContain("externalchannel");
expect(diagnostic?.message).not.toContain(lineBreak);
expect(diagnostic?.message).not.toContain(ansiRed);
});
it("accepts non-bundled channel manifests with channel config descriptors", () => {
const dir = makeTempDir();
writeManifest(dir, {
@@ -571,6 +624,58 @@ describe("loadPluginManifestRegistry", () => {
).toBe(false);
});
it("drops prototype-polluting channel config keys from plugin manifests", () => {
const dir = makeTempDir();
writeTextFile(
dir,
"openclaw.plugin.json",
JSON.stringify({
id: "external-chat",
channels: ["safe-chat"],
configSchema: { type: "object" },
channelConfigs: {
["__proto__"]: {
schema: {
type: "object",
properties: {
polluted: { const: true },
},
},
},
constructor: {
schema: { type: "object" },
},
prototype: {
schema: { type: "object" },
},
"safe-chat": {
schema: {
type: "object",
additionalProperties: false,
},
},
},
}),
);
const registry = loadSingleCandidateRegistry({
idHint: "external-chat",
rootDir: dir,
origin: "global",
});
const channelConfigs = registry.plugins[0]?.channelConfigs;
expect(channelConfigs).toBeDefined();
expect(Object.getPrototypeOf(channelConfigs)).toBe(null);
expect(Object.prototype.hasOwnProperty.call(channelConfigs, "__proto__")).toBe(false);
expect(Object.prototype.hasOwnProperty.call(channelConfigs, "constructor")).toBe(false);
expect(Object.prototype.hasOwnProperty.call(channelConfigs, "prototype")).toBe(false);
expect(channelConfigs?.["safe-chat"]?.schema).toMatchObject({
type: "object",
additionalProperties: false,
});
});
it("falls back providerDiscoverySource from .ts to emitted .js files", () => {
const dir = makeTempDir();
writeManifest(dir, {

View File

@@ -1,11 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/types.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { normalizeOptionalTrimmedStringList } from "../shared/string-normalization.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { resolveUserPath } from "../utils.js";
import { resolveCompatibilityHostVersion } from "../version.js";
import { loadBundleManifest } from "./bundle-manifest.js";
@@ -306,26 +308,38 @@ function mergePackageChannelMetaIntoChannelConfigs(params: {
packageChannel?: OpenClawPackageManifest["channel"];
}): Record<string, PluginManifestChannelConfig> | undefined {
const channelId = params.packageChannel?.id?.trim();
if (!channelId || !params.channelConfigs?.[channelId]) {
if (
!channelId ||
isBlockedObjectKey(channelId) ||
!params.channelConfigs ||
!Object.prototype.hasOwnProperty.call(params.channelConfigs, channelId)
) {
return params.channelConfigs;
}
const existing = params.channelConfigs[channelId];
if (!existing) {
return params.channelConfigs;
}
const label = existing.label ?? normalizeOptionalString(params.packageChannel?.label) ?? "";
const description =
existing.description ?? normalizeOptionalString(params.packageChannel?.blurb) ?? "";
const preferOver =
existing.preferOver ?? normalizePreferredPluginIds(params.packageChannel?.preferOver);
return {
...params.channelConfigs,
[channelId]: {
...existing,
...(label ? { label } : {}),
...(description ? { description } : {}),
...(preferOver?.length ? { preferOver } : {}),
},
const merged: Record<string, PluginManifestChannelConfig> = Object.create(null);
for (const [key, value] of Object.entries(params.channelConfigs)) {
if (!isBlockedObjectKey(key)) {
merged[key] = value;
}
}
merged[channelId] = {
...existing,
...(label ? { label } : {}),
...(description ? { description } : {}),
...(preferOver?.length ? { preferOver } : {}),
};
return merged;
}
function buildRecord(params: {
@@ -468,9 +482,9 @@ function pushProviderAuthEnvVarsCompatDiagnostic(params: {
}
params.diagnostics.push({
level: "warn",
pluginId: params.record.id,
source: params.record.manifestPath,
message: `providerAuthEnvVars is deprecated compatibility metadata for provider env-var lookup; mirror ${providerIds.join(", ")} env vars to setup.providers[].envVars before the deprecation window closes`,
pluginId: sanitizeForLog(params.record.id),
source: sanitizeForLog(params.record.manifestPath),
message: `providerAuthEnvVars is deprecated compatibility metadata for provider env-var lookup; mirror ${providerIds.map(sanitizeForLog).join(", ")} env vars to setup.providers[].envVars before the deprecation window closes`,
});
}
@@ -488,15 +502,18 @@ function pushNonBundledChannelConfigDescriptorDiagnostic(params: {
return;
}
const channelConfigs = params.record.channelConfigs ?? {};
const missingChannels = declaredChannels.filter((channelId) => !channelConfigs[channelId]);
const missingChannels = declaredChannels.filter(
(channelId) => !Object.prototype.hasOwnProperty.call(channelConfigs, channelId),
);
if (missingChannels.length === 0) {
return;
}
const safeMissingChannels = missingChannels.map(sanitizeForLog);
params.diagnostics.push({
level: "warn",
pluginId: params.record.id,
source: params.record.manifestPath,
message: `channel plugin manifest declares ${missingChannels.join(", ")} without channelConfigs metadata; add openclaw.plugin.json#channelConfigs so config schema and setup surfaces work before runtime loads`,
pluginId: sanitizeForLog(params.record.id),
source: sanitizeForLog(params.record.manifestPath),
message: `channel plugin manifest declares ${safeMissingChannels.join(", ")} without channelConfigs metadata; add openclaw.plugin.json#channelConfigs so config schema and setup surfaces work before runtime loads`,
});
}

View File

@@ -4,6 +4,7 @@ import JSON5 from "json5";
import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.config.js";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import type { JsonSchemaObject } from "../shared/json-schema.types.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { normalizeTrimmedStringList } from "../shared/string-normalization.js";
@@ -312,10 +313,10 @@ function normalizeStringListRecord(value: unknown): Record<string, string[]> | u
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, string[]> = {};
const normalized: Record<string, string[]> = Object.create(null);
for (const [key, rawValues] of Object.entries(value)) {
const providerId = normalizeOptionalString(key) ?? "";
if (!providerId) {
if (!providerId || isBlockedObjectKey(providerId)) {
continue;
}
const values = normalizeTrimmedStringList(rawValues);
@@ -331,11 +332,11 @@ function normalizeStringRecord(value: unknown): Record<string, string> | undefin
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, string> = {};
const normalized: Record<string, string> = Object.create(null);
for (const [rawKey, rawValue] of Object.entries(value)) {
const key = normalizeOptionalString(rawKey) ?? "";
const value = normalizeOptionalString(rawValue) ?? "";
if (!key || !value) {
if (!key || isBlockedObjectKey(key) || !value) {
continue;
}
normalized[key] = value;
@@ -404,10 +405,11 @@ function normalizeMediaUnderstandingProviderMetadata(
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, PluginManifestMediaUnderstandingProviderMetadata> = {};
const normalized: Record<string, PluginManifestMediaUnderstandingProviderMetadata> =
Object.create(null);
for (const [rawProviderId, rawMetadata] of Object.entries(value)) {
const providerId = normalizeOptionalString(rawProviderId) ?? "";
if (!providerId || !isRecord(rawMetadata)) {
if (!providerId || isBlockedObjectKey(providerId) || !isRecord(rawMetadata)) {
continue;
}
const capabilities = normalizeMediaUnderstandingCapabilities(rawMetadata.capabilities);
@@ -769,10 +771,10 @@ function normalizeChannelConfigs(
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, PluginManifestChannelConfig> = {};
const normalized: Record<string, PluginManifestChannelConfig> = Object.create(null);
for (const [key, rawEntry] of Object.entries(value)) {
const channelId = normalizeOptionalString(key) ?? "";
if (!channelId || !isRecord(rawEntry)) {
if (!channelId || isBlockedObjectKey(channelId) || !isRecord(rawEntry)) {
continue;
}
const schema = isRecord(rawEntry.schema) ? rawEntry.schema : null;

View File

@@ -1,5 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { finalizeDebugProxyCapture, initializeDebugProxyCapture } from "./runtime.js";
import {
captureHttpExchange,
finalizeDebugProxyCapture,
initializeDebugProxyCapture,
} from "./runtime.js";
const storeState = vi.hoisted(() => {
const events: Record<string, unknown>[] = [];
@@ -82,4 +86,42 @@ describe("debug proxy runtime", () => {
expect(events.some((event) => event.kind === "request")).toBe(true);
expect(events.some((event) => event.kind === "response")).toBe(true);
});
it("redacts sensitive request and response headers before persistence", async () => {
initializeDebugProxyCapture("test");
captureHttpExchange({
url: "https://discord.com/api/v10/gateway/bot",
method: "GET",
requestHeaders: {
Authorization: "Bot discord-token",
Cookie: "sid=session-token",
"x-api-key": "provider-key",
"content-type": "application/json",
"x-safe": "visible",
},
response: new Response("{}", {
status: 200,
headers: {
"content-type": "application/json",
"set-cookie": "sid=response-token",
},
}),
});
await new Promise((resolve) => setImmediate(resolve));
finalizeDebugProxyCapture();
const request = storeState.events.find((event) => event.kind === "request");
expect(JSON.parse(String(request?.headersJson))).toMatchObject({
Authorization: "[REDACTED]",
Cookie: "[REDACTED]",
"x-api-key": "[REDACTED]",
"content-type": "application/json",
"x-safe": "visible",
});
const response = storeState.events.find((event) => event.kind === "response");
expect(JSON.parse(String(response?.headersJson))).toMatchObject({
"content-type": "application/json",
"set-cookie": "[REDACTED]",
});
});
});

View File

@@ -15,6 +15,29 @@ import type {
} from "./types.js";
const DEBUG_PROXY_FETCH_PATCH_KEY = Symbol.for("openclaw.debugProxy.fetchPatch");
const REDACTED_CAPTURE_HEADER_VALUE = "[REDACTED]";
const SENSITIVE_CAPTURE_HEADER_NAMES = new Set([
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"x-api-key",
"api-key",
"apikey",
"x-auth-token",
"auth-token",
"x-access-token",
"access-token",
]);
const SENSITIVE_CAPTURE_HEADER_NAME_FRAGMENTS = [
"api-key",
"apikey",
"token",
"secret",
"password",
"credential",
"session",
];
type GlobalFetchPatchedState = {
originalFetch: typeof globalThis.fetch;
@@ -55,6 +78,32 @@ function resolveUrlString(input: RequestInfo | URL): string | null {
return null;
}
function isSensitiveCaptureHeaderName(name: string): boolean {
const normalized = name.trim().toLowerCase();
if (!normalized) {
return false;
}
if (SENSITIVE_CAPTURE_HEADER_NAMES.has(normalized)) {
return true;
}
return SENSITIVE_CAPTURE_HEADER_NAME_FRAGMENTS.some((fragment) => normalized.includes(fragment));
}
function redactedCaptureHeaders(
headers: Headers | Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
const entries =
headers instanceof Headers ? Array.from(headers.entries()) : Object.entries(headers);
const redacted: Record<string, string> = {};
for (const [name, value] of entries) {
redacted[name] = isSensitiveCaptureHeaderName(name) ? REDACTED_CAPTURE_HEADER_VALUE : value;
}
return redacted;
}
function createHttpCaptureEventBase(params: {
settings: DebugProxySettings;
rawUrl: string;
@@ -237,11 +286,7 @@ export function captureHttpExchange(params: {
params.requestHeaders instanceof Headers
? (params.requestHeaders.get("content-type") ?? undefined)
: params.requestHeaders?.["content-type"],
headersJson: safeJsonString(
params.requestHeaders instanceof Headers
? Object.fromEntries(params.requestHeaders.entries())
: params.requestHeaders,
),
headersJson: safeJsonString(redactedCaptureHeaders(params.requestHeaders)),
metaJson: safeJsonString(params.meta),
...requestPayload,
});
@@ -268,7 +313,7 @@ export function captureHttpExchange(params: {
: undefined,
headersJson:
params.response.headers && typeof params.response.headers.entries === "function"
? safeJsonString(Object.fromEntries(params.response.headers.entries()))
? safeJsonString(redactedCaptureHeaders(params.response.headers))
: undefined,
metaJson: safeJsonString({ ...params.meta, bodyCapture: "unavailable" }),
});
@@ -295,7 +340,7 @@ export function captureHttpExchange(params: {
}),
status: params.response.status,
contentType: params.response.headers.get("content-type") ?? undefined,
headersJson: safeJsonString(Object.fromEntries(params.response.headers.entries())),
headersJson: safeJsonString(redactedCaptureHeaders(params.response.headers)),
metaJson: safeJsonString(params.meta),
...responsePayload,
});