mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix: external plugin tabs fail under gateway auth (#107323)
* fix: load plugin tabs with gateway auth * fix: harden plugin tab cookie signature compare * fix(gateway): authenticate external plugin tabs Co-authored-by: Paul Pitchford <paul@paulpitchford.co.uk> * refactor(gateway): simplify plugin frame auth proof --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -676,6 +676,8 @@ Notes:
|
||||
- `trusted-proxy` callers without an explicit `x-openclaw-scopes` header also keep the legacy `operator.write`-only surface
|
||||
- `trusted-proxy` callers that do send `x-openclaw-scopes` get the declared scopes instead
|
||||
- a route can opt into `gatewayRuntimeScopeSurface: "trusted-operator"` to always honor `x-openclaw-scopes` for identity-bearing auth modes (falling back to the full CLI default scope set when the header is absent)
|
||||
- Sandboxed external Control UI tabs backed by `auth: "gateway"` routes use a short-lived signed cookie grant minted only by authenticated bootstrap; plugin-auth tabs keep their direct iframe path. Before mounting, the parent runs a route-owned probe inside the same opaque sandbox and fails closed when browser privacy policy blocks the cookie. The grant is bound to the owning plugin, matched route root, and current auth generation; its process-random cookie name prevents trusted same-host Gateways from overwriting one another, but cookies never isolate TCP ports. The Gateway hostname is therefore one credential boundary: do not cohost mutually untrusted services on that hostname, including other ports. Route dispatch rejects reuse against a nested route owned by another plugin. Because sandbox descendants are cross-site for cookie purposes, the grant accepts only `GET` and `HEAD` with `operator.read`; mutations and WebSocket upgrades stay on explicit Gateway-authenticated surfaces. The cookie intentionally cannot use CHIPS: current browsers include a cross-site-ancestor bit in the partition key, so nested opaque sandbox frames would lose access to same-route assets. The cookie requires a secure context and browser permission for cross-site cookies, so gateway-auth external tabs are unavailable on plain-HTTP LAN origins or under full third-party-cookie blocking; use HTTPS/Tailscale Serve or browser-trusted loopback with a compatible cookie policy.
|
||||
- The grant prevents Gateway bearer-token disclosure and accidental route/scope reuse; it does not create a security boundary between native plugins. Native plugin code and the UI content it serves remain part of the same trusted in-process plugin boundary.
|
||||
- Practical rule: do not assume a gateway-auth plugin route is an implicit admin surface. If your route needs admin-only behavior, opt into `trusted-operator` scope surface, require an identity-bearing auth mode, and document the explicit `x-openclaw-scopes` header contract.
|
||||
- After route matching and authentication, ordinary handlers participate in Gateway root-work admission. A prepared or restarting Gateway returns `503` before invoking the handler. The narrow exception is a manifest-entitled `auth: "gateway"` route that also opts into the route-specific `trusted-operator` surface; it remains reachable so suspension control dispatch cannot be stranded, while ordinary sibling routes from the same plugin remain behind the admission boundary. WebSocket `handleUpgrade` ownership uses the same atomic admission boundary; once the handler accepts a socket, the socket's later lifetime is plugin-owned and is not tracked by this boundary.
|
||||
|
||||
|
||||
@@ -348,6 +348,30 @@ plugins can set `path` to a plugin HTTP route (see
|
||||
(`control` or `agent`), `order` sorts among plugin tabs, and `requiredScopes`
|
||||
hides the tab from connections lacking those operator scopes:
|
||||
|
||||
For a gateway-protected external tab, register the descriptor `path` under a
|
||||
same-plugin `auth: "gateway"` HTTP route. After authenticated bootstrap, the browser gets a
|
||||
short-lived, HttpOnly grant scoped to that plugin and route root so the
|
||||
sandboxed frame can load without copying the Gateway bearer token into its URL
|
||||
or JavaScript. The authenticated parent renews the grant while the external tab
|
||||
is active and before mounting it after navigation or browser resume. It also
|
||||
probes the grant from the same opaque sandbox before mounting, so browser
|
||||
privacy modes that block the cookie fail closed with an unavailable panel.
|
||||
The frame grant accepts only `GET` and `HEAD` and always carries
|
||||
`operator.read`; `requiredScopes` controls tab visibility but never widens the
|
||||
cookie grant. Mutations remain on explicit Gateway-authenticated parent or
|
||||
bearer surfaces. External tabs require HTTPS/Tailscale Serve or a
|
||||
browser-trusted loopback origin; plain HTTP on a LAN host shows the
|
||||
secure-context error instead of mounting a panel that cannot authenticate.
|
||||
Full third-party-cookie blocking also makes gateway-protected tabs unavailable.
|
||||
As with all native plugin surfaces, the frame remains inside the installed
|
||||
plugin trust boundary; OpenClaw does not treat installed plugins as mutually
|
||||
isolated browser security principals.
|
||||
Cookie grants use the browser's hostname boundary, not its port boundary. Do
|
||||
not cohost mutually untrusted services on the Gateway hostname, even on other
|
||||
ports.
|
||||
Tabs backed by plugin-managed auth keep their direct iframe behavior and do not
|
||||
request or require this Gateway grant.
|
||||
|
||||
```typescript
|
||||
api.session.controls.registerControlUiDescriptor({
|
||||
surface: "tab",
|
||||
|
||||
@@ -94,6 +94,7 @@ export const HelloOkSchema = closedObject({
|
||||
description: Type.Optional(Type.String()),
|
||||
icon: Type.Optional(Type.String()),
|
||||
path: Type.Optional(Type.String()),
|
||||
requiresGatewayAuth: Type.Optional(Type.Boolean()),
|
||||
group: Type.Optional(Type.Union([Type.Literal("control"), Type.Literal("agent")])),
|
||||
order: Type.Optional(Type.Number()),
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,29 @@
|
||||
/** HTTP path for the Control UI bootstrap config payload. */
|
||||
export const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/control-ui-config.json";
|
||||
|
||||
/** Lifetime shared by server-minted plugin-tab grants and parent-side renewal. */
|
||||
export const CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Reserved query key for the sandbox cookie capability probe. */
|
||||
export const CONTROL_UI_PLUGIN_AUTH_PROBE_QUERY = "__openclaw_plugin_frame_auth_probe";
|
||||
|
||||
/** Exact parent origin that may receive the successful probe message. */
|
||||
export const CONTROL_UI_PLUGIN_AUTH_PROBE_ORIGIN_QUERY = "__openclaw_plugin_frame_auth_origin";
|
||||
|
||||
/** Message emitted only by a successful sandbox cookie capability probe. */
|
||||
export const CONTROL_UI_PLUGIN_AUTH_PROBE_MESSAGE = "openclaw-plugin-frame-auth-probe";
|
||||
|
||||
/** Extracts the same-origin route pathname from a tab descriptor URL. */
|
||||
export function resolveControlUiPluginTabPathname(path: string): string | undefined {
|
||||
try {
|
||||
const baseUrl = new URL("http://openclaw.invalid");
|
||||
const tabUrl = new URL(path, baseUrl);
|
||||
return tabUrl.origin === baseUrl.origin ? tabUrl.pathname : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Carries the gateway-configured Control UI mount path into browser bootstrap. */
|
||||
export const CONTROL_UI_BASE_PATH_ATTRIBUTE = "data-openclaw-control-ui-base-path";
|
||||
|
||||
@@ -12,6 +35,13 @@ export const CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE = "data-openclaw-terminal-ena
|
||||
/** Sandbox policy for assistant-provided embed surfaces inside Control UI. */
|
||||
export type ControlUiEmbedSandboxMode = "strict" | "scripts" | "trusted";
|
||||
|
||||
/** Route grant successfully issued during authenticated Control UI bootstrap. */
|
||||
export type ControlUiPluginFrameGrantAck = {
|
||||
pluginId: string;
|
||||
path: string;
|
||||
match: "exact" | "prefix";
|
||||
};
|
||||
|
||||
/** Public GitHub metadata rendered by Control UI link hover cards. */
|
||||
export type ControlUiGitHubPreview = {
|
||||
additions?: number;
|
||||
@@ -122,4 +152,5 @@ export type ControlUiBootstrapConfig = {
|
||||
* switch removes the surface rather than showing a button that errors on open.
|
||||
*/
|
||||
terminalEnabled?: boolean;
|
||||
pluginFrameGrants?: ControlUiPluginFrameGrantAck[];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// Control UI plugin-tab cookie auth lets an authenticated UI open gateway-auth plugin iframes.
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion";
|
||||
import {
|
||||
CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS,
|
||||
CONTROL_UI_PLUGIN_AUTH_PROBE_MESSAGE,
|
||||
CONTROL_UI_PLUGIN_AUTH_PROBE_ORIGIN_QUERY,
|
||||
CONTROL_UI_PLUGIN_AUTH_PROBE_QUERY,
|
||||
} from "./control-ui-contract.js";
|
||||
import type { ControlUiPluginTabAuthGrant } from "./control-ui-plugin-tabs.js";
|
||||
import { isOperatorScope, type OperatorScope } from "./operator-scopes.js";
|
||||
import { resolvePluginRoutePathContext } from "./server/plugins-http/path-context.js";
|
||||
|
||||
// Cookies are hostname-scoped, never port-scoped. The suffix prevents trusted
|
||||
// same-host Gateways from overwriting one another; it does not isolate them.
|
||||
// Do not cohost mutually untrusted services on the Gateway's cookie hostname.
|
||||
const CONTROL_UI_PLUGIN_AUTH_COOKIE_PREFIX = `__openclaw_plugin_tab_auth_${randomBytes(8).toString("hex")}`;
|
||||
const CONTROL_UI_PLUGIN_AUTH_COOKIE_SCOPE = "plugin-tab";
|
||||
const controlUiPluginAuthCookieSecret = randomBytes(32);
|
||||
|
||||
type PluginAuthCookiePayload = {
|
||||
scope: typeof CONTROL_UI_PLUGIN_AUTH_COOKIE_SCOPE;
|
||||
pluginId: string;
|
||||
scopes: OperatorScope[];
|
||||
path: string;
|
||||
match: "exact" | "prefix";
|
||||
generation: string;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
function signPayload(encodedPayload: string): string {
|
||||
return createHmac("sha256", controlUiPluginAuthCookieSecret)
|
||||
.update(encodedPayload)
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const left = createHash("sha256").update(a).digest();
|
||||
const right = createHash("sha256").update(b).digest();
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
function readCookieHeaderValues(
|
||||
header: string | string[] | undefined,
|
||||
namePrefix: string,
|
||||
): string[] {
|
||||
const raw = Array.isArray(header) ? header.join(";") : header;
|
||||
const values: string[] = [];
|
||||
for (const part of raw?.split(";") ?? []) {
|
||||
const index = part.indexOf("=");
|
||||
if (index <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = part.slice(0, index).trim();
|
||||
const value = part.slice(index + 1).trim();
|
||||
if (key.startsWith(`${namePrefix}_`)) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function cookieNameForPlugin(pluginId: string): string {
|
||||
const pluginKey = createHash("sha256").update(pluginId).digest("hex");
|
||||
return `${CONTROL_UI_PLUGIN_AUTH_COOKIE_PREFIX}_${pluginKey}`;
|
||||
}
|
||||
|
||||
function hasInvalidCookiePathCharacter(path: string): boolean {
|
||||
for (const character of path) {
|
||||
const code = character.charCodeAt(0);
|
||||
if (character === ";" || code <= 0x1f || code === 0x7f) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeCookiePath(path: string): string | undefined {
|
||||
if (!path.startsWith("/") || path.startsWith("//") || hasInvalidCookiePathCharacter(path)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const normalized = new URL(path, "http://localhost").pathname;
|
||||
return normalized === path ? normalized : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createControlUiPluginAuthCookie(
|
||||
grant: ControlUiPluginTabAuthGrant,
|
||||
params: {
|
||||
generation: string | undefined;
|
||||
nowMs?: number;
|
||||
},
|
||||
) {
|
||||
const path = normalizeCookiePath(grant.path);
|
||||
if (!path || !grant.pluginId || !params.generation) {
|
||||
return undefined;
|
||||
}
|
||||
const now = asDateTimestampMs(params.nowMs ?? Date.now());
|
||||
if (now === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const exp = asDateTimestampMs(now + CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS);
|
||||
if (exp === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const payload: PluginAuthCookiePayload = {
|
||||
scope: CONTROL_UI_PLUGIN_AUTH_COOKIE_SCOPE,
|
||||
pluginId: grant.pluginId,
|
||||
scopes: grant.scopes.filter(isOperatorScope),
|
||||
path,
|
||||
match: grant.match,
|
||||
generation: params.generation,
|
||||
exp,
|
||||
};
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
||||
const sig = signPayload(encodedPayload);
|
||||
// The sandboxed frame has an opaque origin, so descendant requests are
|
||||
// cross-site for cookie purposes even when the panel URL is same-host.
|
||||
// CHIPS cannot be used here: its cross-site-ancestor key prevents nested
|
||||
// opaque frames from receiving the grant. HTTP auth limits it to safe reads.
|
||||
return `${cookieNameForPlugin(grant.pluginId)}=v1.${encodedPayload}.${sig}; Path=${path}; HttpOnly; Secure; SameSite=None; Max-Age=${Math.ceil(CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS / 1000)}`;
|
||||
}
|
||||
|
||||
export function setControlUiPluginAuthCookie(
|
||||
res: ServerResponse,
|
||||
grants: readonly ControlUiPluginTabAuthGrant[],
|
||||
params: {
|
||||
generation: string | undefined;
|
||||
nowMs?: number;
|
||||
},
|
||||
) {
|
||||
const issuedGrants: ControlUiPluginTabAuthGrant[] = [];
|
||||
const cookiesToAdd = grants.flatMap((grant) => {
|
||||
const cookie = createControlUiPluginAuthCookie(grant, {
|
||||
generation: params.generation,
|
||||
nowMs: params.nowMs,
|
||||
});
|
||||
if (!cookie) {
|
||||
return [];
|
||||
}
|
||||
issuedGrants.push(grant);
|
||||
return [cookie];
|
||||
});
|
||||
if (cookiesToAdd.length === 0) {
|
||||
return issuedGrants;
|
||||
}
|
||||
const existing = typeof res.getHeader === "function" ? res.getHeader("Set-Cookie") : undefined;
|
||||
const cookies = Array.isArray(existing)
|
||||
? [...existing, ...cookiesToAdd]
|
||||
: typeof existing === "string"
|
||||
? [existing, ...cookiesToAdd]
|
||||
: cookiesToAdd;
|
||||
res.setHeader("Set-Cookie", cookies);
|
||||
return issuedGrants;
|
||||
}
|
||||
|
||||
function grantPathMatchesRequest(
|
||||
grantPath: string,
|
||||
match: "exact" | "prefix",
|
||||
requestPath: string,
|
||||
): boolean {
|
||||
if (match === "exact") {
|
||||
return requestPath === grantPath;
|
||||
}
|
||||
return (
|
||||
requestPath === grantPath ||
|
||||
(requestPath.startsWith(grantPath) &&
|
||||
(grantPath.endsWith("/") || requestPath.at(grantPath.length) === "/"))
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveControlUiPluginAuthCookieGrants(
|
||||
req: IncomingMessage,
|
||||
params: {
|
||||
requestPath: string;
|
||||
generation: string | undefined;
|
||||
nowMs?: number;
|
||||
},
|
||||
): ControlUiPluginTabAuthGrant[] {
|
||||
const now = asDateTimestampMs(params.nowMs ?? Date.now());
|
||||
if (now === undefined) {
|
||||
return [];
|
||||
}
|
||||
const requestPath = normalizeCookiePath(params.requestPath);
|
||||
if (!requestPath || !params.generation) {
|
||||
return [];
|
||||
}
|
||||
const requestPathContext = resolvePluginRoutePathContext(requestPath);
|
||||
if (requestPathContext.malformedEncoding || requestPathContext.decodePassLimitReached) {
|
||||
return [];
|
||||
}
|
||||
const grants: ControlUiPluginTabAuthGrant[] = [];
|
||||
for (const value of readCookieHeaderValues(
|
||||
req.headers.cookie,
|
||||
CONTROL_UI_PLUGIN_AUTH_COOKIE_PREFIX,
|
||||
)) {
|
||||
const parts = value.split(".");
|
||||
if (parts.length !== 3 || parts[0] !== "v1") {
|
||||
continue;
|
||||
}
|
||||
const [, encodedPayload, sig] = parts;
|
||||
if (!encodedPayload || !sig || !safeEqual(sig, signPayload(encodedPayload))) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")) as
|
||||
| PluginAuthCookiePayload
|
||||
| undefined;
|
||||
if (
|
||||
payload?.scope !== CONTROL_UI_PLUGIN_AUTH_COOKIE_SCOPE ||
|
||||
payload.exp <= now ||
|
||||
payload.generation !== params.generation ||
|
||||
typeof payload.pluginId !== "string" ||
|
||||
payload.pluginId.length === 0 ||
|
||||
!Array.isArray(payload.scopes) ||
|
||||
typeof payload.path !== "string" ||
|
||||
normalizeCookiePath(payload.path) !== payload.path ||
|
||||
(payload.match !== "exact" && payload.match !== "prefix")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const grantPathContext = resolvePluginRoutePathContext(payload.path);
|
||||
if (
|
||||
grantPathContext.malformedEncoding ||
|
||||
grantPathContext.decodePassLimitReached ||
|
||||
!grantPathMatchesRequest(
|
||||
grantPathContext.canonicalPath,
|
||||
payload.match,
|
||||
requestPathContext.canonicalPath,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const grant = {
|
||||
pluginId: payload.pluginId,
|
||||
path: payload.path,
|
||||
match: payload.match,
|
||||
scopes: payload.scopes.filter(isOperatorScope),
|
||||
};
|
||||
grants.push(grant);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return grants.toSorted((left, right) => right.path.length - left.path.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms that the browser actually sent a grant from inside the opaque
|
||||
* sandbox. Secure contexts can still block third-party cookies, so bootstrap
|
||||
* acknowledgement alone is not enough to mount the plugin frame.
|
||||
*/
|
||||
export function respondControlUiPluginAuthCookieProbe(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): boolean {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const nonce = url.searchParams.get(CONTROL_UI_PLUGIN_AUTH_PROBE_QUERY);
|
||||
if (nonce === null) {
|
||||
return false;
|
||||
}
|
||||
const targetOrigin = url.searchParams.get(CONTROL_UI_PLUGIN_AUTH_PROBE_ORIGIN_QUERY);
|
||||
let validTargetOrigin = false;
|
||||
if (targetOrigin) {
|
||||
try {
|
||||
const parsedOrigin = new URL(targetOrigin);
|
||||
validTargetOrigin =
|
||||
parsedOrigin.origin === targetOrigin &&
|
||||
(parsedOrigin.protocol === "https:" || parsedOrigin.protocol === "http:");
|
||||
} catch {
|
||||
validTargetOrigin = false;
|
||||
}
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]{16,128}$/.test(nonce) || !validTargetOrigin) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Invalid plugin frame auth probe");
|
||||
return true;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
|
||||
);
|
||||
res.setHeader("Referrer-Policy", "no-referrer");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
const message = JSON.stringify({ type: CONTROL_UI_PLUGIN_AUTH_PROBE_MESSAGE, nonce });
|
||||
res.end(
|
||||
`<!doctype html><script>parent.postMessage(${message}, ${JSON.stringify(targetOrigin)})</script>`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PluginControlUiDescriptor } from "../plugins/host-hooks.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { listControlUiPluginTabs } from "./control-ui-plugin-tabs.js";
|
||||
import {
|
||||
listControlUiPluginTabAuthGrants,
|
||||
listControlUiPluginTabs,
|
||||
} from "./control-ui-plugin-tabs.js";
|
||||
|
||||
function tabDescriptor(
|
||||
overrides: Partial<PluginControlUiDescriptor> = {},
|
||||
@@ -18,12 +21,25 @@ function tabDescriptor(
|
||||
|
||||
function activateDescriptors(
|
||||
entries: Array<{ pluginId: string; descriptor: PluginControlUiDescriptor }>,
|
||||
routes: Array<{
|
||||
pluginId: string;
|
||||
path: string;
|
||||
auth?: "gateway" | "plugin";
|
||||
match?: "exact" | "prefix";
|
||||
}> = [],
|
||||
): void {
|
||||
const registry = createTestRegistry([]);
|
||||
registry.controlUiDescriptors = entries.map((entry) => ({
|
||||
...entry,
|
||||
source: `test:${entry.pluginId}`,
|
||||
}));
|
||||
registry.httpRoutes = routes.map((route) => ({
|
||||
...route,
|
||||
auth: route.auth ?? "gateway",
|
||||
match: route.match ?? "prefix",
|
||||
source: `test:${route.pluginId}`,
|
||||
handler: async () => true,
|
||||
}));
|
||||
setActivePluginRegistry(registry);
|
||||
}
|
||||
|
||||
@@ -77,4 +93,245 @@ describe("listControlUiPluginTabs", () => {
|
||||
|
||||
expect(listControlUiPluginTabs([]).map((tab) => tab.id)).toEqual(["beta", "zed", "alpha"]);
|
||||
});
|
||||
|
||||
it("grants only same-plugin gateway routes with least-privilege scopes", () => {
|
||||
activateDescriptors(
|
||||
[
|
||||
{
|
||||
pluginId: "logbook",
|
||||
descriptor: tabDescriptor({ path: "/plugins/logbook/panel" }),
|
||||
},
|
||||
{
|
||||
pluginId: "adminy",
|
||||
descriptor: tabDescriptor({
|
||||
id: "adminy",
|
||||
path: "/plugins/adminy/panel",
|
||||
requiredScopes: ["operator.admin"],
|
||||
}),
|
||||
},
|
||||
{
|
||||
pluginId: "publicish",
|
||||
descriptor: tabDescriptor({ id: "publicish", path: "/plugins/publicish/panel" }),
|
||||
},
|
||||
],
|
||||
[
|
||||
{ pluginId: "logbook", path: "/plugins/logbook", match: "prefix" },
|
||||
{ pluginId: "adminy", path: "/plugins/adminy", match: "prefix" },
|
||||
{
|
||||
pluginId: "publicish",
|
||||
path: "/plugins/publicish",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(listControlUiPluginTabAuthGrants(["operator.admin"])).toEqual([
|
||||
{
|
||||
pluginId: "adminy",
|
||||
path: "/plugins/adminy",
|
||||
match: "prefix",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
{
|
||||
pluginId: "logbook",
|
||||
path: "/plugins/logbook",
|
||||
match: "prefix",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
]);
|
||||
const adminTabs = listControlUiPluginTabs(["operator.admin"]);
|
||||
expect(adminTabs).toEqual([
|
||||
expect.objectContaining({
|
||||
pluginId: "adminy",
|
||||
requiresGatewayAuth: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pluginId: "logbook",
|
||||
requiresGatewayAuth: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pluginId: "publicish",
|
||||
}),
|
||||
]);
|
||||
expect(adminTabs[2]).not.toHaveProperty("requiresGatewayAuth");
|
||||
expect(listControlUiPluginTabAuthGrants(["operator.read"])).toEqual([
|
||||
{
|
||||
pluginId: "logbook",
|
||||
path: "/plugins/logbook",
|
||||
match: "prefix",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches gateway routes against descriptor URL pathnames", () => {
|
||||
const path = "/plugins/logbook/panel?view=activity#settings";
|
||||
activateDescriptors(
|
||||
[{ pluginId: "logbook", descriptor: tabDescriptor({ path }) }],
|
||||
[{ pluginId: "logbook", path: "/plugins/logbook/panel", match: "exact" }],
|
||||
);
|
||||
|
||||
expect(listControlUiPluginTabAuthGrants(["operator.read"])).toEqual([
|
||||
{
|
||||
pluginId: "logbook",
|
||||
path: "/plugins/logbook/panel",
|
||||
match: "exact",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
]);
|
||||
expect(listControlUiPluginTabs(["operator.read"])).toEqual([
|
||||
expect.objectContaining({ path, requiresGatewayAuth: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not grant a matching route owned by another plugin", () => {
|
||||
activateDescriptors(
|
||||
[{ pluginId: "logbook", descriptor: tabDescriptor({ path: "/shared/panel" }) }],
|
||||
[{ pluginId: "other", path: "/shared", match: "prefix" }],
|
||||
);
|
||||
|
||||
expect(listControlUiPluginTabAuthGrants(["operator.admin"])).toEqual([]);
|
||||
expect(listControlUiPluginTabs(["operator.admin"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the first dispatched gateway route as descriptor owner", () => {
|
||||
activateDescriptors(
|
||||
[{ pluginId: "outer", descriptor: tabDescriptor({ path: "/shared/panel" }) }],
|
||||
[
|
||||
{ pluginId: "nested", path: "/shared/panel", match: "exact" },
|
||||
{ pluginId: "outer", path: "/shared", match: "prefix" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(listControlUiPluginTabAuthGrants(["operator.admin"])).toEqual([]);
|
||||
expect(listControlUiPluginTabs(["operator.admin"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not require a cookie grant when gateway auth is disabled", () => {
|
||||
activateDescriptors(
|
||||
[{ pluginId: "logbook", descriptor: tabDescriptor({ path: "/plugins/logbook/panel" }) }],
|
||||
[{ pluginId: "logbook", path: "/plugins/logbook", match: "prefix" }],
|
||||
);
|
||||
|
||||
const [tab] = listControlUiPluginTabs(["operator.admin"], {
|
||||
requireGatewayAuthGrant: false,
|
||||
});
|
||||
expect(tab).toMatchObject({ pluginId: "logbook" });
|
||||
expect(tab).not.toHaveProperty("requiresGatewayAuth");
|
||||
});
|
||||
|
||||
it("coalesces same-plugin tabs that share one read-only cookie path", () => {
|
||||
activateDescriptors(
|
||||
[
|
||||
{
|
||||
pluginId: "logbook",
|
||||
descriptor: tabDescriptor({ path: "/plugins/logbook/read" }),
|
||||
},
|
||||
{
|
||||
pluginId: "logbook",
|
||||
descriptor: tabDescriptor({
|
||||
id: "admin",
|
||||
path: "/plugins/logbook/admin",
|
||||
requiredScopes: ["operator.admin"],
|
||||
}),
|
||||
},
|
||||
],
|
||||
[{ pluginId: "logbook", path: "/plugins/logbook", match: "prefix" }],
|
||||
);
|
||||
|
||||
expect(listControlUiPluginTabAuthGrants(["operator.admin"])).toEqual([
|
||||
{
|
||||
pluginId: "logbook",
|
||||
path: "/plugins/logbook",
|
||||
match: "prefix",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("widens a shared exact cookie path when another visible tab needs prefix matching", () => {
|
||||
activateDescriptors(
|
||||
[
|
||||
{ pluginId: "logbook", descriptor: tabDescriptor({ path: "/plugins/logbook" }) },
|
||||
{
|
||||
pluginId: "logbook",
|
||||
descriptor: tabDescriptor({ id: "child", path: "/plugins/logbook/child" }),
|
||||
},
|
||||
],
|
||||
[
|
||||
{ pluginId: "logbook", path: "/plugins/logbook", match: "exact" },
|
||||
{ pluginId: "logbook", path: "/plugins/logbook", match: "prefix" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(listControlUiPluginTabAuthGrants(["operator.admin"])).toEqual([
|
||||
{
|
||||
pluginId: "logbook",
|
||||
path: "/plugins/logbook",
|
||||
match: "prefix",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps separate grants for different plugins that share a cookie path", () => {
|
||||
activateDescriptors(
|
||||
[
|
||||
{ pluginId: "alpha", descriptor: tabDescriptor({ id: "alpha", path: "/shared" }) },
|
||||
{
|
||||
pluginId: "beta",
|
||||
descriptor: tabDescriptor({ id: "beta", path: "/shared/child" }),
|
||||
},
|
||||
],
|
||||
[
|
||||
{ pluginId: "alpha", path: "/shared", match: "exact" },
|
||||
{ pluginId: "beta", path: "/shared", match: "prefix" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(listControlUiPluginTabAuthGrants(["operator.admin"])).toEqual([
|
||||
{
|
||||
pluginId: "alpha",
|
||||
path: "/shared",
|
||||
match: "exact",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
{
|
||||
pluginId: "beta",
|
||||
path: "/shared",
|
||||
match: "prefix",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
]);
|
||||
expect(listControlUiPluginTabs(["operator.admin"]).map((tab) => tab.pluginId)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses only the first route owner when plugins declare the same path", () => {
|
||||
activateDescriptors(
|
||||
[
|
||||
{ pluginId: "alpha", descriptor: tabDescriptor({ id: "alpha", path: "/shared" }) },
|
||||
{ pluginId: "beta", descriptor: tabDescriptor({ id: "beta", path: "/shared" }) },
|
||||
],
|
||||
[
|
||||
{ pluginId: "alpha", path: "/shared", match: "exact" },
|
||||
{ pluginId: "beta", path: "/shared", match: "prefix" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(listControlUiPluginTabAuthGrants(["operator.admin"])).toEqual([
|
||||
{
|
||||
pluginId: "alpha",
|
||||
path: "/shared",
|
||||
match: "exact",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
]);
|
||||
expect(listControlUiPluginTabs(["operator.admin"]).map((tab) => tab.pluginId)).toEqual([
|
||||
"alpha",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
// Projects plugin "tab" Control UI descriptors into the hello payload so the
|
||||
// dashboard renders plugin tabs without hardcoding plugin ids in core.
|
||||
import type { PluginControlUiDescriptor } from "../plugins/host-hooks.js";
|
||||
import type { PluginRegistry } from "../plugins/registry.js";
|
||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { authorizeOperatorScopesForRequiredScope } from "./method-scopes.js";
|
||||
import { resolveControlUiPluginTabPathname } from "./control-ui-contract.js";
|
||||
import {
|
||||
authorizeOperatorScopesForRequiredScope,
|
||||
READ_SCOPE,
|
||||
type OperatorScope,
|
||||
} from "./method-scopes.js";
|
||||
import { resolvePluginRoutePathContext } from "./server/plugins-http/path-context.js";
|
||||
import { findMatchingPluginHttpRoutes } from "./server/plugins-http/route-match.js";
|
||||
|
||||
type ControlUiPluginTab = {
|
||||
pluginId: string;
|
||||
@@ -13,13 +21,42 @@ type ControlUiPluginTab = {
|
||||
path?: string;
|
||||
group?: "control" | "agent";
|
||||
order?: number;
|
||||
requiresGatewayAuth?: boolean;
|
||||
};
|
||||
|
||||
function findControlUiTabGatewayRoute(
|
||||
registry: PluginRegistry,
|
||||
tab: ControlUiPluginTab,
|
||||
): ReturnType<typeof findMatchingPluginHttpRoutes>[number] | null | undefined {
|
||||
if (!tab.path) {
|
||||
return undefined;
|
||||
}
|
||||
const routePath = resolveControlUiPluginTabPathname(tab.path);
|
||||
if (!routePath) {
|
||||
return undefined;
|
||||
}
|
||||
const route = findMatchingPluginHttpRoutes(
|
||||
registry,
|
||||
resolvePluginRoutePathContext(routePath),
|
||||
).find((candidate) => candidate.auth === "gateway");
|
||||
if (!route) {
|
||||
return undefined;
|
||||
}
|
||||
return route.pluginId === tab.pluginId ? route : null;
|
||||
}
|
||||
|
||||
type ControlUiDescriptorEntry = {
|
||||
pluginId: string;
|
||||
descriptor: PluginControlUiDescriptor;
|
||||
};
|
||||
|
||||
export type ControlUiPluginTabAuthGrant = {
|
||||
pluginId: string;
|
||||
path: string;
|
||||
match: "exact" | "prefix";
|
||||
scopes: OperatorScope[];
|
||||
};
|
||||
|
||||
/** Pure projection of tab descriptors visible to the presented scopes. */
|
||||
function projectControlUiPluginTabs(
|
||||
entries: readonly ControlUiDescriptorEntry[],
|
||||
@@ -58,7 +95,55 @@ function projectControlUiPluginTabs(
|
||||
}
|
||||
|
||||
/** Lists active plugins' tab descriptors visible to the presented scopes. */
|
||||
export function listControlUiPluginTabs(scopes: readonly string[]): ControlUiPluginTab[] {
|
||||
export function listControlUiPluginTabs(
|
||||
scopes: readonly string[],
|
||||
opts: { requireGatewayAuthGrant?: boolean } = {},
|
||||
): ControlUiPluginTab[] {
|
||||
const registry = getActivePluginRegistry();
|
||||
return projectControlUiPluginTabs(registry?.controlUiDescriptors ?? [], scopes);
|
||||
return projectControlUiPluginTabs(registry?.controlUiDescriptors ?? [], scopes).flatMap((tab) => {
|
||||
const route = registry ? findControlUiTabGatewayRoute(registry, tab) : undefined;
|
||||
if (route === null) {
|
||||
// Dispatch authenticates against its first matching gateway route. Hide
|
||||
// a descriptor whose owning plugin cannot receive that request.
|
||||
return [];
|
||||
}
|
||||
return route && opts.requireGatewayAuthGrant !== false
|
||||
? [{ ...tab, requiresGatewayAuth: true }]
|
||||
: [tab];
|
||||
});
|
||||
}
|
||||
|
||||
/** Builds least-privilege grants only for visible tabs backed by same-plugin gateway routes. */
|
||||
export function listControlUiPluginTabAuthGrants(
|
||||
callerScopes: readonly string[],
|
||||
): ControlUiPluginTabAuthGrant[] {
|
||||
const registry = getActivePluginRegistry();
|
||||
if (!registry || !authorizeOperatorScopesForRequiredScope(READ_SCOPE, callerScopes).allowed) {
|
||||
return [];
|
||||
}
|
||||
const grants = new Map<string, ControlUiPluginTabAuthGrant>();
|
||||
for (const tab of projectControlUiPluginTabs(registry.controlUiDescriptors ?? [], callerScopes)) {
|
||||
if (!tab.path) {
|
||||
continue;
|
||||
}
|
||||
const route = findControlUiTabGatewayRoute(registry, tab);
|
||||
if (!route) {
|
||||
continue;
|
||||
}
|
||||
const key = `${tab.pluginId}\n${route.path}`;
|
||||
const existing = grants.get(key);
|
||||
if (existing) {
|
||||
if (existing.match === "exact" && route.match === "prefix") {
|
||||
grants.set(key, { ...existing, match: "prefix" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
grants.set(key, {
|
||||
pluginId: tab.pluginId,
|
||||
path: route.path,
|
||||
match: route.match,
|
||||
scopes: [READ_SCOPE],
|
||||
});
|
||||
}
|
||||
return [...grants.values()];
|
||||
}
|
||||
|
||||
@@ -18,17 +18,23 @@ import {
|
||||
requestDevicePairing,
|
||||
} from "../infra/device-pairing.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { AVATAR_MAX_DATA_URL_CHARS } from "../shared/avatar-limits.js";
|
||||
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "./control-ui-contract.js";
|
||||
import {
|
||||
CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
|
||||
type ControlUiPluginFrameGrantAck,
|
||||
} from "./control-ui-contract.js";
|
||||
import { resolveOpenedControlUiRepresentation } from "./control-ui-static.js";
|
||||
import {
|
||||
handleControlUiAssistantMediaRequest,
|
||||
handleControlUiAvatarRequest,
|
||||
handleControlUiHttpRequest,
|
||||
} from "./control-ui.js";
|
||||
import { setControlUiPluginAuthCookieForRequest } from "./http-auth-utils.js";
|
||||
import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js";
|
||||
import { makeMockHttpResponse } from "./test-http-response.js";
|
||||
|
||||
@@ -44,7 +50,10 @@ const REAL_PNG = Buffer.from(
|
||||
"base64",
|
||||
);
|
||||
const REAL_PNG_DATA_URL = `data:image/png;base64,${REAL_PNG.toString("base64")}`;
|
||||
const avatarTempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
const testTempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
afterEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
});
|
||||
|
||||
describe("handleControlUiHttpRequest", () => {
|
||||
function createAvatarConfig(workspace: string, avatar: string): OpenClawConfig {
|
||||
@@ -93,6 +102,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
seamColor?: string;
|
||||
timeFormat?: "auto" | "12" | "24";
|
||||
terminalEnabled: boolean;
|
||||
pluginFrameGrants?: ControlUiPluginFrameGrantAck[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,7 +155,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
headers?: IncomingMessage["headers"];
|
||||
config?: OpenClawConfig;
|
||||
}) {
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const { res, end, setHeader } = makeMockHttpResponse();
|
||||
const url = params.basePath
|
||||
? `${params.basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`
|
||||
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH;
|
||||
@@ -164,7 +174,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
root: { kind: "resolved", path: params.rootPath },
|
||||
},
|
||||
);
|
||||
return { res, end, handled };
|
||||
return { res, end, setHeader, handled };
|
||||
}
|
||||
|
||||
async function runAvatarRequest(params: {
|
||||
@@ -383,6 +393,30 @@ describe("handleControlUiHttpRequest", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function withScopedPairedOperatorDevice<T>(params: {
|
||||
scopes: string[];
|
||||
fn: (bearer: string) => Promise<T>;
|
||||
}) {
|
||||
const tempHome = testTempDirs.make("openclaw-ui-scoped-device-");
|
||||
return await withEnvAsync({ OPENCLAW_HOME: tempHome }, async () => {
|
||||
const deviceId = `control-ui-device-${randomUUID()}`;
|
||||
const requested = await requestDevicePairing({
|
||||
deviceId,
|
||||
publicKey: "test-public-key",
|
||||
role: "operator",
|
||||
scopes: params.scopes,
|
||||
});
|
||||
const approved = await approveDevicePairing(requested.request.requestId, {
|
||||
callerScopes: params.scopes,
|
||||
});
|
||||
expect(approved).toMatchObject({ status: "approved" });
|
||||
const operatorBearer =
|
||||
approved?.status === "approved" ? approved.device.tokens?.operator?.token : undefined;
|
||||
expect(typeof operatorBearer).toBe("string");
|
||||
return await params.fn(operatorBearer ?? "");
|
||||
});
|
||||
}
|
||||
|
||||
it("sets security headers for Control UI responses", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
@@ -1301,7 +1335,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
await fs.writeFile(path.join(tmp, "avatar.png"), "avatar-bytes\n");
|
||||
const { res, handled, end } = await runBootstrapConfigRequest({
|
||||
const { res, handled, end, setHeader } = await runBootstrapConfigRequest({
|
||||
rootPath: tmp,
|
||||
auth: { mode: "token", token: "test-token", allowTailscale: false },
|
||||
headers: {
|
||||
@@ -1314,6 +1348,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(setHeader.mock.calls.some(([name]) => name === "Set-Cookie")).toBe(false);
|
||||
const parsed = parseBootstrapPayload(end);
|
||||
expect(parsed).toMatchObject({
|
||||
assistantAgentId: "main",
|
||||
@@ -1324,6 +1359,187 @@ describe("handleControlUiHttpRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sets least-privilege route-bound cookies for multiple external plugin tabs", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.controlUiDescriptors.push({
|
||||
pluginId: "demo-plugin",
|
||||
source: "demo-plugin",
|
||||
descriptor: {
|
||||
surface: "tab",
|
||||
id: "demo",
|
||||
label: "Demo",
|
||||
path: "/secure-hook",
|
||||
},
|
||||
});
|
||||
registry.controlUiDescriptors.push({
|
||||
pluginId: "other-plugin",
|
||||
source: "other-plugin",
|
||||
descriptor: {
|
||||
surface: "tab",
|
||||
id: "other",
|
||||
label: "Other",
|
||||
path: "/other-hook/panel",
|
||||
requiredScopes: ["operator.read"],
|
||||
},
|
||||
});
|
||||
registry.httpRoutes.push({
|
||||
pluginId: "demo-plugin",
|
||||
source: "demo-plugin",
|
||||
path: "/secure-hook",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: async () => true,
|
||||
});
|
||||
registry.httpRoutes.push({
|
||||
pluginId: "other-plugin",
|
||||
source: "other-plugin",
|
||||
path: "/other-hook",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: async () => true,
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const { res, handled, setHeader } = await runBootstrapConfigRequest({
|
||||
rootPath: tmp,
|
||||
auth: { mode: "token", token: "test-token", allowTailscale: false },
|
||||
headers: {
|
||||
authorization: "Bearer test-token",
|
||||
},
|
||||
config: {
|
||||
agents: { defaults: { workspace: tmp } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const setCookie = setHeader.mock.calls.find(([name]) => name === "Set-Cookie")?.[1];
|
||||
expect(Array.isArray(setCookie)).toBe(true);
|
||||
const cookies = Array.isArray(setCookie) ? setCookie : [];
|
||||
expect(cookies).toHaveLength(2);
|
||||
const cookieNames = cookies.map((cookie) => String(cookie).split("=", 1)[0] ?? "");
|
||||
expect(new Set(cookieNames).size).toBe(2);
|
||||
expect(
|
||||
cookieNames.every((name) =>
|
||||
/^__openclaw_plugin_tab_auth_[0-9a-f]{16}_[0-9a-f]{64}$/.test(name),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(cookies.map(String)).toEqual([
|
||||
expect.stringContaining("Path=/secure-hook"),
|
||||
expect.stringContaining("Path=/other-hook"),
|
||||
]);
|
||||
expect(cookies.every((cookie) => String(cookie).includes("HttpOnly"))).toBe(true);
|
||||
expect(cookies.every((cookie) => String(cookie).includes("Secure"))).toBe(true);
|
||||
expect(cookies.every((cookie) => String(cookie).includes("SameSite=None"))).toBe(true);
|
||||
const payloads = cookies.map((cookie) => {
|
||||
const encoded = String(cookie).match(new RegExp("=v1\\.([^.]+)\\."))?.[1];
|
||||
return JSON.parse(Buffer.from(encoded ?? "", "base64url").toString("utf8"));
|
||||
});
|
||||
expect(payloads).toMatchObject([
|
||||
{
|
||||
pluginId: "demo-plugin",
|
||||
path: "/secure-hook",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
{
|
||||
pluginId: "other-plugin",
|
||||
path: "/other-hook",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
]);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("acknowledges only plugin frame grants issued by bootstrap", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.controlUiDescriptors.push({
|
||||
pluginId: "demo-plugin",
|
||||
source: "demo-plugin",
|
||||
descriptor: {
|
||||
surface: "tab",
|
||||
id: "demo",
|
||||
label: "Demo",
|
||||
path: "/secure-hook/panel",
|
||||
},
|
||||
});
|
||||
registry.httpRoutes.push({
|
||||
pluginId: "demo-plugin",
|
||||
source: "demo-plugin",
|
||||
path: "/secure-hook",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: async () => true,
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const { end } = await runBootstrapConfigRequest({
|
||||
rootPath: tmp,
|
||||
auth: { mode: "token", token: "test-auth-token", allowTailscale: false },
|
||||
headers: { authorization: "Bearer test-auth-token" },
|
||||
});
|
||||
|
||||
expect(parseBootstrapPayload(end).pluginFrameGrants).toEqual([
|
||||
{
|
||||
pluginId: "demo-plugin",
|
||||
path: "/secure-hook",
|
||||
match: "prefix",
|
||||
},
|
||||
]);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("issues read-only plugin frame grants for Tailscale-authenticated bootstrap", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.controlUiDescriptors.push({
|
||||
pluginId: "demo-plugin",
|
||||
source: "demo-plugin",
|
||||
descriptor: {
|
||||
surface: "tab",
|
||||
id: "demo",
|
||||
label: "Demo",
|
||||
path: "/secure-hook/panel",
|
||||
requiredScopes: ["operator.admin"],
|
||||
},
|
||||
});
|
||||
registry.httpRoutes.push({
|
||||
pluginId: "demo-plugin",
|
||||
source: "demo-plugin",
|
||||
path: "/secure-hook",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: async () => true,
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const { res, setHeader } = makeMockHttpResponse();
|
||||
|
||||
expect(
|
||||
setControlUiPluginAuthCookieForRequest(
|
||||
{ headers: {} } as IncomingMessage,
|
||||
res,
|
||||
"tailscale",
|
||||
true,
|
||||
"test-generation",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
pluginId: "demo-plugin",
|
||||
path: "/secure-hook",
|
||||
match: "prefix",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
]);
|
||||
expect(setHeader).toHaveBeenCalledWith(
|
||||
"Set-Cookie",
|
||||
expect.arrayContaining([expect.stringContaining("Path=/secure-hook")]),
|
||||
);
|
||||
});
|
||||
|
||||
it("serves bootstrap config JSON when paired device-token auth is valid", async () => {
|
||||
await withPairedOperatorDeviceToken({
|
||||
fn: async (operatorToken) => {
|
||||
@@ -1346,6 +1562,52 @@ describe("handleControlUiHttpRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("selects higher-scope frame tabs using paired device-token scopes", async () => {
|
||||
await withScopedPairedOperatorDevice({
|
||||
scopes: ["operator.read", "operator.admin"],
|
||||
fn: async (operatorToken) => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.controlUiDescriptors.push({
|
||||
pluginId: "admin-plugin",
|
||||
source: "admin-plugin",
|
||||
descriptor: {
|
||||
surface: "tab",
|
||||
id: "admin",
|
||||
label: "Admin",
|
||||
path: "/admin-hook/panel",
|
||||
requiredScopes: ["operator.admin"],
|
||||
},
|
||||
});
|
||||
registry.httpRoutes.push({
|
||||
pluginId: "admin-plugin",
|
||||
source: "admin-plugin",
|
||||
path: "/admin-hook",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: async () => true,
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const { end } = await runBootstrapConfigRequest({
|
||||
rootPath: tmp,
|
||||
auth: { mode: "token", token: "test-auth-token", allowTailscale: false },
|
||||
headers: { authorization: `Bearer ${operatorToken}` },
|
||||
});
|
||||
expect(parseBootstrapPayload(end).pluginFrameGrants).toEqual([
|
||||
{
|
||||
pluginId: "admin-plugin",
|
||||
path: "/admin-hook",
|
||||
match: "prefix",
|
||||
},
|
||||
]);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("serves bootstrap config JSON under basePath", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
@@ -1539,7 +1801,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
});
|
||||
|
||||
it("serves local avatar bytes through hardened avatar handler", async () => {
|
||||
const tmp = avatarTempDirs.make("openclaw-avatar-http-");
|
||||
const tmp = testTempDirs.make("openclaw-avatar-http-");
|
||||
try {
|
||||
const avatarPath = path.join(tmp, "main.png");
|
||||
await fs.writeFile(avatarPath, "avatar-bytes\n");
|
||||
@@ -1564,7 +1826,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
] as const)(
|
||||
"validates %s avatar requests without reading bytes and closes the descriptor",
|
||||
async (_name, url, method) => {
|
||||
const tmp = avatarTempDirs.make("openclaw-avatar-no-read-");
|
||||
const tmp = testTempDirs.make("openclaw-avatar-no-read-");
|
||||
const read = vi.spyOn(fsSync, "read");
|
||||
const closeSync = vi.spyOn(fsSync, "closeSync");
|
||||
try {
|
||||
@@ -1588,7 +1850,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
);
|
||||
|
||||
it("rejects hardlinked avatar bytes and reports matching metadata", async () => {
|
||||
const tmp = avatarTempDirs.make("openclaw-avatar-http-hardlink-");
|
||||
const tmp = testTempDirs.make("openclaw-avatar-http-hardlink-");
|
||||
try {
|
||||
await fs.writeFile(path.join(tmp, "original.png"), REAL_PNG);
|
||||
await fs.link(path.join(tmp, "original.png"), path.join(tmp, "avatar.png"));
|
||||
@@ -1616,7 +1878,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
});
|
||||
|
||||
it("bounds an avatar route file that grows after its descriptor is pinned", async () => {
|
||||
const tmp = avatarTempDirs.make("openclaw-avatar-http-growth-");
|
||||
const tmp = testTempDirs.make("openclaw-avatar-http-growth-");
|
||||
const avatarPath = path.join(tmp, "avatar.png");
|
||||
try {
|
||||
await fs.writeFile(avatarPath, REAL_PNG);
|
||||
|
||||
+55
-12
@@ -1,5 +1,3 @@
|
||||
// Gateway Control UI HTTP handler.
|
||||
// Serves bundled UI assets, bootstrap config, avatars, assistant media, and auth checks.
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
@@ -50,6 +48,7 @@ import {
|
||||
CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
|
||||
CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE,
|
||||
type ControlUiBootstrapConfig,
|
||||
type ControlUiPluginFrameGrantAck,
|
||||
} from "./control-ui-contract.js";
|
||||
import { buildControlUiCspHeader, computeInlineScriptHashes } from "./control-ui-csp.js";
|
||||
import {
|
||||
@@ -80,6 +79,7 @@ import {
|
||||
getBearerToken,
|
||||
resolveHttpBrowserOriginPolicy,
|
||||
resolveTrustedHttpOperatorScopes,
|
||||
setControlUiPluginAuthCookieForRequest as setPluginAuthCookie,
|
||||
} from "./http-utils.js";
|
||||
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||
import { resolveRequestClientIp } from "./net.js";
|
||||
@@ -283,9 +283,11 @@ async function authorizeControlUiReadRequest(
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
allowQueryToken?: boolean;
|
||||
requiredOperatorMethod?: string;
|
||||
onPluginFrameGrants?: (grants: readonly ControlUiPluginFrameGrantAck[]) => void;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
if (!opts?.auth) {
|
||||
opts?.onPluginFrameGrants?.([]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -306,7 +308,12 @@ async function authorizeControlUiReadRequest(
|
||||
clientIp,
|
||||
rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
||||
});
|
||||
const sharedAuthGeneration = resolveSharedGatewaySessionGeneration(
|
||||
opts.auth,
|
||||
opts.trustedProxies,
|
||||
);
|
||||
let resolvedAuthResult = authResult;
|
||||
let verifiedDeviceScopes: string[] | undefined;
|
||||
if (
|
||||
!resolvedAuthResult.ok &&
|
||||
token &&
|
||||
@@ -322,13 +329,12 @@ async function authorizeControlUiReadRequest(
|
||||
retryAfterMs: deviceRateCheck.retryAfterMs,
|
||||
};
|
||||
} else {
|
||||
const deviceTokenOk = await authorizeControlUiDeviceReadToken(token, {
|
||||
requiredSharedGatewaySessionGeneration: resolveSharedGatewaySessionGeneration(
|
||||
opts.auth,
|
||||
opts.trustedProxies,
|
||||
),
|
||||
});
|
||||
if (deviceTokenOk) {
|
||||
const deviceTokenOk = await authorizeControlUiDeviceReadToken(token, sharedAuthGeneration);
|
||||
const deviceScopes = deviceTokenOk
|
||||
? await resolveControlUiDeviceReadTokenScopes(token)
|
||||
: null;
|
||||
if (deviceScopes) {
|
||||
verifiedDeviceScopes = deviceScopes;
|
||||
opts.rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
|
||||
opts.rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET);
|
||||
resolvedAuthResult = { ok: true, method: "device-token" };
|
||||
@@ -342,7 +348,20 @@ async function authorizeControlUiReadRequest(
|
||||
return false;
|
||||
}
|
||||
|
||||
const trustDeclaredOperatorScopes = resolvedAuthResult.method === "trusted-proxy";
|
||||
const authMethod = resolvedAuthResult.method;
|
||||
const trustDeclaredOperatorScopes = authMethod === "trusted-proxy" || authMethod === "tailscale";
|
||||
if (opts.onPluginFrameGrants) {
|
||||
opts.onPluginFrameGrants(
|
||||
setPluginAuthCookie(
|
||||
req,
|
||||
res,
|
||||
authMethod,
|
||||
trustDeclaredOperatorScopes,
|
||||
sharedAuthGeneration,
|
||||
verifiedDeviceScopes,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!trustDeclaredOperatorScopes) {
|
||||
return true;
|
||||
}
|
||||
@@ -364,7 +383,7 @@ async function authorizeControlUiReadRequest(
|
||||
|
||||
async function authorizeControlUiDeviceReadToken(
|
||||
token: string,
|
||||
opts: { requiredSharedGatewaySessionGeneration?: string },
|
||||
requiredSharedGatewaySessionGeneration: string | undefined,
|
||||
): Promise<boolean> {
|
||||
const pairing = await listDevicePairing();
|
||||
for (const device of pairing.paired) {
|
||||
@@ -380,7 +399,7 @@ async function authorizeControlUiDeviceReadToken(
|
||||
token,
|
||||
role: CONTROL_UI_OPERATOR_ROLE,
|
||||
scopes: [CONTROL_UI_OPERATOR_READ_SCOPE],
|
||||
requiredSharedGatewaySessionGeneration: opts.requiredSharedGatewaySessionGeneration,
|
||||
requiredSharedGatewaySessionGeneration,
|
||||
});
|
||||
if (verified.ok) {
|
||||
return true;
|
||||
@@ -389,6 +408,21 @@ async function authorizeControlUiDeviceReadToken(
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveControlUiDeviceReadTokenScopes(token: string): Promise<string[] | null> {
|
||||
const pairing = await listDevicePairing();
|
||||
for (const device of pairing.paired) {
|
||||
const operatorBearer = device.tokens?.[CONTROL_UI_OPERATOR_ROLE];
|
||||
if (
|
||||
operatorBearer &&
|
||||
!operatorBearer.revokedAtMs &&
|
||||
verifyPairingToken(token, operatorBearer.token)
|
||||
) {
|
||||
return operatorBearer.scopes;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type AssistantMediaAvailability =
|
||||
| { available: true }
|
||||
| { available: false; reason: string; code: string };
|
||||
@@ -916,12 +950,16 @@ export async function handleControlUiHttpRequest(
|
||||
applyControlUiSecurityHeaders(res);
|
||||
|
||||
if (matchesControlUiBootstrapConfigPath(pathname, basePath)) {
|
||||
let pluginFrameGrants: readonly ControlUiPluginFrameGrantAck[] = [];
|
||||
if (
|
||||
!(await authorizeControlUiReadRequest(req, res, {
|
||||
auth: opts?.auth,
|
||||
trustedProxies: opts?.trustedProxies,
|
||||
allowRealIpFallback: opts?.allowRealIpFallback,
|
||||
rateLimiter: opts?.rateLimiter,
|
||||
onPluginFrameGrants: (grants) => {
|
||||
pluginFrameGrants = grants;
|
||||
},
|
||||
}))
|
||||
) {
|
||||
return true;
|
||||
@@ -963,6 +1001,11 @@ export async function handleControlUiHttpRequest(
|
||||
seamColor: config?.ui?.seamColor,
|
||||
timeFormat: config?.agents?.defaults?.timeFormat,
|
||||
terminalEnabled,
|
||||
pluginFrameGrants: pluginFrameGrants.map(({ pluginId, path: grantPath, match }) => ({
|
||||
pluginId,
|
||||
path: grantPath,
|
||||
match,
|
||||
})),
|
||||
} satisfies ControlUiBootstrapConfig);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,18 @@ import {
|
||||
type GatewayAuthResult,
|
||||
type ResolvedGatewayAuth,
|
||||
} from "./auth.js";
|
||||
import {
|
||||
resolveControlUiPluginAuthCookieGrants,
|
||||
setControlUiPluginAuthCookie,
|
||||
} from "./control-ui-plugin-auth-cookie.js";
|
||||
import {
|
||||
listControlUiPluginTabAuthGrants,
|
||||
type ControlUiPluginTabAuthGrant,
|
||||
} from "./control-ui-plugin-tabs.js";
|
||||
import { sendGatewayAuthFailure, sendMissingScopeForbidden } from "./http-common.js";
|
||||
import { ADMIN_SCOPE, CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js";
|
||||
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||
import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js";
|
||||
|
||||
export function getHeader(req: IncomingMessage, name: string): string | undefined {
|
||||
const raw = req.headers[normalizeLowercaseStringOrEmpty(name)];
|
||||
@@ -42,6 +51,8 @@ type SharedSecretGatewayAuth = Pick<ResolvedGatewayAuth, "mode">;
|
||||
export type AuthorizedGatewayHttpRequest = {
|
||||
authMethod?: GatewayAuthResult["method"];
|
||||
trustDeclaredOperatorScopes: boolean;
|
||||
controlUiPluginGrants?: ControlUiPluginTabAuthGrant[];
|
||||
controlUiPluginGrant?: ControlUiPluginTabAuthGrant;
|
||||
};
|
||||
|
||||
export type GatewayHttpRequestAuthCheckResult =
|
||||
@@ -104,6 +115,93 @@ export async function authorizeGatewayHttpRequestOrReply(params: {
|
||||
return result.requestAuth;
|
||||
}
|
||||
|
||||
export function setControlUiPluginAuthCookieForRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
authMethod: GatewayAuthResult["method"],
|
||||
trustDeclaredOperatorScopes: boolean,
|
||||
authGeneration: string | undefined,
|
||||
authenticatedScopes?: readonly string[],
|
||||
): ControlUiPluginTabAuthGrant[] {
|
||||
const scopes = usesSharedSecretGatewayMethod(authMethod)
|
||||
? [...CLI_DEFAULT_OPERATOR_SCOPES]
|
||||
: authMethod === "trusted-proxy" || authMethod === "tailscale"
|
||||
? resolveTrustedHttpOperatorScopes(req, {
|
||||
trustDeclaredOperatorScopes,
|
||||
})
|
||||
: authMethod === "device-token"
|
||||
? (authenticatedScopes ?? [])
|
||||
: [];
|
||||
const grants = listControlUiPluginTabAuthGrants(scopes);
|
||||
if (grants.length > 0) {
|
||||
return setControlUiPluginAuthCookie(res, grants, { generation: authGeneration });
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function authorizeControlUiPluginCookieRequest(
|
||||
req: IncomingMessage,
|
||||
params: { requestPath: string; authGeneration: string | undefined },
|
||||
): {
|
||||
requestAuth: AuthorizedGatewayHttpRequest;
|
||||
operatorScopes: string[];
|
||||
} | null {
|
||||
// WebSocket upgrades bypass this HTTP-only handoff and use
|
||||
// checkGatewayHttpRequestAuth directly in attachGatewayUpgradeHandler.
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
return null;
|
||||
}
|
||||
// Native plugins and the UI they serve share the Gateway's trusted in-process
|
||||
// boundary. Cross-site sandbox descendants need an ambient cookie, so this
|
||||
// handoff is read-only; mutations stay on explicit Gateway auth surfaces.
|
||||
const grants = resolveControlUiPluginAuthCookieGrants(req, {
|
||||
requestPath: params.requestPath,
|
||||
generation: params.authGeneration,
|
||||
});
|
||||
if (grants.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
requestAuth: {
|
||||
trustDeclaredOperatorScopes: false,
|
||||
controlUiPluginGrants: grants,
|
||||
},
|
||||
// Route dispatch selects the candidate that owns the first matched gateway
|
||||
// route. Do not union scopes before that owner boundary is known.
|
||||
operatorScopes: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function authorizePluginGatewayHttpRequestOrReply(params: {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
auth: ResolvedGatewayAuth;
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
requestPath: string;
|
||||
resolveOperatorScopes: (
|
||||
req: IncomingMessage,
|
||||
requestAuth: AuthorizedGatewayHttpRequest,
|
||||
) => string[];
|
||||
}): Promise<{
|
||||
requestAuth: AuthorizedGatewayHttpRequest;
|
||||
operatorScopes: string[];
|
||||
} | null> {
|
||||
const authGeneration = resolveSharedGatewaySessionGeneration(params.auth, params.trustedProxies);
|
||||
const cookieAuth = authorizeControlUiPluginCookieRequest(params.req, {
|
||||
requestPath: params.requestPath,
|
||||
authGeneration,
|
||||
});
|
||||
if (cookieAuth) {
|
||||
return cookieAuth;
|
||||
}
|
||||
const requestAuth = await authorizeGatewayHttpRequestOrReply(params);
|
||||
return requestAuth
|
||||
? { requestAuth, operatorScopes: params.resolveOperatorScopes(params.req, requestAuth) }
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function checkGatewayHttpRequestAuth(params: {
|
||||
req: IncomingMessage;
|
||||
auth: ResolvedGatewayAuth;
|
||||
|
||||
@@ -41,6 +41,7 @@ export {
|
||||
resolveOpenAiCompatibleHttpSenderIsOwner,
|
||||
resolveSharedSecretHttpOperatorScopes,
|
||||
resolveTrustedHttpOperatorScopes,
|
||||
setControlUiPluginAuthCookieForRequest,
|
||||
type AuthorizedGatewayHttpRequest,
|
||||
} from "./http-auth-utils.js";
|
||||
|
||||
|
||||
@@ -397,26 +397,25 @@ function buildPluginRequestStages(params: {
|
||||
// Bypass paths come only from activated channel plugins' gateway-auth
|
||||
// artifacts (bundled or installed); all other protected plugin routes must
|
||||
// produce an AuthorizedGatewayHttpRequest before runtime scopes are derived.
|
||||
const { authorizeGatewayHttpRequestOrReply } = await getHttpAuthUtilsModule();
|
||||
const requestAuth = await authorizeGatewayHttpRequestOrReply({
|
||||
const { authorizePluginGatewayHttpRequestOrReply } = await getHttpAuthUtilsModule();
|
||||
const { resolvePluginRouteRuntimeOperatorScopes } =
|
||||
await getPluginRouteRuntimeScopesModule();
|
||||
const authResult = await authorizePluginGatewayHttpRequestOrReply({
|
||||
req: params.req,
|
||||
res: params.res,
|
||||
auth: params.resolvedAuth,
|
||||
trustedProxies: params.trustedProxies,
|
||||
allowRealIpFallback: params.allowRealIpFallback,
|
||||
rateLimiter: params.rateLimiter,
|
||||
requestPath: params.requestPath,
|
||||
resolveOperatorScopes: resolvePluginRouteRuntimeOperatorScopes,
|
||||
});
|
||||
if (!requestAuth) {
|
||||
if (!authResult) {
|
||||
return true;
|
||||
}
|
||||
pluginGatewayAuthSatisfied = true;
|
||||
pluginGatewayRequestAuth = requestAuth;
|
||||
const { resolvePluginRouteRuntimeOperatorScopes } =
|
||||
await getPluginRouteRuntimeScopesModule();
|
||||
pluginRequestOperatorScopes = resolvePluginRouteRuntimeOperatorScopes(
|
||||
params.req,
|
||||
requestAuth,
|
||||
);
|
||||
pluginGatewayRequestAuth = authResult.requestAuth;
|
||||
pluginRequestOperatorScopes = authResult.operatorScopes;
|
||||
return false;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
|
||||
import {
|
||||
CONTROL_UI_PLUGIN_AUTH_PROBE_MESSAGE,
|
||||
CONTROL_UI_PLUGIN_AUTH_PROBE_ORIGIN_QUERY,
|
||||
CONTROL_UI_PLUGIN_AUTH_PROBE_QUERY,
|
||||
} from "./control-ui-contract.js";
|
||||
import { setControlUiPluginAuthCookie } from "./control-ui-plugin-auth-cookie.js";
|
||||
import { checkGatewayHttpRequestAuth } from "./http-auth-utils.js";
|
||||
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||
import type { OperatorScope } from "./operator-scopes.js";
|
||||
import {
|
||||
AUTH_TOKEN,
|
||||
createRequest,
|
||||
createResponse,
|
||||
dispatchRequest,
|
||||
withGatewayServer,
|
||||
} from "./server-http.test-harness.js";
|
||||
import { createTestRegistry } from "./server/__tests__/test-utils.js";
|
||||
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
|
||||
import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js";
|
||||
|
||||
function createControlUiPluginAuthCookieForTest(
|
||||
scopes: string[],
|
||||
params: {
|
||||
pluginId?: string;
|
||||
path?: string;
|
||||
match?: "exact" | "prefix";
|
||||
generation?: string;
|
||||
} = {},
|
||||
): string {
|
||||
const response = createResponse();
|
||||
setControlUiPluginAuthCookie(
|
||||
response.res,
|
||||
[
|
||||
{
|
||||
pluginId: params.pluginId ?? "runtime-scope-control-ui-cookie",
|
||||
path: params.path ?? "/secure-hook",
|
||||
match: params.match ?? "exact",
|
||||
scopes: scopes as OperatorScope[],
|
||||
},
|
||||
],
|
||||
{ generation: params.generation ?? resolveSharedGatewaySessionGeneration(AUTH_TOKEN) },
|
||||
);
|
||||
const setCookie = response.setHeader.mock.calls.find(([name]) => name === "Set-Cookie")?.[1];
|
||||
const cookie = Array.isArray(setCookie) ? setCookie[0] : setCookie;
|
||||
if (typeof cookie !== "string") {
|
||||
throw new Error("Expected control ui plugin auth cookie");
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
|
||||
function createRuntimeScopeRecorderHandler(params: {
|
||||
pluginId: string;
|
||||
path: string;
|
||||
method: string;
|
||||
observedRuntimeScopes: string[][];
|
||||
allowedResults: boolean[];
|
||||
gatewayRuntimeScopeSurface?: "trusted-operator";
|
||||
match?: "exact" | "prefix";
|
||||
}) {
|
||||
return createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [
|
||||
{
|
||||
pluginId: params.pluginId,
|
||||
source: params.pluginId,
|
||||
path: params.path,
|
||||
auth: "gateway",
|
||||
...(params.gatewayRuntimeScopeSurface
|
||||
? { gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface }
|
||||
: {}),
|
||||
match: params.match ?? "exact",
|
||||
handler: async (_req: IncomingMessage, res: ServerResponse) => {
|
||||
const runtimeScopes =
|
||||
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [];
|
||||
params.observedRuntimeScopes.push(runtimeScopes);
|
||||
const auth = authorizeOperatorScopesForMethod(params.method, runtimeScopes);
|
||||
params.allowedResults.push(auth.allowed);
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
return true;
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
log: { warn: vi.fn() } as unknown as Parameters<
|
||||
typeof createGatewayPluginRequestHandler
|
||||
>[0]["log"],
|
||||
});
|
||||
}
|
||||
|
||||
async function expectPluginRequestOk(
|
||||
server: Parameters<typeof dispatchRequest>[0],
|
||||
request: Parameters<typeof createRequest>[0],
|
||||
): Promise<void> {
|
||||
const response = createResponse();
|
||||
await dispatchRequest(server, createRequest(request), response.res);
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
expect(response.getBody()).toBe("ok");
|
||||
}
|
||||
|
||||
describe("control ui plugin frame auth route boundaries", () => {
|
||||
test("probes cookie availability inside the sandbox without invoking plugin code", async () => {
|
||||
const observedRuntimeScopes: string[][] = [];
|
||||
const handlePluginRequest = createRuntimeScopeRecorderHandler({
|
||||
pluginId: "runtime-scope-control-ui-cookie",
|
||||
path: "/secure-hook",
|
||||
method: "assistant.media.get",
|
||||
observedRuntimeScopes,
|
||||
allowedResults: [],
|
||||
});
|
||||
const cookie = createControlUiPluginAuthCookieForTest(["operator.read"]);
|
||||
const nonce = "0123456789abcdef0123456789abcdef";
|
||||
const targetOrigin = "https://gateway.example";
|
||||
const path = `/secure-hook?${CONTROL_UI_PLUGIN_AUTH_PROBE_QUERY}=${nonce}&${CONTROL_UI_PLUGIN_AUTH_PROBE_ORIGIN_QUERY}=${encodeURIComponent(targetOrigin)}`;
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-runtime-scope-cookie-probe-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (pathContext) => pathContext.pathname === "/secure-hook",
|
||||
},
|
||||
run: async (server) => {
|
||||
const unauthorized = createResponse();
|
||||
await dispatchRequest(server, createRequest({ path }), unauthorized.res);
|
||||
expect(unauthorized.res.statusCode).toBe(401);
|
||||
|
||||
const authorized = createResponse();
|
||||
await dispatchRequest(server, createRequest({ path, headers: { cookie } }), authorized.res);
|
||||
expect(authorized.res.statusCode).toBe(200);
|
||||
expect(authorized.getBody()).toContain(
|
||||
JSON.stringify({ type: CONTROL_UI_PLUGIN_AUTH_PROBE_MESSAGE, nonce }),
|
||||
);
|
||||
expect(authorized.getBody()).toContain(JSON.stringify(targetOrigin));
|
||||
expect(authorized.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store");
|
||||
expect(authorized.setHeader).toHaveBeenCalledWith(
|
||||
"Content-Security-Policy",
|
||||
expect.stringContaining("frame-ancestors 'self'"),
|
||||
);
|
||||
|
||||
const invalid = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({
|
||||
path: `/secure-hook?${CONTROL_UI_PLUGIN_AUTH_PROBE_QUERY}=${nonce}`,
|
||||
headers: { cookie },
|
||||
}),
|
||||
invalid.res,
|
||||
);
|
||||
expect(invalid.res.statusCode).toBe(400);
|
||||
},
|
||||
});
|
||||
|
||||
expect(observedRuntimeScopes).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects control ui plugin auth cookies on sibling gateway-auth plugin routes", async () => {
|
||||
const observedRuntimeScopes: string[][] = [];
|
||||
const handlePluginRequest = createRuntimeScopeRecorderHandler({
|
||||
pluginId: "runtime-scope-control-ui-cookie-route-bound",
|
||||
path: "/other-secure-hook",
|
||||
method: "assistant.media.get",
|
||||
observedRuntimeScopes,
|
||||
allowedResults: [],
|
||||
});
|
||||
const cookie = createControlUiPluginAuthCookieForTest(["operator.read"], {
|
||||
pluginId: "runtime-scope-control-ui-cookie-route-bound",
|
||||
path: "/secure-hook",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-runtime-scope-cookie-route-bound-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (pathContext) =>
|
||||
pathContext.pathname === "/other-secure-hook",
|
||||
},
|
||||
run: async (server) => {
|
||||
const response = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({ path: "/other-secure-hook", headers: { cookie } }),
|
||||
response.res,
|
||||
);
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
},
|
||||
});
|
||||
|
||||
expect(observedRuntimeScopes).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not broaden an exact-route grant to child paths", async () => {
|
||||
const childHandler = vi.fn(async () => true);
|
||||
const handlePluginRequest = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [
|
||||
{
|
||||
pluginId: "exact-plugin",
|
||||
path: "/secure-hook/child",
|
||||
auth: "gateway",
|
||||
match: "exact",
|
||||
handler: childHandler,
|
||||
},
|
||||
],
|
||||
}),
|
||||
log: { warn: vi.fn() } as unknown as Parameters<
|
||||
typeof createGatewayPluginRequestHandler
|
||||
>[0]["log"],
|
||||
});
|
||||
const cookie = createControlUiPluginAuthCookieForTest(["operator.read"], {
|
||||
pluginId: "exact-plugin",
|
||||
path: "/secure-hook",
|
||||
match: "exact",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-cookie-exact-bound-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: () => true,
|
||||
},
|
||||
run: async (server) => {
|
||||
const response = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({ path: "/secure-hook/child", headers: { cookie } }),
|
||||
response.res,
|
||||
);
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
},
|
||||
});
|
||||
|
||||
expect(childHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects encoded path traversal outside the signed route root", async () => {
|
||||
const outerHandler = vi.fn(async () => true);
|
||||
const adminHandler = vi.fn(async () => true);
|
||||
const handlePluginRequest = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [
|
||||
{
|
||||
pluginId: "same-plugin",
|
||||
path: "/admin",
|
||||
auth: "gateway",
|
||||
match: "exact",
|
||||
handler: adminHandler,
|
||||
},
|
||||
{
|
||||
pluginId: "same-plugin",
|
||||
path: "/plugins/same",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: outerHandler,
|
||||
},
|
||||
],
|
||||
}),
|
||||
log: { warn: vi.fn() } as unknown as Parameters<
|
||||
typeof createGatewayPluginRequestHandler
|
||||
>[0]["log"],
|
||||
});
|
||||
const cookie = createControlUiPluginAuthCookieForTest(["operator.admin"], {
|
||||
pluginId: "same-plugin",
|
||||
path: "/plugins/same",
|
||||
match: "prefix",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-cookie-canonical-path-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: () => true,
|
||||
},
|
||||
run: async (server) => {
|
||||
const response = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({
|
||||
path: "/plugins/same/%252e%252e/%252e%252e/admin",
|
||||
headers: { cookie },
|
||||
}),
|
||||
response.res,
|
||||
);
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
},
|
||||
});
|
||||
|
||||
expect(outerHandler).not.toHaveBeenCalled();
|
||||
expect(adminHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("accepts control ui plugin auth cookies for gateway-auth plugin routes", async () => {
|
||||
const observedRuntimeScopes: string[][] = [];
|
||||
const writeAllowedResults: boolean[] = [];
|
||||
const handlePluginRequest = createRuntimeScopeRecorderHandler({
|
||||
pluginId: "runtime-scope-control-ui-cookie",
|
||||
path: "/secure-hook",
|
||||
method: "node.invoke",
|
||||
observedRuntimeScopes,
|
||||
allowedResults: writeAllowedResults,
|
||||
});
|
||||
const cookie = createControlUiPluginAuthCookieForTest(["operator.read", "operator.write"]);
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-runtime-scope-cookie-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (pathContext) => pathContext.pathname === "/secure-hook",
|
||||
},
|
||||
run: async (server) => {
|
||||
await expectPluginRequestOk(server, {
|
||||
path: "/secure-hook",
|
||||
headers: { cookie },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(observedRuntimeScopes).toEqual([["operator.read", "operator.write"]]);
|
||||
expect(writeAllowedResults).toEqual([true]);
|
||||
});
|
||||
|
||||
test("accepts control ui plugin auth cookies on child paths under the bound tab route", async () => {
|
||||
const observedRuntimeScopes: string[][] = [];
|
||||
const handlePluginRequest = createRuntimeScopeRecorderHandler({
|
||||
pluginId: "runtime-scope-control-ui-cookie-route-child",
|
||||
path: "/secure-hook",
|
||||
match: "prefix",
|
||||
method: "assistant.media.get",
|
||||
observedRuntimeScopes,
|
||||
allowedResults: [],
|
||||
});
|
||||
const cookie = createControlUiPluginAuthCookieForTest(["operator.read"], {
|
||||
pluginId: "runtime-scope-control-ui-cookie-route-child",
|
||||
path: "/secure-hook",
|
||||
match: "prefix",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-runtime-scope-cookie-route-child-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (pathContext) =>
|
||||
pathContext.pathname === "/secure-hook/assets/app.js",
|
||||
},
|
||||
run: async (server) => {
|
||||
await expectPluginRequestOk(server, {
|
||||
path: "/secure-hook/assets/app.js",
|
||||
headers: { cookie },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(observedRuntimeScopes).toEqual([["operator.read"]]);
|
||||
});
|
||||
|
||||
test("rejects mutation requests that present only a control ui plugin auth cookie", async () => {
|
||||
const handlePluginRequest = vi.fn(async () => true);
|
||||
const cookie = createControlUiPluginAuthCookieForTest(["operator.read"], {
|
||||
pluginId: "read-only-plugin",
|
||||
path: "/secure-hook",
|
||||
match: "prefix",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-cookie-read-only-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: () => true,
|
||||
},
|
||||
run: async (server) => {
|
||||
const response = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({
|
||||
path: "/secure-hook/action",
|
||||
method: "POST",
|
||||
headers: { cookie },
|
||||
}),
|
||||
response.res,
|
||||
);
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
},
|
||||
});
|
||||
|
||||
expect(handlePluginRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not accept a control ui plugin auth cookie for websocket upgrade auth", async () => {
|
||||
const cookie = createControlUiPluginAuthCookieForTest(["operator.read"]);
|
||||
const result = await checkGatewayHttpRequestAuth({
|
||||
req: createRequest({
|
||||
path: "/secure-hook",
|
||||
method: "GET",
|
||||
headers: {
|
||||
connection: "Upgrade",
|
||||
cookie,
|
||||
upgrade: "websocket",
|
||||
},
|
||||
}),
|
||||
auth: AUTH_TOKEN,
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects control ui plugin auth cookies after shared auth generation changes", async () => {
|
||||
const observedRuntimeScopes: string[][] = [];
|
||||
const handlePluginRequest = createRuntimeScopeRecorderHandler({
|
||||
pluginId: "runtime-scope-control-ui-cookie-generation-bound",
|
||||
path: "/secure-hook",
|
||||
method: "assistant.media.get",
|
||||
observedRuntimeScopes,
|
||||
allowedResults: [],
|
||||
});
|
||||
const cookie = createControlUiPluginAuthCookieForTest(["operator.read"], {
|
||||
pluginId: "runtime-scope-control-ui-cookie-generation-bound",
|
||||
generation: "stale-generation",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-runtime-scope-cookie-generation-bound-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (pathContext) => pathContext.pathname === "/secure-hook",
|
||||
},
|
||||
run: async (server) => {
|
||||
const response = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({ path: "/secure-hook", headers: { cookie } }),
|
||||
response.res,
|
||||
);
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
},
|
||||
});
|
||||
|
||||
expect(observedRuntimeScopes).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps trusted-operator routes constrained to control ui plugin auth cookie scopes", async () => {
|
||||
const observedRuntimeScopes: string[][] = [];
|
||||
const adminAllowedResults: boolean[] = [];
|
||||
const handlePluginRequest = createRuntimeScopeRecorderHandler({
|
||||
pluginId: "runtime-scope-control-ui-cookie-trusted-operator",
|
||||
path: "/secure-admin-hook",
|
||||
method: "set-heartbeats",
|
||||
observedRuntimeScopes,
|
||||
allowedResults: adminAllowedResults,
|
||||
gatewayRuntimeScopeSurface: "trusted-operator",
|
||||
});
|
||||
const cookie = createControlUiPluginAuthCookieForTest(["operator.read", "operator.write"], {
|
||||
pluginId: "runtime-scope-control-ui-cookie-trusted-operator",
|
||||
path: "/secure-admin-hook",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-runtime-scope-cookie-trusted-operator-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (pathContext) =>
|
||||
pathContext.pathname === "/secure-admin-hook",
|
||||
},
|
||||
run: async (server) => {
|
||||
await expectPluginRequestOk(server, {
|
||||
path: "/secure-admin-hook",
|
||||
headers: { cookie },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(observedRuntimeScopes).toEqual([["operator.read", "operator.write"]]);
|
||||
expect(adminAllowedResults).toEqual([false]);
|
||||
});
|
||||
|
||||
test("rejects a broader plugin grant when a nested gateway route belongs to another plugin", async () => {
|
||||
const outerHandler = vi.fn(async () => true);
|
||||
const nestedHandler = vi.fn(async () => true);
|
||||
const handlePluginRequest = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [
|
||||
{
|
||||
pluginId: "outer-plugin",
|
||||
path: "/plugins/outer",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: outerHandler,
|
||||
},
|
||||
{
|
||||
pluginId: "nested-plugin",
|
||||
path: "/plugins/outer/nested",
|
||||
auth: "gateway",
|
||||
match: "exact",
|
||||
handler: nestedHandler,
|
||||
},
|
||||
],
|
||||
}),
|
||||
log: { warn: vi.fn() } as unknown as Parameters<
|
||||
typeof createGatewayPluginRequestHandler
|
||||
>[0]["log"],
|
||||
});
|
||||
const cookie = createControlUiPluginAuthCookieForTest(["operator.write"], {
|
||||
pluginId: "outer-plugin",
|
||||
path: "/plugins/outer",
|
||||
match: "prefix",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-cookie-plugin-bound-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: () => true,
|
||||
},
|
||||
run: async (server) => {
|
||||
const response = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({ path: "/plugins/outer/nested", headers: { cookie } }),
|
||||
response.res,
|
||||
);
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
},
|
||||
});
|
||||
|
||||
expect(outerHandler).not.toHaveBeenCalled();
|
||||
expect(nestedHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("selects the most-specific valid plugin grant independent of cookie header order", async () => {
|
||||
const observedRuntimeScopes: string[][] = [];
|
||||
const handlePluginRequest = createRuntimeScopeRecorderHandler({
|
||||
pluginId: "nested-plugin",
|
||||
path: "/plugins/outer/nested",
|
||||
method: "assistant.media.get",
|
||||
observedRuntimeScopes,
|
||||
allowedResults: [],
|
||||
});
|
||||
const broadCookie = createControlUiPluginAuthCookieForTest(["operator.write"], {
|
||||
pluginId: "outer-plugin",
|
||||
path: "/plugins/outer",
|
||||
match: "prefix",
|
||||
});
|
||||
const nestedCookie = createControlUiPluginAuthCookieForTest(["operator.read"], {
|
||||
pluginId: "nested-plugin",
|
||||
path: "/plugins/outer/nested",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-cookie-specificity-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: () => true,
|
||||
},
|
||||
run: async (server) => {
|
||||
await expectPluginRequestOk(server, {
|
||||
path: "/plugins/outer/nested",
|
||||
headers: { cookie: `${broadCookie}; ${nestedCookie}` },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(observedRuntimeScopes).toEqual([["operator.read"]]);
|
||||
});
|
||||
|
||||
test("selects the grant owned by the first dispatched gateway route", async () => {
|
||||
const observedRuntimeScopes: string[][] = [];
|
||||
const exactOuterHandler = vi.fn(async (_req: IncomingMessage, res: ServerResponse) => {
|
||||
observedRuntimeScopes.push(
|
||||
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [],
|
||||
);
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
return true;
|
||||
});
|
||||
const nestedHandler = vi.fn(async () => true);
|
||||
const outerHandler = vi.fn(async () => true);
|
||||
const handlePluginRequest = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [
|
||||
{
|
||||
pluginId: "outer-plugin",
|
||||
path: "/plugins/outer/nested/action",
|
||||
auth: "gateway",
|
||||
match: "exact",
|
||||
handler: exactOuterHandler,
|
||||
},
|
||||
{
|
||||
pluginId: "nested-plugin",
|
||||
path: "/plugins/outer/nested",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: nestedHandler,
|
||||
},
|
||||
{
|
||||
pluginId: "outer-plugin",
|
||||
path: "/plugins/outer",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: outerHandler,
|
||||
},
|
||||
],
|
||||
}),
|
||||
log: { warn: vi.fn() } as unknown as Parameters<
|
||||
typeof createGatewayPluginRequestHandler
|
||||
>[0]["log"],
|
||||
});
|
||||
const outerCookie = createControlUiPluginAuthCookieForTest(["operator.write"], {
|
||||
pluginId: "outer-plugin",
|
||||
path: "/plugins/outer",
|
||||
match: "prefix",
|
||||
});
|
||||
const nestedCookie = createControlUiPluginAuthCookieForTest(["operator.read"], {
|
||||
pluginId: "nested-plugin",
|
||||
path: "/plugins/outer/nested",
|
||||
match: "prefix",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-cookie-dispatch-owner-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: () => true,
|
||||
},
|
||||
run: async (server) => {
|
||||
await expectPluginRequestOk(server, {
|
||||
path: "/plugins/outer/nested/action",
|
||||
headers: { cookie: `${outerCookie}; ${nestedCookie}` },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(observedRuntimeScopes).toEqual([["operator.write"]]);
|
||||
expect(exactOuterHandler).toHaveBeenCalledOnce();
|
||||
expect(nestedHandler).not.toHaveBeenCalled();
|
||||
expect(outerHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not fall through from a granted route into another plugin's gateway route", async () => {
|
||||
const nestedHandler = vi.fn(async () => false);
|
||||
const outerHandler = vi.fn(async () => true);
|
||||
const handlePluginRequest = createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
httpRoutes: [
|
||||
{
|
||||
pluginId: "nested-plugin",
|
||||
path: "/plugins/outer/nested",
|
||||
auth: "gateway",
|
||||
match: "exact",
|
||||
handler: nestedHandler,
|
||||
},
|
||||
{
|
||||
pluginId: "outer-plugin",
|
||||
path: "/plugins/outer",
|
||||
auth: "gateway",
|
||||
match: "prefix",
|
||||
handler: outerHandler,
|
||||
},
|
||||
],
|
||||
}),
|
||||
log: { warn: vi.fn() } as unknown as Parameters<
|
||||
typeof createGatewayPluginRequestHandler
|
||||
>[0]["log"],
|
||||
});
|
||||
const cookie = createControlUiPluginAuthCookieForTest(["operator.read"], {
|
||||
pluginId: "nested-plugin",
|
||||
path: "/plugins/outer/nested",
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
prefix: "openclaw-plugin-http-cookie-fallthrough-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: () => true,
|
||||
},
|
||||
run: async (server) => {
|
||||
const response = createResponse();
|
||||
await dispatchRequest(
|
||||
server,
|
||||
createRequest({ path: "/plugins/outer/nested", headers: { cookie } }),
|
||||
response.res,
|
||||
);
|
||||
expect(response.res.statusCode).toBe(404);
|
||||
},
|
||||
});
|
||||
|
||||
expect(nestedHandler).toHaveBeenCalledOnce();
|
||||
expect(outerHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -116,6 +116,7 @@ function createRuntimeScopeRecorderHandler(params: {
|
||||
observedRuntimeScopes: string[][];
|
||||
allowedResults: boolean[];
|
||||
gatewayRuntimeScopeSurface?: "trusted-operator";
|
||||
match?: "exact" | "prefix";
|
||||
}) {
|
||||
return createGatewayPluginRequestHandler({
|
||||
registry: createTestRegistry({
|
||||
@@ -128,7 +129,7 @@ function createRuntimeScopeRecorderHandler(params: {
|
||||
...(params.gatewayRuntimeScopeSurface
|
||||
? { gatewayRuntimeScopeSurface: params.gatewayRuntimeScopeSurface }
|
||||
: {}),
|
||||
match: "exact",
|
||||
match: params.match ?? "exact",
|
||||
handler: async (_req: IncomingMessage, res: ServerResponse) => {
|
||||
const runtimeScopes =
|
||||
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [];
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PROTOCOL_VERSION } from "../../../packages/gateway-protocol/src/index.j
|
||||
import type { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import type { PluginHttpRouteRegistration, PluginRegistry } from "../../plugins/registry.js";
|
||||
import { withPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js";
|
||||
import { respondControlUiPluginAuthCookieProbe } from "../control-ui-plugin-auth-cookie.js";
|
||||
import type { AuthorizedGatewayHttpRequest } from "../http-utils.js";
|
||||
import type { GatewayRequestContext, GatewayRequestOptions } from "../server-methods/types.js";
|
||||
import {
|
||||
@@ -115,13 +116,15 @@ function createPluginRouteRuntimeScope(params: {
|
||||
const runtimeScopes =
|
||||
params.route.auth !== "gateway"
|
||||
? []
|
||||
: params.route.gatewayRuntimeScopeSurface === "trusted-operator"
|
||||
? resolvePluginRouteRuntimeOperatorScopes(
|
||||
params.req,
|
||||
params.gatewayRequestAuth!,
|
||||
"trusted-operator",
|
||||
)
|
||||
: params.gatewayRequestOperatorScopes!;
|
||||
: params.gatewayRequestAuth?.controlUiPluginGrant
|
||||
? params.gatewayRequestOperatorScopes!
|
||||
: params.route.gatewayRuntimeScopeSurface === "trusted-operator"
|
||||
? resolvePluginRouteRuntimeOperatorScopes(
|
||||
params.req,
|
||||
params.gatewayRequestAuth!,
|
||||
"trusted-operator",
|
||||
)
|
||||
: params.gatewayRequestOperatorScopes!;
|
||||
const runtimeClient = createPluginRouteRuntimeClient(
|
||||
runtimeScopes,
|
||||
params.gatewayRequestClientIp,
|
||||
@@ -185,12 +188,41 @@ export function createGatewayPluginRequestHandler(params: {
|
||||
log.warn(`plugin http route blocked without gateway auth (${pathContext.canonicalPath})`);
|
||||
return false;
|
||||
}
|
||||
const gatewayRequestAuth = dispatchContext?.gatewayRequestAuth;
|
||||
const gatewayRequestOperatorScopes = dispatchContext?.gatewayRequestOperatorScopes;
|
||||
const firstGatewayRoute = matchedRoutes.find((route) => route.auth === "gateway");
|
||||
const presentedGatewayRequestAuth = dispatchContext?.gatewayRequestAuth;
|
||||
const presentedControlUiPluginGrants = presentedGatewayRequestAuth?.controlUiPluginGrants;
|
||||
const controlUiPluginGrant = presentedControlUiPluginGrants?.find(
|
||||
(grant) => grant.pluginId === firstGatewayRoute?.pluginId,
|
||||
);
|
||||
if (presentedControlUiPluginGrants && (!firstGatewayRoute || !controlUiPluginGrant)) {
|
||||
log.warn(
|
||||
`plugin http route blocked for mismatched control ui grant (${pathContext.canonicalPath})`,
|
||||
);
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Unauthorized");
|
||||
return true;
|
||||
}
|
||||
const gatewayRequestAuth = controlUiPluginGrant
|
||||
? {
|
||||
...presentedGatewayRequestAuth!,
|
||||
controlUiPluginGrant,
|
||||
}
|
||||
: presentedGatewayRequestAuth;
|
||||
const gatewayRequestOperatorScopes = controlUiPluginGrant
|
||||
? controlUiPluginGrant.scopes
|
||||
: dispatchContext?.gatewayRequestOperatorScopes;
|
||||
|
||||
// Fail closed before invoking any handlers when matched gateway routes are
|
||||
// missing the runtime auth/scope context they require.
|
||||
for (const route of matchedRoutes) {
|
||||
if (
|
||||
controlUiPluginGrant &&
|
||||
route.auth === "gateway" &&
|
||||
route.pluginId !== controlUiPluginGrant.pluginId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const missingRuntimeContext = getMissingPluginRouteRuntimeContext(route, {
|
||||
gatewayRequestAuth,
|
||||
gatewayRequestOperatorScopes,
|
||||
@@ -203,7 +235,20 @@ export function createGatewayPluginRequestHandler(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// The probe is intercepted only after route ownership and cookie auth are
|
||||
// established. Plugin code never sees the reserved capability request.
|
||||
if (controlUiPluginGrant && respondControlUiPluginAuthCookieProbe(req, res)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const route of matchedRoutes) {
|
||||
if (
|
||||
controlUiPluginGrant &&
|
||||
route.auth === "gateway" &&
|
||||
route.pluginId !== controlUiPluginGrant.pluginId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const runRoute = async () =>
|
||||
(await withPluginRuntimeGatewayRequestScope(
|
||||
|
||||
@@ -71,7 +71,9 @@ export async function sendGatewayHello(
|
||||
snapshot.stateVersion.health = getHealthVersion();
|
||||
}
|
||||
const helloOkAuthScopes = deviceToken ? deviceToken.scopes : scopes;
|
||||
const controlUiTabs = listControlUiPluginTabs(helloOkAuthScopes);
|
||||
const controlUiTabs = listControlUiPluginTabs(helloOkAuthScopes, {
|
||||
requireGatewayAuthGrant: resolvedAuth.mode !== "none",
|
||||
});
|
||||
const helloOk = {
|
||||
type: "hello-ok",
|
||||
protocol: PROTOCOL_VERSION,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ControlUiBootstrapConfig } from "../../../src/gateway/control-ui-contract.js";
|
||||
import { createApplicationConfigCapability } from "./config.ts";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function bootstrapResponse(serverVersion: string): Response {
|
||||
const payload: ControlUiBootstrapConfig = {
|
||||
basePath: "",
|
||||
assistantName: "Assistant",
|
||||
assistantAvatar: "A",
|
||||
assistantAgentId: "main",
|
||||
serverVersion,
|
||||
terminalEnabled: false,
|
||||
pluginFrameGrants: [],
|
||||
};
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("createApplicationConfigCapability", () => {
|
||||
it("returns null for a superseded bootstrap response", async () => {
|
||||
const firstResponse = deferred<Response>();
|
||||
const secondResponse = deferred<Response>();
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementationOnce(() => firstResponse.promise)
|
||||
.mockImplementationOnce(() => secondResponse.promise);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const config = createApplicationConfigCapability({ basePath: "" });
|
||||
|
||||
const firstRefresh = config.refresh();
|
||||
const secondRefresh = config.refresh();
|
||||
secondResponse.resolve(bootstrapResponse("new"));
|
||||
await expect(secondRefresh).resolves.toMatchObject({ serverVersion: "new" });
|
||||
firstResponse.resolve(bootstrapResponse("old"));
|
||||
|
||||
await expect(firstRefresh).resolves.toBeNull();
|
||||
expect(config.current.serverVersion).toBe("new");
|
||||
});
|
||||
});
|
||||
+35
-12
@@ -4,6 +4,7 @@ import {
|
||||
CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE,
|
||||
type ControlUiBootstrapConfig,
|
||||
type ControlUiEmbedSandboxMode,
|
||||
type ControlUiPluginFrameGrantAck,
|
||||
} from "../../../src/gateway/control-ui-contract.js";
|
||||
import { normalizeAssistantIdentity } from "../lib/assistant-identity.ts";
|
||||
import { setUiTimeFormatPreference } from "../lib/format.ts";
|
||||
@@ -44,6 +45,7 @@ type ApplicationConfig = {
|
||||
allowExternalEmbedUrls: boolean;
|
||||
chatMessageMaxWidth: string | null;
|
||||
terminalEnabled: boolean;
|
||||
pluginFrameGrants: ControlUiPluginFrameGrantAck[];
|
||||
};
|
||||
|
||||
export type ApplicationConfigCapability = {
|
||||
@@ -51,7 +53,8 @@ export type ApplicationConfigCapability = {
|
||||
refresh: (options?: {
|
||||
auth?: ApplicationConfigAuthSource;
|
||||
skipWithoutAuthCandidate?: boolean;
|
||||
}) => Promise<void>;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<ApplicationConfig | null>;
|
||||
subscribe: (listener: (config: ApplicationConfig) => void) => () => void;
|
||||
};
|
||||
|
||||
@@ -79,6 +82,7 @@ const DEFAULT_APPLICATION_CONFIG: ApplicationConfig = {
|
||||
allowExternalEmbedUrls: false,
|
||||
chatMessageMaxWidth: null,
|
||||
terminalEnabled: readDocumentTerminalEnabled() ?? false,
|
||||
pluginFrameGrants: [],
|
||||
};
|
||||
|
||||
function normalizeSeamColor(value: unknown): string | null {
|
||||
@@ -158,6 +162,14 @@ function normalizeApplicationConfig(parsed: ControlUiBootstrapConfig): Applicati
|
||||
? parsed.chatMessageMaxWidth
|
||||
: null,
|
||||
terminalEnabled: parsed.terminalEnabled === true,
|
||||
pluginFrameGrants: Array.isArray(parsed.pluginFrameGrants)
|
||||
? parsed.pluginFrameGrants.filter(
|
||||
(grant): grant is ControlUiPluginFrameGrantAck =>
|
||||
typeof grant?.pluginId === "string" &&
|
||||
typeof grant.path === "string" &&
|
||||
(grant.match === "exact" || grant.match === "prefix"),
|
||||
)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,6 +177,7 @@ async function loadApplicationConfig(params: {
|
||||
basePath: string;
|
||||
auth?: ApplicationConfigAuthSource;
|
||||
skipWithoutAuthCandidate?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ApplicationConfig | null> {
|
||||
if (typeof window === "undefined" || typeof fetch !== "function") {
|
||||
return null;
|
||||
@@ -189,7 +202,12 @@ async function loadApplicationConfig(params: {
|
||||
if (candidate) {
|
||||
headers.Authorization = `Bearer ${candidate}`;
|
||||
}
|
||||
res = await fetch(url, { method: "GET", headers, credentials: "same-origin" });
|
||||
res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
credentials: "same-origin",
|
||||
signal: params.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
break;
|
||||
}
|
||||
@@ -214,6 +232,7 @@ export function createApplicationConfigCapability(params: {
|
||||
auth?: ApplicationConfigAuthSource;
|
||||
}): ApplicationConfigCapability {
|
||||
let current = DEFAULT_APPLICATION_CONFIG;
|
||||
let currentAuth = params.auth;
|
||||
let refreshVersion = 0;
|
||||
const listeners = new Set<(config: ApplicationConfig) => void>();
|
||||
|
||||
@@ -229,22 +248,26 @@ export function createApplicationConfigCapability(params: {
|
||||
return current;
|
||||
},
|
||||
async refresh(options) {
|
||||
currentAuth = options?.auth ?? currentAuth;
|
||||
const version = ++refreshVersion;
|
||||
const next = await loadApplicationConfig({
|
||||
basePath: params.basePath,
|
||||
auth: options?.auth ?? params.auth,
|
||||
auth: currentAuth,
|
||||
skipWithoutAuthCandidate: options?.skipWithoutAuthCandidate,
|
||||
signal: options?.signal,
|
||||
});
|
||||
if (next && version === refreshVersion) {
|
||||
const documentTerminalEnabled = readDocumentTerminalEnabled();
|
||||
if (documentTerminalEnabled !== null && next.terminalEnabled !== documentTerminalEnabled) {
|
||||
// CSP headers cannot change on a live document. Reload in either
|
||||
// direction so the document and accepted terminal state stay aligned.
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
publish(next);
|
||||
if (!next || version !== refreshVersion) {
|
||||
return null;
|
||||
}
|
||||
const documentTerminalEnabled = readDocumentTerminalEnabled();
|
||||
if (documentTerminalEnabled !== null && next.terminalEnabled !== documentTerminalEnabled) {
|
||||
// CSP headers cannot change on a live document. Reload in either
|
||||
// direction so the document and accepted terminal state stay aligned.
|
||||
window.location.reload();
|
||||
return next;
|
||||
}
|
||||
publish(next);
|
||||
return next;
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS } from "../../../../src/gateway/control-ui-contract.js";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts";
|
||||
import type { RouteId } from "../../app-route-paths.ts";
|
||||
import type { ApplicationConfigCapability } from "../../app/config.ts";
|
||||
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
|
||||
import { getLogbookState, stopLogbookPolling } from "./logbook-controller.ts";
|
||||
import { renderLogbook } from "./logbook-view.ts";
|
||||
@@ -11,6 +13,8 @@ type TestBundledView = {
|
||||
stop: (host: object) => void;
|
||||
};
|
||||
|
||||
type ApplicationConfig = ApplicationConfigCapability["current"];
|
||||
|
||||
const logbookBundledView = {
|
||||
render: renderLogbook,
|
||||
stop: stopLogbookPolling,
|
||||
@@ -40,11 +44,26 @@ class DeferredPluginPage extends PluginPage {
|
||||
}
|
||||
}
|
||||
|
||||
class ExternalPluginPage extends PluginPage {
|
||||
probeResults: Promise<boolean>[] = [Promise.resolve(true)];
|
||||
probeCalls: string[] = [];
|
||||
|
||||
protected override probeExternalTabAuth(path: string, _signal: AbortSignal): Promise<boolean> {
|
||||
this.probeCalls.push(path);
|
||||
return this.probeResults.shift() ?? Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
|
||||
const deferredPluginPageTag = "openclaw-deferred-plugin-page-test";
|
||||
if (!customElements.get(deferredPluginPageTag)) {
|
||||
customElements.define(deferredPluginPageTag, DeferredPluginPage);
|
||||
}
|
||||
|
||||
const externalPluginPageTag = "openclaw-external-plugin-page-test";
|
||||
if (!customElements.get(externalPluginPageTag)) {
|
||||
customElements.define(externalPluginPageTag, ExternalPluginPage);
|
||||
}
|
||||
|
||||
function createLogbookPage(): DeferredPluginPage {
|
||||
const page = document.createElement(deferredPluginPageTag) as DeferredPluginPage;
|
||||
// Import the real owner modules before test timing begins; this suite verifies
|
||||
@@ -55,7 +74,343 @@ function createLogbookPage(): DeferredPluginPage {
|
||||
return page;
|
||||
}
|
||||
|
||||
function externalPluginConfig(
|
||||
pluginFrameGrants: ApplicationConfig["pluginFrameGrants"] = [
|
||||
{
|
||||
pluginId: "external-plugin",
|
||||
path: "/plugins/external",
|
||||
match: "prefix",
|
||||
},
|
||||
],
|
||||
): ApplicationConfig {
|
||||
return {
|
||||
assistantIdentity: {
|
||||
agentId: null,
|
||||
name: "Assistant",
|
||||
avatar: null,
|
||||
avatarSource: null,
|
||||
avatarStatus: null,
|
||||
avatarReason: null,
|
||||
},
|
||||
serverVersion: null,
|
||||
devGitBranch: null,
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandboxMode: "scripts",
|
||||
allowExternalEmbedUrls: false,
|
||||
chatMessageMaxWidth: null,
|
||||
terminalEnabled: false,
|
||||
pluginFrameGrants,
|
||||
};
|
||||
}
|
||||
|
||||
function createExternalPluginPage(
|
||||
refresh: ApplicationConfigCapability["refresh"],
|
||||
requiresGatewayAuth = true,
|
||||
path = "/plugins/external/panel",
|
||||
) {
|
||||
const hello: GatewayHelloOk = {
|
||||
type: "hello-ok",
|
||||
protocol: 3,
|
||||
auth: { role: "operator", scopes: ["operator.write"] },
|
||||
controlUiTabs: [
|
||||
{
|
||||
pluginId: "external-plugin",
|
||||
id: "panel",
|
||||
label: "External panel",
|
||||
path,
|
||||
...(requiresGatewayAuth ? { requiresGatewayAuth: true } : {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
const snapshot: ApplicationGatewaySnapshot = {
|
||||
client: null,
|
||||
connected: true,
|
||||
reconnecting: false,
|
||||
hello,
|
||||
assistantAgentId: null,
|
||||
sessionKey: "main",
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
};
|
||||
const page = document.createElement(externalPluginPageTag) as ExternalPluginPage;
|
||||
page.pluginId = "external-plugin";
|
||||
page.tabId = "panel";
|
||||
(page as unknown as { context: ApplicationContext<RouteId> }).context = {
|
||||
gateway: {
|
||||
snapshot,
|
||||
subscribe: () => () => undefined,
|
||||
},
|
||||
config: {
|
||||
current: externalPluginConfig([]),
|
||||
refresh,
|
||||
},
|
||||
} as unknown as ApplicationContext<RouteId>;
|
||||
return page;
|
||||
}
|
||||
|
||||
describe("PluginPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("refreshes parent auth before mounting an external plugin frame", async () => {
|
||||
const pendingRefresh = deferred<ApplicationConfig | null>();
|
||||
const pendingProbe = deferred<boolean>();
|
||||
const refresh = vi.fn(() => pendingRefresh.promise);
|
||||
const page = createExternalPluginPage(refresh);
|
||||
page.probeResults = [pendingProbe.promise];
|
||||
document.body.append(page);
|
||||
try {
|
||||
await page.updateComplete;
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(page.querySelector("iframe")).toBeNull();
|
||||
|
||||
pendingRefresh.resolve(externalPluginConfig());
|
||||
await vi.waitFor(() => expect(page.probeCalls).toEqual(["/plugins/external/panel"]));
|
||||
expect(page.querySelector("iframe")).toBeNull();
|
||||
|
||||
pendingProbe.resolve(true);
|
||||
await vi.waitFor(() =>
|
||||
expect(page.querySelector("iframe")?.getAttribute("src")).toBe("/plugins/external/panel"),
|
||||
);
|
||||
} finally {
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the frame unmounted when browser policy blocks the sandbox cookie", async () => {
|
||||
const refresh = vi.fn(async () => externalPluginConfig());
|
||||
const page = createExternalPluginPage(refresh);
|
||||
page.probeResults = [Promise.resolve(false)];
|
||||
document.body.append(page);
|
||||
try {
|
||||
await vi.waitFor(() => expect(page.textContent).toContain("Plugin panel unavailable"));
|
||||
expect(page.probeCalls).toEqual(["/plugins/external/panel"]);
|
||||
expect(page.querySelector("iframe")).toBeNull();
|
||||
} finally {
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("matches a route grant against tab URLs with query strings and fragments", async () => {
|
||||
const refresh = vi.fn(async () => externalPluginConfig());
|
||||
const path = "/plugins/external/panel?view=activity#settings";
|
||||
const page = createExternalPluginPage(refresh, true, path);
|
||||
document.body.append(page);
|
||||
try {
|
||||
await vi.waitFor(() => expect(page.querySelector("iframe")?.getAttribute("src")).toBe(path));
|
||||
expect(page.probeCalls).toEqual([path]);
|
||||
} finally {
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("marks the panel unavailable when bootstrap issued no matching grant", async () => {
|
||||
const refresh = vi.fn(async () => externalPluginConfig([]));
|
||||
const page = createExternalPluginPage(refresh);
|
||||
document.body.append(page);
|
||||
try {
|
||||
await vi.waitFor(() => expect(page.textContent).toContain("Plugin panel unavailable"));
|
||||
expect(page.querySelector("iframe")).toBeNull();
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("renews external plugin auth before the route-bound grant expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
const refresh = vi.fn(async () => externalPluginConfig());
|
||||
const page = createExternalPluginPage(refresh);
|
||||
document.body.append(page);
|
||||
try {
|
||||
await page.updateComplete;
|
||||
await Promise.resolve();
|
||||
await page.updateComplete;
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS / 2);
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
|
||||
page.remove();
|
||||
await vi.advanceTimersByTimeAsync(CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS);
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
page.remove();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("unmounts an external frame when renewal hangs past grant expiry", async () => {
|
||||
vi.useFakeTimers();
|
||||
let activeRefreshes = 0;
|
||||
let maxActiveRefreshes = 0;
|
||||
const refresh = vi
|
||||
.fn<ApplicationConfigCapability["refresh"]>()
|
||||
.mockResolvedValueOnce(externalPluginConfig())
|
||||
.mockImplementation(
|
||||
(options) =>
|
||||
new Promise<ApplicationConfig | null>((resolve) => {
|
||||
activeRefreshes += 1;
|
||||
maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);
|
||||
options?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
activeRefreshes -= 1;
|
||||
resolve(null);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const page = createExternalPluginPage(refresh);
|
||||
document.body.append(page);
|
||||
try {
|
||||
await page.updateComplete;
|
||||
await Promise.resolve();
|
||||
await page.updateComplete;
|
||||
await Promise.resolve();
|
||||
await page.updateComplete;
|
||||
expect(page.querySelector("iframe")).not.toBeNull();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS / 2);
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
expect(page.querySelector("iframe")).not.toBeNull();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS / 2);
|
||||
await page.updateComplete;
|
||||
expect(page.querySelector("iframe")).toBeNull();
|
||||
expect(refresh.mock.calls.length).toBeGreaterThan(2);
|
||||
expect(maxActiveRefreshes).toBe(1);
|
||||
} finally {
|
||||
page.remove();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("serially replaces a hung renewal when an expired page resumes", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(0));
|
||||
let activeRefreshes = 0;
|
||||
let maxActiveRefreshes = 0;
|
||||
const refresh = vi
|
||||
.fn<ApplicationConfigCapability["refresh"]>()
|
||||
.mockResolvedValueOnce(externalPluginConfig())
|
||||
.mockImplementation(
|
||||
(options) =>
|
||||
new Promise<ApplicationConfig | null>((resolve) => {
|
||||
activeRefreshes += 1;
|
||||
maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);
|
||||
options?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
activeRefreshes -= 1;
|
||||
resolve(null);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const page = createExternalPluginPage(refresh);
|
||||
document.body.append(page);
|
||||
try {
|
||||
await page.updateComplete;
|
||||
await Promise.resolve();
|
||||
await page.updateComplete;
|
||||
await vi.advanceTimersByTimeAsync(CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS / 2);
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.setSystemTime(new Date(CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS));
|
||||
(
|
||||
page as unknown as {
|
||||
handleVisibilityChange: () => void;
|
||||
}
|
||||
).handleVisibilityChange();
|
||||
await Promise.resolve();
|
||||
await page.updateComplete;
|
||||
|
||||
expect(page.querySelector("iframe")).toBeNull();
|
||||
expect(refresh).toHaveBeenCalledTimes(3);
|
||||
expect(maxActiveRefreshes).toBe(1);
|
||||
} finally {
|
||||
page.remove();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes the frame grant after gateway reconnect", async () => {
|
||||
const refresh = vi.fn(async () => externalPluginConfig());
|
||||
const page = createExternalPluginPage(refresh);
|
||||
document.body.append(page);
|
||||
try {
|
||||
await vi.waitFor(() => expect(page.querySelector("iframe")).not.toBeNull());
|
||||
const context = (page as unknown as { context: ApplicationContext<RouteId> }).context;
|
||||
const gateway = context.gateway;
|
||||
const snapshot = gateway.snapshot as { connected: boolean };
|
||||
|
||||
snapshot.connected = false;
|
||||
(
|
||||
page as unknown as {
|
||||
updateGatewaySource: (source: ApplicationContext<RouteId>["gateway"]) => void;
|
||||
}
|
||||
).updateGatewaySource(gateway);
|
||||
await page.updateComplete;
|
||||
expect(page.querySelector("iframe")).toBeNull();
|
||||
|
||||
snapshot.connected = true;
|
||||
(
|
||||
page as unknown as {
|
||||
updateGatewaySource: (source: ApplicationContext<RouteId>["gateway"]) => void;
|
||||
}
|
||||
).updateGatewaySource(gateway);
|
||||
await vi.waitFor(() => expect(page.querySelector("iframe")).not.toBeNull());
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses external plugin auth outside a secure browser context", async () => {
|
||||
const refresh = vi.fn(async () => externalPluginConfig());
|
||||
const page = createExternalPluginPage(refresh);
|
||||
(
|
||||
page as unknown as {
|
||||
isExternalTabAuthSupported: () => boolean;
|
||||
}
|
||||
).isExternalTabAuthSupported = () => false;
|
||||
document.body.append(page);
|
||||
try {
|
||||
await page.updateComplete;
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(page.querySelector("iframe")).toBeNull();
|
||||
expect(page.textContent).toContain("Secure browser context required");
|
||||
} finally {
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps plugin-auth external panels available outside a secure context", async () => {
|
||||
const refresh = vi.fn(async () => externalPluginConfig());
|
||||
const page = createExternalPluginPage(refresh, false);
|
||||
(
|
||||
page as unknown as {
|
||||
isExternalTabAuthSupported: () => boolean;
|
||||
}
|
||||
).isExternalTabAuthSupported = () => false;
|
||||
document.body.append(page);
|
||||
try {
|
||||
await page.updateComplete;
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(page.querySelector("iframe")?.getAttribute("src")).toBe("/plugins/external/panel");
|
||||
} finally {
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("stops a bundled view when its advertised descriptor disappears", async () => {
|
||||
const bundledView = deferred<TestBundledView>();
|
||||
const stop = vi.fn();
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { html, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import {
|
||||
CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS,
|
||||
CONTROL_UI_PLUGIN_AUTH_PROBE_MESSAGE,
|
||||
CONTROL_UI_PLUGIN_AUTH_PROBE_ORIGIN_QUERY,
|
||||
CONTROL_UI_PLUGIN_AUTH_PROBE_QUERY,
|
||||
resolveControlUiPluginTabPathname,
|
||||
type ControlUiPluginFrameGrantAck,
|
||||
} from "../../../../src/gateway/control-ui-contract.js";
|
||||
import type { GatewayBrowserClient, GatewayControlUiPluginTab } from "../../api/gateway.ts";
|
||||
import type { RouteId } from "../../app-route-paths.ts";
|
||||
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
|
||||
@@ -33,6 +41,30 @@ type BundledPluginTabView = {
|
||||
stop: (host: object) => void;
|
||||
};
|
||||
|
||||
function pluginFrameGrantCoversTab(
|
||||
grant: ControlUiPluginFrameGrantAck,
|
||||
info: GatewayControlUiPluginTab,
|
||||
): boolean {
|
||||
if (!info.path || grant.pluginId !== info.pluginId) {
|
||||
return false;
|
||||
}
|
||||
const tabPath = resolveControlUiPluginTabPathname(info.path);
|
||||
if (!tabPath) {
|
||||
return false;
|
||||
}
|
||||
if (grant.match === "exact") {
|
||||
return tabPath === grant.path;
|
||||
}
|
||||
return (
|
||||
tabPath === grant.path ||
|
||||
(tabPath.startsWith(grant.path) &&
|
||||
(grant.path.endsWith("/") || tabPath.at(grant.path.length) === "/"))
|
||||
);
|
||||
}
|
||||
|
||||
const EXTERNAL_AUTH_REFRESH_TIMEOUT_MS = 10_000;
|
||||
const EXTERNAL_AUTH_PROBE_TIMEOUT_MS = 5_000;
|
||||
|
||||
// Keyed by pluginId/tabId: tab ids are only unique within their plugin.
|
||||
const BUNDLED_TAB_VIEWS: Record<string, () => Promise<BundledPluginTabView>> = {
|
||||
"workspaces/workspaces": async () => {
|
||||
@@ -59,6 +91,8 @@ export class PluginPage extends OpenClawLightDomContentsElement {
|
||||
private context?: ApplicationContext<RouteId>;
|
||||
|
||||
@state() private bundledView: BundledPluginTabView | null = null;
|
||||
@state() private externalAuthReadyKey: string | null = null;
|
||||
@state() private externalAuthUnavailableKey: string | null = null;
|
||||
|
||||
private bundledViewId: string | null = null;
|
||||
private bundledViewLoadToken: object | null = null;
|
||||
@@ -66,13 +100,45 @@ export class PluginPage extends OpenClawLightDomContentsElement {
|
||||
private gatewaySource?: ApplicationContext<RouteId>["gateway"];
|
||||
private gatewayClient: GatewayBrowserClient | null = null;
|
||||
private gatewayConnected = false;
|
||||
private externalAuthTargetKey: string | null = null;
|
||||
private externalAuthRefreshMarker: object | null = null;
|
||||
private externalAuthRefreshAbortController: AbortController | null = null;
|
||||
private externalAuthRefreshWatchdog: ReturnType<typeof setTimeout> | null = null;
|
||||
private externalAuthProbeMarker: object | null = null;
|
||||
private externalAuthProbeAbortController: AbortController | null = null;
|
||||
private externalAuthRestartKey: string | null = null;
|
||||
private externalAuthRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private externalAuthExpiryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private externalAuthRefreshedAt = 0;
|
||||
private readonly subscriptions = new SubscriptionsController(this).watch(
|
||||
() => this.context?.gateway,
|
||||
(gateway, notify) => gateway.subscribe(notify),
|
||||
(gateway) => this.updateGatewaySource(gateway),
|
||||
);
|
||||
|
||||
private readonly handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible" || !this.externalAuthTargetKey) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - this.externalAuthRefreshedAt >= CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS) {
|
||||
// A suspended browser may miss renewal timers. Remove an expired frame
|
||||
// until the parent refreshes its route-bound cookie on resume.
|
||||
this.externalAuthReadyKey = null;
|
||||
this.externalAuthRefreshedAt = 0;
|
||||
this.requestExternalTabAuthRestart(this.externalAuthTargetKey);
|
||||
return;
|
||||
}
|
||||
this.refreshExternalTabAuth(this.externalAuthTargetKey);
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
this.clearExternalTabAuth();
|
||||
this.subscriptions.clear();
|
||||
this.stopBundledView();
|
||||
super.disconnectedCallback();
|
||||
@@ -92,7 +158,8 @@ export class PluginPage extends OpenClawLightDomContentsElement {
|
||||
return;
|
||||
}
|
||||
const key = this.tabKey();
|
||||
const hasBundledDescriptor = this.tabInfo() !== undefined && key in BUNDLED_TAB_VIEWS;
|
||||
const info = this.tabInfo();
|
||||
const hasBundledDescriptor = info !== undefined && key in BUNDLED_TAB_VIEWS;
|
||||
// Switching between plugin tabs reuses this element; the previous bundled
|
||||
// view must stop its background polling before the next one renders. A
|
||||
// descriptor can also disappear in place after disablement or scope loss.
|
||||
@@ -113,6 +180,315 @@ export class PluginPage extends OpenClawLightDomContentsElement {
|
||||
}
|
||||
});
|
||||
}
|
||||
this.syncExternalTabAuth(info, hasBundledDescriptor);
|
||||
}
|
||||
|
||||
private externalTabAuthKey(
|
||||
info: GatewayControlUiPluginTab | undefined,
|
||||
hasBundledDescriptor: boolean,
|
||||
): string | null {
|
||||
return info?.path &&
|
||||
info.requiresGatewayAuth === true &&
|
||||
!hasBundledDescriptor &&
|
||||
this.isExternalTabAuthSupported()
|
||||
? `${this.tabKey()}\n${info.path}`
|
||||
: null;
|
||||
}
|
||||
|
||||
private isExternalTabAuthSupported(): boolean {
|
||||
// Secure cross-site cookies work on HTTPS and browser-trusted loopback.
|
||||
// Insecure LAN HTTP must not fall back to an ambient bearer substitute.
|
||||
return window.isSecureContext;
|
||||
}
|
||||
|
||||
protected probeExternalTabAuth(path: string, signal: AbortSignal): Promise<boolean> {
|
||||
const url = new URL(path, window.location.href);
|
||||
if (url.origin !== window.location.origin) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const random = new Uint8Array(16);
|
||||
crypto.getRandomValues(random);
|
||||
const nonce = Array.from(random, (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
url.searchParams.set(CONTROL_UI_PLUGIN_AUTH_PROBE_QUERY, nonce);
|
||||
url.searchParams.set(CONTROL_UI_PLUGIN_AUTH_PROBE_ORIGIN_QUERY, window.location.origin);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.hidden = true;
|
||||
frame.setAttribute("aria-hidden", "true");
|
||||
frame.setAttribute("sandbox", "allow-scripts");
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const finish = (result: boolean) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
window.removeEventListener("message", handleMessage);
|
||||
signal.removeEventListener("abort", handleAbort);
|
||||
frame.remove();
|
||||
resolve(result);
|
||||
};
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (
|
||||
event.source === frame.contentWindow &&
|
||||
event.data?.type === CONTROL_UI_PLUGIN_AUTH_PROBE_MESSAGE &&
|
||||
event.data?.nonce === nonce
|
||||
) {
|
||||
finish(true);
|
||||
}
|
||||
};
|
||||
const handleAbort = () => finish(false);
|
||||
window.addEventListener("message", handleMessage);
|
||||
signal.addEventListener("abort", handleAbort, { once: true });
|
||||
timeout = setTimeout(() => finish(false), EXTERNAL_AUTH_PROBE_TIMEOUT_MS);
|
||||
frame.src = url.toString();
|
||||
document.body.append(frame);
|
||||
});
|
||||
}
|
||||
|
||||
private syncExternalTabAuth(
|
||||
info: GatewayControlUiPluginTab | undefined,
|
||||
hasBundledDescriptor: boolean,
|
||||
) {
|
||||
const targetKey = this.externalTabAuthKey(info, hasBundledDescriptor);
|
||||
if (this.externalAuthTargetKey !== targetKey) {
|
||||
this.clearExternalTabAuth();
|
||||
this.externalAuthTargetKey = targetKey;
|
||||
}
|
||||
if (
|
||||
targetKey &&
|
||||
this.externalAuthReadyKey !== targetKey &&
|
||||
this.externalAuthUnavailableKey !== targetKey
|
||||
) {
|
||||
this.refreshExternalTabAuth(targetKey);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshExternalTabAuth(targetKey: string) {
|
||||
const context = this.context;
|
||||
if (
|
||||
!context ||
|
||||
!context.gateway.snapshot.connected ||
|
||||
this.externalAuthTargetKey !== targetKey ||
|
||||
this.externalAuthRefreshMarker ||
|
||||
this.externalAuthProbeMarker
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const refreshMarker = {};
|
||||
const refreshStartedAt = Date.now();
|
||||
const abortController = new AbortController();
|
||||
this.externalAuthUnavailableKey = null;
|
||||
this.externalAuthRefreshMarker = refreshMarker;
|
||||
this.externalAuthRefreshAbortController = abortController;
|
||||
this.externalAuthRefreshWatchdog = setTimeout(() => {
|
||||
if (this.externalAuthRefreshMarker === refreshMarker) {
|
||||
this.requestExternalTabAuthRestart(targetKey);
|
||||
}
|
||||
}, EXTERNAL_AUTH_REFRESH_TIMEOUT_MS);
|
||||
void context.config
|
||||
.refresh({ signal: abortController.signal })
|
||||
.then((refreshed) => {
|
||||
if (
|
||||
this.externalAuthRefreshMarker !== refreshMarker ||
|
||||
this.externalAuthTargetKey !== targetKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const shouldRestart = this.finishExternalTabAuthRefreshAttempt(targetKey);
|
||||
if (shouldRestart) {
|
||||
this.refreshExternalTabAuth(targetKey);
|
||||
return;
|
||||
}
|
||||
const info = this.tabInfo();
|
||||
const path = info?.path;
|
||||
const granted =
|
||||
refreshed !== null &&
|
||||
info !== undefined &&
|
||||
path !== undefined &&
|
||||
refreshed.pluginFrameGrants.some((grant) => pluginFrameGrantCoversTab(grant, info));
|
||||
if (granted) {
|
||||
this.startExternalTabAuthProbe(targetKey, path, refreshStartedAt);
|
||||
} else if (refreshed) {
|
||||
this.externalAuthReadyKey = null;
|
||||
this.externalAuthUnavailableKey = targetKey;
|
||||
this.externalAuthRefreshedAt = 0;
|
||||
} else {
|
||||
this.scheduleExternalTabAuthRefresh(targetKey, false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (
|
||||
this.externalAuthRefreshMarker !== refreshMarker ||
|
||||
this.externalAuthTargetKey !== targetKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const shouldRestart = this.finishExternalTabAuthRefreshAttempt(targetKey);
|
||||
if (shouldRestart) {
|
||||
this.refreshExternalTabAuth(targetKey);
|
||||
} else {
|
||||
this.scheduleExternalTabAuthRefresh(targetKey, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startExternalTabAuthProbe(targetKey: string, path: string, refreshedAt: number) {
|
||||
this.cancelExternalTabAuthProbe();
|
||||
const probeMarker = {};
|
||||
const abortController = new AbortController();
|
||||
this.externalAuthProbeMarker = probeMarker;
|
||||
this.externalAuthProbeAbortController = abortController;
|
||||
let probeResult: Promise<boolean>;
|
||||
try {
|
||||
probeResult = this.probeExternalTabAuth(path, abortController.signal);
|
||||
} catch {
|
||||
probeResult = Promise.resolve(false);
|
||||
}
|
||||
void probeResult
|
||||
.catch(() => false)
|
||||
.then((available) => {
|
||||
if (
|
||||
this.externalAuthProbeMarker !== probeMarker ||
|
||||
this.externalAuthTargetKey !== targetKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.externalAuthProbeMarker = null;
|
||||
this.externalAuthProbeAbortController = null;
|
||||
if (available) {
|
||||
this.externalAuthReadyKey = targetKey;
|
||||
this.externalAuthRefreshedAt = refreshedAt;
|
||||
this.scheduleExternalTabAuthExpiry(targetKey, refreshedAt);
|
||||
this.scheduleExternalTabAuthRefresh(targetKey, true);
|
||||
return;
|
||||
}
|
||||
this.externalAuthReadyKey = null;
|
||||
this.externalAuthUnavailableKey = targetKey;
|
||||
this.externalAuthRefreshedAt = 0;
|
||||
if (this.externalAuthRefreshTimer) {
|
||||
clearTimeout(this.externalAuthRefreshTimer);
|
||||
this.externalAuthRefreshTimer = null;
|
||||
}
|
||||
if (this.externalAuthExpiryTimer) {
|
||||
clearTimeout(this.externalAuthExpiryTimer);
|
||||
this.externalAuthExpiryTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private cancelExternalTabAuthProbe() {
|
||||
this.externalAuthProbeMarker = null;
|
||||
const abortController = this.externalAuthProbeAbortController;
|
||||
this.externalAuthProbeAbortController = null;
|
||||
abortController?.abort();
|
||||
}
|
||||
|
||||
private finishExternalTabAuthRefreshAttempt(targetKey: string): boolean {
|
||||
const shouldRestart = this.externalAuthRestartKey === targetKey;
|
||||
if (this.externalAuthRefreshWatchdog) {
|
||||
clearTimeout(this.externalAuthRefreshWatchdog);
|
||||
}
|
||||
this.externalAuthRefreshWatchdog = null;
|
||||
this.externalAuthRefreshAbortController = null;
|
||||
this.externalAuthRefreshMarker = null;
|
||||
this.externalAuthRestartKey = null;
|
||||
return shouldRestart;
|
||||
}
|
||||
|
||||
private requestExternalTabAuthRestart(targetKey: string) {
|
||||
if (this.externalAuthTargetKey !== targetKey) {
|
||||
return;
|
||||
}
|
||||
if (this.externalAuthRefreshMarker) {
|
||||
// Wait for abort settlement before starting the replacement request so a
|
||||
// stale response cannot overwrite its newer route cookie.
|
||||
this.externalAuthRestartKey = targetKey;
|
||||
this.externalAuthRefreshAbortController?.abort();
|
||||
return;
|
||||
}
|
||||
if (this.externalAuthProbeMarker) {
|
||||
this.cancelExternalTabAuthProbe();
|
||||
}
|
||||
this.refreshExternalTabAuth(targetKey);
|
||||
}
|
||||
|
||||
private scheduleExternalTabAuthExpiry(targetKey: string, refreshedAt: number) {
|
||||
if (this.externalAuthExpiryTimer) {
|
||||
clearTimeout(this.externalAuthExpiryTimer);
|
||||
}
|
||||
const delay = Math.max(0, refreshedAt + CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS - Date.now());
|
||||
this.externalAuthExpiryTimer = setTimeout(() => {
|
||||
this.externalAuthExpiryTimer = null;
|
||||
if (this.externalAuthTargetKey !== targetKey || this.externalAuthReadyKey !== targetKey) {
|
||||
return;
|
||||
}
|
||||
// Cookie expiry is independent of renewal completion. Unmount the frame,
|
||||
// abandon any hung refresh, and obtain a fresh grant before remounting.
|
||||
this.externalAuthReadyKey = null;
|
||||
this.externalAuthRefreshedAt = 0;
|
||||
if (this.externalAuthRefreshTimer) {
|
||||
clearTimeout(this.externalAuthRefreshTimer);
|
||||
this.externalAuthRefreshTimer = null;
|
||||
}
|
||||
this.requestExternalTabAuthRestart(targetKey);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private scheduleExternalTabAuthRefresh(targetKey: string, refreshed: boolean) {
|
||||
if (this.externalAuthRefreshTimer) {
|
||||
clearTimeout(this.externalAuthRefreshTimer);
|
||||
}
|
||||
const delay = refreshed ? CONTROL_UI_PLUGIN_AUTH_GRANT_TTL_MS / 2 : 5_000;
|
||||
this.externalAuthRefreshTimer = setTimeout(() => {
|
||||
this.externalAuthRefreshTimer = null;
|
||||
this.refreshExternalTabAuth(targetKey);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private clearExternalTabAuth() {
|
||||
if (this.externalAuthRefreshTimer) {
|
||||
clearTimeout(this.externalAuthRefreshTimer);
|
||||
}
|
||||
if (this.externalAuthExpiryTimer) {
|
||||
clearTimeout(this.externalAuthExpiryTimer);
|
||||
}
|
||||
if (this.externalAuthRefreshWatchdog) {
|
||||
clearTimeout(this.externalAuthRefreshWatchdog);
|
||||
}
|
||||
this.externalAuthRefreshAbortController?.abort();
|
||||
this.cancelExternalTabAuthProbe();
|
||||
this.externalAuthRefreshTimer = null;
|
||||
this.externalAuthExpiryTimer = null;
|
||||
this.externalAuthRefreshWatchdog = null;
|
||||
this.externalAuthRefreshAbortController = null;
|
||||
this.externalAuthRefreshMarker = null;
|
||||
this.externalAuthRestartKey = null;
|
||||
this.externalAuthTargetKey = null;
|
||||
this.externalAuthReadyKey = null;
|
||||
this.externalAuthUnavailableKey = null;
|
||||
this.externalAuthRefreshedAt = 0;
|
||||
}
|
||||
|
||||
private resetExternalTabAuthForGatewayChange(targetKey: string, connected: boolean) {
|
||||
if (this.externalAuthRefreshTimer) {
|
||||
clearTimeout(this.externalAuthRefreshTimer);
|
||||
this.externalAuthRefreshTimer = null;
|
||||
}
|
||||
if (this.externalAuthExpiryTimer) {
|
||||
clearTimeout(this.externalAuthExpiryTimer);
|
||||
this.externalAuthExpiryTimer = null;
|
||||
}
|
||||
this.externalAuthReadyKey = null;
|
||||
this.externalAuthUnavailableKey = null;
|
||||
this.externalAuthRefreshedAt = 0;
|
||||
this.externalAuthTargetKey = targetKey;
|
||||
this.cancelExternalTabAuthProbe();
|
||||
if (this.externalAuthRefreshMarker) {
|
||||
this.externalAuthRestartKey = connected ? targetKey : null;
|
||||
this.externalAuthRefreshAbortController?.abort();
|
||||
} else if (connected) {
|
||||
this.refreshExternalTabAuth(targetKey);
|
||||
}
|
||||
}
|
||||
|
||||
private stopBundledView() {
|
||||
@@ -138,10 +514,14 @@ export class PluginPage extends OpenClawLightDomContentsElement {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const externalAuthTargetKey = this.externalAuthTargetKey;
|
||||
this.replaceBundledViewHost();
|
||||
this.gatewaySource = gateway;
|
||||
this.gatewayClient = client;
|
||||
this.gatewayConnected = connected;
|
||||
if (externalAuthTargetKey) {
|
||||
this.resetExternalTabAuthForGatewayChange(externalAuthTargetKey, connected);
|
||||
}
|
||||
}
|
||||
|
||||
private tabInfo(): GatewayControlUiPluginTab | undefined {
|
||||
@@ -181,6 +561,29 @@ export class PluginPage extends OpenClawLightDomContentsElement {
|
||||
});
|
||||
}
|
||||
if (info?.path) {
|
||||
if (info.requiresGatewayAuth === true && !this.isExternalTabAuthSupported()) {
|
||||
return html`
|
||||
<section class="card lazy-view-state" role="status">
|
||||
<div class="card-title">${t("login.failure.insecure.title")}</div>
|
||||
<div class="card-sub">${t("login.failure.insecure.stepHttps")}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
const externalAuthKey = this.externalTabAuthKey(info, false);
|
||||
if (
|
||||
info.requiresGatewayAuth === true &&
|
||||
this.externalAuthUnavailableKey === externalAuthKey
|
||||
) {
|
||||
return html`
|
||||
<section class="card lazy-view-state" role="status">
|
||||
<div class="card-title">${t("pluginTabs.unavailableTitle")}</div>
|
||||
<div class="card-sub">${t("pluginTabs.unavailableSubtitle")}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
if (info.requiresGatewayAuth === true && this.externalAuthReadyKey !== externalAuthKey) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<section class="plugin-tab-embed">
|
||||
<iframe
|
||||
|
||||
Reference in New Issue
Block a user