mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:10:43 +00:00
Merge branch 'main' into meow/tweakcn-custom-theme-import
This commit is contained in:
@@ -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.
|
||||
|
||||
3215
CHANGELOG.md
3215
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ function createProfile(overrides: Partial<ResolvedBrowserProfile>): ResolvedBrow
|
||||
driver: "openclaw",
|
||||
attachOnly: false,
|
||||
...overrides,
|
||||
headless: overrides.headless ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ const localProfile: ResolvedBrowserProfile = {
|
||||
cdpIsLoopback: true,
|
||||
color: "#FF4500",
|
||||
driver: "openclaw",
|
||||
headless: false,
|
||||
attachOnly: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("browser tab routes attachOnly loopback profiles", () => {
|
||||
cdpPort: 9222,
|
||||
color: "#00AA00",
|
||||
driver: "openclaw",
|
||||
headless: false,
|
||||
attachOnly: true,
|
||||
},
|
||||
resolvedOverrides: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("browser server-context listProfiles", () => {
|
||||
cdpPort: 9222,
|
||||
color: "#00AA00",
|
||||
driver: "openclaw",
|
||||
headless: false,
|
||||
attachOnly: true,
|
||||
},
|
||||
resolvedOverrides: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ function localOpenClawProfile(): Parameters<typeof createProfileResetOps>[0]["pr
|
||||
cdpPort: 18800,
|
||||
color: "#f60",
|
||||
driver: "openclaw",
|
||||
headless: false,
|
||||
attachOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export function makeBrowserProfile(
|
||||
cdpPort: 18800,
|
||||
color: "#FF4500",
|
||||
driver: "openclaw",
|
||||
headless: false,
|
||||
attachOnly: false,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
155
extensions/google-meet/src/test-support/plugin-harness.ts
Normal file
155
extensions/google-meet/src/test-support/plugin-harness.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
115
src/cron/service/store.load-missing-session-target.test.ts
Normal file
115
src/cron/service/store.load-missing-session-target.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -43,6 +43,7 @@ export type ResolvedBrowserProfile = {
|
||||
userDataDir?: string;
|
||||
color: string;
|
||||
driver: "openclaw" | "existing-session";
|
||||
headless?: boolean;
|
||||
attachOnly: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user