fix(control ui): accept paired device token for assistant media auth (#70741)

* fix control ui assistant media auth

* test control ui device token read routes

* defer assistant attachment blob downloads

* fix grouped render rebase merge

* evict stale assistant attachment blob urls

* fix assistant media device token query auth
This commit is contained in:
Patrick Erichsen
2026-04-23 16:31:11 -07:00
committed by GitHub
parent d3997bcf7a
commit ef88cabe39
2 changed files with 192 additions and 9 deletions

View File

@@ -3,7 +3,8 @@ import fs from "node:fs/promises";
import type { IncomingMessage } from "node:http";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { approveDevicePairing, requestDevicePairing } from "../infra/device-pairing.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "./control-ui-contract.js";
@@ -264,6 +265,33 @@ describe("handleControlUiHttpRequest", () => {
}
}
async function withPairedOperatorDeviceToken<T>(params: { fn: (token: string) => Promise<T> }) {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-device-token-"));
vi.stubEnv("OPENCLAW_HOME", tempHome);
try {
const deviceId = "control-ui-device";
const requested = await requestDevicePairing({
deviceId,
publicKey: "test-public-key",
role: "operator",
scopes: ["operator.read"],
clientId: "openclaw-control-ui",
clientMode: "webchat",
});
const approved = await approveDevicePairing(requested.request.requestId, {
callerScopes: ["operator.read"],
});
expect(approved?.status).toBe("approved");
const operatorToken =
approved?.status === "approved" ? approved.device.tokens?.operator?.token : undefined;
expect(typeof operatorToken).toBe("string");
return await params.fn(operatorToken ?? "");
} finally {
vi.unstubAllEnvs();
await fs.rm(tempHome, { recursive: true, force: true });
}
}
it("sets security headers for Control UI responses", async () => {
await withControlUiRoot({
fn: async (tmp) => {
@@ -370,6 +398,51 @@ describe("handleControlUiHttpRequest", () => {
});
});
it("accepts paired operator device tokens on assistant media requests", async () => {
await withPairedOperatorDeviceToken({
fn: async (operatorToken) => {
await withAllowedAssistantMediaRoot({
prefix: "ui-media-device-token-",
fn: async (tmpRoot) => {
const filePath = path.join(tmpRoot, "photo.png");
await fs.writeFile(filePath, Buffer.from("not-a-real-png"));
const { res, handled } = await runAssistantMediaRequest({
url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}`,
method: "GET",
auth: { mode: "token", token: "shared-token", allowTailscale: false },
headers: {
authorization: `Bearer ${operatorToken}`,
},
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
},
});
},
});
});
it("accepts paired operator device tokens in assistant media query auth", async () => {
await withPairedOperatorDeviceToken({
fn: async (operatorToken) => {
await withAllowedAssistantMediaRoot({
prefix: "ui-media-device-token-query-",
fn: async (tmpRoot) => {
const filePath = path.join(tmpRoot, "photo.png");
await fs.writeFile(filePath, Buffer.from("not-a-real-png"));
const { res, handled } = await runAssistantMediaRequest({
url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}&token=${encodeURIComponent(operatorToken)}`,
method: "GET",
auth: { mode: "token", token: "shared-token", allowTailscale: false },
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
},
});
},
});
});
it("rejects trusted-proxy assistant media requests from disallowed browser origins", async () => {
await withAllowedAssistantMediaRoot({
prefix: "ui-media-proxy-",
@@ -526,6 +599,28 @@ describe("handleControlUiHttpRequest", () => {
});
});
it("serves bootstrap config JSON when paired device-token auth is valid", async () => {
await withPairedOperatorDeviceToken({
fn: async (operatorToken) => {
await withControlUiRoot({
fn: async (tmp) => {
const { res, handled, end } = await runBootstrapConfigRequest({
rootPath: tmp,
auth: { mode: "token", token: "shared-token", allowTailscale: false },
headers: {
authorization: `Bearer ${operatorToken}`,
},
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
const parsed = parseBootstrapPayload(end);
expect(parsed.assistantAgentId).toBe("main");
},
});
},
});
});
it("serves bootstrap config JSON under basePath", async () => {
await withControlUiRoot({
fn: async (tmp) => {
@@ -618,6 +713,34 @@ describe("handleControlUiHttpRequest", () => {
}
});
it("serves local avatar bytes when paired device-token auth is valid", async () => {
await withPairedOperatorDeviceToken({
fn: async (operatorToken) => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-device-token-"));
try {
const avatarPath = path.join(tmp, "main.png");
await fs.writeFile(avatarPath, "avatar-bytes\n");
const { res, handled, end } = await runAvatarRequest({
url: "/avatar/main",
method: "GET",
auth: { mode: "token", token: "shared-token", allowTailscale: false },
headers: {
authorization: `Bearer ${operatorToken}`,
},
resolveAvatar: () => ({ kind: "local", filePath: avatarPath }),
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("avatar-bytes\n");
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
},
});
});
it("returns avatar metadata when auth is enabled and the token is valid", async () => {
const { res, end, handled } = await runAvatarRequest({
url: "/avatar/main?meta=1",

View File

@@ -7,8 +7,10 @@ import {
isPackageProvenControlUiRootSync,
resolveControlUiRootSync,
} from "../infra/control-ui-assets.js";
import { listDevicePairing, verifyDeviceToken } from "../infra/device-pairing.js";
import { openLocalFileSafely, SafeOpenError } from "../infra/fs-safe.js";
import { safeFileURLToPath } from "../infra/local-file-access.js";
import { verifyPairingToken } from "../infra/pairing-token.js";
import { isWithinDir } from "../infra/path-safety.js";
import { openVerifiedFileSync } from "../infra/safe-open-sync.js";
import { assertLocalMediaAllowed, getDefaultLocalRoots } from "../media/local-media-access.js";
@@ -18,7 +20,11 @@ import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
import { resolveUserPath } from "../utils.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import {
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
type AuthRateLimiter,
} from "./auth-rate-limit.js";
import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js";
import {
CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
@@ -44,11 +50,14 @@ import {
resolveTrustedHttpOperatorScopes,
} from "./http-utils.js";
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
import { resolveRequestClientIp } from "./net.js";
const ROOT_PREFIX = "/";
const CONTROL_UI_ASSISTANT_MEDIA_PREFIX = "/__openclaw__/assistant-media";
const CONTROL_UI_ASSETS_MISSING_MESSAGE =
"Control UI assets not found. Build them with `pnpm ui:build` (auto-installs UI deps), or run `pnpm ui:dev` during development.";
const CONTROL_UI_OPERATOR_READ_SCOPE = "operator.read";
const CONTROL_UI_OPERATOR_ROLE = "operator";
export type ControlUiRequestOptions = {
basePath?: string;
@@ -247,6 +256,9 @@ async function authorizeControlUiReadRequest(
const token = resolveControlUiReadAuthToken(req, {
allowQueryToken: opts.allowQueryToken,
});
const clientIp =
resolveRequestClientIp(req, opts.trustedProxies, opts.allowRealIpFallback === true) ??
req.socket?.remoteAddress;
const authResult = await authorizeHttpGatewayConnect({
auth: opts.auth,
connectAuth: token ? { token, password: token } : null,
@@ -254,17 +266,42 @@ async function authorizeControlUiReadRequest(
browserOriginPolicy: resolveHttpBrowserOriginPolicy(req),
trustedProxies: opts.trustedProxies,
allowRealIpFallback: opts.allowRealIpFallback,
rateLimiter: opts.rateLimiter,
rateLimiter: token ? opts.rateLimiter : undefined,
clientIp,
rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
});
if (!authResult.ok) {
sendGatewayAuthFailure(res, authResult);
let resolvedAuthResult = authResult;
if (
!resolvedAuthResult.ok &&
token &&
opts.auth.mode !== "trusted-proxy" &&
opts.auth.mode !== "none"
) {
const deviceRateCheck = opts.rateLimiter?.check(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
if (deviceRateCheck && !deviceRateCheck.allowed) {
resolvedAuthResult = {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: deviceRateCheck.retryAfterMs,
};
} else {
const deviceTokenOk = await authorizeControlUiDeviceReadToken(token);
if (deviceTokenOk) {
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" };
} else {
opts.rateLimiter?.recordFailure(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
}
}
}
if (!resolvedAuthResult.ok) {
sendGatewayAuthFailure(res, resolvedAuthResult);
return false;
}
const trustDeclaredOperatorScopes =
authResult.method !== "token" &&
authResult.method !== "password" &&
authResult.method !== "none";
const trustDeclaredOperatorScopes = resolvedAuthResult.method === "trusted-proxy";
if (!trustDeclaredOperatorScopes) {
return true;
}
@@ -287,6 +324,29 @@ async function authorizeControlUiReadRequest(
return true;
}
async function authorizeControlUiDeviceReadToken(token: string): Promise<boolean> {
const pairing = await listDevicePairing();
for (const device of pairing.paired) {
const operatorToken = device.tokens?.[CONTROL_UI_OPERATOR_ROLE];
if (!operatorToken || operatorToken.revokedAtMs) {
continue;
}
if (!verifyPairingToken(token, operatorToken.token)) {
continue;
}
const verified = await verifyDeviceToken({
deviceId: device.deviceId,
token,
role: CONTROL_UI_OPERATOR_ROLE,
scopes: [CONTROL_UI_OPERATOR_READ_SCOPE],
});
if (verified.ok) {
return true;
}
}
return false;
}
type AssistantMediaAvailability =
| { available: true }
| { available: false; reason: string; code: string };