mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
fix: harden connect auth flow and exec policy diagnostics
This commit is contained in:
66
src/gateway/protocol/connect-error-details.ts
Normal file
66
src/gateway/protocol/connect-error-details.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
124
src/gateway/server/ws-connection/auth-context.test.ts
Normal file
124
src/gateway/server/ws-connection/auth-context.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
148
src/node-host/exec-policy.test.ts
Normal file
148
src/node-host/exec-policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
116
src/node-host/exec-policy.ts
Normal file
116
src/node-host/exec-policy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user