diff --git a/src/gateway/protocol/connect-error-details.ts b/src/gateway/protocol/connect-error-details.ts new file mode 100644 index 00000000000..5a0975fed78 --- /dev/null +++ b/src/gateway/protocol/connect-error-details.ts @@ -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; +} diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index a9b1b97c14a..d85e06b38fc 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -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(); diff --git a/src/gateway/server/ws-connection/auth-context.test.ts b/src/gateway/server/ws-connection/auth-context.test.ts new file mode 100644 index 00000000000..d743f3bb3ce --- /dev/null +++ b/src/gateway/server/ws-connection/auth-context.test.ts @@ -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; +} { + 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 { + 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(); + }); +}); diff --git a/src/gateway/server/ws-connection/auth-context.ts b/src/gateway/server/ws-connection/auth-context.ts index 70cac275a86..d5e98dfd533 100644 --- a/src/gateway/server/ws-connection/auth-context.ts +++ b/src/gateway/server/ws-connection/auth-context.ts @@ -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; +}): Promise { + 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 }; +} diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 6df1bedefb2..5305e68305d 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -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"); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index d60fc2490c2..0d79144eda8 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -409,7 +409,7 @@ type ConnectResponse = { id: string; ok: boolean; payload?: Record; - error?: { message?: string }; + error?: { message?: string; code?: string; details?: unknown }; }; export async function readConnectChallengeNonce( diff --git a/src/infra/npm-pack-install.ts b/src/infra/npm-pack-install.ts index 447563c3015..f343653c415 100644 --- a/src/infra/npm-pack-install.ts +++ b/src/infra/npm-pack-install.ts @@ -57,20 +57,31 @@ export type NpmSpecArchiveFinalInstallResult = integrityDrift?: NpmIntegrityDrift; }); +function isSuccessfulInstallResult( + result: TResult, +): result is Extract { + return result.ok; +} + export function finalizeNpmSpecArchiveInstall( flowResult: NpmSpecArchiveInstallFlowResult, ): NpmSpecArchiveFinalInstallResult { if (!flowResult.ok) { return flowResult; } - if (!flowResult.installResult.ok) { - return flowResult.installResult; + const installResult = flowResult.installResult; + if (!isSuccessfulInstallResult(installResult)) { + return installResult as Exclude; } - return { - ...flowResult.installResult, + const finalized: Extract & { + npmResolution: NpmSpecResolution; + integrityDrift?: NpmIntegrityDrift; + } = { + ...installResult, npmResolution: flowResult.npmResolution, - integrityDrift: flowResult.integrityDrift, + ...(flowResult.integrityDrift ? { integrityDrift: flowResult.integrityDrift } : {}), }; + return finalized; } export async function installFromNpmSpecArchive(params: { diff --git a/src/node-host/exec-policy.test.ts b/src/node-host/exec-policy.test.ts new file mode 100644 index 00000000000..67a14cc8ad1 --- /dev/null +++ b/src/node-host/exec-policy.test.ts @@ -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); + }); +}); diff --git a/src/node-host/exec-policy.ts b/src/node-host/exec-policy.ts new file mode 100644 index 00000000000..cbc266ed3e2 --- /dev/null +++ b/src/node-host/exec-policy.ts @@ -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, + }; +} diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index 87df62926ae..a83900b1441 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -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(); 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 ) { diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 30e4a1203ca..b0f91f17310 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -5,7 +5,11 @@ import { connectGateway } from "./app-gateway.ts"; type GatewayClientMock = { start: ReturnType; stop: ReturnType; - 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"); }); }); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 4126b5707c3..338c3b5806c 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -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[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; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a87f9a8059c..8e441e9dcdc 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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, diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index e7c7735c8bf..e8fcad4de74 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index db4b290b10e..24b6198481a 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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; diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 19bc201a584..e22f744bb07 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -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; } diff --git a/ui/src/ui/views/overview-hints.ts b/ui/src/ui/views/overview-hints.ts index c7ff78b9e69..9db33a2b577 100644 --- a/ui/src/ui/views/overview-hints.ts +++ b/ui/src/ui/views/overview-hints.ts @@ -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"); } diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/ui/views/overview.node.test.ts index b8096661a3a..3fa65b93391 100644 --- a/ui/src/ui/views/overview.node.test.ts +++ b/ui/src/ui/views/overview.node.test.ts @@ -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); + }); }); diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index b18c2ce2248..7a1e1dfaf02 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -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([ + 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([ + ...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`
${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`