fix: harden connect auth flow and exec policy diagnostics

This commit is contained in:
Peter Steinberger
2026-02-22 20:20:11 +01:00
parent 7e83e7b3a7
commit bbdfba5694
19 changed files with 797 additions and 145 deletions

View File

@@ -0,0 +1,66 @@
export const ConnectErrorDetailCodes = {
AUTH_REQUIRED: "AUTH_REQUIRED",
AUTH_UNAUTHORIZED: "AUTH_UNAUTHORIZED",
AUTH_TOKEN_MISSING: "AUTH_TOKEN_MISSING",
AUTH_TOKEN_MISMATCH: "AUTH_TOKEN_MISMATCH",
AUTH_TOKEN_NOT_CONFIGURED: "AUTH_TOKEN_NOT_CONFIGURED",
AUTH_PASSWORD_MISSING: "AUTH_PASSWORD_MISSING",
AUTH_PASSWORD_MISMATCH: "AUTH_PASSWORD_MISMATCH",
AUTH_PASSWORD_NOT_CONFIGURED: "AUTH_PASSWORD_NOT_CONFIGURED",
AUTH_DEVICE_TOKEN_MISMATCH: "AUTH_DEVICE_TOKEN_MISMATCH",
AUTH_RATE_LIMITED: "AUTH_RATE_LIMITED",
AUTH_TAILSCALE_IDENTITY_MISSING: "AUTH_TAILSCALE_IDENTITY_MISSING",
AUTH_TAILSCALE_PROXY_MISSING: "AUTH_TAILSCALE_PROXY_MISSING",
AUTH_TAILSCALE_WHOIS_FAILED: "AUTH_TAILSCALE_WHOIS_FAILED",
AUTH_TAILSCALE_IDENTITY_MISMATCH: "AUTH_TAILSCALE_IDENTITY_MISMATCH",
CONTROL_UI_DEVICE_IDENTITY_REQUIRED: "CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
DEVICE_IDENTITY_REQUIRED: "DEVICE_IDENTITY_REQUIRED",
DEVICE_AUTH_INVALID: "DEVICE_AUTH_INVALID",
PAIRING_REQUIRED: "PAIRING_REQUIRED",
} as const;
export type ConnectErrorDetailCode =
(typeof ConnectErrorDetailCodes)[keyof typeof ConnectErrorDetailCodes];
export function resolveAuthConnectErrorDetailCode(
reason: string | undefined,
): ConnectErrorDetailCode {
switch (reason) {
case "token_missing":
return ConnectErrorDetailCodes.AUTH_TOKEN_MISSING;
case "token_mismatch":
return ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH;
case "token_missing_config":
return ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED;
case "password_missing":
return ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING;
case "password_mismatch":
return ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH;
case "password_missing_config":
return ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED;
case "tailscale_user_missing":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING;
case "tailscale_proxy_missing":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING;
case "tailscale_whois_failed":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED;
case "tailscale_user_mismatch":
return ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH;
case "rate_limited":
return ConnectErrorDetailCodes.AUTH_RATE_LIMITED;
case "device_token_mismatch":
return ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH;
case undefined:
return ConnectErrorDetailCodes.AUTH_REQUIRED;
default:
return ConnectErrorDetailCodes.AUTH_UNAUTHORIZED;
}
}
export function readConnectErrorDetailCode(details: unknown): string | null {
if (!details || typeof details !== "object" || Array.isArray(details)) {
return null;
}
const code = (details as { code?: unknown }).code;
return typeof code === "string" && code.trim().length > 0 ? code : null;
}

View File

