Compare commits

...

46 Commits

Author SHA1 Message Date
Vincent Koc
568c7c729a docs: clarify native node approval docs 2026-05-31 23:36:15 +02:00
Vincent Koc
404bce7caa fix(cli): verify loopback password auth 2026-05-31 23:36:15 +02:00
Vincent Koc
102967bcc9 fix(protocol): model native node approvals 2026-05-31 23:36:14 +02:00
Vincent Koc
eb00a1e5b6 test(ui): normalize vite resolve hook 2026-05-31 23:36:14 +02:00
Vincent Koc
80a2a7f7f0 test: remove stale unsafe assertion suppressions 2026-05-31 23:36:14 +02:00
Vincent Koc
6fa8e695a8 test(cli): remove stale lint suppressions 2026-05-31 23:36:14 +02:00
Vincent Koc
1356b27198 fix(gateway): remove stale native approval type 2026-05-31 23:36:14 +02:00
Vincent Koc
54b60c838d fix(cli): repair Windows node typecheck 2026-05-31 23:36:14 +02:00
Vincent Koc
6768cd33fd fix(cli): repair Windows node validation 2026-05-31 23:36:14 +02:00
Vincent Koc
a286d7262e fix(protocol): reject empty native approval rules 2026-05-31 23:36:14 +02:00
Vincent Koc
c4be467cf0 test(cli): complete program gateway mock 2026-05-31 23:36:14 +02:00
Vincent Koc
0fc2bce669 fix(build): run TypeScript helpers through tsx 2026-05-31 23:36:14 +02:00
Vincent Koc
52ae286a9a fix(cli): clarify node shell execution guidance 2026-05-31 23:36:13 +02:00
Vincent Koc
2fea1837eb fix(ui): render native node approvals read-only 2026-05-31 23:36:13 +02:00
Vincent Koc
4bb4d9bd01 fix(cli): satisfy Windows node approval lint 2026-05-31 23:36:13 +02:00
Vincent Koc
fa41a77c5e fix(cli): resolve gateway token refs before direct auth 2026-05-31 23:36:13 +02:00
Vincent Koc
82cffcbf32 test(cli): keep gateway call mocks complete 2026-05-31 23:36:13 +02:00
Vincent Koc
acf1b75f8b fix(cli): hide transient commandless node rows 2026-05-31 23:36:13 +02:00
Vincent Koc
f614938006 fix(gateway): probe explicit shared tokens best-effort 2026-05-31 23:36:13 +02:00
Vincent Koc
eef48732a0 fix(gateway): preserve stable node event identity 2026-05-31 23:36:13 +02:00
Vincent Koc
aa0e3e7fa7 test(gateway): mark direct scoped call shared 2026-05-31 23:36:13 +02:00
Vincent Koc
85fb433392 fix(gateway): require proven shared explicit tokens 2026-05-31 23:36:13 +02:00
Vincent Koc
c62866a292 test(cli): isolate device auth environment 2026-05-31 23:36:13 +02:00
Vincent Koc
a91cc0e8d0 test(cli): keep direct auth URL probing optional 2026-05-31 23:36:12 +02:00
Vincent Koc
4e75e6677c fix(cli): detect implicit shared loopback auth 2026-05-31 23:36:12 +02:00
Vincent Koc
5ddfccd08d fix(gateway): merge stable node catalog rows 2026-05-31 23:36:12 +02:00
Vincent Koc
d09f1e9e98 fix(gateway): reject unbound stable node spoofing 2026-05-31 23:36:12 +02:00
Vincent Koc
31157b6386 fix(gateway): gate node exec approval RPCs 2026-05-31 23:36:12 +02:00
Vincent Koc
6a7d095a47 fix(gateway): tolerate unresolved auth probes 2026-05-31 23:36:12 +02:00
Vincent Koc
30d8c0bd59 fix(protocol): carry node pairing device id 2026-05-31 23:36:12 +02:00
Vincent Koc
95f955b8e0 fix(gateway): resolve secret-backed loopback tokens 2026-05-31 23:36:12 +02:00
Vincent Koc
4e931ef4a6 fix(gateway): bind unbound stable node approvals 2026-05-31 23:36:12 +02:00
Vincent Koc
c901c12f3e fix(cli): preserve device-token loopback auth 2026-05-31 23:36:12 +02:00
Vincent Koc
0abd9d5658 fix(cli): preserve empty native approval rules 2026-05-31 23:36:12 +02:00
Vincent Koc
4c670ef3c9 test(cli): keep gateway call mock complete 2026-05-31 23:36:11 +02:00
Vincent Koc
db55387598 fix(gateway): isolate rejected stable pairing metadata 2026-05-31 23:36:11 +02:00
Vincent Koc
3a36b695eb fix(protocol): reject empty native exec approval policies 2026-05-31 23:36:11 +02:00
Vincent Koc
8ff7e53bf0 fix(gateway): narrow node adoption device id 2026-05-31 23:36:11 +02:00
Vincent Koc
0e0dc93053 fix(auth): discard stale device token scopes 2026-05-31 23:36:11 +02:00
Vincent Koc
602b763c1d fix(gateway): bind node pairing to device identity 2026-05-31 23:36:11 +02:00
Vincent Koc
6bd53b2072 test(tui): tighten command handler mock types 2026-05-31 23:36:11 +02:00
Vincent Koc
45c9ba7866 fix(gateway): expose Windows exec approval node commands 2026-05-31 23:36:11 +02:00
Vincent Koc
1f6e89e307 fix(cli): support host-native node exec approvals 2026-05-31 23:36:11 +02:00
Vincent Koc
f6f89a1a96 test(cli): use documentation IP in device auth test 2026-05-31 23:36:10 +02:00
Vincent Koc
a3b6afdcb6 fix(cli): harden Windows node gateway commands 2026-05-31 23:36:10 +02:00
Vincent Koc
53a474e503 fix(pairing): preserve stable device tokens on metadata refresh 2026-05-31 23:36:10 +02:00
68 changed files with 4259 additions and 332 deletions

View File

@@ -76,7 +76,8 @@ Notes:
extra approval scopes:
- commandless request: `operator.pairing`
- non-exec command request: `operator.pairing` + `operator.write`
- `system.run` / `system.run.prepare` / `system.which` request:
- `system.run` / `system.run.prepare` / `system.which` /
`system.execApprovals.*` request:
`operator.pairing` + `operator.admin`
<Warning>

View File

@@ -417,6 +417,13 @@ Nodes must advertise `system.execApprovals.get/set` (macOS app or
headless node host). If a node does not advertise exec approvals yet,
edit its local `~/.openclaw/exec-approvals.json` directly.
Some node hosts, including native Windows hosts, expose host-native
approval snapshots instead of a file-backed OpenClaw approvals file. The
Control UI shows those snapshots as read-only because the native host owns
the policy format and editor. Use the Windows companion app or
`openclaw approvals set --node <id|name|ip>` for supported updates on
those nodes.
CLI: `openclaw approvals` supports gateway or node editing - see
[Approvals CLI](/cli/approvals).

View File

@@ -1425,10 +1425,10 @@
"audit:seams": "node scripts/audit-seams.mjs",
"build": "node scripts/build-all.mjs",
"build:ci-artifacts": "node scripts/build-all.mjs ciArtifacts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm plugins:assets:build && pnpm plugins:assets:copy && node --experimental-strip-types scripts/copy-hook-metadata.ts && node --experimental-strip-types scripts/copy-export-html-templates.ts && node --experimental-strip-types scripts/write-build-info.ts && node --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --experimental-strip-types scripts/write-cli-compat.ts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm plugins:assets:build && pnpm plugins:assets:copy && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "node scripts/run-tsgo.mjs -p tsconfig.plugin-sdk.dts.json --declaration true",
"build:plugin-sdk:strict-smoke": "pnpm build:plugin-sdk:dts && node --experimental-strip-types scripts/write-plugin-sdk-entry-dts.ts",
"build:strict-smoke": "pnpm plugins:assets:build && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm build:plugin-sdk:dts && node --experimental-strip-types scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs",
"build:plugin-sdk:strict-smoke": "pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts",
"build:strict-smoke": "pnpm plugins:assets:build && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs",
"canvas:a2ui:bundle": "node scripts/bundle-a2ui.mjs",
"changed:lanes": "node scripts/changed-lanes.mjs",
"check": "node scripts/check.mjs",

View File