@@ -3,6 +3,7 @@ import { WebSocket } from "ws";
import { withEnvAsync } from "../test-utils/env.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
import { ConnectErrorDetailCodes } from "./protocol/connect-error-details.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { getHandshakeTimeoutMs } from "./server-constants.js";
import {
@@ -716,6 +717,9 @@ describe("gateway server auth/connect", () => {
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("secure context");
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
);
ws.close();
});
});
@@ -898,6 +902,9 @@ describe("gateway server auth/connect", () => {
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("pairing required");
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.PAIRING_REQUIRED,
);
ws.close();
});
} finally {
@@ -1004,6 +1011,9 @@ describe("gateway server auth/connect", () => {
expect(res2.ok).toBe(false);
expect(res2.error?.message ?? "").toContain("gateway token mismatch");
expect(res2.error?.message ?? "").not.toContain("device token mismatch");
expect((res2.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
);
ws2.close();
await server.close();
@@ -1023,6 +1033,9 @@ describe("gateway server auth/connect", () => {
});
expect(res2.ok).toBe(false);
expect(res2.error?.message ?? "").toContain("device token mismatch");
expect((res2.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
);
ws2.close();
await server.close();

View File

@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import { resolveConnectAuthDecision, type ConnectAuthState } from "./auth-context.js";
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
limiter: AuthRateLimiter;
reset: ReturnType<typeof vi.fn>;
} {
const allowed = params?.allowed ?? true;
const retryAfterMs = params?.retryAfterMs ?? 5_000;
const check = vi.fn(() => ({ allowed, retryAfterMs }));
const reset = vi.fn();
const recordFailure = vi.fn();
return {
limiter: {
check,
reset,
recordFailure,
} as unknown as AuthRateLimiter,
reset,
};
}
function createBaseState(overrides?: Partial<ConnectAuthState>): ConnectAuthState {
return {
authResult: { ok: false, reason: "token_mismatch" },
authOk: false,
authMethod: "token",
sharedAuthOk: false,
sharedAuthProvided: true,
deviceTokenCandidate: "device-token",
deviceTokenCandidateSource: "shared-token-fallback",
...overrides,
};
}
describe("resolveConnectAuthDecision", () => {
it("keeps shared-secret mismatch when fallback device-token check fails", async () => {
const verifyDeviceToken = vi.fn(async () => ({ ok: false }));
const decision = await resolveConnectAuthDecision({
state: createBaseState(),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: ["operator.read"],
verifyDeviceToken,
});
expect(decision.authOk).toBe(false);
expect(decision.authResult.reason).toBe("token_mismatch");
expect(verifyDeviceToken).toHaveBeenCalledOnce();
});
it("reports explicit device-token mismatches as device_token_mismatch", async () => {
const verifyDeviceToken = vi.fn(async () => ({ ok: false }));
const decision = await resolveConnectAuthDecision({
state: createBaseState({
deviceTokenCandidateSource: "explicit-device-token",
}),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: ["operator.read"],
verifyDeviceToken,
});
expect(decision.authOk).toBe(false);
expect(decision.authResult.reason).toBe("device_token_mismatch");
});
it("accepts valid device tokens and marks auth method as device-token", async () => {
const rateLimiter = createRateLimiter();
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState(),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: ["operator.read"],
rateLimiter: rateLimiter.limiter,
clientIp: "203.0.113.20",
verifyDeviceToken,
});
expect(decision.authOk).toBe(true);
expect(decision.authMethod).toBe("device-token");
expect(verifyDeviceToken).toHaveBeenCalledOnce();
expect(rateLimiter.reset).toHaveBeenCalledOnce();
});
it("returns rate-limited auth result without verifying device token", async () => {
const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 });
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState(),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: ["operator.read"],
rateLimiter: rateLimiter.limiter,
clientIp: "203.0.113.20",
verifyDeviceToken,
});
expect(decision.authOk).toBe(false);
expect(decision.authResult.reason).toBe("rate_limited");
expect(decision.authResult.retryAfterMs).toBe(60_000);
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
it("returns the original decision when device fallback does not apply", async () => {
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState({
authResult: { ok: true, method: "token" },
authOk: true,
}),
hasDeviceIdentity: true,
deviceId: "dev-1",
role: "operator",
scopes: [],
verifyDeviceToken,
});
expect(decision.authOk).toBe(true);
expect(decision.authMethod).toBe("token");
expect(verifyDeviceToken).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import type { IncomingMessage } from "node:http";
import {
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
type AuthRateLimiter,
type RateLimitCheckResult,
@@ -29,6 +30,14 @@ export type ConnectAuthState = {
deviceTokenCandidateSource?: DeviceTokenCandidateSource;
};
type VerifyDeviceTokenResult = { ok: boolean };
export type ConnectAuthDecision = {
authResult: GatewayAuthResult;
authOk: boolean;
authMethod: GatewayAuthResult["method"];
};
function trimToUndefined(value: string | undefined): string | undefined {
if (!value) {
return undefined;
@@ -139,3 +148,67 @@ export async function resolveConnectAuthState(params: {
deviceTokenCandidateSource,
};
}
export async function resolveConnectAuthDecision(params: {
state: ConnectAuthState;
hasDeviceIdentity: boolean;
deviceId?: string;
role: string;
scopes: string[];
rateLimiter?: AuthRateLimiter;
clientIp?: string;
verifyDeviceToken: (params: {
deviceId: string;
token: string;
role: string;
scopes: string[];
}) => Promise<VerifyDeviceTokenResult>;
}): Promise<ConnectAuthDecision> {
let authResult = params.state.authResult;
let authOk = params.state.authOk;
let authMethod = params.state.authMethod;
const deviceTokenCandidate = params.state.deviceTokenCandidate;
if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) {
return { authResult, authOk, authMethod };
}
if (params.rateLimiter) {
const deviceRateCheck = params.rateLimiter.check(
params.clientIp,
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
);
if (!deviceRateCheck.allowed) {
authResult = {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: deviceRateCheck.retryAfterMs,
};
}
}
if (!authResult.rateLimited) {
const tokenCheck = await params.verifyDeviceToken({
deviceId: params.deviceId,
token: deviceTokenCandidate,
role: params.role,
scopes: params.scopes,
});
if (tokenCheck.ok) {
authOk = true;
authMethod = "device-token";
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
} else {
authResult = {
ok: false,
reason:
params.state.deviceTokenCandidateSource === "explicit-device-token"
? "device_token_mismatch"
: (authResult.reason ?? "device_token_mismatch"),
};
params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
}
}
return { authResult, authOk, authMethod };
}

View File

@@ -24,7 +24,7 @@ import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
import { resolveRuntimeServiceVersion } from "../../../version.js";
import { AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN, type AuthRateLimiter } from "../../auth-rate-limit.js";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
import { isLocalDirectRequest } from "../../auth.js";
import {
@@ -42,6 +42,10 @@ import {
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import { checkBrowserOrigin } from "../../origin-check.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
import {
ConnectErrorDetailCodes,
resolveAuthConnectErrorDetailCode,
} from "../../protocol/connect-error-details.js";
import {
type ConnectParams,
ErrorCodes,
@@ -67,7 +71,7 @@ import {
refreshGatewayHealthSnapshot,
} from "../health-state.js";
import type { GatewayWsClient } from "../ws-types.js";
import { resolveConnectAuthState } from "./auth-context.js";
import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-context.js";
import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js";
import {
evaluateMissingDeviceIdentity,
@@ -401,7 +405,12 @@ export function attachGatewayWsMessageHandler(params: {
reason: failedAuth.reason,
client: connectParams.client,
});
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, authMessage);
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, authMessage, {
details: {
code: resolveAuthConnectErrorDetailCode(failedAuth.reason),
authReason: failedAuth.reason,
},
});
close(1008, truncateCloseReason(authMessage));
};
const clearUnboundScopes = () => {
@@ -434,7 +443,9 @@ export function attachGatewayWsMessageHandler(params: {
markHandshakeFailure("control-ui-insecure-auth", {
insecureAuthConfigured: controlUiAuthPolicy.allowInsecureAuthConfigured,
});
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage);
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage, {
details: { code: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED },
});
close(1008, errorMessage);
return false;
}
@@ -445,7 +456,9 @@ export function attachGatewayWsMessageHandler(params: {
}
markHandshakeFailure("device-required");
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required");
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required", {
details: { code: ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED },
});
close(1008, "device identity required");
return false;
};
@@ -464,7 +477,12 @@ export function attachGatewayWsMessageHandler(params: {
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, message),
error: errorShape(ErrorCodes.INVALID_REQUEST, message, {
details: {
code: ConnectErrorDetailCodes.DEVICE_AUTH_INVALID,
reason,
},
}),
});
close(1008, message);
};
@@ -514,39 +532,24 @@ export function attachGatewayWsMessageHandler(params: {
}
}
if (!authOk && device && deviceTokenCandidate) {
if (rateLimiter) {
const deviceRateCheck = rateLimiter.check(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
if (!deviceRateCheck.allowed) {
authResult = {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: deviceRateCheck.retryAfterMs,
};
}
}
if (!authResult.rateLimited) {
const tokenCheck = await verifyDeviceToken({
deviceId: device.id,
token: deviceTokenCandidate,
role,
scopes,
});
if (tokenCheck.ok) {
authOk = true;
authMethod = "device-token";
rateLimiter?.reset(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
} else {
const mismatchReason =
deviceTokenCandidateSource === "explicit-device-token"
? "device_token_mismatch"
: (authResult.reason ?? "device_token_mismatch");
authResult = { ok: false, reason: mismatchReason };
rateLimiter?.recordFailure(clientIp, AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN);
}
}
}
({ authResult, authOk, authMethod } = await resolveConnectAuthDecision({
state: {
authResult,
authOk,
authMethod,
sharedAuthOk,
sharedAuthProvided: hasSharedAuth,
deviceTokenCandidate,
deviceTokenCandidateSource,
},
hasDeviceIdentity: Boolean(device),
deviceId: device?.id,
role,
scopes,
rateLimiter,
clientIp,
verifyDeviceToken,
}));
if (!authOk) {
rejectUnauthorized(authResult);
return;
@@ -636,7 +639,11 @@ export function attachGatewayWsMessageHandler(params: {
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.NOT_PAIRED, "pairing required", {
details: { requestId: pairing.request.requestId },
details: {
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
requestId: pairing.request.requestId,
reason,
},
}),
});
close(1008, "pairing required");

View File

@@ -409,7 +409,7 @@ type ConnectResponse = {
id: string;
ok: boolean;
payload?: Record<string, unknown>;
error?: { message?: string };
error?: { message?: string; code?: string; details?: unknown };
};
export async function readConnectChallengeNonce(

View File

@@ -57,20 +57,31 @@ export type NpmSpecArchiveFinalInstallResult<TResult extends { ok: boolean }> =
integrityDrift?: NpmIntegrityDrift;
});
function isSuccessfulInstallResult<TResult extends { ok: boolean }>(
result: TResult,
): result is Extract<TResult, { ok: true }> {
return result.ok;
}
export function finalizeNpmSpecArchiveInstall<TResult extends { ok: boolean }>(
flowResult: NpmSpecArchiveInstallFlowResult<TResult>,
): NpmSpecArchiveFinalInstallResult<TResult> {
if (!flowResult.ok) {
return flowResult;
}
if (!flowResult.installResult.ok) {
return flowResult.installResult;
const installResult = flowResult.installResult;
if (!isSuccessfulInstallResult(installResult)) {
return installResult as Exclude<TResult, { ok: true }>;
}
return {
...flowResult.installResult,
const finalized: Extract<TResult, { ok: true }> & {
npmResolution: NpmSpecResolution;
integrityDrift?: NpmIntegrityDrift;
} = {
...installResult,
npmResolution: flowResult.npmResolution,
integrityDrift: flowResult.integrityDrift,
...(flowResult.integrityDrift ? { integrityDrift: flowResult.integrityDrift } : {}),
};
return finalized;
}
export async function installFromNpmSpecArchive<TResult extends { ok: boolean }>(params: {

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
evaluateSystemRunPolicy,
formatSystemRunAllowlistMissMessage,
resolveExecApprovalDecision,
} from "./exec-policy.js";
describe("resolveExecApprovalDecision", () => {
it("accepts known approval decisions", () => {
expect(resolveExecApprovalDecision("allow-once")).toBe("allow-once");
expect(resolveExecApprovalDecision("allow-always")).toBe("allow-always");
});
it("normalizes unknown approval decisions to null", () => {
expect(resolveExecApprovalDecision("deny")).toBeNull();
expect(resolveExecApprovalDecision(undefined)).toBeNull();
});
});
describe("formatSystemRunAllowlistMissMessage", () => {
it("returns legacy allowlist miss message by default", () => {
expect(formatSystemRunAllowlistMissMessage()).toBe("SYSTEM_RUN_DENIED: allowlist miss");
});
it("adds Windows shell-wrapper guidance when blocked by cmd.exe policy", () => {
expect(
formatSystemRunAllowlistMissMessage({
windowsShellWrapperBlocked: true,
}),
).toContain("Windows shell wrappers like cmd.exe /c require approval");
});
});
describe("evaluateSystemRunPolicy", () => {
it("denies when security mode is deny", () => {
const decision = evaluateSystemRunPolicy({
security: "deny",
ask: "off",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.eventReason).toBe("security=deny");
expect(decision.errorMessage).toBe("SYSTEM_RUN_DISABLED: security=deny");
});
it("requires approval when ask policy requires it", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "always",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.eventReason).toBe("approval-required");
expect(decision.requiresAsk).toBe(true);
});
it("allows allowlist miss when explicit approval is provided", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "on-miss",
analysisOk: false,
allowlistSatisfied: false,
approvalDecision: "allow-once",
approved: false,
isWindows: false,
cmdInvocation: false,
});
expect(decision.allowed).toBe(true);
if (!decision.allowed) {
throw new Error("expected allowed decision");
}
expect(decision.approvedByAsk).toBe(true);
});
it("denies allowlist misses without approval", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "off",
analysisOk: false,
allowlistSatisfied: false,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.eventReason).toBe("allowlist-miss");
expect(decision.errorMessage).toBe("SYSTEM_RUN_DENIED: allowlist miss");
});
it("treats Windows cmd.exe wrappers as allowlist misses", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "off",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: true,
cmdInvocation: true,
});
expect(decision.allowed).toBe(false);
if (decision.allowed) {
throw new Error("expected denied decision");
}
expect(decision.windowsShellWrapperBlocked).toBe(true);
expect(decision.errorMessage).toContain("Windows shell wrappers like cmd.exe /c");
});
it("allows execution when policy checks pass", () => {
const decision = evaluateSystemRunPolicy({
security: "allowlist",
ask: "on-miss",
analysisOk: true,
allowlistSatisfied: true,
approvalDecision: null,
approved: false,
isWindows: false,
cmdInvocation: false,
});
expect(decision.allowed).toBe(true);
if (!decision.allowed) {
throw new Error("expected allowed decision");
}
expect(decision.requiresAsk).toBe(false);
expect(decision.analysisOk).toBe(true);
expect(decision.allowlistSatisfied).toBe(true);
});
});