@@ -3,6 +3,7 @@ import {
validateExecApprovalRequestParams,
validateExecApprovalsNodeSetParams,
validateExecApprovalsSetParams,
validateExecApprovalsSnapshot,
} from "./index.js";
describe("exec approvals protocol validators", () => {
@@ -37,6 +38,78 @@ describe("exec approvals protocol validators", () => {
).toBe(true);
});
it("accepts host-native node approval policy payloads", () => {
expect(
validateExecApprovalsNodeSetParams({
nodeId: "node-1",
native: {
defaultAction: "deny",
rules: [{ pattern: "echo *", action: "allow", enabled: true }],
},
baseHash: "native-hash-1",
}),
).toBe(true);
});
it("accepts host-native node approval snapshot payloads", () => {
expect(
validateExecApprovalsSnapshot({
enabled: true,
defaultAction: "deny",
hash: "native-hash-1",
rules: [{ pattern: "hostname", action: "allow", enabled: true }],
constraints: { source: "windows-node" },
}),
).toBe(true);
});
it("rejects empty host-native node approval policy payloads", () => {
expect(
validateExecApprovalsNodeSetParams({
nodeId: "node-1",
native: {},
baseHash: "native-hash-1",
}),
).toBe(false);
expect(
validateExecApprovalsNodeSetParams({
nodeId: "node-1",
native: { enabled: true },
baseHash: "native-hash-1",
}),
).toBe(false);
expect(
validateExecApprovalsNodeSetParams({
nodeId: "node-1",
native: { rules: [] },
baseHash: "native-hash-1",
}),
).toBe(false);
});
it("rejects host-native node approval rules with empty required fields", () => {
expect(
validateExecApprovalsNodeSetParams({
nodeId: "node-1",
native: {
defaultAction: "deny",
rules: [{ pattern: "", action: "allow", enabled: true }],
},
baseHash: "native-hash-1",
}),
).toBe(false);
expect(
validateExecApprovalsNodeSetParams({
nodeId: "node-1",
native: {
defaultAction: "deny",
rules: [{ pattern: "echo *", action: "", enabled: true }],
},
baseHash: "native-hash-1",
}),
).toBe(false);
});
it("rejects unknown allowlist metadata", () => {
expect(
validateExecApprovalsSetParams({

View File

@@ -705,6 +705,7 @@ describe("validateNodePairRequestParams", () => {
expect(
validateNodePairRequestParams({
nodeId: "ios-node-1",
deviceId: "device-1",
commands: ["canvas.snapshot"],
permissions: { camera: true, notifications: false },
}),

View File

@@ -191,6 +191,7 @@ import {
type ExecApprovalsSetParams,
ExecApprovalsSetParamsSchema,
type ExecApprovalsSnapshot,
ExecApprovalsSnapshotSchema,
type ExecApprovalGetParams,
ExecApprovalGetParamsSchema,
type ExecApprovalRequestParams,
@@ -844,6 +845,9 @@ export const validateExecApprovalsNodeGetParams = lazyCompile<ExecApprovalsNodeG
export const validateExecApprovalsNodeSetParams = lazyCompile<ExecApprovalsNodeSetParams>(
ExecApprovalsNodeSetParamsSchema,
);
export const validateExecApprovalsSnapshot = lazyCompile<ExecApprovalsSnapshot>(
ExecApprovalsSnapshotSchema,
);
export const validateLogsTailParams = lazyCompile<LogsTailParams>(LogsTailParamsSchema);
export const validateChatHistoryParams = lazyCompile(ChatHistoryParamsSchema);
export const validateChatMessageGetParams = lazyCompile(ChatMessageGetParamsSchema);

View File

@@ -52,16 +52,6 @@ export const ExecApprovalsFileSchema = Type.Object(
{ additionalProperties: false },
);
export const ExecApprovalsSnapshotSchema = Type.Object(
{
path: NonEmptyString,
exists: Type.Boolean(),
hash: NonEmptyString,
file: ExecApprovalsFileSchema,
},
{ additionalProperties: false },
);
export const ExecApprovalsGetParamsSchema = Type.Object({}, { additionalProperties: false });
export const ExecApprovalsSetParamsSchema = Type.Object(
@@ -79,15 +69,82 @@ export const ExecApprovalsNodeGetParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ExecApprovalsNodeSetParamsSchema = Type.Object(
export const NativeExecApprovalRuleSchema = Type.Object(
{
nodeId: NonEmptyString,
file: ExecApprovalsFileSchema,
baseHash: Type.Optional(NonEmptyString),
pattern: NonEmptyString,
action: NonEmptyString,
shells: Type.Optional(Type.Array(Type.String())),
description: Type.Optional(Type.String()),
enabled: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const ExecApprovalsFileSnapshotSchema = Type.Object(
{
path: NonEmptyString,
exists: Type.Boolean(),
hash: NonEmptyString,
file: ExecApprovalsFileSchema,
},
{ additionalProperties: false },
);
export const NativeExecApprovalsSnapshotSchema = Type.Object(
{
hash: Type.Optional(NonEmptyString),
baseHash: Type.Optional(NonEmptyString),
enabled: Type.Optional(Type.Boolean()),
defaultAction: Type.Optional(NonEmptyString),
rules: Type.Optional(Type.Array(NativeExecApprovalRuleSchema)),
constraints: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
},
{ additionalProperties: false },
);
export const ExecApprovalsSnapshotSchema = Type.Union([
ExecApprovalsFileSnapshotSchema,
NativeExecApprovalsSnapshotSchema,
]);
export const NativeExecApprovalPolicySchema = Type.Union([
Type.Object(
{
enabled: Type.Optional(Type.Boolean()),
defaultAction: NonEmptyString,
rules: Type.Optional(Type.Array(NativeExecApprovalRuleSchema)),
},
{ additionalProperties: false },
),
Type.Object(
{
enabled: Type.Optional(Type.Boolean()),
defaultAction: Type.Optional(NonEmptyString),
rules: Type.Array(NativeExecApprovalRuleSchema, { minItems: 1 }),
},
{ additionalProperties: false },
),
]);
export const ExecApprovalsNodeSetParamsSchema = Type.Union([
Type.Object(
{
nodeId: NonEmptyString,
file: ExecApprovalsFileSchema,
baseHash: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
Type.Object(
{
nodeId: NonEmptyString,
native: NativeExecApprovalPolicySchema,
baseHash: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
]);
export const ExecApprovalGetParamsSchema = Type.Object(
{
id: NonEmptyString,

View File

@@ -47,6 +47,7 @@ export const NodeEventResultSchema = Type.Object(
export const NodePairRequestParamsSchema = Type.Object(
{
nodeId: NonEmptyString,
deviceId: Type.Optional(NonEmptyString),
displayName: Type.Optional(NonEmptyString),
platform: Type.Optional(NonEmptyString),
version: Type.Optional(NonEmptyString),

View File

@@ -80,7 +80,7 @@ export const BUILD_ALL_STEPS = [
{
label: "write-plugin-sdk-entry-dts",
kind: "node",
args: ["--experimental-strip-types", "scripts/write-plugin-sdk-entry-dts.ts"],
args: ["--import", "tsx", "scripts/write-plugin-sdk-entry-dts.ts"],
},
{
label: "check-plugin-sdk-exports",
@@ -95,12 +95,12 @@ export const BUILD_ALL_STEPS = [
{
label: "copy-hook-metadata",
kind: "node",
args: ["--experimental-strip-types", "scripts/copy-hook-metadata.ts"],
args: ["--import", "tsx", "scripts/copy-hook-metadata.ts"],
},
{
label: "copy-export-html-templates",
kind: "node",
args: ["--experimental-strip-types", "scripts/copy-export-html-templates.ts"],
args: ["--import", "tsx", "scripts/copy-export-html-templates.ts"],
cache: {
inputs: [
"scripts/copy-export-html-templates.ts",
@@ -123,17 +123,17 @@ export const BUILD_ALL_STEPS = [
{
label: "write-build-info",
kind: "node",
args: ["--experimental-strip-types", "scripts/write-build-info.ts"],
args: ["--import", "tsx", "scripts/write-build-info.ts"],
},
{
label: "write-cli-startup-metadata",
kind: "node",
args: ["--experimental-strip-types", "scripts/write-cli-startup-metadata.ts"],
args: ["--import", "tsx", "scripts/write-cli-startup-metadata.ts"],
},
{
label: "write-cli-compat",
kind: "node",
args: ["--experimental-strip-types", "scripts/write-cli-compat.ts"],
args: ["--import", "tsx", "scripts/write-cli-compat.ts"],
},
];

View File

@@ -270,6 +270,7 @@ vi.mock("../commands/models/list.status-command.js", () => ({
vi.mock("../gateway/call.js", () => ({
callGateway: mocks.callGateway as typeof import("../gateway/call.js").callGateway,
randomIdempotencyKey: () => "run-1",
resolveGatewayCliScopes: () => [],
}));
vi.mock("../gateway/connection-details.js", () => ({

View File

@@ -62,6 +62,7 @@ vi.mock("../globals.js", () => ({
vi.mock("../gateway/call.js", () => ({
callGateway: mocks.callGateway,
resolveGatewayCliScopes: () => [],
}));
vi.mock("./plugins-install-record-commit.js", () => ({

View File

@@ -19,6 +19,7 @@ const { callGateway } = mocks;
vi.mock("../gateway/call.js", () => ({
callGateway: mocks.callGateway,
resolveGatewayCliScopes: () => [],
}));
vi.mock("../secrets/runtime-web-tools.js", () => ({

View File

@@ -112,6 +112,7 @@ vi.mock("../../gateway/probe.js", () => ({
vi.mock("../../gateway/call.js", () => ({
callGatewayCli: (opts: unknown) => callGatewayCli(opts),
resolveGatewayCliScopes: () => [],
}));
vi.mock("../../config/commands.js", () => ({

View File

@@ -6,6 +6,7 @@ const probeGatewayMock = vi.hoisted(() => vi.fn());
vi.mock("../../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
resolveGatewayCliScopes: () => [],
}));
vi.mock("../../gateway/probe.js", () => ({

View File

@@ -8,6 +8,7 @@ vi.mock("../gateway/call.js", () => ({
}),
callGateway: callGatewayMock,
formatGatewayTransportErrorJson: () => undefined,
resolveGatewayCliScopes: () => [],
}));
vi.mock("./progress.js", () => ({

View File

@@ -19,6 +19,7 @@ import {
buildGatewayConnectionDetails,
callGateway,
formatGatewayTransportErrorJson,
resolveGatewayCliScopes,
} from "../gateway/call.js";
import { ADMIN_SCOPE, PAIRING_SCOPE, type OperatorScope } from "../gateway/method-scopes.js";
import { isLoopbackHost } from "../gateway/net.js";
@@ -38,6 +39,7 @@ import {
type PendingDeviceApprovalKind,
} from "../shared/device-pairing-access.js";
import { formatCliCommand } from "./command-format.js";
import { shouldUseDirectLoopbackGatewayAuth } from "./direct-loopback-gateway-auth.js";
import { parseTimeoutMsWithFallback } from "./parse-timeout.js";
import { withProgress } from "./progress.js";
@@ -61,9 +63,9 @@ type DeviceTokenSummary = {
revokedAtMs?: number;
};
type PendingDevice = {
type PendingDevice = Record<string, unknown> & {
requestId: string;
deviceId: string;
deviceId?: string;
publicKey?: string;
displayName?: string;
clientId?: string;
@@ -76,7 +78,7 @@ type PendingDevice = {
ts?: number;
};
type PairedDevice = {
type PairedDevice = Record<string, unknown> & {
deviceId: string;
publicKey?: string;
displayName?: string;
@@ -104,13 +106,13 @@ const FALLBACK_STATE_MISMATCH_MESSAGE =
"Gateway requires device pairing, but local fallback pairing state does not contain the gateway request.";
const OPERATOR_ROLE = "operator";
const OPERATOR_SCOPE_PREFIX = "operator.";
const KNOWN_NON_ADMIN_OPERATOR_SCOPES = new Set<OperatorScope>([
const KNOWN_NON_ADMIN_OPERATOR_SCOPES: readonly OperatorScope[] = [
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.talk.secrets",
"operator.write",
]);
];
const callGatewayCli = async (
method: string,
@@ -124,18 +126,22 @@ const callGatewayCli = async (
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
async () => {
const useDirectAuth = await shouldUseDirectLoopbackGatewayAuth(opts);
return await callGateway({
url: opts.url,
token: opts.token,
password: opts.password,
method,
params,
timeoutMs: parseTimeoutMsWithFallback(opts.timeout, DEFAULT_DEVICES_TIMEOUT_MS),
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
scopes: callOpts?.scopes,
}),
clientName: useDirectAuth ? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT : GATEWAY_CLIENT_NAMES.CLI,
mode: useDirectAuth ? GATEWAY_CLIENT_MODES.BACKEND : GATEWAY_CLIENT_MODES.CLI,
scopes:
callOpts?.scopes ?? (useDirectAuth ? resolveGatewayCliScopes(method, params) : undefined),
deviceIdentity: useDirectAuth ? null : undefined,
});
},
);
function normalizeErrorMessage(error: unknown): string {
@@ -206,10 +212,12 @@ function assertLocalFallbackMatchesGatewayRequest(
function redactLocalPairedDevice(device: InfraPairedDevice): PairedDevice {
const { tokens, ...rest } = device;
return {
...(rest as unknown as PairedDevice),
tokens: summarizeDeviceTokens(tokens) as DeviceTokenSummary[] | undefined,
const tokenSummaries: DeviceTokenSummary[] | undefined = summarizeDeviceTokens(tokens);
const redacted: PairedDevice = {
...rest,
tokens: tokenSummaries,
};
return redacted;
}
async function listPairingWithFallback(opts: DevicesRpcOpts): Promise<DevicePairingList> {
@@ -334,13 +342,25 @@ async function approvePairingWithFallback(
}
function parseDevicePairingList(value: unknown): DevicePairingList {
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
const obj = isRecord(value) ? value : {};
return {
pending: Array.isArray(obj.pending) ? (obj.pending as PendingDevice[]) : [],
paired: Array.isArray(obj.paired) ? (obj.paired as PairedDevice[]) : [],
pending: Array.isArray(obj["pending"]) ? obj["pending"].filter(isPendingDevice) : [],
paired: Array.isArray(obj["paired"]) ? obj["paired"].filter(isPairedDevice) : [],
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isPendingDevice(value: unknown): value is PendingDevice {
return isRecord(value) && typeof value["requestId"] === "string";
}
function isPairedDevice(value: unknown): value is PairedDevice {
return isRecord(value) && typeof value["deviceId"] === "string";
}
function normalizeDeviceRoles(request: PendingDevice): string[] {
const roles = new Set<string>();
for (const role of request.roles ?? []) {
@@ -498,7 +518,7 @@ function resolvePendingOperatorApprovalScopes(
}
function isKnownNonAdminOperatorScope(scope: string): scope is OperatorScope {
return KNOWN_NON_ADMIN_OPERATOR_SCOPES.has(scope as OperatorScope);
return KNOWN_NON_ADMIN_OPERATOR_SCOPES.some((knownScope) => knownScope === scope);
}
function resolveApprovePairingScopesForRequest(
@@ -754,7 +774,7 @@ export async function runDevicesListCommand(opts: DevicesRpcOpts): Promise<void>
{ key: "IP", header: "IP", minWidth: 12 },
],
rows: list.paired.map((device) => ({
Device: sanitizeForLog(device.displayName || device.deviceId),
Device: sanitizeForLog(device.displayName || device.deviceId || ""),
Roles: device.roles?.length
? device.roles.map((role) => sanitizeForLog(role)).join(", ")
: "",

View File

@@ -33,10 +33,23 @@ const {
summarizeDeviceTokens,
} = mocks;
const gatewayAuthEnvSnapshot = {
token: process.env.OPENCLAW_GATEWAY_TOKEN,
password: process.env.OPENCLAW_GATEWAY_PASSWORD,
};
vi.mock("../gateway/call.js", () => ({
callGateway: mocks.callGateway,
formatGatewayTransportErrorJson: mocks.formatGatewayTransportErrorJson,
buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails,
resolveGatewayCliScopes: () => [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
"operator.talk.secrets",
],
}));
vi.mock("./progress.js", () => ({
@@ -160,6 +173,103 @@ function hasGatewayMethod(method: string): boolean {
});
}
async function withSharedGatewayToken<T>(token: string, fn: () => Promise<T>): Promise<T> {
const previous = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = token;
try {
return await fn();
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previous;
}
}
}
describe("devices cli gateway auth", () => {
it("omits persisted device identity for explicit loopback token auth", async () => {
callGateway.mockResolvedValueOnce({ pending: [], paired: [] });
await withSharedGatewayToken("shared-token", async () => {
await runDevicesCommand([
"list",
"--url",
"ws://127.0.0.1:18789",
"--token",
"shared-token",
"--json",
]);
});
expectGatewayCall(0, {
method: "device.pair.list",
token: "shared-token",
clientName: "gateway-client",
mode: "backend",
deviceIdentity: null,
});
});
it("omits persisted device identity for implicit local token auth", async () => {
callGateway.mockResolvedValueOnce({ pending: [], paired: [] });
await withSharedGatewayToken("shared-token", async () => {
await runDevicesCommand(["list", "--token", "shared-token", "--json"]);
});
expectGatewayCall(0, {
method: "device.pair.list",
token: "shared-token",
clientName: "gateway-client",
mode: "backend",
deviceIdentity: null,
});
});
it("keeps device identity available for explicit remote token auth", async () => {
callGateway.mockResolvedValueOnce({ pending: [], paired: [] });
await runDevicesCommand([
"list",
"--url",
"ws://192.0.2.7:18789",
"--token",
"shared-token",
"--json",
]);
expectGatewayCall(0, {
method: "device.pair.list",
token: "shared-token",
clientName: "cli",
mode: "cli",
deviceIdentity: undefined,
});
});
it("keeps device identity available for unproven loopback token auth", async () => {
callGateway.mockResolvedValueOnce({ pending: [], paired: [] });
await runDevicesCommand([
"list",
"--url",
"ws://127.0.0.1:18789",
"--token",
"operator-device-token",
"--json",
]);
expectGatewayCall(0, {
method: "device.pair.list",
token: "operator-device-token",
clientName: "cli",
mode: "cli",
deviceIdentity: undefined,
});
});
});
describe("devices cli approve", () => {
it("uses admin scope when approving an admin-scope request", async () => {
callGateway
@@ -1408,11 +1518,23 @@ describe("devices cli list", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
runtime.exit.mockImplementation(() => {});
formatGatewayTransportErrorJson.mockReturnValue(null);
});
afterEach(() => {
if (gatewayAuthEnvSnapshot.token === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = gatewayAuthEnvSnapshot.token;
}
if (gatewayAuthEnvSnapshot.password === undefined) {
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
} else {
process.env.OPENCLAW_GATEWAY_PASSWORD = gatewayAuthEnvSnapshot.password;
}
buildGatewayConnectionDetails.mockReturnValue({
url: "ws://127.0.0.1:18789",
urlSource: "local loopback",

View File

@@ -0,0 +1,261 @@
import { describe, expect, it } from "vitest";
import { withEnvOverride, withTempHomeConfig } from "../config/test-helpers.js";
import { shouldUseDirectLoopbackGatewayAuth } from "./direct-loopback-gateway-auth.js";
describe("shouldUseDirectLoopbackGatewayAuth", () => {
it("recognizes literal configured gateway tokens as shared loopback auth", async () => {
await withTempHomeConfig(
{
gateway: {
auth: { mode: "token", token: "shared-token" },
},
},
async () => {
await withEnvOverride({ OPENCLAW_GATEWAY_TOKEN: undefined }, async () => {
expect(
await shouldUseDirectLoopbackGatewayAuth({
url: "ws://127.0.0.1:18789",
token: "shared-token",
}),
).toBe(true);
});
},
);
});
it("keeps unconfigured loopback tokens on the device-token-capable path", async () => {
await withTempHomeConfig(
{
gateway: {
auth: { mode: "token", token: "shared-token" },
},
},
async () => {
await withEnvOverride({ OPENCLAW_GATEWAY_TOKEN: undefined }, async () => {
expect(
await shouldUseDirectLoopbackGatewayAuth({
url: "ws://127.0.0.1:18789",
token: "operator-device-token",
}),
).toBe(false);
});
},
);
});
it("recognizes SecretRef-backed configured gateway tokens as shared loopback auth", async () => {
await withEnvOverride({ CONFIG_GATEWAY_TOKEN: "resolved-shared-token" }, async () => {
expect(
await shouldUseDirectLoopbackGatewayAuth({
url: "ws://127.0.0.1:18789",
token: "resolved-shared-token",
config: {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "CONFIG_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
}),
).toBe(true);
});
});
it("does not treat env fallback tokens as shared when a configured token ref differs", async () => {
await withEnvOverride(
{
CONFIG_GATEWAY_TOKEN: "resolved-shared-token",
OPENCLAW_GATEWAY_TOKEN: "stale-device-token",
},
async () => {
expect(
await shouldUseDirectLoopbackGatewayAuth({
url: "ws://127.0.0.1:18789",
token: "stale-device-token",
config: {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "CONFIG_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
}),
).toBe(false);
},
);
});
it("does not treat inactive token auth as shared in password mode", async () => {
await withEnvOverride(
{
OPENCLAW_GATEWAY_TOKEN: "stale-device-token",
OPENCLAW_GATEWAY_PASSWORD: "shared-password",
},
async () => {
expect(
await shouldUseDirectLoopbackGatewayAuth({
url: "ws://127.0.0.1:18789",
token: "stale-device-token",
config: {
gateway: {
auth: { mode: "password", password: "shared-password" },
},
},
}),
).toBe(false);
},
);
});
it("recognizes literal configured gateway passwords as shared loopback auth", async () => {
await withTempHomeConfig(
{
gateway: {
auth: { mode: "password", password: "shared-password" },
},
},
async () => {
await withEnvOverride({ OPENCLAW_GATEWAY_PASSWORD: undefined }, async () => {
expect(
await shouldUseDirectLoopbackGatewayAuth({
url: "ws://127.0.0.1:18789",
password: "shared-password",
}),
).toBe(true);
});
},
);
});
it("keeps unconfigured loopback passwords on the device-token-capable path", async () => {
await withTempHomeConfig(
{
gateway: {
auth: { mode: "password", password: "shared-password" },
},
},
async () => {
await withEnvOverride({ OPENCLAW_GATEWAY_PASSWORD: undefined }, async () => {
expect(
await shouldUseDirectLoopbackGatewayAuth({
url: "ws://127.0.0.1:18789",
password: "operator-password",
}),
).toBe(false);
});
},
);
});
it("does not treat explicit loopback passwords as shared when gateway auth is disabled", async () => {
expect(
await shouldUseDirectLoopbackGatewayAuth({
url: "ws://127.0.0.1:18789",
password: "anything",
config: {
gateway: {
auth: { mode: "none" },
},
},
}),
).toBe(false);
});
it("recognizes implicit env gateway tokens as shared loopback auth", async () => {
await withTempHomeConfig(
{
gateway: {
auth: { mode: "token" },
},
},
async () => {
await withEnvOverride({ OPENCLAW_GATEWAY_TOKEN: "env-shared-token" }, async () => {
expect(await shouldUseDirectLoopbackGatewayAuth({})).toBe(true);
});
},
);
});
it("does not resolve configured token refs for remote explicit-token URLs", async () => {
await withEnvOverride({ CONFIG_GATEWAY_TOKEN: undefined }, async () => {
expect(
await shouldUseDirectLoopbackGatewayAuth({
url: "wss://remote.example.test/ws",
token: "explicit-token",
config: {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "CONFIG_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
}),
).toBe(false);
});
});
it("treats unresolved configured token refs as non-shared loopback tokens", async () => {
await withEnvOverride({ CONFIG_GATEWAY_TOKEN: undefined }, async () => {
expect(
await shouldUseDirectLoopbackGatewayAuth({
url: "ws://127.0.0.1:18789",
token: "explicit-token",
config: {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "CONFIG_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
}),
).toBe(false);
});
});
it("does not resolve configured token refs for remote implicit-auth URLs", async () => {
await withEnvOverride({ CONFIG_GATEWAY_TOKEN: undefined }, async () => {
expect(
await shouldUseDirectLoopbackGatewayAuth({
url: "wss://remote.example.test/ws",
config: {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "CONFIG_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
}),
).toBe(false);
});
});
});

View File

@@ -0,0 +1,169 @@
import { normalizeStringifiedOptionalString } from "@openclaw/normalization-core/string-coerce";
import { loadConfig } from "../config/io.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js";
import { isLoopbackHost } from "../gateway/net.js";
export type DirectLoopbackGatewayAuthOpts = {
url?: string;
token?: string;
password?: string;
config?: OpenClawConfig;
};
type SharedGatewayCredentials = {
token?: string;
password?: string;
};
function pickActiveGatewayCredential(
mode: "token" | "password",
credentials: SharedGatewayCredentials | undefined,
): SharedGatewayCredentials | undefined {
if (!credentials) {
return undefined;
}
const credential =
mode === "token" ? { token: credentials.token } : { password: credentials.password };
return credential.token || credential.password ? credential : undefined;
}
function hasActiveGatewayAuthSecretRef(
config: OpenClawConfig | undefined,
mode: "token" | "password",
): boolean {
if (!config) {
return false;
}
const value = mode === "token" ? config.gateway?.auth?.token : config.gateway?.auth?.password;
return Boolean(resolveSecretInputRef({ value, defaults: config.secrets?.defaults }).ref);
}
async function resolveConfiguredSharedGatewayCredentials(
config: OpenClawConfig | undefined,
): Promise<SharedGatewayCredentials | undefined> {
const resolvedConfig = config ?? loadConfigForDirectAuthProbe();
if (!resolvedConfig) {
return undefined;
}
const auth = resolveGatewayAuth({
authConfig: resolvedConfig?.gateway?.auth,
tailscaleMode: resolvedConfig?.gateway?.tailscale?.mode,
env: process.env,
});
if (auth.mode !== "token" && auth.mode !== "password") {
return undefined;
}
if (hasActiveGatewayAuthSecretRef(resolvedConfig, auth.mode)) {
try {
const credentials = await resolveGatewayConnectionAuth({
config: resolvedConfig,
env: process.env,
localTokenPrecedence: "config-first",
localPasswordPrecedence: "config-first",
});
return pickActiveGatewayCredential(auth.mode, credentials);
} catch {
return undefined;
}
}
const literalCredentials = pickActiveGatewayCredential(auth.mode, {
token: auth.token,
password: auth.password,
});
if (literalCredentials?.token || literalCredentials?.password) {
return literalCredentials;
}
try {
const credentials = await resolveGatewayConnectionAuth({
config: resolvedConfig,
env: process.env,
});
return pickActiveGatewayCredential(auth.mode, credentials);
} catch {
return undefined;
}
}
async function isConfiguredSharedGatewayToken(params: {
token: string | undefined;
config?: OpenClawConfig;
}): Promise<boolean> {
if (!params.token) {
return false;
}
const credentials = await resolveConfiguredSharedGatewayCredentials(params.config);
return credentials?.token === params.token;
}
async function isConfiguredSharedGatewayPassword(params: {
password: string | undefined;
config?: OpenClawConfig;
}): Promise<boolean> {
if (!params.password) {
return false;
}
const credentials = await resolveConfiguredSharedGatewayCredentials(params.config);
return credentials?.password === params.password;
}
function loadConfigForDirectAuthProbe(): OpenClawConfig | undefined {
try {
return loadConfig({ skipPluginValidation: true, pin: false });
} catch {
return undefined;
}
}
function isLoopbackGatewayUrl(url: string | undefined): boolean {
if (!url) {
return false;
}
try {
return isLoopbackHost(new URL(url).hostname);
} catch {
return false;
}
}
function resolveGatewayUrlForDirectAuth(opts: DirectLoopbackGatewayAuthOpts): string | undefined {
const explicitUrl = normalizeStringifiedOptionalString(opts.url);
if (explicitUrl) {
return explicitUrl;
}
try {
return buildGatewayConnectionDetails({ config: opts.config }).url;
} catch {
return undefined;
}
}
export async function shouldUseDirectLoopbackGatewayAuth(
opts: DirectLoopbackGatewayAuthOpts,
): Promise<boolean> {
const token = normalizeStringifiedOptionalString(opts.token);
const password = normalizeStringifiedOptionalString(opts.password);
const explicitUrl = normalizeStringifiedOptionalString(opts.url);
if (explicitUrl && !isLoopbackGatewayUrl(explicitUrl)) {
return false;
}
const configuredCredentials =
!token && !password ? await resolveConfiguredSharedGatewayCredentials(opts.config) : undefined;
if (!token && !password && !configuredCredentials?.token && !configuredCredentials?.password) {
return false;
}
const gatewayUrl = resolveGatewayUrlForDirectAuth(opts);
if (!isLoopbackGatewayUrl(gatewayUrl)) {
return false;
}
if (!token && !password) {
return true;
}
return (
(await isConfiguredSharedGatewayPassword({ password, config: opts.config })) ||
(await isConfiguredSharedGatewayToken({ token, config: opts.config }))
);
}

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as execApprovals from "../infra/exec-approvals.js";
@@ -217,7 +220,9 @@ describe("exec approvals CLI", () => {
await runApprovalsCommand(["approvals", "get", "--node", "macbook"]);
expectGatewayCall(0, "exec.approvals.node.get", { nodeId: "node-1" });
const call = gatewayCall(0);
expect(call[0]).toBe("exec.approvals.node.get");
expect(call[2]).toEqual({ nodeId: "node-1" });
expectGatewayCall(1, "config.get", {});
expect(runtimeErrors).toHaveLength(0);
});
@@ -359,6 +364,219 @@ describe("exec approvals CLI", () => {
);
});
it("keeps host-native node approvals output without local policy math", async () => {
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "config.get") {
return {
config: {
tools: {
exec: {
security: "full",
ask: "off",
},
},
},
};
}
if (method === "exec.approvals.node.get") {
return {
enabled: true,
hash: "native-hash-1",
baseHash: "native-hash-1",
defaultAction: "deny",
rules: [
{ pattern: "echo *", action: "allow", enabled: true },
{ pattern: "Start-Process *", action: "deny", enabled: true },
],
} as never;
}
return { method, params };
},
);
await runApprovalsCommand(["approvals", "get", "--node", "macbook", "--json"]);
const output = writtenJson();
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(output, 0);
expect(output.defaultAction).toBe("deny");
expect(requireArray(output.rules, "rules")).toHaveLength(2);
expect(effectivePolicy(output)).toEqual({
note: "Node exposes a host-native exec policy. The node enforces its own rules; local approvals-file effective-policy math does not apply.",
scopes: [],
});
expect(runtimeErrors).toHaveLength(0);
});
it("sets host-native node approvals through the node-native policy shape", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-native-approvals-"));
const policyPath = path.join(dir, "policy.json");
fs.writeFileSync(
policyPath,
JSON.stringify({
defaultAction: "deny",
rules: [{ pattern: "echo *", action: "allow", enabled: true }],
}),
);
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "exec.approvals.node.get") {
return {
enabled: true,
hash: "native-hash-1",
baseHash: "native-hash-1",
defaultAction: "deny",
rules: [{ pattern: "echo *", action: "allow", enabled: true }],
} as never;
}
return { method, params } as never;
},
);
try {
await runApprovalsCommand([
"approvals",
"set",
"--node",
"macbook",
"--file",
policyPath,
"--json",
]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
expect(callGatewayFromCli.mock.calls[0]?.[0]).toBe("exec.approvals.node.get");
expect(callGatewayFromCli.mock.calls[0]?.[2]).toEqual({ nodeId: "node-1" });
expect(callGatewayFromCli.mock.calls[1]?.[0]).toBe("exec.approvals.node.set");
expect(callGatewayFromCli.mock.calls[1]?.[2]).toEqual({
nodeId: "node-1",
native: {
defaultAction: "deny",
rules: [{ pattern: "echo *", action: "allow", enabled: true }],
},
baseHash: "native-hash-1",
});
expect(callGatewayFromCli.mock.calls[2]?.[0]).toBe("exec.approvals.node.get");
expect(callGatewayFromCli.mock.calls[2]?.[2]).toEqual({ nodeId: "node-1" });
expect(writtenJson().defaultAction).toBe("deny");
expect(runtimeErrors).toHaveLength(0);
});
it("preserves explicit empty host-native node approval rules with a default action", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-native-approvals-"));
const policyPath = path.join(dir, "policy.json");
fs.writeFileSync(policyPath, JSON.stringify({ defaultAction: "deny", rules: [] }));
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "exec.approvals.node.get") {
return {
enabled: true,
hash: "native-hash-1",
baseHash: "native-hash-1",
defaultAction: "deny",
rules: [{ pattern: "echo *", action: "allow", enabled: true }],
} as never;
}
return { method, params } as never;
},
);
try {
await runApprovalsCommand([
"approvals",
"set",
"--node",
"macbook",
"--file",
policyPath,
"--json",
]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
expect(callGatewayFromCli.mock.calls[1]?.[0]).toBe("exec.approvals.node.set");
expect(callGatewayFromCli.mock.calls[1]?.[2]).toEqual({
nodeId: "node-1",
native: {
defaultAction: "deny",
rules: [],
},
baseHash: "native-hash-1",
});
expect(runtimeErrors).toHaveLength(0);
});
it("rejects empty host-native node approval rule lists without a default action", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-native-approvals-"));
const policyPath = path.join(dir, "policy.json");
fs.writeFileSync(policyPath, JSON.stringify({ rules: [] }));
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "exec.approvals.node.get") {
return {
enabled: true,
hash: "native-hash-1",
baseHash: "native-hash-1",
defaultAction: "deny",
rules: [{ pattern: "echo *", action: "allow", enabled: true }],
} as never;
}
return { method } as never;
});
try {
await expect(
runApprovalsCommand([
"approvals",
"set",
"--node",
"macbook",
"--file",
policyPath,
"--json",
]),
).rejects.toThrow("__exit__:1");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
expect(callGatewayFromCli.mock.calls[0]?.[0]).toBe("exec.approvals.node.get");
expect(runtimeErrors[0]).toContain(
"Host-native exec approvals JSON must include defaultAction or rules.",
);
});
it("rejects allowlist mutations for host-native node approvals", async () => {
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "exec.approvals.node.get") {
return {
enabled: true,
hash: "native-hash-1",
baseHash: "native-hash-1",
defaultAction: "deny",
rules: [{ pattern: "echo *", action: "allow", enabled: true }],
} as never;
}
return { method } as never;
});
await expect(
runApprovalsCommand(["approvals", "allowlist", "add", "--node", "macbook", "/usr/bin/uname"]),
).rejects.toThrow("__exit__:1");
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
const call = gatewayCall(0);
expect(call[0]).toBe("exec.approvals.node.get");
expect(call[2]).toEqual({ nodeId: "node-1" });
expect(runtimeErrors[0]).toContain("Host-native node approvals do not support allowlist");
});
it("keeps gateway approvals output when config.get fails", async () => {
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {

View File

@@ -13,6 +13,7 @@ import {
type ExecPolicyScopeSnapshot,
} from "../infra/exec-approvals-effective.js";
import {
normalizeExecApprovals,
readExecApprovalsSnapshot,
saveExecApprovals,
type ExecApprovalsAgent,
@@ -26,15 +27,32 @@ import type { NodesRpcOpts } from "./nodes-cli/types.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
path?: string;
exists?: boolean;
hash?: string;
baseHash?: string;
file?: ExecApprovalsFile;
enabled?: boolean;
defaultAction?: string;
rules?: NativeExecApprovalRule[];
constraints?: Record<string, unknown>;
};
type ConfigSnapshotLike = {
config?: OpenClawConfig;
};
type NativeExecApprovalRule = {
pattern?: string;
action?: string;
shells?: string[];
description?: string;
enabled?: boolean;
};
type NativeExecApprovalPolicy = {
enabled?: boolean;
defaultAction?: string;
rules?: NativeExecApprovalRule[];
};
type ConfigLoadResult = {
config: OpenClawConfig | null;
timedOut: boolean;
@@ -93,6 +111,92 @@ function loadSnapshotLocal(): ExecApprovalsSnapshot {
};
}
function hasApprovalsFile(
snapshot: ExecApprovalsSnapshot,
): snapshot is ExecApprovalsSnapshot & { file: ExecApprovalsFile } {
return (
Boolean(snapshot.file) && typeof snapshot.file === "object" && !Array.isArray(snapshot.file)
);
}
function isNativeExecApprovalsSnapshot(snapshot: ExecApprovalsSnapshot): boolean {
return (
Array.isArray(snapshot.rules) ||
typeof snapshot.defaultAction === "string" ||
typeof snapshot.enabled === "boolean"
);
}
function normalizeNativeExecApprovalPolicyInput(value: unknown): NativeExecApprovalPolicy {
if (!isRecord(value)) {
exitWithError("Host-native exec approvals JSON must be an object.");
}
if ("version" in value || "defaults" in value || "agents" in value) {
exitWithError(
"Host-native node approvals require JSON with defaultAction and rules, not exec-approvals.json file syntax.",
);
}
const defaultAction = normalizeOptionalString(value["defaultAction"]) ?? undefined;
const rulesRaw = value["rules"];
if (rulesRaw !== undefined && !Array.isArray(rulesRaw)) {
exitWithError("Host-native exec approvals rules must be an array.");
}
const rules = Array.isArray(rulesRaw)
? rulesRaw.map((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
exitWithError("Host-native exec approval rules must be objects.");
}
if (!isRecord(entry)) {
exitWithError("Host-native exec approval rules must be objects.");
}
const pattern = normalizeOptionalString(entry["pattern"]);
const action = normalizeOptionalString(entry["action"]);
if (!pattern || !action) {
exitWithError("Host-native exec approval rules require pattern and action.");
}
const shellsRaw = entry["shells"];
if (shellsRaw !== undefined && !Array.isArray(shellsRaw)) {
exitWithError("Host-native exec approval rule shells must be an array.");
}
const shells = Array.isArray(shellsRaw)
? shellsRaw.map((shell) => String(shell)).filter((shell) => shell.trim().length > 0)
: undefined;
const nextRule: NativeExecApprovalRule = {
pattern,
action,
};
if (shells && shells.length > 0) {
nextRule.shells = shells;
}
if (typeof entry["description"] === "string") {
nextRule.description = entry["description"];
}
if (typeof entry["enabled"] === "boolean") {
nextRule.enabled = entry["enabled"];
}
return nextRule;
})
: undefined;
const hasRulesField = Array.isArray(rulesRaw);
const hasNonEmptyRules = Boolean(rules && rules.length > 0);
if (!defaultAction && !hasNonEmptyRules) {
exitWithError("Host-native exec approvals JSON must include defaultAction or rules.");
}
return {
...(typeof value["enabled"] === "boolean" ? { enabled: value["enabled"] } : {}),
...(defaultAction ? { defaultAction } : {}),
...(hasRulesField ? { rules: rules ?? [] } : {}),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isString(value: string | null): value is string {
return value !== null;
}
function saveSnapshotLocal(file: ExecApprovalsFile): ExecApprovalsSnapshot {
saveExecApprovals(file);
return loadSnapshotLocal();
@@ -131,31 +235,51 @@ async function loadWritableSnapshotTarget(opts: ExecApprovalsCliOpts): Promise<{
source: ApprovalsTargetSource;
targetLabel: string;
baseHash: string;
nativePolicy: boolean;
}> {
const { snapshot, nodeId, source } = await loadSnapshotTarget(opts);
if (source === "local") {
defaultRuntime.log(theme.muted("Writing local approvals."));
}
const targetLabel = source === "local" ? "local" : nodeId ? `node:${nodeId}` : "gateway";
const baseHash = snapshot.hash;
const baseHash = snapshot.hash ?? snapshot.baseHash;
if (!baseHash) {
exitWithError("Exec approvals hash missing; reload and retry.");
}
return { snapshot, nodeId, source, targetLabel, baseHash };
if (!hasApprovalsFile(snapshot)) {
if (source === "node" && isNativeExecApprovalsSnapshot(snapshot)) {
return { snapshot, nodeId, source, targetLabel, baseHash, nativePolicy: true };
}
exitWithError(
"This node exposes a host-native exec policy. Editing it through approvals set/allowlist is not supported yet.",
);
}
return { snapshot, nodeId, source, targetLabel, baseHash, nativePolicy: false };
}
async function saveSnapshotTargeted(params: {
opts: ExecApprovalsCliOpts;
source: ApprovalsTargetSource;
nodeId: string | null;
file: ExecApprovalsFile;
file?: ExecApprovalsFile;
native?: NativeExecApprovalPolicy;
baseHash: string;
targetLabel: string;
}): Promise<void> {
const next =
params.source === "local"
? saveSnapshotLocal(params.file)
: await saveSnapshot(params.opts, params.nodeId, params.file, params.baseHash);
let next: ExecApprovalsSnapshot;
if (params.source === "local") {
next = saveSnapshotLocal(params.file ?? { version: 1 });
} else if (params.native) {
await saveSnapshotNative(params.opts, params.nodeId, params.native, params.baseHash);
next = await loadSnapshot(params.opts, params.nodeId);
} else {
next = await saveSnapshot(
params.opts,
params.nodeId,
params.file ?? { version: 1 },
params.baseHash,
);
}
if (params.opts.json) {
defaultRuntime.writeJson(next, 0);
return;
@@ -199,13 +323,22 @@ async function loadConfigForApprovalsTarget(params: {
function buildEffectivePolicyReport(params: {
configLoad: ConfigLoadResult;
source: ApprovalsTargetSource;
approvals: ExecApprovalsFile;
approvals?: ExecApprovalsFile;
hostPath: string;
nativePolicy: boolean;
}): EffectivePolicyReport {
const cfg = params.configLoad.config;
const timeoutNote = params.configLoad.timedOut
? "Config fetch timed out. Re-run with a higher --timeout to inspect Effective Policy."
: null;
if (!params.approvals) {
return {
scopes: [],
note: params.nativePolicy
? "Node exposes a host-native exec policy. The node enforces its own rules; local approvals-file effective-policy math does not apply."
: "Approvals file unavailable.",
};
}
if (params.source === "node") {
if (!cfg) {
return {
@@ -278,6 +411,10 @@ function renderEffectivePolicy(params: { report: EffectivePolicyReport }) {
}
function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: string) {
if (!hasApprovalsFile(snapshot) && isNativeExecApprovalsSnapshot(snapshot)) {
renderNativeExecPolicySnapshot(snapshot, targetLabel);
return;
}
const rich = isRich();
const heading = (text: string) => (rich ? theme.heading(text) : text);
const muted = (text: string) => (rich ? theme.muted(text) : text);
@@ -292,7 +429,7 @@ function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: s
typeof defaults.autoAllowSkills === "boolean"
? `autoAllowSkills=${defaults.autoAllowSkills ? "on" : "off"}`
: null,
].filter(Boolean) as string[];
].filter(isString);
const agents = file.agents ?? {};
const allowlistRows: Array<{ Target: string; Agent: string; Pattern: string; LastUsed: string }> =
[];
@@ -316,9 +453,9 @@ function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: s
const summaryRows = [
{ Field: "Target", Value: targetLabel },
{ Field: "Path", Value: snapshot.path },
{ Field: "Path", Value: snapshot.path ?? "unknown" },
{ Field: "Exists", Value: snapshot.exists ? "yes" : "no" },
{ Field: "Hash", Value: snapshot.hash },
{ Field: "Hash", Value: snapshot.hash ?? "unknown" },
{ Field: "Version", Value: String(file.version ?? 1) },
{ Field: "Socket", Value: file.socket?.path ?? "default" },
{ Field: "Defaults", Value: defaultsParts.length > 0 ? defaultsParts.join(", ") : "none" },
@@ -372,6 +509,18 @@ async function saveSnapshot(
return snapshot;
}
async function saveSnapshotNative(
opts: ExecApprovalsCliOpts,
nodeId: string | null,
native: NativeExecApprovalPolicy,
baseHash: string,
): Promise<void> {
if (!nodeId) {
exitWithError("Host-native exec approvals can only target a node.");
}
await callGatewayFromCli("exec.approvals.node.set", opts, { nodeId, native, baseHash });
}
function resolveAgentKey(value?: string | null): string {
const trimmed = normalizeOptionalString(value) ?? "";
return trimmed ? trimmed : "*";
@@ -410,8 +559,13 @@ async function loadWritableAllowlistAgent(opts: ExecApprovalsCliOpts): Promise<{
agent: ExecApprovalsAgent;
allowlistEntries: NonNullable<ExecApprovalsAgent["allowlist"]>;
}> {
const { snapshot, nodeId, source, targetLabel, baseHash } =
const { snapshot, nodeId, source, targetLabel, baseHash, nativePolicy } =
await loadWritableSnapshotTarget(opts);
if (nativePolicy) {
exitWithError(
"Host-native node approvals do not support allowlist mutations. Use approvals set --node with host-native JSON.",
);
}
const file = snapshot.file ?? { version: 1 };
file.version = 1;
@@ -453,6 +607,66 @@ async function runAllowlistMutation(
}
}
function renderNativeExecPolicySnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: string) {
const rich = isRich();
const heading = (text: string) => (rich ? theme.heading(text) : text);
const muted = (text: string) => (rich ? theme.muted(text) : text);
const tableWidth = getTerminalTableWidth();
const rules = Array.isArray(snapshot.rules) ? snapshot.rules : [];
const summaryRows = [
{ Field: "Target", Value: targetLabel },
{ Field: "Kind", Value: "host-native" },
{
Field: "Enabled",
Value: typeof snapshot.enabled === "boolean" ? (snapshot.enabled ? "yes" : "no") : "unknown",
},
{ Field: "Default Action", Value: snapshot.defaultAction ?? "unknown" },
{ Field: "Hash", Value: snapshot.hash ?? snapshot.baseHash ?? "unknown" },
{ Field: "Rules", Value: String(rules.length) },
];
defaultRuntime.log(heading("Approvals"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Field", header: "Field", minWidth: 8 },
{ key: "Value", header: "Value", minWidth: 24, flex: true },
],
rows: summaryRows,
}).trimEnd(),
);
if (rules.length === 0) {
defaultRuntime.log("");
defaultRuntime.log(muted("No host-native rules."));
return;
}
defaultRuntime.log("");
defaultRuntime.log(heading("Host Rules"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Pattern", header: "Pattern", minWidth: 20, flex: true },
{ key: "Action", header: "Action", minWidth: 8 },
{ key: "Shells", header: "Shells", minWidth: 12 },
{ key: "Enabled", header: "Enabled", minWidth: 8 },
{ key: "Description", header: "Description", minWidth: 16, flex: true },
],
rows: rules.map((rule) => ({
Pattern: rule.pattern ?? "",
Action: rule.action ?? "",
Shells:
Array.isArray(rule.shells) && rule.shells.length > 0 ? rule.shells.join(",") : "all",
Enabled: rule.enabled === false ? "no" : "yes",
Description: rule.description ?? "",
})),
}).trimEnd(),
);
}
function registerAllowlistMutationCommand(params: {
allowlist: Command;
name: "add" | "remove";
@@ -499,7 +713,8 @@ export function registerExecApprovalsCli(program: Command) {
configLoad,
source,
approvals: snapshot.file,
hostPath: snapshot.path,
hostPath: snapshot.path ?? "node host policy",
nativePolicy: isNativeExecApprovalsSnapshot(snapshot),
});
if (opts.json) {
defaultRuntime.writeJson({ ...snapshot, effectivePolicy }, 0);
@@ -536,14 +751,21 @@ export function registerExecApprovalsCli(program: Command) {
if (opts.file && opts.stdin) {
exitWithError("Use either --file or --stdin (not both).");
}
const { source, nodeId, targetLabel, baseHash } = await loadWritableSnapshotTarget(opts);
const { source, nodeId, targetLabel, baseHash, nativePolicy } =
await loadWritableSnapshotTarget(opts);
const raw = opts.stdin ? await readStdin() : await fs.readFile(String(opts.file), "utf8");
let file: ExecApprovalsFile;
let parsed: unknown;
try {
file = JSON5.parse(raw);
parsed = JSON5.parse(raw);
} catch (err) {
exitWithError(`Failed to parse approvals JSON: ${String(err)}`);
}
if (nativePolicy) {
const native = normalizeNativeExecApprovalPolicyInput(parsed);
await saveSnapshotTargeted({ opts, source, nodeId, native, baseHash, targetLabel });
return;
}
const file = normalizeExecApprovals(parsed as ExecApprovalsFile);
file.version = 1;
await saveSnapshotTargeted({ opts, source, nodeId, file, baseHash, targetLabel });
} catch (err) {

View File

@@ -43,7 +43,8 @@ const { runtimeLogs, runtimeErrors, defaultRuntime } = mocks;
vi.mock(
new URL("../../gateway/call.ts", new URL("./gateway-cli/call.ts", import.meta.url)).href,
() => ({
async () => ({
...(await vi.importActual<typeof import("../gateway/call.js")>("../gateway/call.js")),
callGateway: (opts: unknown) => callGateway(opts),
formatGatewayTransportErrorJson: (error: unknown) => formatGatewayTransportErrorJson(error),
randomIdempotencyKey: () => "rk_test",
@@ -158,6 +159,54 @@ describe("gateway-cli coverage", () => {
expect(runtimeLogs.join("\n")).toContain('"ok": true');
});
it("uses backend auth for explicit loopback token gateway calls", async () => {
callGateway.mockClear();
await withEnvOverride({ OPENCLAW_GATEWAY_TOKEN: "shared-token" }, async () => {
await runGatewayCommand([
"gateway",
"call",
"health",
"--url",
"ws://127.0.0.1:18789",
"--token",
"shared-token",
"--json",
]);
});
expect(callGateway).toHaveBeenCalledTimes(1);
expect(firstMockArg(callGateway)).toMatchObject({
method: "health",
clientName: "gateway-client",
mode: "backend",
deviceIdentity: null,
});
});
it("keeps device identity available for unproven loopback token gateway calls", async () => {
callGateway.mockClear();
await runGatewayCommand([
"gateway",
"call",
"health",
"--url",
"ws://127.0.0.1:18789",
"--token",
"operator-device-token",
"--json",
]);
expect(callGateway).toHaveBeenCalledTimes(1);
expect(firstMockArg(callGateway)).toMatchObject({
method: "health",
clientName: "cli",
mode: "cli",
deviceIdentity: undefined,
});
});
it("registers gateway probe and routes to gatewayStatusCommand", async () => {
gatewayStatusCommand.mockClear();

View File

@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CLI_DEFAULT_OPERATOR_SCOPES } from "../../gateway/method-scopes.js";
import { callGatewayCli } from "./call.js";
const { callGatewaySpy } = vi.hoisted(() => ({
callGatewaySpy: vi.fn(async (_opts: Record<string, unknown>) => ({ ok: true })),
}));
vi.mock("../../gateway/call.js", async () => {
const actual =
await vi.importActual<typeof import("../../gateway/call.js")>("../../gateway/call.js");
return {
...actual,
callGateway: callGatewaySpy,
};
});
vi.mock("../progress.js", () => ({
withProgress: (_opts: unknown, fn: () => unknown) => fn(),
}));
function firstGatewayCall(): Record<string, unknown> {
const [callOpts] = callGatewaySpy.mock.calls[0] ?? [];
if (!callOpts) {
throw new Error("expected gateway call");
}
return callOpts;
}
async function withSharedGatewayToken<T>(token: string, fn: () => Promise<T>): Promise<T> {
const previous = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = token;
try {
return await fn();
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previous;
}
}
}
describe("gateway CLI call transport", () => {
beforeEach(() => {
callGatewaySpy.mockClear();
});
it("keeps least-privilege CLI scopes when direct loopback token auth uses backend identity", async () => {
await withSharedGatewayToken("shared-token", async () => {
await callGatewayCli("health", {
url: "ws://127.0.0.1:18789",
token: "shared-token",
});
});
expect(firstGatewayCall()).toMatchObject({
method: "health",
clientName: "gateway-client",
mode: "backend",
scopes: ["operator.read"],
deviceIdentity: null,
});
});
it("keeps broad CLI fallback scopes for unclassified direct loopback token calls", async () => {
await withSharedGatewayToken("shared-token", async () => {
await callGatewayCli("plugin.custom.unclassified", {
url: "ws://127.0.0.1:18789",
token: "shared-token",
});
});
expect(firstGatewayCall()).toMatchObject({
method: "plugin.custom.unclassified",
clientName: "gateway-client",
mode: "backend",
scopes: CLI_DEFAULT_OPERATOR_SCOPES,
deviceIdentity: null,
});
});
it("keeps device identity available when loopback token is not proven shared auth", async () => {
await callGatewayCli("health", {
url: "ws://127.0.0.1:18789",
token: "operator-device-token",
});
expect(firstGatewayCall()).toMatchObject({
method: "health",
clientName: "cli",
mode: "cli",
deviceIdentity: undefined,
});
});
});

View File

@@ -4,7 +4,8 @@ import {
GATEWAY_CLIENT_NAMES,
} from "../../../packages/gateway-protocol/src/client-info.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { callGateway } from "../../gateway/call.js";
import { callGateway, resolveGatewayCliScopes } from "../../gateway/call.js";
import { shouldUseDirectLoopbackGatewayAuth } from "../direct-loopback-gateway-auth.js";
import { parseTimeoutMsWithFallback } from "../parse-timeout.js";
import { withProgress } from "../progress.js";
@@ -36,8 +37,9 @@ export const callGatewayCli = async (method: string, opts: GatewayRpcOpts, param
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
async () => {
const useDirectAuth = await shouldUseDirectLoopbackGatewayAuth(opts);
return await callGateway({
config: opts.config,
url: opts.url,
token: opts.token,
@@ -46,7 +48,10 @@ export const callGatewayCli = async (method: string, opts: GatewayRpcOpts, param
params,
expectFinal: Boolean(opts.expectFinal),
timeoutMs: parseTimeoutMsWithFallback(opts.timeout, DEFAULT_GATEWAY_RPC_TIMEOUT_MS),
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
}),
clientName: useDirectAuth ? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT : GATEWAY_CLIENT_NAMES.CLI,
mode: useDirectAuth ? GATEWAY_CLIENT_MODES.BACKEND : GATEWAY_CLIENT_MODES.CLI,
scopes: useDirectAuth ? resolveGatewayCliScopes(method, params) : undefined,
deviceIdentity: useDirectAuth ? null : undefined,
});
},
);

View File

@@ -2,7 +2,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn(async () => ({ ok: true }));
vi.mock("../gateway/call.js", () => ({
buildGatewayConnectionDetails: () => ({
url: "ws://127.0.0.1:18789",
urlSource: "local loopback",
}),
callGateway: callGatewayMock,
resolveGatewayCliScopes: (method: string) =>
method === "health"
? ["operator.read"]
: [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
"operator.talk.secrets",
],
}));
vi.mock("./progress.js", () => ({
@@ -11,6 +26,20 @@ vi.mock("./progress.js", () => ({
const { callGatewayFromCliRuntime } = await import("./gateway-rpc.runtime.js");
async function withSharedGatewayToken<T>(token: string, fn: () => Promise<T>): Promise<T> {
const previous = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = token;
try {
return await fn();
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previous;
}
}
}
describe("callGatewayFromCliRuntime", () => {
beforeEach(() => {
callGatewayMock.mockClear().mockResolvedValue({ ok: true });
@@ -53,4 +82,87 @@ describe("callGatewayFromCliRuntime", () => {
}),
);
});
it("uses backend auth for explicit loopback token calls", async () => {
await withSharedGatewayToken("shared-token", async () => {
await callGatewayFromCliRuntime("health", {
url: "ws://127.0.0.1:18789",
token: "shared-token",
});
});
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "health",
clientName: "gateway-client",
mode: "backend",
scopes: ["operator.read"],
deviceIdentity: null,
}),
);
});
it("uses CLI fallback scopes for unclassified explicit loopback token calls", async () => {
await withSharedGatewayToken("shared-token", async () => {
await callGatewayFromCliRuntime("plugin.custom.unclassified", {
url: "ws://127.0.0.1:18789",
token: "shared-token",
});
});
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "plugin.custom.unclassified",
clientName: "gateway-client",
mode: "backend",
scopes: [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
"operator.talk.secrets",
],
deviceIdentity: null,
}),
);
});
it("keeps device identity available for unproven loopback token calls", async () => {
await callGatewayFromCliRuntime("health", {
url: "ws://127.0.0.1:18789",
token: "operator-device-token",
});
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "health",
clientName: "cli",
mode: "cli",
scopes: undefined,
deviceIdentity: undefined,
}),
);
});
it("preserves explicit extra client identity overrides", async () => {
await callGatewayFromCliRuntime(
"health",
{
url: "ws://127.0.0.1:18789",
token: "shared-token",
},
undefined,
{ clientName: "cli", mode: "cli", deviceIdentity: undefined },
);
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "health",
clientName: "cli",
mode: "cli",
deviceIdentity: undefined,
}),
);
});
});

View File

@@ -2,7 +2,8 @@ import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../../packages/gateway-protocol/src/client-info.js";
import { callGateway } from "../gateway/call.js";
import { callGateway, resolveGatewayCliScopes } from "../gateway/call.js";
import { shouldUseDirectLoopbackGatewayAuth } from "./direct-loopback-gateway-auth.js";
import type { GatewayRpcOpts } from "./gateway-rpc.types.js";
import { parseTimeoutMsWithFallback } from "./parse-timeout.js";
import { withProgress } from "./progress.js";
@@ -25,6 +26,11 @@ export async function callGatewayFromCliRuntime(
extra?: CallGatewayFromCliRuntimeExtra,
) {
const showProgress = extra?.progress ?? opts.json !== true;
const useDirectAuth =
(await shouldUseDirectLoopbackGatewayAuth(opts)) &&
extra?.clientName === undefined &&
extra?.mode === undefined &&
extra?.deviceIdentity === undefined;
return await withProgress(
{
label: `Gateway ${method}`,
@@ -37,12 +43,21 @@ export async function callGatewayFromCliRuntime(
token: opts.token,
method,
params,
deviceIdentity: extra?.deviceIdentity,
deviceIdentity:
extra?.deviceIdentity !== undefined
? extra.deviceIdentity
: useDirectAuth
? null
: undefined,
expectFinal: extra?.expectFinal ?? Boolean(opts.expectFinal),
scopes: extra?.scopes,
scopes:
extra?.scopes ?? (useDirectAuth ? resolveGatewayCliScopes(method, params) : undefined),
timeoutMs: parseTimeoutMsWithFallback(opts.timeout, DEFAULT_GATEWAY_RPC_TIMEOUT_MS),
clientName: extra?.clientName ?? GATEWAY_CLIENT_NAMES.CLI,
mode: extra?.mode ?? GATEWAY_CLIENT_MODES.CLI,
clientName:
extra?.clientName ??
(useDirectAuth ? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT : GATEWAY_CLIENT_NAMES.CLI),
mode:
extra?.mode ?? (useDirectAuth ? GATEWAY_CLIENT_MODES.BACKEND : GATEWAY_CLIENT_MODES.CLI),
}),
);
}

View File

@@ -30,6 +30,7 @@ vi.mock("../gateway/call.js", () => {
loaded.mark("gateway-transport-runtime");
return {
formatGatewayTransportErrorJson: vi.fn(() => null),
resolveGatewayCliScopes: vi.fn(() => []),
};
});

View File

@@ -56,6 +56,7 @@ const { runtimeErrors, defaultRuntime } = mocks;
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGateway(opts as NodeInvokeCall),
randomIdempotencyKey: () => randomIdempotencyKey(),
resolveGatewayCliScopes: () => [],
}));
vi.mock("../runtime.js", async () => ({
@@ -128,7 +129,9 @@ describe("nodes-cli coverage", () => {
from: "user",
}),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors.at(-1)).toContain('command "system.run" is reserved for shell execution');
expect(runtimeErrors.at(-1)).toContain(
'command "system.run" is reserved for shell execution; use an agent /exec host=node request instead of nodes invoke',
);
});
it("invokes system.notify with provided fields", async () => {

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CLI_DEFAULT_OPERATOR_SCOPES } from "../../gateway/method-scopes.js";
import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS } from "../../infra/exec-approvals.js";
import { parseTimeoutMs } from "../parse-timeout.js";
import { callGatewayCli, callNodePairApprovalGatewayCli } from "./rpc.js";
@@ -26,8 +27,20 @@ const { callGatewaySpy } = vi.hoisted(() => ({
}));
vi.mock("../../gateway/call.js", () => ({
buildGatewayConnectionDetails: () => ({
url: "ws://127.0.0.1:18789",
urlSource: "local loopback",
}),
callGateway: callGatewaySpy,
randomIdempotencyKey: () => "mock-key",
resolveGatewayCliScopes: () => [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
"operator.talk.secrets",
],
}));
vi.mock("../progress.js", () => ({
@@ -42,6 +55,20 @@ function firstGatewayCall(): Record<string, unknown> {
return callOpts;
}
async function withSharedGatewayToken<T>(token: string, fn: () => Promise<T>): Promise<T> {
const previous = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = token;
try {
return await fn();
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previous;
}
}
}
describe("exec approval transport timeout (#12098)", () => {
const approvalTransportFloorMs = DEFAULT_EXEC_APPROVAL_TIMEOUT_MS + 10_000;
@@ -61,6 +88,79 @@ describe("exec approval transport timeout (#12098)", () => {
expect(callOpts.timeoutMs).toBe(35_000);
});
it("callGatewayCli uses backend auth for explicit loopback token calls", async () => {
await withSharedGatewayToken("shared-token", async () => {
await callGatewayCli("node.list", {
url: "ws://127.0.0.1:18789",
token: "shared-token",
} as never);
});
expect(callGatewaySpy).toHaveBeenCalledTimes(1);
expect(firstGatewayCall()).toMatchObject({
method: "node.list",
clientName: "gateway-client",
mode: "backend",
deviceIdentity: null,
});
});
it("callGatewayCli preserves CLI fallback scopes for unclassified direct loopback token calls", async () => {
await withSharedGatewayToken("shared-token", async () => {
await callGatewayCli("plugin.custom.unclassified", {
url: "ws://127.0.0.1:18789",
token: "shared-token",
} as never);
});
expect(callGatewaySpy).toHaveBeenCalledTimes(1);
expect(firstGatewayCall()).toMatchObject({
method: "plugin.custom.unclassified",
clientName: "gateway-client",
mode: "backend",
scopes: CLI_DEFAULT_OPERATOR_SCOPES,
deviceIdentity: null,
});
});
it("callNodePairApprovalGatewayCli omits device identity for explicit loopback token calls", async () => {
await withSharedGatewayToken("shared-token", async () => {
await callNodePairApprovalGatewayCli(
"node.pair.list",
{
url: "ws://127.0.0.1:18789",
token: "shared-token",
} as never,
{},
{ scopes: ["operator.pairing"] },
);
});
expect(callGatewaySpy).toHaveBeenCalledTimes(1);
expect(firstGatewayCall()).toMatchObject({
method: "node.pair.list",
clientName: "gateway-client",
mode: "backend",
deviceIdentity: null,
scopes: ["operator.pairing"],
});
});
it("callGatewayCli keeps identity available when loopback token is not proven shared", async () => {
await callGatewayCli("node.list", {
url: "ws://127.0.0.1:18789",
token: "operator-device-token",
} as never);
expect(callGatewaySpy).toHaveBeenCalledTimes(1);
expect(firstGatewayCall()).toMatchObject({
method: "node.list",
clientName: "cli",
mode: "cli",
deviceIdentity: undefined,
});
});
it("callGatewayCli rejects invalid opts.timeout instead of forwarding NaN", async () => {
await expect(
callGatewayCli("exec.approval.request", { timeout: "nope" } as never, {

View File

@@ -38,7 +38,7 @@ export function registerNodesInvokeCommands(nodes: Command) {
}
if (BLOCKED_NODE_INVOKE_COMMANDS.has(normalizeLowercaseStringOrEmpty(command))) {
throw new Error(
`command "${command}" is reserved for shell execution; use the exec tool with host=node instead`,
`command "${command}" is reserved for shell execution; use an agent /exec host=node request instead of nodes invoke`,
);
}
const params = JSON.parse(opts.params ?? "{}") as unknown;

View File

@@ -166,6 +166,17 @@ function mergePairedNodesWithEffectiveNodes(
return rows;
}
function hasReportedNodeSurface(node: Partial<NodeListNode>): boolean {
return (node.commands?.length ?? 0) > 0 || (node.caps?.length ?? 0) > 0;
}
function suppressTransientCommandlessPairingRow(
node: PairedNodeListRow,
pendingNodeIds: Set<string>,
): boolean {
return pendingNodeIds.has(node.nodeId) && !hasReportedNodeSurface(node);
}
async function tryReadNodeList(opts: NodesRpcOpts): Promise<NodeListNode[] | null> {
try {
return parseNodeList(await callGatewayCli("node.list", opts, {}));
@@ -175,9 +186,15 @@ async function tryReadNodeList(opts: NodesRpcOpts): Promise<NodeListNode[] | nul
}
function sanitizePairedNodeForListJson(node: PairedNodeListRow): Omit<PairedNodeListRow, "token"> {
const copy: Record<string, unknown> = { ...node };
delete copy.token;
return copy as Omit<PairedNodeListRow, "token">;
const { token: _token, ...copy } = node;
return copy;
}
function isNodeDetailRow(value: { Field: string; Value: string } | null): value is {
Field: string;
Value: string;
} {
return value !== null;
}
export function registerNodesStatusCommands(nodes: Command) {
@@ -357,7 +374,7 @@ export function registerNodesStatusCommands(nodes: Command) {
pathEnv ? { Field: "PATH", Value: sanitizeTerminalText(pathEnv) } : null,
{ Field: "Status", Value: status },
{ Field: "Caps", Value: caps ? sanitizeTerminalText(caps.join(", ")) : "?" },
].filter(Boolean) as Array<{ Field: string; Value: string }>;
].filter(isNodeDetailRow);
defaultRuntime.log(heading("Node"));
defaultRuntime.log(
@@ -403,7 +420,11 @@ export function registerNodesStatusCommands(nodes: Command) {
const effectiveNodes = hasFilters
? parseNodeList(await callGatewayCli("node.list", opts, {}))
: await tryReadNodeList(opts);
const effectivePairedRows = mergePairedNodesWithEffectiveNodes(paired, effectiveNodes);
const pendingNodeIds = new Set(pendingRows.map((entry) => entry.nodeId));
const effectivePairedRows = mergePairedNodesWithEffectiveNodes(
paired,
effectiveNodes,
).filter((node) => !suppressTransientCommandlessPairingRow(node, pendingNodeIds));
const filteredPaired = effectivePairedRows.filter((node) => {
if (connectedOnly) {
if (!node.connected) {

View File

@@ -2,8 +2,9 @@ import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../../../packages/gateway-protocol/src/client-info.js";
import { callGateway } from "../../gateway/call.js";
import { callGateway, resolveGatewayCliScopes } from "../../gateway/call.js";
import type { OperatorScope } from "../../gateway/method-scopes.js";
import { shouldUseDirectLoopbackGatewayAuth } from "../direct-loopback-gateway-auth.js";
import { parseTimeoutMsWithFallback } from "../parse-timeout.js";
import { withProgress } from "../progress.js";
import type { NodesRpcOpts } from "./types.js";
@@ -27,16 +28,20 @@ export async function callGatewayCliRuntime(
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
async () => {
const useDirectAuth = await shouldUseDirectLoopbackGatewayAuth(opts);
return await callGateway({
url: opts.url,
token: opts.token,
method,
params,
timeoutMs: resolveNodesTransportTimeoutMs(opts, callOpts?.transportTimeoutMs),
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
}),
clientName: useDirectAuth ? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT : GATEWAY_CLIENT_NAMES.CLI,
mode: useDirectAuth ? GATEWAY_CLIENT_MODES.BACKEND : GATEWAY_CLIENT_MODES.CLI,
scopes: useDirectAuth ? resolveGatewayCliScopes(method, params) : undefined,
deviceIdentity: useDirectAuth ? null : undefined,
});
},
);
}
@@ -67,6 +72,7 @@ export async function callNodePairApprovalGatewayCliRuntime(
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
mode: GATEWAY_CLIENT_MODES.BACKEND,
scopes: callOpts.scopes,
deviceIdentity: (await shouldUseDirectLoopbackGatewayAuth(opts)) ? null : undefined,
}),
);
}

View File

@@ -229,6 +229,72 @@ describe("cli program (nodes basics)", () => {
expect(output).toContain("Pairing Scoped");
});
it("hides transient commandless paired rows while a command upgrade is pending", async () => {
const now = Date.now();
callGateway.mockImplementation(async (...args: unknown[]) => {
const opts = (args[0] ?? {}) as { method?: string };
if (opts.method === "node.pair.list") {
return {
pending: [
{
requestId: "upgrade-1",
nodeId: "node-upgrade",
displayName: "Windows Node",
commands: ["system.run", "system.which"],
ts: now - 1_000,
},
],
paired: [
{
nodeId: "node-upgrade",
displayName: "Windows Node",
remoteIp: "192.0.2.9",
},
{
nodeId: "ready-node",
displayName: "Ready Node",
remoteIp: "192.0.2.10",
},
],
};
}
if (opts.method === "node.list") {
return {
nodes: [
{
nodeId: "node-upgrade",
displayName: "Windows Node",
remoteIp: "192.0.2.9",
paired: true,
connected: true,
commands: [],
caps: [],
},
{
nodeId: "ready-node",
displayName: "Ready Node",
remoteIp: "192.0.2.10",
paired: true,
connected: true,
commands: ["system.which"],
caps: ["system"],
},
],
};
}
return { ok: true };
});
await runProgram(["nodes", "list", "--json"]);
const json = writeJsonArgAt(0) as {
pending?: Array<Record<string, unknown>>;
paired?: Array<Record<string, unknown>>;
};
expect(json.pending?.map((node) => node.nodeId)).toEqual(["node-upgrade"]);
expect(json.paired?.map((node) => node.nodeId)).toEqual(["ready-node"]);
});
it("sanitizes untrusted nodes list table fields while preserving JSON values", async () => {
const now = Date.now();
callGateway.mockImplementation(async (...args: unknown[]) => {

View File

@@ -91,6 +91,7 @@ vi.mock("../tui/tui.js", () => ({ runTui: programMocks.runTui }));
vi.mock("../crestodian/crestodian.js", () => ({ runCrestodian: programMocks.runCrestodian }));
vi.mock("../gateway/call.js", () => ({
callGateway: programMocks.callGateway,
resolveGatewayCliScopes: () => [],
randomIdempotencyKey: () => "idem-test",
buildGatewayConnectionDetails: () => ({
url: "ws://127.0.0.1:1234",

View File

@@ -307,6 +307,7 @@ describe("callGateway url resolution", () => {
"OPENCLAW_GATEWAY_URL",
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_STATE_DIR",
"LOCAL_REF_TOKEN",
]);
beforeEach(() => {
@@ -424,8 +425,53 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.token).toBe("test-token");
});
it("loads config to prove explicit loopback backend shared-token auth", async () => {
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: { mode: "token", token: "shared-token" },
},
});
await callGateway({
method: "health",
url: "ws://127.0.0.1:18800",
token: "shared-token",
});
expect(getRuntimeConfig).toHaveBeenCalled();
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800");
expect(lastClientOptions?.token).toBe("shared-token");
expect(lastClientOptions?.deviceIdentity).toBeNull();
});
it("keeps explicit loopback backend token auth usable when shared-token probe config load fails", async () => {
getRuntimeConfig.mockImplementation(() => {
throw new Error("config broken");
});
await callGateway({
method: "health",
url: "ws://127.0.0.1:18800",
token: "explicit-token",
});
expect(getRuntimeConfig).toHaveBeenCalled();
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800");
expect(lastClientOptions?.token).toBe("explicit-token");
expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityState.value);
});
it("keeps direct-local backend shared-token auth independent of paired device state", async () => {
setLocalLoopbackGatewayConfig();
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: { mode: "token", token: "explicit-token" },
},
});
await callGateway({
method: "health",
@@ -439,6 +485,64 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.deviceIdentity).toBeNull();
});
it("keeps direct-local backend SecretRef token auth independent of paired device state", async () => {
setLocalLoopbackGatewayConfig();
process.env.LOCAL_REF_TOKEN = "explicit-token";
process.env.OPENCLAW_GATEWAY_TOKEN = "stale-device-token";
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "LOCAL_REF_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as never);
await callGateway({
method: "health",
token: "explicit-token",
});
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789");
expect(lastClientOptions?.token).toBe("explicit-token");
expect(lastClientOptions?.deviceIdentity).toBeNull();
});
it("keeps explicit backend token auth usable when configured token SecretRef is unavailable", async () => {
setLocalLoopbackGatewayConfig();
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as never);
await callGateway({
method: "health",
token: "explicit-token",
});
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789");
expect(lastClientOptions?.token).toBe("explicit-token");
expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityState.value);
});
it("fails before opening a websocket when backend token auth has no shared or paired credential", async () => {
getRuntimeConfig.mockReturnValue({
gateway: { mode: "local", bind: "loopback", auth: { mode: "token" } },
@@ -745,6 +849,13 @@ describe("callGateway url resolution", () => {
it("uses backend client metadata for explicit scoped default calls", async () => {
setLocalLoopbackGatewayConfig();
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: { mode: "token", token: "explicit-token" },
},
});
await callGateway({
method: "sessions.delete",
@@ -1734,6 +1845,7 @@ describe("callGateway password resolution", () => {
"OPENCLAW_GATEWAY_PASSWORD",
"OPENCLAW_GATEWAY_TOKEN",
"LOCAL_REMOTE_FALLBACK_TOKEN",
"LOCAL_REF_TOKEN",
"LOCAL_REF_PASSWORD",
"REMOTE_REF_TOKEN",
"REMOTE_REF_PASSWORD",
@@ -1742,6 +1854,7 @@ describe("callGateway password resolution", () => {
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.LOCAL_REMOTE_FALLBACK_TOKEN;
delete process.env.LOCAL_REF_TOKEN;
delete process.env.LOCAL_REF_PASSWORD;
delete process.env.REMOTE_REF_TOKEN;
delete process.env.REMOTE_REF_PASSWORD;

View File

@@ -18,6 +18,7 @@ import {
resolveGatewayPort as resolveGatewayPortFromPaths,
resolveStateDir as resolveStateDirFromPaths,
} from "../config/paths.js";
import type { GatewayRemoteConfig } from "../config/types.gateway.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadDeviceAuthToken } from "../infra/device-auth-store.js";
import { loadOrCreateDeviceIdentity, type DeviceIdentity } from "../infra/device-identity.js";
@@ -406,11 +407,12 @@ function shouldOmitDeviceIdentityForGatewayCall(params: {
opts: CallGatewayBaseOptions;
url: string;
token?: string;
tokenIsSharedAuth: boolean;
password?: string;
}): boolean {
const mode = params.opts.mode ?? GATEWAY_CLIENT_MODES.CLI;
const clientName = params.opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI;
const hasSharedAuth = Boolean(params.token || params.password);
const hasSharedAuth = Boolean(params.password || (params.token && params.tokenIsSharedAuth));
return (
mode === GATEWAY_CLIENT_MODES.BACKEND &&
clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT &&
@@ -423,6 +425,7 @@ function resolveDeviceIdentityForGatewayCall(params: {
opts: CallGatewayBaseOptions;
url: string;
token?: string;
tokenIsSharedAuth: boolean;
password?: string;
}): ReturnType<typeof loadOrCreateDeviceIdentity> | null {
if (shouldOmitDeviceIdentityForGatewayCall(params)) {
@@ -539,18 +542,11 @@ export function ensureExplicitGatewayAuth(params: {
throw new GatewayExplicitAuthRequiredError(message);
}
type GatewayRemoteSettings = {
url?: string;
token?: string;
password?: string;
tlsFingerprint?: string;
};
type ResolvedGatewayCallContext = {
config: OpenClawConfig;
configPath: string;
isRemoteMode: boolean;
remote?: GatewayRemoteSettings;
remote?: GatewayRemoteConfig;
urlOverride?: string;
urlOverrideSource?: "cli" | "env";
remoteUrl?: string;
@@ -611,9 +607,7 @@ async function resolveGatewayCallContext(
opts.config ?? (canSkipConfigLoad ? ({} as OpenClawConfig) : await loadGatewayConfig());
const configPath = opts.configPath ?? resolveGatewayConfigPath(process.env);
const isRemoteMode = config.gateway?.mode === "remote";
const remote = isRemoteMode
? (config.gateway?.remote as GatewayRemoteSettings | undefined)
: undefined;
const remote = isRemoteMode ? config.gateway?.remote : undefined;
const remoteUrl = trimToUndefined(remote?.url);
return {
config,
@@ -678,6 +672,56 @@ async function resolveGatewayCredentialsWithEnv(
export { resolveGatewayCredentialsWithSecretInputs };
async function resolveConfiguredGatewayCredentialsForExplicitToken(
context: ResolvedGatewayCallContext,
opts: CallGatewayBaseOptions,
): Promise<
| {
token?: string;
password?: string;
}
| undefined
> {
if (!context.explicitAuth.token) {
return undefined;
}
const isBackendGatewayClient =
(opts.mode ?? GATEWAY_CLIENT_MODES.CLI) === GATEWAY_CLIENT_MODES.BACKEND &&
(opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI) === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT;
if (!isBackendGatewayClient) {
return undefined;
}
let configForProbe: OpenClawConfig | undefined;
if (context.config.gateway) {
configForProbe = context.config;
} else {
try {
configForProbe = await loadGatewayConfig();
} catch {
configForProbe = undefined;
}
}
if (!configForProbe) {
return undefined;
}
try {
return await resolveGatewayCredentialsWithEnv(
{
...context,
config: configForProbe,
explicitAuth: {},
urlOverride: undefined,
urlOverrideSource: undefined,
localTokenPrecedence: "config-first",
localPasswordPrecedence: "config-first",
},
process.env,
);
} catch {
return undefined;
}
}
async function resolveGatewayTlsFingerprint(params: {
opts: CallGatewayBaseOptions;
context: ResolvedGatewayCallContext;
@@ -841,17 +885,9 @@ async function executeGatewayRequestWithScopes<T>(params: {
clearTimeout(timer);
}
};
const stopClientThenSettle = (
activeClient: GatewayClient | undefined,
err?: Error,
value?: T,
) => {
const stopClientThenSettle = (activeClient: GatewayClient | undefined, settle: () => void) => {
const complete = () => {
if (err) {
reject(err);
} else {
resolve(value as T);
}
settle();
};
if (!activeClient) {
complete();
@@ -859,13 +895,21 @@ async function executeGatewayRequestWithScopes<T>(params: {
}
void stopGatewayClient(activeClient).finally(complete);
};
const stop = (err?: Error, value?: T) => {
const stopWithError = (err: Error) => {
if (settled) {
return;
}
settled = true;
cleanup();
stopClientThenSettle(client, err, value);
stopClientThenSettle(client, () => reject(err));
};
const stopWithValue = (value: T) => {
if (settled) {
return;
}
settled = true;
cleanup();
stopClientThenSettle(client, () => resolve(value));
};
const abortHandler: (() => void) | undefined = () => {
if (settled) {
@@ -876,7 +920,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
cleanup();
const err = createGatewayRequestAbortError(opts.method);
const activeClient = client;
const stopAfterAbortHook = () => stopClientThenSettle(activeClient, err);
const stopAfterAbortHook = () => stopClientThenSettle(activeClient, () => reject(err));
if (!activeClient || !opts.onSignalAbort || !primaryRequestStarted) {
stopAfterAbortHook();
return;
@@ -927,10 +971,10 @@ async function executeGatewayRequestWithScopes<T>(params: {
onAccepted: opts.onAccepted,
});
ignoreClose = true;
stop(undefined, result);
stopWithValue(result);
} catch (err) {
ignoreClose = true;
stop(err as Error);
stopWithError(err instanceof Error ? err : new Error(String(err)));
}
})();
},
@@ -939,7 +983,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
return;
}
ignoreClose = true;
stop(
stopWithError(
createGatewayCloseTransportError({
code,
reason,
@@ -952,13 +996,13 @@ async function executeGatewayRequestWithScopes<T>(params: {
return;
}
ignoreClose = true;
stop(err);
stopWithError(err);
},
});
const timer: NodeJS.Timeout | undefined = setTimeout(() => {
ignoreClose = true;
stop(
stopWithError(
createGatewayTimeoutTransportError({
timeoutMs,
connectionDetails: params.connectionDetails,
@@ -975,7 +1019,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
return;
}
ignoreClose = true;
stop(
stopWithError(
createGatewayTimeoutTransportError({
timeoutMs,
connectionDetails: params.connectionDetails,
@@ -987,7 +1031,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
return;
}
ignoreClose = true;
stop(err instanceof Error ? err : new Error(String(err)));
stopWithError(err instanceof Error ? err : new Error(String(err)));
});
});
}
@@ -1020,9 +1064,13 @@ async function callGatewayWithScopes<T = Record<string, unknown>>(
const url = connectionDetails.url;
const tlsFingerprint = await resolveGatewayTlsFingerprint({ opts, context, url });
const { token, password } = resolvedCredentials;
const configuredCredentials = context.explicitAuth.token
? await resolveConfiguredGatewayCredentialsForExplicitToken(context, opts)
: resolvedCredentials;
const tokenIsSharedAuth = Boolean(token && configuredCredentials?.token === token);
const deviceIdentity =
opts.deviceIdentity === undefined
? resolveDeviceIdentityForGatewayCall({ opts, url, token, password })
? resolveDeviceIdentityForGatewayCall({ opts, url, token, tokenIsSharedAuth, password })
: opts.deviceIdentity;
ensureGatewayCallCanAuthenticate({
opts,
@@ -1052,15 +1100,26 @@ export async function callGatewayScoped<T = Record<string, unknown>>(
return await callGatewayWithScopes(opts, opts.scopes);
}
export function resolveGatewayCliScopes(method: string, params?: unknown): OperatorScope[] {
return isGatewayMethodClassified(method)
? resolveLeastPrivilegeOperatorScopesForMethod(method, params)
: [...CLI_DEFAULT_OPERATOR_SCOPES];
}
export async function callGatewayCli<T = Record<string, unknown>>(
opts: CallGatewayCliOptions,
): Promise<T> {
const scopes = Array.isArray(opts.scopes)
? opts.scopes
: isGatewayMethodClassified(opts.method)
? resolveLeastPrivilegeOperatorScopesForMethod(opts.method, opts.params)
: CLI_DEFAULT_OPERATOR_SCOPES;
return await callGatewayWithScopes(opts, scopes);
: resolveGatewayCliScopes(opts.method, opts.params);
return await callGatewayWithScopes(
{
...opts,
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
clientName: opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI,
},
scopes,
);
}
export async function callGatewayLeastPrivilege<T = Record<string, unknown>>(

View File

@@ -138,6 +138,66 @@ describe("gateway/node-catalog", () => {
expect(node?.connected).toBe(true);
});
it("merges stable node pairings with their underlying device pairing", () => {
const catalog = createKnownNodeCatalog({
pairedDevices: [
{
deviceId: "device-token-id",
publicKey: "public-key",
displayName: "Windows Host",
clientId: "openclaw-node-host",
clientMode: "node",
role: "node",
roles: ["node"],
tokens: {
node: {
token: "current-token",
role: "node",
scopes: [],
createdAtMs: 1,
},
},
createdAtMs: 1,
approvedAtMs: 99,
},
],
pairedNodes: [
{
nodeId: "stable-windows-node",
deviceId: "device-token-id",
token: "node-token",
platform: "windows",
caps: ["system"],
commands: ["system.execApprovals.set"],
createdAtMs: 1,
approvedAtMs: 100,
},
],
connectedNodes: [
{
nodeId: "stable-windows-node",
connId: "conn-1",
client: {} as never,
displayName: "Windows Host",
platform: "windows",
declaredCaps: ["system"],
caps: ["system"],
declaredCommands: ["system.execApprovals.set"],
commands: ["system.execApprovals.set"],
connectedAtMs: 123,
},
],
});
expect(listKnownNodes(catalog).map((node) => node.nodeId)).toEqual(["stable-windows-node"]);
expect(getKnownNode(catalog, "device-token-id")).toBeNull();
const entry = getKnownNodeEntry(catalog, "stable-windows-node");
expect(entry?.devicePairing?.nodeId).toBe("device-token-id");
expect(entry?.nodePairing?.nodeId).toBe("stable-windows-node");
expect(entry?.effective.connected).toBe(true);
expect(entry?.effective.clientId).toBe("openclaw-node-host");
});
it("surfaces node-pair metadata even when the node is offline", () => {
const catalog = createKnownNodeCatalog({
pairedDevices: [

View File

@@ -174,11 +174,22 @@ export function createKnownNodeCatalog(params: {
pairedNodes?: readonly NodePairingPairedNode[];
connectedNodes: readonly NodeSession[];
}): KnownNodeCatalog {
const devicePairingById = new Map(
params.pairedDevices
.filter((entry) => hasEffectivePairedDeviceRole(entry, "node"))
.map((entry) => [entry.deviceId, buildDevicePairingSource(entry)]),
);
const stableNodeIdByDeviceId = new Map<string, string>();
for (const entry of params.pairedNodes ?? []) {
if (entry.deviceId && entry.deviceId !== entry.nodeId) {
stableNodeIdByDeviceId.set(entry.deviceId, entry.nodeId);
}
}
const devicePairingById = new Map<string, KnownNodeDevicePairingSource>();
for (const entry of params.pairedDevices) {
if (!hasEffectivePairedDeviceRole(entry, "node")) {
continue;
}
devicePairingById.set(
stableNodeIdByDeviceId.get(entry.deviceId) ?? entry.deviceId,
buildDevicePairingSource(entry),
);
}
const nodePairingById = new Map(
(params.pairedNodes ?? []).map((entry) => [entry.nodeId, buildApprovedNodeSource(entry)]),
);

View File

@@ -11,6 +11,7 @@ import {
isNodeCommandAllowed,
normalizeDeclaredNodeCommands,
resolveNodeCommandAllowlist,
resolveNodePairingCommandAllowlist,
} from "./node-command-policy.js";
describe("gateway/node-command-policy", () => {
@@ -191,6 +192,26 @@ describe("gateway/node-command-policy", () => {
}
});
it("requires pairing before exposing desktop exec-approval host commands", () => {
const cfg = {} as OpenClawConfig;
const node = { platform: "windows", deviceFamily: "Windows" };
const pairingAllowlist = resolveNodePairingCommandAllowlist(cfg, node);
expect(pairingAllowlist.has("system.execApprovals.get")).toBe(true);
expect(pairingAllowlist.has("system.execApprovals.set")).toBe(true);
const runtimeBeforeApproval = resolveNodeCommandAllowlist(cfg, node);
expect(runtimeBeforeApproval.has("system.execApprovals.get")).toBe(false);
expect(runtimeBeforeApproval.has("system.execApprovals.set")).toBe(false);
const runtimeAfterApproval = resolveNodeCommandAllowlist(cfg, {
...node,
approvedCommands: ["system.execApprovals.get", "system.execApprovals.set"],
});
expect(runtimeAfterApproval.has("system.execApprovals.get")).toBe(true);
expect(runtimeAfterApproval.has("system.execApprovals.set")).toBe(true);
});
it("keeps approved host commands on live desktop node sessions", () => {
const allowlist = resolveNodeCommandAllowlist({} as OpenClawConfig, {
nodeId: "node-1",

View File

@@ -3,6 +3,7 @@ import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/strin
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
NODE_BROWSER_PROXY_COMMAND,
NODE_EXEC_APPROVALS_COMMANDS,
NODE_SYSTEM_NOTIFY_COMMAND,
NODE_SYSTEM_RUN_COMMANDS,
} from "../infra/node-commands.js";
@@ -47,11 +48,13 @@ const IOS_SYSTEM_COMMANDS = [NODE_SYSTEM_NOTIFY_COMMAND];
const SYSTEM_COMMANDS = [
...NODE_SYSTEM_RUN_COMMANDS,
...NODE_EXEC_APPROVALS_COMMANDS,
NODE_SYSTEM_NOTIFY_COMMAND,
NODE_BROWSER_PROXY_COMMAND,
];
const DESKTOP_HOST_COMMANDS = new Set<string>([
...NODE_SYSTEM_RUN_COMMANDS,
...NODE_EXEC_APPROVALS_COMMANDS,
NODE_BROWSER_PROXY_COMMAND,
...SCREEN_COMMANDS,
]);
@@ -122,17 +125,18 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
};
type PlatformId = "ios" | "android" | "macos" | "windows" | "linux" | "unknown";
type CanonicalPlatformId = Exclude<PlatformId, "unknown">;
const CANONICAL_PLATFORM_IDS = new Set<Exclude<PlatformId, "unknown">>([
const CANONICAL_PLATFORM_IDS: readonly CanonicalPlatformId[] = [
"ios",
"android",
"macos",
"windows",
"linux",
]);
];
const DEVICE_FAMILY_TOKEN_RULES: ReadonlyArray<{
id: Exclude<PlatformId, "unknown">;
id: CanonicalPlatformId;
tokens: readonly string[];
}> = [
{ id: "ios", tokens: ["iphone", "ipad", "ios"] },
@@ -142,17 +146,16 @@ const DEVICE_FAMILY_TOKEN_RULES: ReadonlyArray<{
{ id: "linux", tokens: ["linux"] },
] as const;
function resolvePlatformIdByExactMatch(value: string): Exclude<PlatformId, "unknown"> | undefined {
if (CANONICAL_PLATFORM_IDS.has(value as Exclude<PlatformId, "unknown">)) {
return value as Exclude<PlatformId, "unknown">;
function resolvePlatformIdByExactMatch(value: string): CanonicalPlatformId | undefined {
for (const id of CANONICAL_PLATFORM_IDS) {
if (id === value) {
return id;
}
}
return undefined;
}
function platformMatchesDeviceFamily(
platformId: Exclude<PlatformId, "unknown">,
family: string,
): boolean {
function platformMatchesDeviceFamily(platformId: CanonicalPlatformId, family: string): boolean {
switch (platformId) {
case "ios":
return family === "" || /^(?:iphone|ipad|ios)$/.test(family);

View File

@@ -105,6 +105,49 @@ describe("reconcileNodePairingOnConnect", () => {
);
});
it("uses node instance id for command pairing when device auth is present", async () => {
const requestPairing = vi.fn(async (input: NodePairingRequestInput) => ({
status: "pending" as const,
request: { ...input, requestId: "req-device-token", ts: 1 },
created: true,
}));
const result = await reconcileNodePairingOnConnect({
cfg: {} as never,
connectParams: makeNodeConnectParams({
client: {
id: GATEWAY_CLIENT_IDS.NODE_HOST,
version: "test",
platform: "windows",
deviceFamily: "Windows",
mode: GATEWAY_CLIENT_MODES.NODE,
instanceId: "stable-host-node",
},
device: {
id: "rotated-device-identity",
publicKey: "public-key",
signature: "signature",
signedAt: 1,
nonce: "nonce",
},
caps: ["system"],
commands: ["system.which"],
}),
pairedNode: makePairedNode({
nodeId: "stable-host-node",
deviceId: "rotated-device-identity",
caps: ["system"],
commands: ["system.which"],
}),
requestPairing,
});
expect(result.nodeId).toBe("stable-host-node");
expect(result.effectiveCaps).toEqual(["system"]);
expect(result.effectiveCommands).toEqual(["system.which"]);
expect(requestPairing).not.toHaveBeenCalled();
});
it.each([
["conflicts with device family", { deviceFamily: "iPhone" }],
["omits device family", {}],

View File

@@ -15,6 +15,7 @@ import {
resolveNodeCommandAllowlist,
resolveNodePairingCommandAllowlist,
} from "./node-command-policy.js";
import { resolveConnectNodeId } from "./node-identity.js";
export type NodeConnectPairingReconcileResult = {
nodeId: string;
@@ -89,6 +90,7 @@ function buildNodePairingRequestInput(params: {
}): NodePairingRequestInput {
return {
nodeId: params.nodeId,
...(params.connectParams.device?.id ? { deviceId: params.connectParams.device.id } : {}),
displayName: params.connectParams.client.displayName,
platform: params.connectParams.client.platform,
version: params.connectParams.client.version,
@@ -108,7 +110,7 @@ export async function reconcileNodePairingOnConnect(params: {
reportedClientIp?: string;
requestPairing: (input: NodePairingRequestInput) => Promise<RequestNodePairingResult>;
}): Promise<NodeConnectPairingReconcileResult> {
const nodeId = params.connectParams.device?.id ?? params.connectParams.client.id;
const nodeId = params.pairedNode?.nodeId ?? resolveConnectNodeId(params.connectParams);
const policyNode = {
platform: params.connectParams.client.platform,
deviceFamily: params.connectParams.client.deviceFamily,

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
GATEWAY_CLIENT_IDS,
GATEWAY_CLIENT_MODES,
} from "../../packages/gateway-protocol/src/client-info.js";
import type { ConnectParams } from "../../packages/gateway-protocol/src/index.js";
import {
nodePairingMatchesConnectDevice,
resolveConnectNodeIdCandidates,
} from "./node-identity.js";
function makeConnect(overrides?: Partial<ConnectParams>): ConnectParams {
return {
minProtocol: 1,
maxProtocol: 1,
client: {
id: GATEWAY_CLIENT_IDS.NODE_HOST,
version: "test",
platform: "windows",
mode: GATEWAY_CLIENT_MODES.NODE,
instanceId: "stable-node",
},
device: {
id: "verified-device",
publicKey: "public-key",
signature: "signature",
signedAt: 1,
nonce: "nonce",
},
...overrides,
};
}
describe("gateway/node-identity", () => {
it("prefers stable node instance id before the authenticated device id", () => {
expect(resolveConnectNodeIdCandidates(makeConnect())).toEqual([
"stable-node",
"verified-device",
GATEWAY_CLIENT_IDS.NODE_HOST,
]);
});
it("rejects unbound stable node pairings that only match client-supplied instance id", () => {
expect(
nodePairingMatchesConnectDevice({
connect: makeConnect(),
pairedNode: {
nodeId: "stable-node",
token: "token",
caps: ["system"],
commands: ["system.which"],
createdAtMs: 1,
approvedAtMs: 1,
},
}),
).toBe(false);
});
it("accepts stable node pairings bound to the verified device id", () => {
expect(
nodePairingMatchesConnectDevice({
connect: makeConnect(),
pairedNode: {
nodeId: "stable-node",
deviceId: "verified-device",
token: "token",
caps: ["system"],
commands: ["system.which"],
createdAtMs: 1,
approvedAtMs: 1,
},
}),
).toBe(true);
});
it("accepts legacy records whose node id is the verified device id", () => {
expect(
nodePairingMatchesConnectDevice({
connect: makeConnect(),
pairedNode: {
nodeId: "verified-device",
token: "token",
caps: ["system"],
commands: ["system.which"],
createdAtMs: 1,
approvedAtMs: 1,
},
}),
).toBe(true);
});
it("rejects records whose explicit device binding differs from the verified device id", () => {
expect(
nodePairingMatchesConnectDevice({
connect: makeConnect(),
pairedNode: {
nodeId: "verified-device",
deviceId: "other-device",
token: "token",
caps: ["system"],
commands: ["system.which"],
createdAtMs: 1,
approvedAtMs: 1,
},
}),
).toBe(false);
});
it("rejects device-bound stable records when device auth is missing", () => {
expect(
nodePairingMatchesConnectDevice({
connect: makeConnect({ device: undefined }),
pairedNode: {
nodeId: "stable-node",
deviceId: "verified-device",
token: "token",
caps: ["system"],
commands: ["system.which"],
createdAtMs: 1,
approvedAtMs: 1,
},
}),
).toBe(false);
});
it("rejects stable records bound to a different device id", () => {
expect(
nodePairingMatchesConnectDevice({
connect: makeConnect({
device: {
id: "rotated-device",
publicKey: "new-public-key",
signature: "signature",
signedAt: 1,
nonce: "nonce",
},
}),
pairedNode: {
nodeId: "stable-node",
deviceId: "verified-device",
token: "token",
caps: ["system"],
commands: ["system.which"],
createdAtMs: 1,
approvedAtMs: 1,
},
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,47 @@
import { GATEWAY_CLIENT_MODES } from "../../packages/gateway-protocol/src/client-info.js";
import type { ConnectParams } from "../../packages/gateway-protocol/src/index.js";
import type { NodePairingPairedNode } from "../infra/node-pairing.js";
function normalizeNodeIdentityPart(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function resolveConnectNodeId(connect: ConnectParams): string {
return resolveConnectNodeIdCandidates(connect)[0] ?? connect.client.id;
}
export function resolveConnectNodeIdCandidates(connect: ConnectParams): string[] {
const candidates: string[] = [];
if (connect.client.mode === GATEWAY_CLIENT_MODES.NODE) {
const instanceId = normalizeNodeIdentityPart(connect.client.instanceId);
if (instanceId) {
candidates.push(instanceId);
}
}
const deviceId = normalizeNodeIdentityPart(connect.device?.id);
if (deviceId && !candidates.includes(deviceId)) {
candidates.push(deviceId);
}
if (!candidates.includes(connect.client.id)) {
candidates.push(connect.client.id);
}
return candidates;
}
export function nodePairingMatchesConnectDevice(params: {
connect: ConnectParams;
pairedNode: NodePairingPairedNode;
}): boolean {
const deviceId = normalizeNodeIdentityPart(params.connect.device?.id);
if (!deviceId) {
return params.pairedNode.deviceId === undefined;
}
// A stable instance id is client supplied. It can carry an approved command
// surface only while bound to the verified device identity that earned it.
// A changed device id must re-pair unless the node proves a separate token.
if (params.pairedNode.deviceId) {
return params.pairedNode.deviceId === deviceId;
}
return params.pairedNode.nodeId === deviceId;
}

View File

@@ -15,6 +15,7 @@ function makeClient(
sent: string[] = [],
opts: {
clientId?: string;
instanceId?: string;
platform?: string;
version?: string;
caps?: string[];
@@ -46,6 +47,7 @@ function makeClient(
version: opts.version ?? "1.0.0",
platform: opts.platform ?? "darwin",
mode: "node",
instanceId: opts.instanceId,
},
device: {
id: nodeId,
@@ -65,6 +67,60 @@ function makeClient(
}
describe("gateway/node-registry", () => {
it("registers node-mode clients by stable instance id instead of rotated device id", () => {
const registry = new NodeRegistry();
const session = registry.register(
makeClient("conn-1", "device-token-id", [], {
clientId: "node-host",
instanceId: "stable-host-node",
}),
{},
);
expect(session.nodeId).toBe("stable-host-node");
expect(registry.get("stable-host-node")).toBe(session);
expect(registry.get("device-token-id")).toBeUndefined();
});
it("uses reconciled node id overrides for legacy device-id approvals", () => {
const registry = new NodeRegistry();
const client = makeClient("conn-1", "device-token-id", [], {
clientId: "node-host",
instanceId: "stable-host-node",
});
const session = registry.register(client, { nodeId: "device-token-id" });
expect(session.nodeId).toBe("device-token-id");
expect(registry.get("device-token-id")).toBe(session);
expect(registry.get("stable-host-node")).toBeUndefined();
});
it("adopts a quarantined device-id session under the approved stable id", () => {
const registry = new NodeRegistry();
const session = registry.register(
makeClient("conn-1", "device-token-id", [], {
commands: ["system.which"],
declaredCommands: ["system.which"],
}),
{},
);
const adopted = registry.adoptNodeId("device-token-id", "stable-host-node", {
caps: ["system"],
commands: ["system.which"],
});
expect(adopted).toBe(session);
expect(adopted?.nodeId).toBe("stable-host-node");
expect(adopted?.commands).toEqual(["system.which"]);
expect(registry.get("device-token-id")).toBeUndefined();
expect(registry.get("stable-host-node")).toBe(session);
expect(registry.getByConnId("conn-1")).toBe(session);
expect(registry.unregister("conn-1")).toBe("stable-host-node");
});
it("checks node websocket connectivity with ping/pong", async () => {
const registry = new NodeRegistry();
const socket = new EventEmitter() as EventEmitter & {
@@ -143,6 +199,16 @@ describe("gateway/node-registry", () => {
await expect(oldDisconnected).resolves.toBeInstanceOf(Error);
});
it("does not resolve an old connection to its replacement session", () => {
const registry = new NodeRegistry();
registry.register(makeClient("conn-old", "node-1"), {});
const replacement = registry.register(makeClient("conn-new", "node-1"), {});
expect(registry.getByConnId("conn-old")).toBeUndefined();
expect(registry.getByConnId("conn-new")).toBe(replacement);
});
it("matches pending system.run events to the issuing connection", async () => {
const registry = new NodeRegistry();
const frames: string[] = [];

View File

@@ -6,6 +6,7 @@ import {
resolveTimerTimeoutMs,
} from "@openclaw/normalization-core/number-coercion";
import { logRejectedLargePayload } from "../logging/diagnostic-payload.js";
import { resolveConnectNodeId } from "./node-identity.js";
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
import type { GatewayWsClient } from "./server/ws-types.js";
@@ -108,6 +109,33 @@ function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function getStringArrayProperty(value: object, key: string): string[] | undefined {
const raw = Reflect.get(value, key);
return Array.isArray(raw)
? raw.filter((entry): entry is string => typeof entry === "string")
: undefined;
}
function getBooleanRecordProperty(value: object, key: string): Record<string, boolean> | undefined {
const raw = Reflect.get(value, key);
if (!isRecord(raw)) {
return undefined;
}
const entries = Object.entries(raw).filter(
(entry): entry is [string, boolean] => typeof entry[1] === "boolean",
);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function getStringProperty(value: object, key: string): string | undefined {
const raw = Reflect.get(value, key);
return typeof raw === "string" ? raw : undefined;
}
function normalizeSystemRunTimeoutMs(value: unknown): number | null | undefined {
if (value === undefined) {
return undefined;
@@ -126,13 +154,13 @@ function resolvePendingSystemRunEvent(params: {
if (params.command !== "system.run" || !params.params || typeof params.params !== "object") {
return undefined;
}
const obj = params.params as Record<string, unknown>;
const runId = normalizeString(obj.runId);
const obj = isRecord(params.params) ? params.params : {};
const runId = normalizeString(obj["runId"]);
if (!runId) {
return undefined;
}
const timeoutMs = normalizeSystemRunTimeoutMs(obj.timeoutMs);
const sessionKey = normalizeString(obj.sessionKey);
const timeoutMs = normalizeSystemRunTimeoutMs(obj["timeoutMs"]);
const sessionKey = normalizeString(obj["sessionKey"]);
return {
runId,
...(sessionKey ? { sessionKey } : {}),
@@ -149,8 +177,8 @@ function withSystemRunEventRunId(params: { command: string; params?: unknown }):
) {
return params.params;
}
const obj = params.params as Record<string, unknown>;
if (normalizeString(obj.runId)) {
const obj = isRecord(params.params) ? params.params : {};
if (normalizeString(obj["runId"])) {
return params.params;
}
return { ...obj, runId: randomUUID() };
@@ -162,35 +190,17 @@ export class NodeRegistry {
private pendingInvokes = new Map<string, PendingInvoke>();
private authorizedSystemRunEvents = new Map<string, AuthorizedSystemRunEvent>();
register(client: GatewayWsClient, opts: { remoteIp?: string | undefined }) {
register(client: GatewayWsClient, opts: { remoteIp?: string | undefined; nodeId?: string }) {
const connect = client.connect;
const nodeId = connect.device?.id ?? connect.client.id;
const nodeId = opts.nodeId?.trim() || resolveConnectNodeId(connect);
const caps = Array.isArray(connect.caps) ? connect.caps : [];
const declaredCaps = Array.isArray((connect as { declaredCaps?: string[] }).declaredCaps)
? ((connect as { declaredCaps?: string[] }).declaredCaps ?? [])
: caps;
const commands = Array.isArray((connect as { commands?: string[] }).commands)
? ((connect as { commands?: string[] }).commands ?? [])
: [];
const declaredCommands = Array.isArray(
(connect as { declaredCommands?: string[] }).declaredCommands,
)
? ((connect as { declaredCommands?: string[] }).declaredCommands ?? [])
: commands;
const permissions =
typeof (connect as { permissions?: Record<string, boolean> }).permissions === "object"
? ((connect as { permissions?: Record<string, boolean> }).permissions ?? undefined)
: undefined;
const declaredCaps = getStringArrayProperty(connect, "declaredCaps") ?? caps;
const commands = getStringArrayProperty(connect, "commands") ?? [];
const declaredCommands = getStringArrayProperty(connect, "declaredCommands") ?? commands;
const permissions = getBooleanRecordProperty(connect, "permissions");
const declaredPermissions =
typeof (connect as { declaredPermissions?: Record<string, boolean> }).declaredPermissions ===
"object"
? ((connect as { declaredPermissions?: Record<string, boolean> }).declaredPermissions ??
undefined)
: permissions;
const pathEnv =
typeof (connect as { pathEnv?: string }).pathEnv === "string"
? (connect as { pathEnv?: string }).pathEnv
: undefined;
getBooleanRecordProperty(connect, "declaredPermissions") ?? permissions;
const pathEnv = getStringProperty(connect, "pathEnv");
const session: NodeSession = {
nodeId,
connId: client.connId,
@@ -200,8 +210,8 @@ export class NodeRegistry {
displayName: connect.client.displayName,
platform: connect.client.platform,
version: connect.client.version,
coreVersion: (connect as { coreVersion?: string }).coreVersion,
uiVersion: (connect as { uiVersion?: string }).uiVersion,
coreVersion: getStringProperty(connect, "coreVersion"),
uiVersion: getStringProperty(connect, "uiVersion"),
deviceFamily: connect.client.deviceFamily,
modelIdentifier: connect.client.modelIdentifier,
remoteIp: opts.remoteIp,
@@ -253,6 +263,15 @@ export class NodeRegistry {
return this.nodesById.get(nodeId);
}
getByConnId(connId: string | undefined): NodeSession | undefined {
if (!connId) {
return undefined;
}
const nodeId = this.nodesByConn.get(connId);
const session = nodeId ? this.nodesById.get(nodeId) : undefined;
return session?.connId === connId ? session : undefined;
}
async checkConnectivity(nodeId: string, timeoutMs = 2_000): Promise<NodeConnectivityResult> {
const node = this.nodesById.get(nodeId);
if (!node) {
@@ -344,6 +363,33 @@ export class NodeRegistry {
return this.updateSurface(nodeId, { commands });
}
adoptNodeId(
currentNodeId: string,
nextNodeId: string,
surface: {
caps?: readonly string[];
commands: readonly string[];
permissions?: Record<string, boolean> | undefined;
},
): NodeSession | null {
const current = currentNodeId.trim();
const next = nextNodeId.trim();
if (!current || !next) {
return null;
}
const node = this.nodesById.get(current);
if (!node) {
return null;
}
if (current !== next) {
this.nodesById.delete(current);
node.nodeId = next;
this.nodesById.set(next, node);
this.nodesByConn.set(node.connId, next);
}
return this.updateSurface(next, surface);
}
updateSurface(
nodeId: string,
surface: {

View File

@@ -15,13 +15,14 @@ import {
type ExecApprovalsFile,
type ExecApprovalsSnapshot,
} from "../../infra/exec-approvals.js";
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
import { resolveBaseHashParam } from "./base-hash.js";
import {
respondUnavailableOnNodeInvokeError,
respondUnavailableOnThrow,
safeParseJson,
} from "./nodes.helpers.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
function requireApprovalsBaseHash(
@@ -99,6 +100,42 @@ function resolveNodeIdOrRespond(nodeId: string, respond: RespondFn): string | nu
return id;
}
function ensureNodeCommandAllowed(params: {
context: GatewayRequestContext;
nodeId: string;
command: string;
respond: RespondFn;
}): boolean {
const nodeSession = params.context.nodeRegistry.get(params.nodeId);
if (!nodeSession) {
return true;
}
const allowlist = resolveNodeCommandAllowlist(params.context.getRuntimeConfig(), {
...nodeSession,
approvedCommands: nodeSession.commands,
});
const allowed = isNodeCommandAllowed({
command: params.command,
declaredCommands: nodeSession.commands,
allowlist,
});
if (allowed.ok) {
return true;
}
params.respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`node command not allowed: "${params.command}" is not approved for node "${params.nodeId}"`,
{
details: { reason: allowed.reason, command: params.command },
},
),
);
return false;
}
export const execApprovalsHandlers: GatewayRequestHandlers = {
"exec.approvals.get": ({ params, respond }) => {
if (!assertValidParams(params, validateExecApprovalsGetParams, "exec.approvals.get", respond)) {
@@ -117,7 +154,7 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
if (!requireApprovalsBaseHash(params, snapshot, respond)) {
return;
}
const incoming = (params as { file?: unknown }).file;
const incoming = params.file;
if (!incoming || typeof incoming !== "object") {
respond(
false,
@@ -143,11 +180,21 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
) {
return;
}
const { nodeId } = params as { nodeId: string };
const { nodeId } = params;
const id = resolveNodeIdOrRespond(nodeId, respond);
if (!id) {
return;
}
if (
!ensureNodeCommandAllowed({
context,
nodeId: id,
command: "system.execApprovals.get",
respond,
})
) {
return;
}
await respondUnavailableOnThrow(respond, async () => {
const res = await context.nodeRegistry.invoke({
nodeId: id,
@@ -174,28 +221,37 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
) {
return;
}
const { nodeId, file, baseHash } = params as {
nodeId: string;
file: ExecApprovalsFile;
baseHash?: string;
};
const { nodeId } = params;
const file = "file" in params ? params.file : undefined;
const native = "native" in params ? params.native : undefined;
const baseHash = "baseHash" in params ? params.baseHash : undefined;
const id = resolveNodeIdOrRespond(nodeId, respond);
if (!id) {
return;
}
if (
!ensureNodeCommandAllowed({
context,
nodeId: id,
command: "system.execApprovals.set",
respond,
})
) {
return;
}
await respondUnavailableOnThrow(respond, async () => {
const res = await context.nodeRegistry.invoke({
nodeId: id,
command: "system.execApprovals.set",
params: { file, baseHash },
params: native ? { ...native, baseHash } : { file, baseHash },
});
if (!respondUnavailableOnNodeInvokeError(respond, res)) {
return;
}
// node.set returns JSON on the command channel; keep the gateway response
// shape aligned with local exec.approvals.set.
const payload = safeParseJson(res.payloadJSON ?? null);
respond(true, payload, undefined);
// Node transports may return structured payloads or JSON strings; keep
// node.set aligned with the local exec.approvals.set response shape.
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
respond(true, payload ?? {}, undefined);
});
},
};

View File

@@ -22,15 +22,25 @@ import type { GatewayRequestHandlers } from "./types.js";
function resolveClientNodeId(
client: { connect?: { device?: { id?: string }; client?: { id?: string } } } | null,
registeredNodeId?: string,
): string | null {
const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id ?? "";
const nodeId =
registeredNodeId ?? client?.connect?.device?.id ?? client?.connect?.client?.id ?? "";
const trimmed = nodeId.trim();
return trimmed.length > 0 ? trimmed : null;
}
function isNodePendingWorkType(value: string): value is NodePendingWorkType {
return value === "location.request" || value === "status.request";
}
function isNodePendingWorkPriority(value: string): value is NodePendingWorkPriority {
return value === "default" || value === "high" || value === "normal";
}
/** Gateway handlers for queueing work until a paired node reconnects. */
export const nodePendingHandlers: GatewayRequestHandlers = {
"node.pending.drain": async ({ params, respond, client }) => {
"node.pending.drain": async ({ params, respond, client, context }) => {
if (!validateNodePendingDrainParams(params)) {
respondInvalidParams({
respond,
@@ -39,7 +49,10 @@ export const nodePendingHandlers: GatewayRequestHandlers = {
});
return;
}
const nodeId = resolveClientNodeId(client);
const nodeId = resolveClientNodeId(
client,
context.nodeRegistry?.getByConnId?.(client?.connId)?.nodeId,
);
if (!nodeId) {
respond(
false,
@@ -51,7 +64,7 @@ export const nodePendingHandlers: GatewayRequestHandlers = {
);
return;
}
const p = params as { maxItems?: number };
const p = params;
const drained = drainNodePendingWork(nodeId, {
maxItems: p.maxItems,
includeDefaultStatus: true,
@@ -67,18 +80,25 @@ export const nodePendingHandlers: GatewayRequestHandlers = {
});
return;
}
const p = params as {
nodeId: string;
type: NodePendingWorkType;
priority?: NodePendingWorkPriority;
expiresInMs?: number;
wake?: boolean;
};
const p = params;
if (
!isNodePendingWorkType(p.type) ||
(p.priority !== undefined && !isNodePendingWorkPriority(p.priority))
) {
respondInvalidParams({
respond,
method: "node.pending.enqueue",
validator: validateNodePendingEnqueueParams,
});
return;
}
const pendingType = p.type;
const pendingPriority = p.priority;
await respondUnavailableOnThrow(respond, async () => {
const queued = enqueueNodePendingWork({
nodeId: p.nodeId,
type: p.type,
priority: p.priority,
type: pendingType,
priority: pendingPriority,
expiresInMs: p.expiresInMs,
});
let wakeTriggered = false;

View File

@@ -7,11 +7,10 @@ import { respondInvalidParams } from "./nodes.helpers.js";
import type { GatewayRequestHandler } from "./types.js";
function normalizeNodeInvokeResultParams(params: unknown): unknown {
if (!params || typeof params !== "object") {
if (!isRecord(params)) {
return params;
}
const raw = params as Record<string, unknown>;
const normalized: Record<string, unknown> = { ...raw };
const normalized: Record<string, unknown> = { ...params };
if (normalized.payloadJSON === null) {
delete normalized.payloadJSON;
} else if (normalized.payloadJSON !== undefined && typeof normalized.payloadJSON !== "string") {
@@ -26,6 +25,10 @@ function normalizeNodeInvokeResultParams(params: unknown): unknown {
return normalized;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export const handleNodeInvokeResult: GatewayRequestHandler = async ({
params,
respond,
@@ -41,15 +44,11 @@ export const handleNodeInvokeResult: GatewayRequestHandler = async ({
});
return;
}
const p = normalizedParams as {
id: string;
nodeId: string;
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
};
const callerNodeId = client?.connect?.device?.id ?? client?.connect?.client?.id;
const p = normalizedParams;
const callerNodeId =
context.nodeRegistry?.getByConnId?.(client?.connId)?.nodeId ??
client?.connect?.device?.id ??
client?.connect?.client?.id;
if (callerNodeId && callerNodeId !== p.nodeId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId mismatch"));
return;

View File

@@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => ({
sendApnsAlert: vi.fn(),
shouldClearStoredApnsRegistration: vi.fn(() => false),
requestNodePairing: vi.fn(),
handleNodeEvent: vi.fn(),
}));
vi.mock("../../config/io.js", () => ({
@@ -72,6 +73,10 @@ vi.mock("../../infra/node-pairing.js", async () => {
};
});
vi.mock("../server-node-events.js", () => ({
handleNodeEvent: mocks.handleNodeEvent,
}));
type RespondCall = [
boolean,
unknown?,
@@ -341,6 +346,55 @@ function createNodeClient(nodeId: string, commands?: string[]) {
};
}
async function sendNodeEvent(params?: { nodeId?: string; deviceId?: string; connId?: string }) {
const respond = vi.fn();
const connId = params?.connId ?? "conn-stable";
await nodeHandlers["node.event"]({
params: { event: "exec.finished", payload: { runId: "run-1" } },
respond: respond as never,
context: {
deps: {},
broadcast: vi.fn(),
nodeSendToSession: vi.fn(),
nodeSubscribe: vi.fn(),
nodeUnsubscribe: vi.fn(),
broadcastVoiceWakeChanged: vi.fn(),
addChatRun: vi.fn(),
removeChatRun: vi.fn(),
chatAbortControllers: new Map(),
chatAbortedRuns: new Map(),
chatRunBuffers: new Map(),
chatDeltaSentAt: new Map(),
dedupe: new Map(),
agentRunSeq: new Map(),
getHealthCache: vi.fn(),
refreshHealthSnapshot: vi.fn(),
loadGatewayModelCatalog: vi.fn(),
logGateway: { warn: vi.fn() },
nodeRegistry: {
getByConnId: vi.fn(() =>
params?.nodeId
? {
nodeId: params.nodeId,
}
: undefined,
),
authorizeSystemRunEvent: vi.fn(),
},
} as never,
client: {
connId,
connect: {
device: { id: params?.deviceId ?? "device-token-id" },
client: { id: "node-host" },
},
} as never,
req: { type: "req", id: "req-node-event", method: "node.event" },
isWebchatConnect: () => false,
});
return respond;
}
async function pullPending(nodeId: string, commands?: string[]) {
const respond = vi.fn();
await nodeHandlers["node.pending.pull"]({
@@ -367,6 +421,22 @@ async function ackPending(nodeId: string, ids: string[], commands?: string[]) {
return respond;
}
describe("node.event", () => {
it("handles stable node events under the registered node id", async () => {
await sendNodeEvent({
nodeId: "stable-windows-node",
deviceId: "device-token-id",
});
expect(mocks.handleNodeEvent).toHaveBeenCalledWith(
expect.anything(),
"stable-windows-node",
expect.objectContaining({ event: "exec.finished" }),
expect.objectContaining({ connId: "conn-stable", deviceId: "device-token-id" }),
);
});
});
describe("node.pair.request", () => {
it("passes permissions and resolves superseded prompts before broadcasting replacement requests", async () => {
mocks.requestNodePairing.mockResolvedValue({
@@ -387,6 +457,7 @@ describe("node.pair.request", () => {
await nodeHandlers["node.pair.request"]({
params: {
nodeId: "ios-node-1",
deviceId: "device-1",
commands: ["canvas.snapshot"],
permissions: { camera: true },
},
@@ -399,6 +470,7 @@ describe("node.pair.request", () => {
expect(mocks.requestNodePairing).toHaveBeenCalledWith({
nodeId: "ios-node-1",
deviceId: "device-1",
displayName: undefined,
platform: undefined,
version: undefined,

View File

@@ -117,6 +117,10 @@ type PendingNodeAction = {
const pendingNodeActionsById = new Map<string, PendingNodeAction[]>();
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeBrowserProxyPath(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
@@ -141,20 +145,19 @@ function isPersistentBrowserProxyMutation(method: string, path: string): boolean
}
function isForbiddenBrowserProxyMutation(params: unknown): boolean {
if (!params || typeof params !== "object") {
if (!isRecord(params)) {
return false;
}
const candidate = params as { method?: unknown; path?: unknown };
const method = (normalizeOptionalString(candidate.method) ?? "").toUpperCase();
const path = normalizeOptionalString(candidate.path) ?? "";
const method = (normalizeOptionalString(params["method"]) ?? "").toUpperCase();
const path = normalizeOptionalString(params["path"]) ?? "";
return Boolean(method && path && isPersistentBrowserProxyMutation(method, path));
}
function normalizePluginSurfaceRefreshParams(params: unknown): { surface: string } | undefined {
if (!params || typeof params !== "object") {
if (!isRecord(params)) {
return undefined;
}
const surface = normalizeOptionalString((params as { surface?: unknown }).surface);
const surface = normalizeOptionalString(params["surface"]);
if (!surface) {
return undefined;
}
@@ -416,10 +419,7 @@ function emitTalkPttNodeEvent(params: {
if (!TALK_PTT_COMMANDS.has(params.command)) {
return;
}
const payloadObj =
typeof params.payload === "object" && params.payload !== null
? (params.payload as Record<string, unknown>)
: {};
const payloadObj = isRecord(params.payload) ? params.payload : {};
const captureId = normalizeOptionalString(payloadObj.captureId) ?? randomUUID();
const sessionId = `node:${params.nodeId}:talk:${captureId}`;
const seq = (talkPttEventSeqBySessionId.get(sessionId) ?? 0) + 1;
@@ -706,6 +706,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
await respondUnavailableOnThrow(respond, async () => {
const result = await requestNodePairing({
nodeId: p.nodeId,
deviceId: p.deviceId,
displayName: p.displayName,
platform: p.platform,
version: p.version,
@@ -797,11 +798,31 @@ export const nodeHandlers: GatewayRequestHandlers = {
declaredCommands: approvedNode.commands ?? [],
allowlist: currentAllowlist,
});
const updatedNode = context.nodeRegistry.updateSurface(approvedNode.nodeId, {
const liveNode = context.nodeRegistry.get(approvedNode.nodeId);
const approvedDeviceId = normalizeOptionalString(approvedNode.deviceId);
const liveDeviceId = normalizeOptionalString(liveNode?.client.connect.device?.id);
const liveNodeMatchesApprovedDevice = !approvedDeviceId || liveDeviceId === approvedDeviceId;
if (liveNode && !liveNodeMatchesApprovedDevice) {
context.nodeRegistry.unregister(liveNode.connId);
liveNode.client.socket.close(1008, "node pairing device changed");
}
const approvedSurface = {
caps: approvedNode.caps ?? [],
commands: currentAllowedCommands,
permissions: approvedNode.permissions,
});
};
const quarantinedNode =
approvedDeviceId && approvedDeviceId !== approvedNode.nodeId
? context.nodeRegistry.get(approvedDeviceId)
: undefined;
const quarantinedNodeMatchesApprovedDevice =
normalizeOptionalString(quarantinedNode?.client.connect.device?.id) === approvedDeviceId;
const updatedNode =
quarantinedNode && approvedDeviceId && quarantinedNodeMatchesApprovedDevice
? context.nodeRegistry.adoptNodeId(approvedDeviceId, approvedNode.nodeId, approvedSurface)
: liveNodeMatchesApprovedDevice
? context.nodeRegistry.updateSurface(approvedNode.nodeId, approvedSurface)
: null;
if (updatedNode) {
refreshConnectedNodeSurfaceCaches({ context, nodeSession: updatedNode, cfg });
}
@@ -1005,7 +1026,10 @@ export const nodeHandlers: GatewayRequestHandlers = {
});
return;
}
const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id;
const nodeId =
context?.nodeRegistry?.getByConnId?.(client?.connId)?.nodeId ??
client?.connect?.device?.id ??
client?.connect?.client?.id;
const trimmedNodeId = normalizeOptionalString(nodeId) ?? "";
if (!trimmedNodeId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
@@ -1031,7 +1055,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
undefined,
);
},
"node.pending.ack": async ({ params, respond, client }) => {
"node.pending.ack": async ({ params, respond, client, context }) => {
if (!validateNodePendingAckParams(params)) {
respondInvalidParams({
respond,
@@ -1040,7 +1064,10 @@ export const nodeHandlers: GatewayRequestHandlers = {
});
return;
}
const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id;
const nodeId =
context?.nodeRegistry?.getByConnId?.(client?.connId)?.nodeId ??
client?.connect?.device?.id ??
client?.connect?.client?.id;
const trimmedNodeId = normalizeOptionalString(nodeId) ?? "";
if (!trimmedNodeId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
@@ -1380,7 +1407,11 @@ export const nodeHandlers: GatewayRequestHandlers = {
: null;
await respondUnavailableOnThrow(respond, async () => {
const { handleNodeEvent } = await import("../server-node-events.js");
const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id ?? "node";
const nodeId =
context.nodeRegistry.getByConnId(client?.connId)?.nodeId ??
client?.connect?.device?.id ??
client?.connect?.client?.id ??
"node";
const nodeContext: NodeEventContext = {
deps: context.deps,
broadcast: context.broadcast,

View File

@@ -332,6 +332,12 @@ describe("node.invoke approval bypass", () => {
onInvoke: (payload: unknown) => void,
deviceIdentity?: DeviceIdentity,
commands: string[] = ["system.run"],
resolveInvokeResult?: (payload: unknown) => {
ok?: boolean;
payload?: unknown;
payloadJSON?: string;
},
platform = "linux",
) => {
const resolvedDeviceIdentity = deviceIdentity ?? createDeviceIdentity();
@@ -349,7 +355,8 @@ describe("node.invoke approval bypass", () => {
role: "node",
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientVersion: "1.0.0",
platform: "linux",
platform,
deviceFamily: platform,
mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [],
caps: ["system"],
@@ -370,11 +377,15 @@ describe("node.invoke approval bypass", () => {
if (!id || !nodeId) {
return;
}
const result = resolveInvokeResult?.(evt.payload) ?? {
payloadJSON: JSON.stringify({ ok: true }),
};
void client.request("node.invoke.result", {
id,
nodeId,
ok: true,
payloadJSON: JSON.stringify({ ok: true }),
ok: result.ok ?? true,
...(result.payload !== undefined ? { payload: result.payload } : {}),
...(result.payloadJSON !== undefined ? { payloadJSON: result.payloadJSON } : {}),
});
},
});
@@ -467,6 +478,59 @@ describe("node.invoke approval bypass", () => {
}
});
test("rejects dedicated node exec-approval mutations before forwarding when unapproved", async () => {
let sawInvoke = false;
const node = await connectLinuxNode(() => {
sawInvoke = true;
});
const ws = await connectOperator(["operator.admin"]);
try {
const nodeId = await getConnectedNodeId(ws);
const res = await rpcReq(ws, "exec.approvals.node.set", {
nodeId,
native: { enabled: true, defaultAction: "deny", rules: [] },
baseHash: "stale",
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("node command not allowed");
await expectNoForwardedInvoke(() => sawInvoke);
} finally {
ws.close();
node.stop();
}
});
test("returns dedicated node exec-approval set payload when node omits payloadJSON", async () => {
const nodePayload = {
enabled: true,
defaultAction: "deny",
rules: [{ pattern: "echo *", action: "allow", enabled: true }],
};
const node = await connectLinuxNode(
() => {},
undefined,
["system.execApprovals.get", "system.execApprovals.set"],
() => ({ payload: nodePayload }),
"windows",
);
const ws = await connectOperator(["operator.admin"]);
try {
const nodeId = await getConnectedNodeId(ws);
const res = await rpcReq(ws, "exec.approvals.node.set", {
nodeId,
native: nodePayload,
baseHash: "hash-1",
});
expect(res.ok, res.error?.message ?? "").toBe(true);
expect(res.payload).toEqual(nodePayload);
} finally {
ws.close();
node.stop();
}
});
test("rejects browser.proxy persistent profile mutations before forwarding", async () => {
let sawInvoke = false;
const node = await connectLinuxNode(

View File

@@ -16,6 +16,7 @@ import {
import { connectGatewayClient } from "./test-helpers.e2e.js";
import {
connectOk,
connectReq,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
@@ -42,6 +43,7 @@ async function connectNodeClient(params: {
port: number;
deviceIdentity: ReturnType<typeof loadDeviceIdentity>["identity"];
commands: string[];
instanceId?: string;
}) {
return await connectGatewayClient({
url: `ws://127.0.0.1:${params.port}`,
@@ -53,6 +55,7 @@ async function connectNodeClient(params: {
platform: "macos",
deviceFamily: "Mac",
mode: GATEWAY_CLIENT_MODES.NODE,
instanceId: params.instanceId,
scopes: [],
commands: params.commands,
deviceIdentity: params.deviceIdentity,
@@ -332,5 +335,507 @@ describe("gateway node pairing authorization", () => {
expectedVisibleCommands: [],
});
});
test("keeps approved commands when a device-token node reconnects with a stable instance id", async () => {
const pairedDevice = await pairDeviceIdentity({
name: "node-device-token-rotation",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const stableNodeId = "stable-node-token-rotation";
const request = await requestNodePairing({
nodeId: stableNodeId,
deviceId: pairedDevice.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
commands: ["system.which"],
});
requireApprovedPairing(
await approveNodePairing(request.request.requestId, {
callerScopes: ["operator.pairing", "operator.admin"],
}),
);
const controlWs = await openTrackedWs(started.port);
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
try {
await connectOk(controlWs, { token: "secret" });
nodeClient = await connectNodeClient({
port: started.port,
deviceIdentity: pairedDevice.identity,
instanceId: stableNodeId,
commands: ["system.which"],
});
await vi.waitFor(async () => {
const list = await rpcReq<{
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
}>(controlWs, "node.list", {});
const node = list.payload?.nodes?.find((entry) => entry.nodeId === stableNodeId);
if (node?.connected && node.commands?.includes("system.which")) {
return;
}
throw new Error(`stable node commands not visible yet: ${JSON.stringify(list.payload)}`);
});
} finally {
controlWs.close();
await nodeClient?.stopAndWait();
}
});
test("accepts public stable-node approvals when the request carries a verified device id", async () => {
const pairedDevice = await pairDeviceIdentity({
name: "node-public-stable-bind",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const stableNodeId = "stable-node-public-approval";
const request = await requestNodePairing({
nodeId: stableNodeId,
deviceId: pairedDevice.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
commands: ["system.which"],
});
requireApprovedPairing(
await approveNodePairing(request.request.requestId, {
callerScopes: ["operator.pairing", "operator.admin"],
}),
);
const controlWs = await openTrackedWs(started.port);
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
try {
await connectOk(controlWs, { token: "secret" });
nodeClient = await connectNodeClient({
port: started.port,
deviceIdentity: pairedDevice.identity,
instanceId: stableNodeId,
commands: ["system.which"],
});
await vi.waitFor(async () => {
const list = await rpcReq<{
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
}>(controlWs, "node.list", {});
const node = list.payload?.nodes?.find((entry) => entry.nodeId === stableNodeId);
if (node?.connected && node.commands?.includes("system.which")) {
return;
}
throw new Error(
`public stable node approval not usable yet: ${JSON.stringify(list.payload)}`,
);
});
const pairedStableNode = await getPairedNode(stableNodeId);
expect(pairedStableNode?.deviceId).toBe(pairedDevice.identity.deviceId);
} finally {
controlWs.close();
await nodeClient?.stopAndWait();
}
});
test("live-updates a first-time stable node when its pending request is approved", async () => {
const pairedDevice = await pairDeviceIdentity({
name: "node-first-stable",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const stableNodeId = "stable-node-first-approval";
const controlWs = await openTrackedWs(started.port);
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
try {
await connectOk(controlWs, {
token: "secret",
scopes: ["operator.pairing", "operator.admin"],
});
nodeClient = await connectNodeClient({
port: started.port,
deviceIdentity: pairedDevice.identity,
instanceId: stableNodeId,
commands: ["system.which"],
});
const pairing = await listNodePairing();
const pending = pairing.pending.find((entry) => entry.nodeId === stableNodeId);
expect(pending?.deviceId).toBe(pairedDevice.identity.deviceId);
const approve = await rpcReq(controlWs, "node.pair.approve", {
requestId: pending?.requestId,
});
expect(approve.ok).toBe(true);
await vi.waitFor(async () => {
const list = await rpcReq<{
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
}>(controlWs, "node.list", {});
const node = list.payload?.nodes?.find((entry) => entry.nodeId === stableNodeId);
if (node?.connected && node.commands?.includes("system.which")) {
return;
}
throw new Error(
`approved stable node not live-updated yet: ${JSON.stringify(list.payload)}`,
);
});
} finally {
controlWs.close();
await nodeClient?.stopAndWait();
}
});
test("keeps legacy device-id approvals registered under the approved node id", async () => {
const pairedDevice = await pairDeviceIdentity({
name: "node-legacy-device-id",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const request = await requestNodePairing({
nodeId: pairedDevice.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
commands: ["system.which"],
});
requireApprovedPairing(
await approveNodePairing(request.request.requestId, {
callerScopes: ["operator.pairing", "operator.admin"],
}),
);
const controlWs = await openTrackedWs(started.port);
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
try {
await connectOk(controlWs, { token: "secret" });
nodeClient = await connectNodeClient({
port: started.port,
deviceIdentity: pairedDevice.identity,
instanceId: "stable-instance-for-legacy-device-id",
commands: ["system.which"],
});
await vi.waitFor(async () => {
const list = await rpcReq<{
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
}>(controlWs, "node.list", {});
const node = list.payload?.nodes?.find(
(entry) => entry.nodeId === pairedDevice.identity.deviceId,
);
if (node?.connected && node.commands?.includes("system.which")) {
return;
}
throw new Error(`legacy node commands not visible yet: ${JSON.stringify(list.payload)}`);
});
} finally {
controlWs.close();
await nodeClient?.stopAndWait();
}
});
test("does not let an unmatched device overwrite an approved stable node session", async () => {
const approvedDevice = await pairDeviceIdentity({
name: "node-stable-approved",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const otherDevice = await pairDeviceIdentity({
name: "node-stable-other",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const stableNodeId = "stable-node-spoof-guard";
const request = await requestNodePairing({
nodeId: stableNodeId,
deviceId: approvedDevice.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
commands: ["system.which"],
});
requireApprovedPairing(
await approveNodePairing(request.request.requestId, {
callerScopes: ["operator.pairing", "operator.admin"],
}),
);
const controlWs = await openTrackedWs(started.port);
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
try {
await connectOk(controlWs, { token: "secret" });
nodeClient = await connectNodeClient({
port: started.port,
deviceIdentity: otherDevice.identity,
instanceId: stableNodeId,
commands: ["system.which"],
});
await vi.waitFor(async () => {
const list = await rpcReq<{
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
}>(controlWs, "node.list", {});
const stableNode = list.payload?.nodes?.find((entry) => entry.nodeId === stableNodeId);
const quarantinedNode = list.payload?.nodes?.find(
(entry) => entry.nodeId === otherDevice.identity.deviceId,
);
if (
stableNode?.connected !== true &&
quarantinedNode?.connected === true &&
(quarantinedNode.commands ?? []).length === 0
) {
return;
}
throw new Error(
`spoofed stable node not quarantined yet: ${JSON.stringify(list.payload)}`,
);
});
const pairedStableNode = await getPairedNode(stableNodeId);
expect(pairedStableNode?.lastConnectedAtMs).toBeUndefined();
} finally {
controlWs.close();
await nodeClient?.stopAndWait();
}
});
test("does not refresh rejected stable node metadata through a legacy pairing match", async () => {
const approvedDevice = await pairDeviceIdentity({
name: "node-stable-metadata-approved",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const legacyDevice = await pairDeviceIdentity({
name: "node-stable-metadata-legacy",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const stableNodeId = "stable-node-metadata-spoof-guard";
const stableRequest = await requestNodePairing({
nodeId: stableNodeId,
deviceId: approvedDevice.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
commands: ["system.which"],
});
requireApprovedPairing(
await approveNodePairing(stableRequest.request.requestId, {
callerScopes: ["operator.pairing", "operator.admin"],
}),
);
const legacyRequest = await requestNodePairing({
nodeId: legacyDevice.identity.deviceId,
deviceId: legacyDevice.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
commands: ["system.which"],
});
requireApprovedPairing(
await approveNodePairing(legacyRequest.request.requestId, {
callerScopes: ["operator.pairing", "operator.admin"],
}),
);
const controlWs = await openTrackedWs(started.port);
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
try {
await connectOk(controlWs, { token: "secret" });
nodeClient = await connectNodeClient({
port: started.port,
deviceIdentity: legacyDevice.identity,
instanceId: stableNodeId,
commands: ["system.which"],
});
await vi.waitFor(async () => {
const list = await rpcReq<{
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
}>(controlWs, "node.list", {});
const stableNode = list.payload?.nodes?.find((entry) => entry.nodeId === stableNodeId);
const legacyNode = list.payload?.nodes?.find(
(entry) => entry.nodeId === legacyDevice.identity.deviceId,
);
if (
stableNode?.connected !== true &&
legacyNode?.connected === true &&
legacyNode.commands?.includes("system.which")
) {
return;
}
throw new Error(`legacy-matched spoof not isolated yet: ${JSON.stringify(list.payload)}`);
});
await vi.waitFor(async () => {
const pairedLegacyNode = await getPairedNode(legacyDevice.identity.deviceId);
expect(pairedLegacyNode?.lastConnectedAtMs).toBeDefined();
});
const pairedStableNode = await getPairedNode(stableNodeId);
expect(pairedStableNode?.lastConnectedAtMs).toBeUndefined();
} finally {
controlWs.close();
await nodeClient?.stopAndWait();
}
});
test("deauthorizes a stale stable session when approval moves to another device", async () => {
const approvedDevice = await pairDeviceIdentity({
name: "node-stable-rebind-approved",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const otherDevice = await pairDeviceIdentity({
name: "node-stable-rebind-other",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const stableNodeId = "stable-node-rebind-guard";
const request = await requestNodePairing({
nodeId: stableNodeId,
deviceId: approvedDevice.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
commands: ["system.which"],
});
requireApprovedPairing(
await approveNodePairing(request.request.requestId, {
callerScopes: ["operator.pairing", "operator.admin"],
}),
);
const controlWs = await openTrackedWs(started.port);
let approvedClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
let otherClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
try {
await connectOk(controlWs, {
token: "secret",
scopes: ["operator.pairing", "operator.admin"],
});
approvedClient = await connectNodeClient({
port: started.port,
deviceIdentity: approvedDevice.identity,
instanceId: stableNodeId,
commands: ["system.which"],
});
await vi.waitFor(async () => {
const list = await rpcReq<{
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
}>(controlWs, "node.list", {});
const node = list.payload?.nodes?.find((entry) => entry.nodeId === stableNodeId);
if (node?.connected && node.commands?.includes("system.which")) {
return;
}
throw new Error(
`approved stable node not connected yet: ${JSON.stringify(list.payload)}`,
);
});
otherClient = await connectNodeClient({
port: started.port,
deviceIdentity: otherDevice.identity,
instanceId: stableNodeId,
commands: ["system.which"],
});
const pairing = await listNodePairing();
const pending = pairing.pending.find(
(entry) =>
entry.nodeId === stableNodeId && entry.deviceId === otherDevice.identity.deviceId,
);
expect(pending?.requestId).toBeTruthy();
const approve = await rpcReq(controlWs, "node.pair.approve", {
requestId: pending?.requestId,
});
expect(approve.ok).toBe(true);
await vi.waitFor(async () => {
const list = await rpcReq<{
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
}>(controlWs, "node.list", {});
const node = list.payload?.nodes?.find((entry) => entry.nodeId === stableNodeId);
const quarantinedNode = list.payload?.nodes?.find(
(entry) => entry.nodeId === otherDevice.identity.deviceId,
);
if (
node?.connected === true &&
node.commands?.includes("system.which") &&
quarantinedNode?.connected !== true
) {
return;
}
throw new Error(`rebound stable node not promoted yet: ${JSON.stringify(list.payload)}`);
});
} finally {
controlWs.close();
await approvedClient?.stopAndWait();
await otherClient?.stopAndWait();
}
});
test("does not let a no-device node overwrite an approved stable node session", async () => {
const approvedDevice = await pairDeviceIdentity({
name: "node-stable-nodevice-approved",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const stableNodeId = "stable-node-nodevice-spoof-guard";
const request = await requestNodePairing({
nodeId: stableNodeId,
deviceId: approvedDevice.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
commands: ["system.which"],
});
requireApprovedPairing(
await approveNodePairing(request.request.requestId, {
callerScopes: ["operator.pairing", "operator.admin"],
}),
);
const controlWs = await openTrackedWs(started.port);
const nodeWs = await openTrackedWs(started.port);
try {
await connectOk(controlWs, { token: "secret" });
const connect = await connectReq(nodeWs, {
token: "secret",
role: "node",
scopes: [],
device: null,
client: {
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
displayName: "no-device-node",
version: "1.0.0",
platform: "macos",
deviceFamily: "Mac",
mode: GATEWAY_CLIENT_MODES.NODE,
instanceId: stableNodeId,
},
commands: ["system.which"],
});
expect(connect.ok).toBe(false);
expect(connect.error?.message).toContain("device identity required");
const list = await rpcReq<{
nodes?: Array<{ nodeId: string; connected?: boolean; commands?: string[] }>;
}>(controlWs, "node.list", {});
const stableNode = list.payload?.nodes?.find((entry) => entry.nodeId === stableNodeId);
expect(stableNode?.connected).not.toBe(true);
} finally {
controlWs.close();
nodeWs.close();
}
});
});
});

View File

@@ -184,6 +184,7 @@ const connectNodeClientWithNodePairing = async (
const request = await requestNodePairing({
nodeId,
deviceId: params.deviceIdentity?.deviceId,
displayName: params.displayName,
platform: params.platform ?? "ios",
deviceFamily: params.deviceFamily,

View File

@@ -257,6 +257,7 @@ describe("gateway silent scope-upgrade reconnect", () => {
token: "secret",
method: "health",
scopes: ["operator.admin"],
deviceIdentity: null,
timeoutMs: 2_000,
});
expect(health.ok).toBe(true);

View File

@@ -25,6 +25,7 @@ import {
formatValidationErrors,
MIN_PROBE_PROTOCOL_VERSION,
PROTOCOL_VERSION,
type RequestFrame,
validateConnectParams,
validateRequestFrame,
} from "../../../../packages/gateway-protocol/src/index.js";
@@ -105,6 +106,11 @@ import {
resolveClientIp,
} from "../../net.js";
import { reconcileNodePairingOnConnect } from "../../node-connect-reconcile.js";
import {
nodePairingMatchesConnectDevice,
resolveConnectNodeId,
resolveConnectNodeIdCandidates,
} from "../../node-identity.js";
import {
resolveNodePairingClientIpSource,
shouldAutoApproveNodePairingFromTrustedCidrs,
@@ -163,6 +169,10 @@ import {
import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
type ConnectRequestFrame = RequestFrame & {
method: "connect";
params: ConnectParams;
};
const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000;
const DEVICE_CREDENTIAL_INVALIDATING_METHODS = new Set([
@@ -184,14 +194,27 @@ function isReleasedVersion(version: string): boolean {
* Process-stable: only changes on `openclaw node install`, which requires restart.
*/
let cachedLocalNodeId: string | null | undefined;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isConnectRequestFrame(frame: unknown): frame is ConnectRequestFrame {
return (
validateRequestFrame(frame) &&
frame.method === "connect" &&
validateConnectParams(frame.params)
);
}
function resolveLocalNodeId(): string | null {
if (cachedLocalNodeId !== undefined) {
return cachedLocalNodeId;
}
try {
const raw = fs.readFileSync(path.join(resolveStateDir(), "node.json"), "utf8");
const parsed = JSON.parse(raw) as { nodeId?: string };
cachedLocalNodeId = typeof parsed.nodeId === "string" ? parsed.nodeId.trim() || null : null;
const parsed: unknown = JSON.parse(raw);
const nodeId = isRecord(parsed) ? parsed["nodeId"] : undefined;
cachedLocalNodeId = typeof nodeId === "string" ? nodeId.trim() || null : null;
} catch {
cachedLocalNodeId = null;
}
@@ -506,25 +529,13 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
const text = rawDataToString(data);
try {
const parsed = JSON.parse(text);
const parsed: unknown = JSON.parse(text);
const parsedRecord = isRecord(parsed) ? parsed : undefined;
const frameType =
parsed && typeof parsed === "object" && "type" in parsed
? typeof (parsed as { type?: unknown }).type === "string"
? String((parsed as { type?: unknown }).type)
: undefined
: undefined;
typeof parsedRecord?.["type"] === "string" ? parsedRecord["type"] : undefined;
const frameMethod =
parsed && typeof parsed === "object" && "method" in parsed
? typeof (parsed as { method?: unknown }).method === "string"
? String((parsed as { method?: unknown }).method)
: undefined
: undefined;
const frameId =
parsed && typeof parsed === "object" && "id" in parsed
? typeof (parsed as { id?: unknown }).id === "string"
? String((parsed as { id?: unknown }).id)
: undefined
: undefined;
typeof parsedRecord?.["method"] === "string" ? parsedRecord["method"] : undefined;
const frameId = typeof parsedRecord?.["id"] === "string" ? parsedRecord["id"] : undefined;
if (frameType || frameMethod || frameId) {
setLastFrameMeta({ type: frameType, method: frameMethod, id: frameId });
}
@@ -534,11 +545,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
// Handshake must be a normal request:
// { type:"req", method:"connect", params: ConnectParams }.
const isRequestFrame = validateRequestFrame(parsed);
if (
!isRequestFrame ||
parsed.method !== "connect" ||
!validateConnectParams(parsed.params)
) {
if (!isConnectRequestFrame(parsed)) {
const handshakeError = isRequestFrame
? parsed.method === "connect"
? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}`
@@ -574,7 +581,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}
const frame = parsed;
const connectParams = frame.params as ConnectParams;
const connectParams = frame.params;
const resolvedAuth = getResolvedAuth();
const clientLabel = connectParams.client.displayName ?? connectParams.client.id;
const clientMeta = {
@@ -1579,11 +1586,30 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
});
}
}
const nodePairingId = resolveConnectNodeId(connectParams);
let reconciledNodeId: string | undefined;
let matchedPairedNode = false;
let rejectedStablePairedNode = false;
if (role === "node") {
let pairedNode = null;
for (const candidateNodeId of resolveConnectNodeIdCandidates(connectParams)) {
const candidate = await getPairedNode(candidateNodeId);
if (
candidate &&
nodePairingMatchesConnectDevice({ connect: connectParams, pairedNode: candidate })
) {
pairedNode = candidate;
break;
}
if (candidate && candidateNodeId === nodePairingId) {
rejectedStablePairedNode = true;
}
}
matchedPairedNode = pairedNode !== null;
const reconciliation = await reconcileNodePairingOnConnect({
cfg: getRuntimeConfig(),
connectParams,
pairedNode: await getPairedNode(connectParams.device?.id ?? connectParams.client.id),
pairedNode,
reportedClientIp,
requestPairing: async (input) => await requestNodePairing(input),
});
@@ -1606,14 +1632,12 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
dropIfSlow: true,
});
}
const nodeConnectParams = connectParams as ConnectParams & {
declaredCaps?: string[];
declaredCommands?: string[];
declaredPermissions?: Record<string, boolean>;
};
nodeConnectParams.declaredCaps = reconciliation.declaredCaps;
nodeConnectParams.declaredCommands = reconciliation.declaredCommands;
nodeConnectParams.declaredPermissions = reconciliation.declaredPermissions;
reconciledNodeId = reconciliation.nodeId;
Object.assign(connectParams, {
declaredCaps: reconciliation.declaredCaps,
declaredCommands: reconciliation.declaredCommands,
declaredPermissions: reconciliation.declaredPermissions,
});
connectParams.caps = reconciliation.effectiveCaps;
connectParams.commands = reconciliation.effectiveCommands;
connectParams.permissions = reconciliation.effectivePermissions;
@@ -1767,14 +1791,22 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}
if (role === "node") {
const context = buildRequestContext();
const unmatchedNodeId = connectParams.device?.id ?? connId;
const nodeSession = context.nodeRegistry.register(nextClient, {
remoteIp: reportedClientIp,
nodeId:
matchedPairedNode || !rejectedStablePairedNode
? (reconciledNodeId ?? nodePairingId)
: unmatchedNodeId,
});
const instanceIdRaw = connectParams.client.instanceId;
const instanceIdLocal = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
const clientInstanceId = typeof instanceId === "string" ? instanceId.trim() : "";
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);
if (instanceIdLocal) {
nodeIdsForPairing.add(instanceIdLocal);
const shouldRecordStablePairingMetadata = !rejectedStablePairedNode;
if (nodePairingId && shouldRecordStablePairingMetadata) {
nodeIdsForPairing.add(nodePairingId);
}
if (clientInstanceId && shouldRecordStablePairingMetadata) {
nodeIdsForPairing.add(clientInstanceId);
}
for (const nodeId of nodeIdsForPairing) {
void updatePairedNodeMetadata(nodeId, {
@@ -1931,7 +1963,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
if (!validateRequestFrame(parsed)) {
send({
type: "res",
id: (parsed as { id?: unknown })?.id ?? "invalid",
id: isRecord(parsed) ? (parsed["id"] ?? "invalid") : "invalid",
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
@@ -2067,8 +2099,8 @@ function getRawDataByteLength(data: unknown): number {
}
function setSocketMaxPayload(socket: WebSocket, maxPayload: number): void {
const receiver = (socket as { _receiver?: { _maxPayload?: number } })["_receiver"];
if (receiver) {
const receiver = Reflect.get(socket, "_receiver");
if (isRecord(receiver)) {
receiver["_maxPayload"] = maxPayload;
}
}

View File

@@ -391,6 +391,7 @@ describe("device pairing tokens", () => {
test("preserves existing operator token scopes when approving a scope upgrade", async () => {
const baseDir = await makeDevicePairingDir();
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
const before = await getPairedDevice("device-1", baseDir);
const upgrade = await requestDevicePairing(
{
@@ -412,6 +413,237 @@ describe("device pairing tokens", () => {
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.approvedScopes).toEqual(["operator.read", "operator.write"]);
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read", "operator.write"]);
expect(paired?.tokens?.operator?.token).not.toBe(before?.tokens?.operator?.token);
expect(paired?.tokens?.operator?.rotatedAtMs).toBeTypeOf("number");
});
test("does not reuse a token that was outside the previous approval baseline", async () => {
const baseDir = await makeDevicePairingDir();
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
await overwritePairedOperatorTokenScopes(baseDir, ["operator.admin"]);
const before = await getPairedDevice("device-1", baseDir);
const staleToken = requireToken(before?.tokens?.operator?.token);
await expect(
verifyOperatorToken({ baseDir, token: staleToken, scopes: ["operator.admin"] }),
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
const upgrade = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "operator",
scopes: ["operator.write"],
},
baseDir,
);
const approved = await approveDevicePairing(
upgrade.request.requestId,
{ callerScopes: ["operator.read", "operator.write"] },
baseDir,
);
expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.approvedScopes).toEqual(["operator.read", "operator.write"]);
expect(paired?.tokens?.operator?.token).not.toBe(staleToken);
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read", "operator.write"]);
await expect(
verifyOperatorToken({
baseDir,
token: requireToken(paired?.tokens?.operator?.token),
scopes: ["operator.admin"],
}),
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
await expect(
verifyOperatorToken({ baseDir, token: staleToken, scopes: ["operator.admin"] }),
).resolves.toEqual({ ok: false, reason: "token-mismatch" });
});
test("keeps same-device operator token stable when approving a metadata refresh", async () => {
const baseDir = await makeDevicePairingDir();
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
const before = await getPairedDevice("device-1", baseDir);
const operatorToken = requireToken(before?.tokens?.operator?.token);
const refresh = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
displayName: "Windows Node",
platform: "win32",
role: "operator",
scopes: ["operator.read"],
},
baseDir,
);
const approved = await approveDevicePairing(
refresh.request.requestId,
{ callerScopes: ["operator.read"] },
baseDir,
);
expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.displayName).toBe("Windows Node");
expect(paired?.platform).toBe("win32");
expect(paired?.tokens?.operator?.token).toBe(operatorToken);
expect(paired?.tokens?.operator?.rotatedAtMs).toBeUndefined();
await expect(
verifyOperatorToken({ baseDir, token: operatorToken, scopes: ["operator.read"] }),
).resolves.toEqual({ ok: true });
});
test("keeps legacy operator tokens without role metadata stable on refresh", async () => {
const baseDir = await makeDevicePairingDir();
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
await mutatePairedDevice(baseDir, "device-1", (device) => {
const operatorToken = requireValue(device.tokens?.operator, "expected operator token");
delete (operatorToken as { role?: string }).role;
});
const before = await getPairedDevice("device-1", baseDir);
const operatorToken = requireToken(before?.tokens?.operator?.token);
const refresh = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
displayName: "Windows Node",
platform: "win32",
role: "operator",
scopes: ["operator.read"],
},
baseDir,
);
const approved = await approveDevicePairing(
refresh.request.requestId,
{ callerScopes: ["operator.read"] },
baseDir,
);
expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.tokens?.operator?.token).toBe(operatorToken);
expect(paired?.tokens?.operator?.role).toBe("operator");
expect(paired?.tokens?.operator?.rotatedAtMs).toBeUndefined();
});
test("rotates the operator token when a same-device repair changes public key", async () => {
const baseDir = await makeDevicePairingDir();
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
const before = await getPairedDevice("device-1", baseDir);
const operatorToken = requireToken(before?.tokens?.operator?.token);
const repair = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-2",
role: "operator",
scopes: ["operator.read"],
},
baseDir,
);
const approved = await approveDevicePairing(
repair.request.requestId,
{ callerScopes: ["operator.read"] },
baseDir,
);
expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.publicKey).toBe("public-key-2");
expect(paired?.tokens?.operator?.token).not.toBe(operatorToken);
expect(paired?.tokens?.operator?.rotatedAtMs).toBeTypeOf("number");
});
test("normalizes malformed token role metadata instead of reusing it", async () => {
const baseDir = await makeDevicePairingDir();
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
await mutatePairedDevice(baseDir, "device-1", (device) => {
const operatorToken = requireValue(device.tokens?.operator, "expected operator token");
operatorToken.role = "node";
});
const before = await getPairedDevice("device-1", baseDir);
const staleToken = requireToken(before?.tokens?.operator?.token);
const refresh = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "operator",
scopes: ["operator.read"],
},
baseDir,
);
const approved = await approveDevicePairing(
refresh.request.requestId,
{ callerScopes: ["operator.read"] },
baseDir,
);
expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.tokens?.operator?.role).toBe("operator");
expect(paired?.tokens?.operator?.token).not.toBe(staleToken);
expect(paired && listEffectivePairedDeviceRoles(paired)).toEqual(["operator"]);
});
test.each([
{
name: "empty token",
mutate: (token: NonNullable<PairedDevice["tokens"]>[string]) => {
token.token = "";
},
},
{
name: "non-array scopes",
mutate: (token: NonNullable<PairedDevice["tokens"]>[string]) => {
(token as unknown as { scopes: unknown }).scopes = "operator.read";
},
},
{
name: "non-string scope entry",
mutate: (token: NonNullable<PairedDevice["tokens"]>[string]) => {
(token as unknown as { scopes: unknown }).scopes = ["operator.read", 1];
},
},
])("normalizes malformed token payload instead of reusing it: $name", async ({ mutate }) => {
const baseDir = await makeDevicePairingDir();
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
await mutatePairedDevice(baseDir, "device-1", (device) => {
const operatorToken = requireValue(device.tokens?.operator, "expected operator token");
mutate(operatorToken);
});
const refresh = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "operator",
scopes: ["operator.read"],
},
baseDir,
);
const approved = await approveDevicePairing(
refresh.request.requestId,
{ callerScopes: ["operator.read"] },
baseDir,
);
expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("device-1", baseDir);
const replacementToken = requireToken(paired?.tokens?.operator?.token);
expect(replacementToken).not.toBe("");
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
await expect(
verifyOperatorToken({ baseDir, token: replacementToken, scopes: ["operator.read"] }),
).resolves.toEqual({ ok: true });
});
test("does not widen a down-scoped operator token when approving a scope upgrade", async () => {

View File

@@ -533,33 +533,61 @@ function resolveApprovedTokenScopes(params: {
approvedScopes?: string[];
existing?: PairedDevice;
}): string[] {
const existingTokenScopes =
normalizePersistedDeviceTokenScopes(params.existingToken?.scopes) ?? undefined;
const pendingScopes = resolveRoleScopedDeviceTokenScopes(params.role, params.pending.scopes);
const previousApprovedBaseline = resolveRoleScopedDeviceTokenScopes(
params.role,
params.existing?.approvedScopes ?? params.existing?.scopes,
);
const nextApprovedBaseline = resolveRoleScopedDeviceTokenScopes(
params.role,
params.approvedScopes ?? params.existing?.approvedScopes ?? params.existing?.scopes,
);
const existingTokenScopesWithinBaseline =
existingTokenScopes &&
scopesWithinApprovedDeviceBaseline({
role: params.role,
scopes: existingTokenScopes,
approvedScopes: previousApprovedBaseline,
}) &&
scopesWithinApprovedDeviceBaseline({
role: params.role,
scopes: existingTokenScopes,
approvedScopes: nextApprovedBaseline,
});
const existingSafeTokenScopes = existingTokenScopesWithinBaseline
? existingTokenScopes
: undefined;
if (pendingScopes.length > 0) {
const approvedBaseline = resolveRoleScopedDeviceTokenScopes(
params.role,
params.existing?.approvedScopes ?? params.existing?.scopes,
);
const requestedScopeDelta =
params.existingToken && approvedBaseline.length > 0
? pendingScopes.filter((scope) => !approvedBaseline.includes(scope))
previousApprovedBaseline.length > 0
? pendingScopes.filter((scope) => !previousApprovedBaseline.includes(scope))
: pendingScopes;
if (requestedScopeDelta.length === 0 && params.existingToken) {
return resolveRoleScopedDeviceTokenScopes(params.role, params.existingToken.scopes);
if (requestedScopeDelta.length === 0 && existingSafeTokenScopes) {
return resolveRoleScopedDeviceTokenScopes(params.role, existingSafeTokenScopes);
}
return resolveRoleScopedDeviceTokenScopes(
params.role,
mergeScopes(params.existingToken?.scopes, requestedScopeDelta),
mergeScopes(existingSafeTokenScopes ?? previousApprovedBaseline, requestedScopeDelta),
);
}
return resolveRoleScopedDeviceTokenScopes(
params.role,
params.existingToken?.scopes ??
params.approvedScopes ??
params.existing?.approvedScopes ??
params.existing?.scopes,
existingSafeTokenScopes ?? nextApprovedBaseline,
);
}
function normalizePersistedDeviceTokenScopes(scopes: unknown): string[] | null {
if (!Array.isArray(scopes)) {
return null;
}
if (scopes.some((scope) => typeof scope !== "string")) {
return null;
}
return normalizeDeviceAuthScopes(scopes);
}
function resolveApprovedDeviceScopeBaseline(device: PairedDevice): string[] | null {
const baseline = device.approvedScopes ?? device.scopes;
if (!Array.isArray(baseline)) {
@@ -583,6 +611,60 @@ function scopesWithinApprovedDeviceBaseline(params: {
});
}
function canReuseExistingApprovedToken(params: {
role: string;
existingToken?: DeviceAuthToken;
publicKeyMatches: boolean;
nextScopes: readonly string[];
previousApprovedScopes: readonly string[] | null;
approvedScopes: readonly string[] | undefined;
}): boolean {
const { existingToken } = params;
if (!existingToken || existingToken.revokedAtMs) {
return false;
}
if (typeof existingToken.token !== "string" || existingToken.token.length === 0) {
return false;
}
const existingTokenScopes = normalizePersistedDeviceTokenScopes(existingToken.scopes);
if (!existingTokenScopes) {
return false;
}
const existingTokenRole = (existingToken as { role?: unknown }).role;
if (existingTokenRole !== undefined && existingTokenRole !== params.role) {
return false;
}
if (!params.publicKeyMatches) {
return false;
}
if (!deviceTokenIssuerMatches(existingToken, undefined)) {
return false;
}
if (
!scopesWithinApprovedDeviceBaseline({
role: params.role,
scopes: existingTokenScopes,
approvedScopes: params.previousApprovedScopes,
})
) {
return false;
}
if (
!scopesWithinApprovedDeviceBaseline({
role: params.role,
scopes: existingTokenScopes,
approvedScopes: params.approvedScopes ?? null,
})
) {
return false;
}
return roleScopesAllow({
role: params.role,
requestedScopes: params.nextScopes,
allowedScopes: existingToken.scopes,
});
}
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
const state = await loadState(baseDir);
const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts);
@@ -717,6 +799,7 @@ export async function approveDevicePairing(
existing?.approvedScopes ?? existing?.scopes,
pending.scopes,
);
const previousApprovedScopes = existing ? resolveApprovedDeviceScopeBaseline(existing) : null;
const tokens = existing?.tokens ? { ...existing.tokens } : {};
const nextTokenScopesByRole = new Map<string, string[]>();
for (const roleForToken of requestedRoles) {
@@ -755,15 +838,26 @@ export async function approveDevicePairing(
for (const [roleForToken, nextScopes] of nextTokenScopesByRole) {
const existingToken = tokens[roleForToken];
const tokenNow = Date.now();
tokens[roleForToken] = {
token: newToken(),
const shouldReuseExistingToken = canReuseExistingApprovedToken({
role: roleForToken,
scopes: nextScopes,
createdAtMs: existingToken?.createdAtMs ?? tokenNow,
rotatedAtMs: existingToken ? tokenNow : undefined,
revokedAtMs: undefined,
lastUsedAtMs: existingToken?.lastUsedAtMs,
};
existingToken,
publicKeyMatches: !existing || existing.publicKey === pending.publicKey,
nextScopes,
previousApprovedScopes,
approvedScopes,
});
tokens[roleForToken] =
shouldReuseExistingToken && existingToken
? { ...existingToken, role: roleForToken }
: {
token: newToken(),
role: roleForToken,
scopes: nextScopes,
createdAtMs: existingToken?.createdAtMs ?? tokenNow,
rotatedAtMs: existingToken ? tokenNow : undefined,
revokedAtMs: undefined,
lastUsedAtMs: existingToken?.lastUsedAtMs,
};
}
const device = buildApprovedPairedDevice({
pending,

View File

@@ -1,4 +1,4 @@
import { NODE_SYSTEM_RUN_COMMANDS } from "./node-commands.js";
import { NODE_EXEC_APPROVALS_COMMANDS, NODE_SYSTEM_RUN_COMMANDS } from "./node-commands.js";
/** Operator scopes required to approve a pending node pairing surface. */
export type NodeApprovalScope = "operator.pairing" | "operator.write" | "operator.admin";
@@ -13,7 +13,11 @@ export function resolveNodePairApprovalScopes(commands: unknown): NodeApprovalSc
? commands.filter((command): command is string => typeof command === "string")
: [];
if (
normalized.some((command) => NODE_SYSTEM_RUN_COMMANDS.some((allowed) => allowed === command))
normalized.some((command) =>
[...NODE_SYSTEM_RUN_COMMANDS, ...NODE_EXEC_APPROVALS_COMMANDS].some(
(allowed) => allowed === command,
),
)
) {
return [OPERATOR_PAIRING_SCOPE, OPERATOR_ADMIN_SCOPE];
}

View File

@@ -66,6 +66,151 @@ describe("node pairing tokens", () => {
await tempDirs.cleanup();
});
test("persists the verified device id on approved stable node pairings", async () => {
await withNodePairingDir(async (baseDir) => {
const request = await requestNodePairing(
{
nodeId: "stable-node-1",
deviceId: "device-1",
platform: "windows",
commands: ["system.which"],
},
baseDir,
);
await approveNodePairing(
request.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
);
const paired = await getPairedNode("stable-node-1", baseDir);
expect(paired?.deviceId).toBe("device-1");
});
});
test("preserves an existing device binding when re-approving an unbound request", async () => {
await withNodePairingDir(async (baseDir) => {
const boundRequest = await requestNodePairing(
{
nodeId: "stable-node-1",
deviceId: "device-1",
platform: "windows",
commands: ["system.which"],
},
baseDir,
);
await approveNodePairing(
boundRequest.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
);
const unboundRequest = await requestNodePairing(
{
nodeId: "stable-node-1",
platform: "windows",
commands: ["system.run"],
},
baseDir,
);
await approveNodePairing(
unboundRequest.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
);
const paired = await getPairedNode("stable-node-1", baseDir);
expect(paired?.deviceId).toBe("device-1");
expect(paired?.commands).toEqual(["system.run"]);
});
});
test("does not overwrite a stable node device binding through metadata updates", async () => {
await withNodePairingDir(async (baseDir) => {
const request = await requestNodePairing(
{
nodeId: "stable-node-1",
deviceId: "device-1",
platform: "windows",
commands: ["system.which"],
},
baseDir,
);
await approveNodePairing(
request.request.requestId,
{ callerScopes: ["operator.pairing", "operator.admin"] },
baseDir,
);
await expect(
updatePairedNodeMetadata("stable-node-1", { deviceId: "device-2" }, baseDir),
).resolves.toBe(false);
await expect(
updatePairedNodeMetadata("stable-node-1", { deviceId: "device-1" }, baseDir),
).resolves.toBe(true);
const paired = await getPairedNode("stable-node-1", baseDir);
expect(paired?.deviceId).toBe("device-1");
});
});
test("does not refresh a pending stable node request with a different device id", async () => {
await withNodePairingDir(async (baseDir) => {
const first = await requestNodePairing(
{
nodeId: "stable-node-1",
deviceId: "device-1",
platform: "windows",
commands: ["system.which"],
},
baseDir,
);
const second = await requestNodePairing(
{
nodeId: "stable-node-1",
deviceId: "device-2",
platform: "windows",
commands: ["system.which"],
},
baseDir,
);
expect(second.created).toBe(true);
expect(second.request.requestId).not.toBe(first.request.requestId);
expect(second.superseded).toEqual([
{ requestId: first.request.requestId, nodeId: "stable-node-1" },
]);
const list = await listNodePairing(baseDir);
expect(list.pending).toHaveLength(1);
expect(list.pending[0]?.deviceId).toBe("device-2");
});
});
test("preserves the verified device id in replacement pending requests", async () => {
await withNodePairingDir(async (baseDir) => {
await requestNodePairing(
{
nodeId: "stable-node-1",
deviceId: "device-1",
platform: "windows",
commands: ["system.which"],
},
baseDir,
);
await requestNodePairing(
{
nodeId: "stable-node-1",
platform: "windows",
commands: ["system.which", "system.run.prepare"],
},
baseDir,
);
const list = await listNodePairing(baseDir);
expect(list.pending).toHaveLength(1);
expect(list.pending[0]?.deviceId).toBe("device-1");
});
});
test("reuses pending requests for metadata refreshes", async () => {
await withNodePairingDir(async (baseDir) => {
const first = await requestNodePairing(
@@ -349,6 +494,27 @@ describe("node pairing tokens", () => {
});
await expect(getPairedNode("node-1", baseDir)).resolves.toBeNull();
const execApprovalsRequest = await requestNodePairing(
{
nodeId: "node-exec-approvals",
platform: "windows",
deviceFamily: "Windows",
commands: ["system.execApprovals.get", "system.execApprovals.set"],
},
baseDir,
);
await expect(
approveNodePairing(
execApprovalsRequest.request.requestId,
{ callerScopes: ["operator.pairing", "operator.write"] },
baseDir,
),
).resolves.toEqual({
status: "forbidden",
missingScope: "operator.admin",
});
const commandlessRequest = await requestNodePairing(
{
nodeId: "node-2",

View File

@@ -17,6 +17,7 @@ import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
type NodeDeclaredSurface = {
nodeId: string;
deviceId?: string;
clientId?: string;
clientMode?: string;
displayName?: string;
@@ -94,6 +95,7 @@ function buildPendingNodePairingRequest(params: {
return {
requestId: params.requestId ?? randomUUID(),
nodeId: params.req.nodeId,
deviceId: params.req.deviceId,
clientId: params.req.clientId,
clientMode: params.req.clientMode,
displayName: params.req.displayName,
@@ -118,6 +120,7 @@ function refreshPendingNodePairingRequest(
): NodePairingPendingRequest {
return {
...existing,
deviceId: existing.deviceId ?? incoming.deviceId,
clientId: incoming.clientId ?? existing.clientId,
clientMode: incoming.clientMode ?? existing.clientMode,
displayName: incoming.displayName ?? existing.displayName,
@@ -145,8 +148,10 @@ function samePendingApprovalSurface(
const incomingCommands =
normalizeArrayBackedTrimmedStringList(incoming.commands) ?? existing.commands;
const incomingPermissions = incoming.permissions ?? existing.permissions;
const incomingDeviceId = incoming.deviceId ?? existing.deviceId;
return (
// Metadata-only reconnects may refresh one pending request; approval-surface changes supersede.
existing.deviceId === incomingDeviceId &&
sameNodeApprovalSurfaceSet(existing.caps, incomingCaps) &&
sameNodeApprovalSurfaceSet(existing.commands, incomingCommands) &&
sameNodePermissionSurface(existing.permissions, incomingPermissions)
@@ -160,6 +165,7 @@ function mergeNodePairingReplacementInput(params: {
const latest = params.existing[0];
return {
nodeId: params.incoming.nodeId,
deviceId: params.incoming.deviceId ?? latest?.deviceId,
clientId: params.incoming.clientId ?? latest?.clientId,
clientMode: params.incoming.clientMode ?? latest?.clientMode,
displayName: params.incoming.displayName ?? latest?.displayName,
@@ -311,6 +317,7 @@ export async function approveNodePairing(
const existing = state.pairedByNodeId[pending.nodeId];
const node: NodePairingPairedNode = {
nodeId: pending.nodeId,
deviceId: pending.deviceId ?? existing?.deviceId,
token: newToken(),
clientId: pending.clientId,
clientMode: pending.clientMode,
@@ -401,10 +408,23 @@ export async function updatePairedNodeMetadata(
if (!existing) {
return false;
}
const nextDeviceId = (() => {
if (patch.deviceId === undefined) {
return existing.deviceId;
}
if (existing.deviceId && existing.deviceId !== patch.deviceId) {
return null;
}
return patch.deviceId;
})();
if (nextDeviceId === null) {
return false;
}
const next: NodePairingPairedNode = {
...existing,
clientId: patch.clientId ?? existing.clientId,
deviceId: nextDeviceId,
clientMode: patch.clientMode ?? existing.clientMode,
displayName: patch.displayName ?? existing.displayName,
platform: patch.platform ?? existing.platform,

View File

@@ -5,19 +5,20 @@ import {
TUI_SESSION_PICKER_LIMIT,
} from "./tui-session-list-policy.js";
type LoadHistoryMock = ReturnType<typeof vi.fn> & (() => Promise<void>);
type LoadHistoryMock = ReturnType<typeof vi.fn<() => Promise<void>>>;
type RunAuthFlow = NonNullable<Parameters<typeof createCommandHandlers>[0]["runAuthFlow"]>;
type AbortActiveMock = ReturnType<typeof vi.fn> &
((params?: { preferActive?: boolean }) => Promise<void>);
type AbortActiveMock = ReturnType<
typeof vi.fn<(params?: { preferActive?: boolean }) => Promise<void>>
>;
type SelectableOverlay = {
items?: Array<{ value: string; label?: string; description?: string }>;
onSelect?: (item: { value: string; label?: string; description?: string }) => void;
};
type SetActivityStatusMock = ReturnType<typeof vi.fn> & ((text: string) => void);
type SetSessionMock = ReturnType<typeof vi.fn> & ((key: string) => Promise<void>);
type SetEmptySessionMock = ReturnType<typeof vi.fn> & ((key: string) => Promise<void>);
type ConsumeCompletedRunMock = ReturnType<typeof vi.fn> & ((runId: string) => boolean);
type FlushPendingHistoryRefreshMock = ReturnType<typeof vi.fn> & (() => void);
type SetActivityStatusMock = ReturnType<typeof vi.fn<(text: string) => void>>;
type SetSessionMock = ReturnType<typeof vi.fn<(key: string) => Promise<void>>>;
type SetEmptySessionMock = ReturnType<typeof vi.fn<(key: string) => Promise<void>>>;
type ConsumeCompletedRunMock = ReturnType<typeof vi.fn<(runId: string) => boolean>>;
type FlushPendingHistoryRefreshMock = ReturnType<typeof vi.fn<() => void>>;
async function flushAsyncSelect() {
await new Promise<void>((resolve) => setImmediate(resolve));
@@ -96,9 +97,11 @@ function createHarness(params?: {
const patchSession = params?.patchSession ?? vi.fn().mockResolvedValue({});
const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true });
const runGoalCommand = params?.runGoalCommand ?? vi.fn().mockResolvedValue({ text: "Goal" });
const setSession = params?.setSession ?? (vi.fn().mockResolvedValue(undefined) as SetSessionMock);
const setSession =
params?.setSession ?? vi.fn<(key: string) => Promise<void>>().mockResolvedValue(undefined);
const setEmptySession =
params?.setEmptySession ?? (vi.fn().mockResolvedValue(undefined) as SetEmptySessionMock);
params?.setEmptySession ??
vi.fn<(key: string) => Promise<void>>().mockResolvedValue(undefined);
const addUser = vi.fn();
const addSystem = vi.fn();
const clearTools = vi.fn();
@@ -107,17 +110,18 @@ function createHarness(params?: {
const noteLocalRunId = vi.fn();
const noteLocalBtwRunId = vi.fn();
const loadHistory =
params?.loadHistory ?? (vi.fn().mockResolvedValue(undefined) as LoadHistoryMock);
params?.loadHistory ?? vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const refreshSessionInfo = params?.refreshSessionInfo ?? vi.fn().mockResolvedValue(undefined);
const applySessionInfoFromPatch = params?.applySessionInfoFromPatch ?? vi.fn();
const applySessionMutationResult = params?.applySessionMutationResult ?? vi.fn();
const setActivityStatus = params?.setActivityStatus ?? (vi.fn() as SetActivityStatusMock);
const setActivityStatus = params?.setActivityStatus ?? vi.fn<(text: string) => void>();
const forgetLocalRunId = vi.fn();
const openOverlay = vi.fn();
const closeOverlay = vi.fn();
const requestExit = vi.fn();
const abortActive =
params?.abortActive ?? (vi.fn().mockResolvedValue(undefined) as AbortActiveMock);
params?.abortActive ??
vi.fn<(params?: { preferActive?: boolean }) => Promise<void>>().mockResolvedValue(undefined);
const runAuthFlow: RunAuthFlow | undefined =
params?.runAuthFlow ??
(params?.opts?.local
@@ -649,8 +653,12 @@ describe("tui command handlers", () => {
it("creates unique session for /new and resets shared session for /reset", async () => {
const loadHistory = vi.fn().mockResolvedValue(undefined);
const setSessionMock = vi.fn().mockResolvedValue(undefined) as SetSessionMock;
const setEmptySessionMock = vi.fn().mockResolvedValue(undefined) as SetEmptySessionMock;
const setSessionMock: SetSessionMock = vi
.fn<(key: string) => Promise<void>>()
.mockResolvedValue(undefined);
const setEmptySessionMock: SetEmptySessionMock = vi
.fn<(key: string) => Promise<void>>()
.mockResolvedValue(undefined);
const applySessionMutationResult = vi.fn().mockReturnValue(true);
const refreshSessionInfo = vi.fn().mockResolvedValue(undefined);
const resetResult = {

View File

@@ -113,6 +113,28 @@ describe("resolveBuildAllStep", () => {
});
});
it("runs TypeScript helper steps through the repo tsx loader", () => {
const tsHelperLabels = [
"write-plugin-sdk-entry-dts",
"copy-hook-metadata",
"copy-export-html-templates",
"write-build-info",
"write-cli-startup-metadata",
"write-cli-compat",
];
for (const label of tsHelperLabels) {
const step = getBuildAllStep(label);
const result = resolveBuildAllStep(step, {
nodeExecPath: "/custom/node",
env: {},
});
expect(result.command).toBe("/custom/node");
expect(result.args).toEqual(["--import", "tsx", expect.stringMatching(/\.ts$/u)]);
}
});
it("can route pnpm script steps through direct node entrypoints", () => {
const step = getBuildAllStep("plugins:assets:build");

View File

@@ -65,7 +65,7 @@ describe("Control UI Vite config", () => {
typeof resolveIdHook === "function" ? resolveIdHook : resolveIdHook?.handler
) as ResolveIdHandler | undefined;
if (!resolveIdHandler) {
throw new Error("Expected browser-only shared module alias plugin to expose resolveId");
throw new Error("expected resolveId hook");
}
const resolved = await resolveIdHandler.call(

View File

@@ -0,0 +1,58 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../gateway.ts";
import {
loadExecApprovals,
saveExecApprovals,
updateExecApprovalsFormValue,
type ExecApprovalsState,
} from "./exec-approvals.ts";
function createState(request: ReturnType<typeof vi.fn>): ExecApprovalsState {
return {
client: { request } as unknown as GatewayBrowserClient,
connected: true,
execApprovalsLoading: false,
execApprovalsSaving: false,
execApprovalsDirty: false,
execApprovalsSnapshot: null,
execApprovalsForm: null,
execApprovalsSelectedAgent: null,
lastError: null,
};
}
describe("exec approvals controller", () => {
it("keeps host-native node approval snapshots read-only", async () => {
const request = vi.fn(async () => ({
enabled: true,
defaultAction: "deny",
hash: "native-hash",
rules: [{ pattern: "echo *", action: "allow", enabled: true }],
}));
const state = createState(request);
await loadExecApprovals(state, { kind: "node", nodeId: "node-1" });
expect(request).toHaveBeenCalledWith("exec.approvals.node.get", { nodeId: "node-1" });
expect(state.execApprovalsSnapshot).toEqual({
enabled: true,
defaultAction: "deny",
hash: "native-hash",
rules: [{ pattern: "echo *", action: "allow", enabled: true }],
});
expect(state.execApprovalsForm).toBeNull();
expect(state.execApprovalsDirty).toBe(false);
updateExecApprovalsFormValue(state, ["defaults", "security"], "full");
expect(state.execApprovalsForm).toBeNull();
expect(state.execApprovalsDirty).toBe(false);
expect(state.lastError).toContain("read-only");
await saveExecApprovals(state, { kind: "node", nodeId: "node-1" });
expect(request).toHaveBeenCalledTimes(1);
expect(state.lastError).toContain("read-only");
});
});

View File

@@ -30,13 +30,38 @@ export type ExecApprovalsFile = {
agents?: Record<string, ExecApprovalsAgent>;
};
export type ExecApprovalsSnapshot = {
export type FileExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
};
export type NativeExecApprovalRule = {
pattern?: string;
action?: string;
shells?: string[];
description?: string;
enabled?: boolean;
};
export type NativeExecApprovalPolicy = {
enabled?: boolean;
defaultAction?: string;
rules?: NativeExecApprovalRule[];
};
export type NativeExecApprovalsSnapshot = NativeExecApprovalPolicy & {
hash?: string;
baseHash?: string;
constraints?: Record<string, unknown>;
file?: never;
path?: never;
exists?: never;
};
export type ExecApprovalsSnapshot = FileExecApprovalsSnapshot | NativeExecApprovalsSnapshot;
export type ExecApprovalsTarget = { kind: "gateway" } | { kind: "node"; nodeId: string };
export type ExecApprovalsState = {
@@ -80,6 +105,19 @@ function resolveExecApprovalsSaveRpc(
return { method: "exec.approvals.node.set", params: { ...params, nodeId } };
}
export function isNativeExecApprovalsSnapshot(
snapshot: ExecApprovalsSnapshot | null | undefined,
): snapshot is NativeExecApprovalsSnapshot {
if (!snapshot || "file" in snapshot) {
return false;
}
return (
Array.isArray(snapshot.rules) ||
typeof snapshot.defaultAction === "string" ||
typeof snapshot.enabled === "boolean"
);
}
export async function loadExecApprovals(
state: ExecApprovalsState,
target?: ExecApprovalsTarget | null,
@@ -110,8 +148,13 @@ export async function loadExecApprovals(
function applyExecApprovalsSnapshot(state: ExecApprovalsState, snapshot: ExecApprovalsSnapshot) {
state.execApprovalsSnapshot = snapshot;
if (isNativeExecApprovalsSnapshot(snapshot)) {
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
return;
}
if (!state.execApprovalsDirty) {
state.execApprovalsForm = cloneConfigObject(snapshot.file ?? {});
state.execApprovalsForm = cloneConfigObject(snapshot.file);
}
}
@@ -126,6 +169,11 @@ export async function saveExecApprovals(
state.lastError = null;
state.chatError = null;
try {
if (isNativeExecApprovalsSnapshot(state.execApprovalsSnapshot)) {
state.lastError =
"Native node exec approvals are read-only in Control UI; use the Windows companion or CLI to edit them.";
return;
}
const baseHash = state.execApprovalsSnapshot?.hash;
if (!baseHash) {
state.lastError = "Exec approvals hash missing; reload and retry.";
@@ -152,6 +200,11 @@ export function updateExecApprovalsFormValue(
path: Array<string | number>,
value: unknown,
) {
if (isNativeExecApprovalsSnapshot(state.execApprovalsSnapshot)) {
state.lastError =
"Native node exec approvals are read-only in Control UI; use the Windows companion or CLI to edit them.";
return;
}
const base = cloneConfigObject(
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
);
@@ -164,6 +217,11 @@ export function removeExecApprovalsFormValue(
state: ExecApprovalsState,
path: Array<string | number>,
) {
if (isNativeExecApprovalsSnapshot(state.execApprovalsSnapshot)) {
state.lastError =
"Native node exec approvals are read-only in Control UI; use the Windows companion or CLI to edit them.";
return;
}
const base = cloneConfigObject(
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
);

View File

@@ -3,7 +3,10 @@ import { t } from "../../i18n/index.ts";
import type {
ExecApprovalsAllowlistEntry,
ExecApprovalsFile,
NativeExecApprovalPolicy,
NativeExecApprovalRule,
} from "../controllers/exec-approvals.ts";
import { isNativeExecApprovalsSnapshot } from "../controllers/exec-approvals.ts";
import { clampText, formatRelativeTimestamp } from "../format.ts";
import {
resolveConfigAgents as resolveSharedConfigAgents,
@@ -37,6 +40,8 @@ type ExecApprovalsState = {
loading: boolean;
saving: boolean;
form: ExecApprovalsFile | null;
nativePolicy: NativeExecApprovalPolicy | null;
nativeHash: string | null;
defaults: ExecApprovalsResolvedDefaults;
selectedScope: string;
selectedAgent: Record<string, unknown> | null;
@@ -147,8 +152,13 @@ function resolveExecApprovalsScope(
}
export function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
const form = props.execApprovalsForm ?? props.execApprovalsSnapshot?.file ?? null;
const ready = Boolean(form);
const nativeSnapshot = isNativeExecApprovalsSnapshot(props.execApprovalsSnapshot)
? props.execApprovalsSnapshot
: null;
const form = nativeSnapshot
? null
: (props.execApprovalsForm ?? props.execApprovalsSnapshot?.file ?? null);
const ready = Boolean(form) || Boolean(nativeSnapshot);
const defaults = resolveExecApprovalsDefaults(form);
const agents = resolveExecApprovalsAgents(props.configForm, form);
const targetNodes = resolveExecApprovalsNodes(props.nodes);
@@ -163,9 +173,7 @@ export function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
? (((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ?? null)
: null;
const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist)
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ?? [])
: [];
const allowlist = resolveExecApprovalsAllowlist(selectedAgent);
return {
ready,
disabled: props.execApprovalsSaving || props.execApprovalsLoading,
@@ -173,6 +181,8 @@ export function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState
loading: props.execApprovalsLoading,
saving: props.execApprovalsSaving,
form,
nativePolicy: nativeSnapshot,
nativeHash: nativeSnapshot?.hash ?? nativeSnapshot?.baseHash ?? null,
defaults,
selectedScope,
selectedAgent,
@@ -190,6 +200,20 @@ export function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState
};
}
function resolveExecApprovalsAllowlist(
selectedAgent: Record<string, unknown> | null,
): ExecApprovalsAllowlistEntry[] {
const allowlist = selectedAgent?.allowlist;
if (!Array.isArray(allowlist)) {
return [];
}
return allowlist.filter(isExecApprovalsAllowlistEntry);
}
function isExecApprovalsAllowlistEntry(value: unknown): value is ExecApprovalsAllowlistEntry {
return typeof value === "object" && value !== null && "pattern" in value;
}
export function renderExecApprovals(state: ExecApprovalsState) {
const ready = state.ready;
const targetReady = state.target !== "node" || Boolean(state.targetNodeId);
@@ -204,7 +228,7 @@ export function renderExecApprovals(state: ExecApprovalsState) {
</div>
<button
class="btn"
?disabled=${state.disabled || !state.dirty || !targetReady}
?disabled=${state.disabled || !state.dirty || !targetReady || Boolean(state.nativePolicy)}
@click=${state.onSave}
>
${state.saving ? "Saving…" : "Save"}
@@ -220,15 +244,130 @@ export function renderExecApprovals(state: ExecApprovalsState) {
</button>
</div>`
: html`
${renderExecApprovalsTabs(state)} ${renderExecApprovalsPolicy(state)}
${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing
: renderExecApprovalsAllowlist(state)}
${state.nativePolicy
? renderNativeExecApprovals(state)
: html`
${renderExecApprovalsTabs(state)} ${renderExecApprovalsPolicy(state)}
${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing
: renderExecApprovalsAllowlist(state)}
`}
`}
</section>
`;
}
function renderNativeExecApprovals(state: ExecApprovalsState) {
const policy = state.nativePolicy;
if (!policy) {
return nothing;
}
const rules = normalizeNativeExecApprovalRules(policy.rules);
return html`
<div class="list" style="margin-top: 16px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Host-native policy</div>
<div class="list-sub">
Read-only in Control UI. Edit from the Windows companion or CLI.
</div>
</div>
<div class="list-meta">
<span class="badge">Native</span>
</div>
</div>
<div class="list-item">
<div class="list-main">
<div class="list-title">Enabled</div>
<div class="list-sub">${formatNativeBoolean(policy.enabled)}</div>
</div>
<div class="list-meta">
<span class="mono">${state.nativeHash ?? "hash unknown"}</span>
</div>
</div>
<div class="list-item">
<div class="list-main">
<div class="list-title">Default action</div>
<div class="list-sub">${policy.defaultAction ?? "unknown"}</div>
</div>
<div class="list-meta">
<span>${rules.length} ${rules.length === 1 ? "rule" : "rules"}</span>
</div>
</div>
</div>
<div class="list" style="margin-top: 12px;">
${rules.length === 0
? html` <div class="muted">No host-native rules.</div> `
: rules.map((rule) => renderNativeExecApprovalRule(rule))}
</div>
`;
}
function renderNativeExecApprovalRule(rule: NativeExecApprovalRule) {
const shells =
Array.isArray(rule.shells) && rule.shells.length > 0 ? rule.shells.join(", ") : "all";
const enabled = rule.enabled === false ? "off" : "on";
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${rule.pattern?.trim() ? rule.pattern : "(no pattern)"}</div>
<div class="list-sub">
action: ${rule.action ?? "unknown"} · shells: ${shells} · ${enabled}
</div>
${rule.description
? html`<div class="list-sub">${clampText(rule.description, 120)}</div>`
: nothing}
</div>
</div>
`;
}
function normalizeNativeExecApprovalRules(rules: unknown): NativeExecApprovalRule[] {
if (!Array.isArray(rules)) {
return [];
}
return rules.flatMap((rule) => {
const normalized = normalizeNativeExecApprovalRule(rule);
return normalized ? [normalized] : [];
});
}
function normalizeNativeExecApprovalRule(value: unknown): NativeExecApprovalRule | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return null;
}
const rule: NativeExecApprovalRule = {};
if ("pattern" in value && typeof value.pattern === "string") {
rule.pattern = value.pattern;
}
if ("action" in value && typeof value.action === "string") {
rule.action = value.action;
}
if ("description" in value && typeof value.description === "string") {
rule.description = value.description;
}
if ("enabled" in value && typeof value.enabled === "boolean") {
rule.enabled = value.enabled;
}
if ("shells" in value && Array.isArray(value.shells)) {
const shells = value.shells.filter((shell): shell is string => typeof shell === "string");
if (shells.length > 0) {
rule.shells = shells;
}
}
return Object.keys(rule).length > 0 ? rule : null;
}
function formatNativeBoolean(value: boolean | undefined): string {
if (value === true) {
return "yes";
}
if (value === false) {
return "no";
}
return "unknown";
}
function renderExecApprovalsTarget(state: ExecApprovalsState) {
const hasNodes = state.targetNodes.length > 0;
const nodeValue = state.targetNodeId ?? "";
@@ -245,7 +384,10 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) {
return;
}
const value = target.value;
if (value === "node") {
const first = state.targetNodes[0]?.id ?? null;
@@ -266,7 +408,10 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
<select
?disabled=${state.disabled || !hasNodes}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) {
return;
}
const value = target.value.trim();
state.onSelectTarget("node", value ? value : null);
}}
@@ -351,7 +496,10 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) {
return;
}
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "security"]);
@@ -389,7 +537,10 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) {
return;
}
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "ask"]);
@@ -429,7 +580,10 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const target = event.currentTarget;
if (!(target instanceof HTMLSelectElement)) {
return;
}
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "askFallback"]);
@@ -473,7 +627,10 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
?disabled=${state.disabled}
.checked=${autoEffective}
@change=${(event: Event) => {
const target = event.target as HTMLInputElement;
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) {
return;
}
state.onPatch([...basePath, "autoAllowSkills"], target.checked);
}}
/>
@@ -545,7 +702,10 @@ function renderAllowlistEntry(
.value=${entry.pattern ?? ""}
?disabled=${state.disabled}
@input=${(event: Event) => {
const target = event.target as HTMLInputElement;
const target = event.currentTarget;
if (!(target instanceof HTMLInputElement)) {
return;
}
state.onPatch(
["agents", state.selectedScope, "allowlist", index, "pattern"],
target.value,

View File

@@ -190,3 +190,69 @@ describe("nodes devices pending rendering", () => {
expect(details[1]).toBe("requested: roles: node, operator \u00b7 scopes: operator.read");
});
});
describe("nodes exec approvals rendering", () => {
it("renders host-native node approval snapshots as read-only", () => {
const container = renderNodesContainer({
nodes: [
{
id: "node-1",
label: "Windows node",
commands: ["system.execApprovals.get", "system.execApprovals.set"],
},
],
execApprovalsTarget: "node",
execApprovalsTargetNodeId: "node-1",
execApprovalsSnapshot: {
enabled: true,
defaultAction: "deny",
hash: "native-hash",
rules: [{ pattern: "echo *", action: "allow", enabled: true }],
},
});
const card = Array.from(container.querySelectorAll(".card")).find(
(candidate) =>
candidate.querySelector(".card-title")?.textContent?.trim() === "Exec approvals",
);
expect(card).toBeInstanceOf(Element);
if (!(card instanceof Element)) {
throw new Error("Expected exec approvals card");
}
const titles = Array.from(card.querySelectorAll(".list-title")).map(
(line) => line.textContent?.trim() ?? "",
);
const save = Array.from(card.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Save",
);
expect(titles).toContain("Host-native policy");
expect(titles).toContain("Default action");
expect(titles).toContain("echo *");
expect(titles).not.toContain("Security");
expect(card.textContent).toContain("Read-only in Control UI");
expect(card.textContent).toContain("deny");
expect(save).toBeInstanceOf(HTMLButtonElement);
expect(save?.hasAttribute("disabled")).toBe(true);
});
it("ignores malformed host-native node approval rules", () => {
const snapshot = {
enabled: true,
defaultAction: "deny",
hash: "native-hash",
rules: [null, { pattern: 42 }, { pattern: "echo *", action: "allow", shells: [1, "cmd"] }],
};
const container = renderNodesContainer({
execApprovalsTarget: "node",
execApprovalsTargetNodeId: "node-1",
execApprovalsSnapshot: snapshot as unknown as NodesProps["execApprovalsSnapshot"],
});
expect(container.textContent).toContain("Host-native policy");
expect(container.textContent).toContain("echo *");
expect(container.textContent).toContain("cmd");
expect(container.textContent).not.toContain("42");
});
});