View File

@@ -0,0 +1,116 @@
import { requiresExecApproval, type ExecAsk, type ExecSecurity } from "../infra/exec-approvals.js";
export type ExecApprovalDecision = "allow-once" | "allow-always" | null;
export type SystemRunPolicyDecision = {
analysisOk: boolean;
allowlistSatisfied: boolean;
windowsShellWrapperBlocked: boolean;
requiresAsk: boolean;
approvalDecision: ExecApprovalDecision;
approvedByAsk: boolean;
} & (
| {
allowed: true;
}
| {
allowed: false;
eventReason: "security=deny" | "approval-required" | "allowlist-miss";
errorMessage: string;
}
);
export function resolveExecApprovalDecision(value: unknown): ExecApprovalDecision {
if (value === "allow-once" || value === "allow-always") {
return value;
}
return null;
}
export function formatSystemRunAllowlistMissMessage(params?: {
windowsShellWrapperBlocked?: boolean;
}): string {
if (params?.windowsShellWrapperBlocked) {
return (
"SYSTEM_RUN_DENIED: allowlist miss " +
"(Windows shell wrappers like cmd.exe /c require approval; " +
"approve once/always or run with --ask on-miss|always)"
);
}
return "SYSTEM_RUN_DENIED: allowlist miss";
}
export function evaluateSystemRunPolicy(params: {
security: ExecSecurity;
ask: ExecAsk;
analysisOk: boolean;
allowlistSatisfied: boolean;
approvalDecision: ExecApprovalDecision;
approved?: boolean;
isWindows: boolean;
cmdInvocation: boolean;
}): SystemRunPolicyDecision {
const windowsShellWrapperBlocked =
params.security === "allowlist" && params.isWindows && params.cmdInvocation;
const analysisOk = windowsShellWrapperBlocked ? false : params.analysisOk;
const allowlistSatisfied = windowsShellWrapperBlocked ? false : params.allowlistSatisfied;
const approvedByAsk = params.approvalDecision !== null || params.approved === true;
if (params.security === "deny") {
return {
allowed: false,
eventReason: "security=deny",
errorMessage: "SYSTEM_RUN_DISABLED: security=deny",
analysisOk,
allowlistSatisfied,
windowsShellWrapperBlocked,
requiresAsk: false,
approvalDecision: params.approvalDecision,
approvedByAsk,
};
}
const requiresAsk = requiresExecApproval({
ask: params.ask,
security: params.security,
analysisOk,
allowlistSatisfied,
});
if (requiresAsk && !approvedByAsk) {
return {
allowed: false,
eventReason: "approval-required",
errorMessage: "SYSTEM_RUN_DENIED: approval required",
analysisOk,
allowlistSatisfied,
windowsShellWrapperBlocked,
requiresAsk,
approvalDecision: params.approvalDecision,
approvedByAsk,
};
}
if (params.security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
return {
allowed: false,
eventReason: "allowlist-miss",
errorMessage: formatSystemRunAllowlistMissMessage({ windowsShellWrapperBlocked }),
analysisOk,
allowlistSatisfied,
windowsShellWrapperBlocked,
requiresAsk,
approvalDecision: params.approvalDecision,
approvedByAsk,
};
}
return {
allowed: true,
analysisOk,
allowlistSatisfied,
windowsShellWrapperBlocked,
requiresAsk,
approvalDecision: params.approvalDecision,
approvedByAsk,
};
}

View File

@@ -8,7 +8,6 @@ import {
evaluateExecAllowlist,
evaluateShellAllowlist,
recordAllowlistUse,
requiresExecApproval,
resolveAllowAlwaysPatterns,
resolveExecApprovals,
type ExecAllowlistEntry,
@@ -20,6 +19,7 @@ import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../in
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js";
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js";
import type {
ExecEventPayload,
RunResult,
@@ -32,19 +32,7 @@ type SystemRunInvokeResult = {
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
};
export function formatSystemRunAllowlistMissMessage(params?: {
windowsShellWrapperBlocked?: boolean;
}): string {
if (params?.windowsShellWrapperBlocked) {
return (
"SYSTEM_RUN_DENIED: allowlist miss " +
"(Windows shell wrappers like cmd.exe /c require approval; " +
"approve once/always or run with --ask on-miss|always)"
);
}
return "SYSTEM_RUN_DENIED: allowlist miss";
}
export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js";
export async function handleSystemRunInvoke(opts: {
client: GatewayClient;
@@ -122,6 +110,7 @@ export async function handleSystemRunInvoke(opts: {
const autoAllowSkills = approvals.agent.autoAllowSkills;
const sessionKey = opts.params.sessionKey?.trim() || "node";
const runId = opts.params.runId?.trim() || crypto.randomUUID();
const approvalDecision = resolveExecApprovalDecision(opts.params.approvalDecision);
const envOverrides = sanitizeSystemRunEnvOverrides({
overrides: opts.params.env ?? undefined,
shellWrapper: shellCommand !== null,
@@ -176,19 +165,9 @@ export async function handleSystemRunInvoke(opts: {
const cmdInvocation = shellCommand
? opts.isCmdExeInvocation(segments[0]?.argv ?? [])
: opts.isCmdExeInvocation(argv);
const windowsShellWrapperBlocked = security === "allowlist" && isWindows && cmdInvocation;
if (windowsShellWrapperBlocked) {
analysisOk = false;
allowlistSatisfied = false;
}
const useMacAppExec = process.platform === "darwin";
if (useMacAppExec) {
const approvalDecision =
opts.params.approvalDecision === "allow-once" ||
opts.params.approvalDecision === "allow-always"
? opts.params.approvalDecision
: null;
const execRequest: ExecHostRequest = {
command: argv,
rawCommand: rawCommand || shellCommand || null,
@@ -252,38 +231,19 @@ export async function handleSystemRunInvoke(opts: {
}
}
if (security === "deny") {
await opts.sendNodeEvent(
opts.client,
"exec.denied",
opts.buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "security=deny",
}),
);
await opts.sendInvokeResult({
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny" },
});
return;
}
const requiresAsk = requiresExecApproval({
ask,
const policy = evaluateSystemRunPolicy({
security,
ask,
analysisOk,
allowlistSatisfied,
approvalDecision,
approved: opts.params.approved === true,
isWindows,
cmdInvocation,
});
const approvalDecision =
opts.params.approvalDecision === "allow-once" || opts.params.approvalDecision === "allow-always"
? opts.params.approvalDecision
: null;
const approvedByAsk = approvalDecision !== null || opts.params.approved === true;
if (requiresAsk && !approvedByAsk) {
analysisOk = policy.analysisOk;
allowlistSatisfied = policy.allowlistSatisfied;
if (!policy.allowed) {
await opts.sendNodeEvent(
opts.client,
"exec.denied",
@@ -292,17 +252,18 @@ export async function handleSystemRunInvoke(opts: {
runId,
host: "node",
command: cmdText,
reason: "approval-required",
reason: policy.eventReason,
}),
);
await opts.sendInvokeResult({
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
error: { code: "UNAVAILABLE", message: policy.errorMessage },
});
return;
}
if (approvalDecision === "allow-always" && security === "allowlist") {
if (analysisOk) {
if (policy.approvalDecision === "allow-always" && security === "allowlist") {
if (policy.analysisOk) {
const patterns = resolveAllowAlwaysPatterns({
segments,
cwd: opts.params.cwd ?? undefined,
@@ -317,28 +278,6 @@ export async function handleSystemRunInvoke(opts: {
}
}
if (security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
await opts.sendNodeEvent(
opts.client,
"exec.denied",
opts.buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "allowlist-miss",
}),
);
await opts.sendInvokeResult({
ok: false,
error: {
code: "UNAVAILABLE",
message: formatSystemRunAllowlistMissMessage({ windowsShellWrapperBlocked }),
},
});
return;
}
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
@@ -379,10 +318,10 @@ export async function handleSystemRunInvoke(opts: {
if (
security === "allowlist" &&
isWindows &&
!approvedByAsk &&
!policy.approvedByAsk &&
shellCommand &&
analysisOk &&
allowlistSatisfied &&
policy.analysisOk &&
policy.allowlistSatisfied &&
segments.length === 1 &&
segments[0]?.argv.length > 0
) {

View File

@@ -5,7 +5,11 @@ import { connectGateway } from "./app-gateway.ts";
type GatewayClientMock = {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
emitClose: (code: number, reason?: string) => void;
emitClose: (info: {
code: number;
reason?: string;
error?: { code: string; message: string; details?: unknown };
}) => void;
emitGap: (expected: number, received: number) => void;
emitEvent: (evt: { event: string; payload?: unknown; seq?: number }) => void;
};
@@ -19,7 +23,11 @@ vi.mock("./gateway.ts", () => {
constructor(
private opts: {
onClose?: (info: { code: number; reason: string }) => void;
onClose?: (info: {
code: number;
reason: string;
error?: { code: string; message: string; details?: unknown };
}) => void;
onGap?: (info: { expected: number; received: number }) => void;
onEvent?: (evt: { event: string; payload?: unknown; seq?: number }) => void;
},
@@ -27,8 +35,12 @@ vi.mock("./gateway.ts", () => {
gatewayClientInstances.push({
start: this.start,
stop: this.stop,
emitClose: (code, reason) => {
this.opts.onClose?.({ code, reason: reason ?? "" });
emitClose: (info) => {
this.opts.onClose?.({
code: info.code,
reason: info.reason ?? "",
error: info.error,
});
},
emitGap: (expected, received) => {
this.opts.onGap?.({ expected, received });
@@ -62,6 +74,7 @@ function createHost() {
connected: false,
hello: null,
lastError: null,
lastErrorCode: null,
eventLogBuffer: [],
eventLog: [],
tab: "overview",
@@ -171,10 +184,34 @@ describe("connectGateway", () => {
const secondClient = gatewayClientInstances[1];
expect(secondClient).toBeDefined();
firstClient.emitClose(1005);
firstClient.emitClose({ code: 1005 });
expect(host.lastError).toBeNull();
expect(host.lastErrorCode).toBeNull();
secondClient.emitClose(1005);
secondClient.emitClose({ code: 1005 });
expect(host.lastError).toBe("disconnected (1005): no reason");
expect(host.lastErrorCode).toBeNull();
});
it("prefers structured connect errors over close reason", () => {
const host = createHost();
connectGateway(host);
const client = gatewayClientInstances[0];
expect(client).toBeDefined();
client.emitClose({
code: 4008,
reason: "connect failed",
error: {
code: "INVALID_REQUEST",
message:
"unauthorized: gateway token mismatch (open the dashboard URL and paste the token in Control UI settings)",
details: { code: "AUTH_TOKEN_MISMATCH" },
},
});
expect(host.lastError).toContain("gateway token mismatch");
expect(host.lastErrorCode).toBe("AUTH_TOKEN_MISMATCH");
});
});

View File

@@ -26,7 +26,11 @@ import {
} from "./controllers/exec-approval.ts";
import { loadNodes } from "./controllers/nodes.ts";
import { loadSessions } from "./controllers/sessions.ts";
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway.ts";
import {
resolveGatewayErrorDetailCode,
type GatewayEventFrame,
type GatewayHelloOk,
} from "./gateway.ts";
import { GatewayBrowserClient } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import type { UiSettings } from "./storage.ts";
@@ -45,6 +49,7 @@ type GatewayHost = {
connected: boolean;
hello: GatewayHelloOk | null;
lastError: string | null;
lastErrorCode: string | null;
onboarding?: boolean;
eventLogBuffer: EventLogEntry[];
eventLog: EventLogEntry[];
@@ -128,6 +133,7 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps
export function connectGateway(host: GatewayHost) {
host.lastError = null;
host.lastErrorCode = null;
host.hello = null;
host.connected = false;
host.execApprovalQueue = [];
@@ -146,6 +152,7 @@ export function connectGateway(host: GatewayHost) {
}
host.connected = true;
host.lastError = null;
host.lastErrorCode = null;
host.hello = hello;
applySnapshot(host, hello);
// Reset orphaned chat run state from before disconnect.
@@ -160,14 +167,24 @@ export function connectGateway(host: GatewayHost) {
void loadDevices(host as unknown as OpenClawApp, { quiet: true });
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
},
onClose: ({ code, reason }) => {
onClose: ({ code, reason, error }) => {
if (host.client !== client) {
return;
}
host.connected = false;
// Code 1012 = Service Restart (expected during config saves, don't show as error)
host.lastErrorCode =
resolveGatewayErrorDetailCode(error) ??
(typeof error?.code === "string" ? error.code : null);
if (code !== 1012) {
if (error?.message) {
host.lastError = error.message;
return;
}
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
} else {
host.lastError = null;
host.lastErrorCode = null;
}
},
onEvent: (evt) => {
@@ -181,6 +198,7 @@ export function connectGateway(host: GatewayHost) {
return;
}
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
host.lastErrorCode = null;
},
});
host.client = client;

View File

@@ -220,6 +220,7 @@ export function renderApp(state: AppViewState) {
settings: state.settings,
password: state.password,
lastError: state.lastError,
lastErrorCode: state.lastErrorCode,
presenceCount,
sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null,

View File

@@ -46,6 +46,7 @@ export type AppViewState = {
themeResolved: "light" | "dark";
hello: GatewayHelloOk | null;
lastError: string | null;
lastErrorCode: string | null;
eventLog: EventLogEntry[];
assistantName: string;
assistantAvatar: string | null;

View File

@@ -122,6 +122,7 @@ export class OpenClawApp extends LitElement {
@state() themeResolved: ResolvedTheme = "dark";
@state() hello: GatewayHelloOk | null = null;
@state() lastError: string | null = null;
@state() lastErrorCode: string | null = null;
@state() eventLog: EventLogEntry[] = [];
private eventLogBuffer: EventLogEntry[] = [];
private toolStreamSyncTimer: number | null = null;

View File

@@ -5,6 +5,7 @@ import {
type GatewayClientMode,
type GatewayClientName,
} from "../../../src/gateway/protocol/client-info.js";
import { readConnectErrorDetailCode } from "../../../src/gateway/protocol/connect-error-details.js";
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts";
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity.ts";
import { generateUUID } from "./uuid.ts";
@@ -25,6 +26,30 @@ export type GatewayResponseFrame = {
error?: { code: string; message: string; details?: unknown };
};
export type GatewayErrorInfo = {
code: string;
message: string;
details?: unknown;
};
export class GatewayRequestError extends Error {
readonly gatewayCode: string;
readonly details?: unknown;
constructor(error: GatewayErrorInfo) {
super(error.message);
this.name = "GatewayRequestError";
this.gatewayCode = error.code;
this.details = error.details;
}
}
export function resolveGatewayErrorDetailCode(
error: { details?: unknown } | null | undefined,
): string | null {
return readConnectErrorDetailCode(error?.details);
}
export type GatewayHelloOk = {
type: "hello-ok";
protocol: number;
@@ -55,7 +80,7 @@ export type GatewayBrowserClientOptions = {
instanceId?: string;
onHello?: (hello: GatewayHelloOk) => void;
onEvent?: (evt: GatewayEventFrame) => void;
onClose?: (info: { code: number; reason: string }) => void;
onClose?: (info: { code: number; reason: string; error?: GatewayErrorInfo }) => void;
onGap?: (info: { expected: number; received: number }) => void;
};
@@ -71,6 +96,7 @@ export class GatewayBrowserClient {
private connectSent = false;
private connectTimer: number | null = null;
private backoffMs = 800;
private pendingConnectError: GatewayErrorInfo | undefined;
constructor(private opts: GatewayBrowserClientOptions) {}
@@ -83,6 +109,7 @@ export class GatewayBrowserClient {
this.closed = true;
this.ws?.close();
this.ws = null;
this.pendingConnectError = undefined;
this.flushPending(new Error("gateway client stopped"));
}
@@ -99,9 +126,11 @@ export class GatewayBrowserClient {
this.ws.addEventListener("message", (ev) => this.handleMessage(String(ev.data ?? "")));
this.ws.addEventListener("close", (ev) => {
const reason = String(ev.reason ?? "");
const connectError = this.pendingConnectError;
this.pendingConnectError = undefined;
this.ws = null;
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.opts.onClose?.({ code: ev.code, reason });
this.opts.onClose?.({ code: ev.code, reason, error: connectError });
this.scheduleReconnect();
});
this.ws.addEventListener("error", () => {
@@ -227,7 +256,16 @@ export class GatewayBrowserClient {
this.backoffMs = 800;
this.opts.onHello?.(hello);
})
.catch(() => {
.catch((err: unknown) => {
if (err instanceof GatewayRequestError) {
this.pendingConnectError = {
code: err.gatewayCode,
message: err.message,
details: err.details,
};
} else {
this.pendingConnectError = undefined;
}
if (canFallbackToShared && deviceIdentity) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
}
@@ -280,7 +318,13 @@ export class GatewayBrowserClient {
if (res.ok) {
pending.resolve(res.payload);
} else {
pending.reject(new Error(res.error?.message ?? "request failed"));
pending.reject(
new GatewayRequestError({
code: res.error?.code ?? "UNAVAILABLE",
message: res.error?.message ?? "request failed",
details: res.error?.details,
}),
);
}
return;
}

View File

@@ -1,7 +1,16 @@
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
/** Whether the overview should show device-pairing guidance for this error. */
export function shouldShowPairingHint(connected: boolean, lastError: string | null): boolean {
export function shouldShowPairingHint(
connected: boolean,
lastError: string | null,
lastErrorCode?: string | null,
): boolean {
if (connected || !lastError) {
return false;
}
if (lastErrorCode === ConnectErrorDetailCodes.PAIRING_REQUIRED) {
return true;
}
return lastError.toLowerCase().includes("pairing required");
}

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
import { shouldShowPairingHint } from "./overview-hints.ts";
describe("shouldShowPairingHint", () => {
@@ -25,4 +26,14 @@ describe("shouldShowPairingHint", () => {
it("returns false for auth errors", () => {
expect(shouldShowPairingHint(false, "disconnected (4008): unauthorized")).toBe(false);
});
it("returns true for structured pairing code", () => {
expect(
shouldShowPairingHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.PAIRING_REQUIRED,
),
).toBe(true);
});
});

View File

@@ -1,4 +1,5 @@
import { html } from "lit";
import { html, nothing } from "lit";
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
import { t, i18n, type Locale } from "../../i18n/index.ts";
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import type { GatewayHelloOk } from "../gateway.ts";
@@ -12,6 +13,7 @@ export type OverviewProps = {
settings: UiSettings;
password: string;
lastError: string | null;
lastErrorCode: string | null;
presenceCount: number;
sessionsCount: number | null;
cronEnabled: boolean | null;
@@ -40,7 +42,7 @@ export function renderOverview(props: OverviewProps) {
const isTrustedProxy = authMode === "trusted-proxy";
const pairingHint = (() => {
if (!shouldShowPairingHint(props.connected, props.lastError)) {
if (!shouldShowPairingHint(props.connected, props.lastError, props.lastErrorCode)) {
return null;
}
return html`
@@ -72,13 +74,37 @@ export function renderOverview(props: OverviewProps) {
return null;
}
const lower = props.lastError.toLowerCase();
const authFailed = lower.includes("unauthorized") || lower.includes("connect failed");
const authRequiredCodes = new Set<string>([
ConnectErrorDetailCodes.AUTH_REQUIRED,
ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING,
ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED,
ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED,
]);
const authFailureCodes = new Set<string>([
...authRequiredCodes,
ConnectErrorDetailCodes.AUTH_UNAUTHORIZED,
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING,
ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING,
ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED,
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
]);
const authFailed = props.lastErrorCode
? authFailureCodes.has(props.lastErrorCode)
: lower.includes("unauthorized") || lower.includes("connect failed");
if (!authFailed) {
return null;
}
const hasToken = Boolean(props.settings.token.trim());
const hasPassword = Boolean(props.password.trim());
if (!hasToken && !hasPassword) {
const isAuthRequired = props.lastErrorCode
? authRequiredCodes.has(props.lastErrorCode)
: !hasToken && !hasPassword;
if (isAuthRequired) {
return html`
<div class="muted" style="margin-top: 8px">
${t("overview.auth.required")}
@@ -125,7 +151,14 @@ export function renderOverview(props: OverviewProps) {
return null;
}
const lower = props.lastError.toLowerCase();
if (!lower.includes("secure context") && !lower.includes("device identity required")) {
const insecureContextCode =
props.lastErrorCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
props.lastErrorCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED;
if (
!insecureContextCode &&
!lower.includes("secure context") &&
!lower.includes("device identity required")
) {
return null;
}
return html`