mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 01:43:29 +08:00
Compare commits
46 Commits
v2026.6.10
...
fix-window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
568c7c729a | ||
|
|
404bce7caa | ||
|
|
102967bcc9 | ||
|
|
eb00a1e5b6 | ||
|
|
80a2a7f7f0 | ||
|
|
6fa8e695a8 | ||
|
|
1356b27198 | ||
|
|
54b60c838d | ||
|
|
6768cd33fd | ||
|
|
a286d7262e | ||
|
|
c4be467cf0 | ||
|
|
0fc2bce669 | ||
|
|
52ae286a9a | ||
|
|
2fea1837eb | ||
|
|
4bb4d9bd01 | ||
|
|
fa41a77c5e | ||
|
|
82cffcbf32 | ||
|
|
acf1b75f8b | ||
|
|
f614938006 | ||
|
|
eef48732a0 | ||
|
|
aa0e3e7fa7 | ||
|
|
85fb433392 | ||
|
|
c62866a292 | ||
|
|
a91cc0e8d0 | ||
|
|
4e75e6677c | ||
|
|
5ddfccd08d | ||
|
|
d09f1e9e98 | ||
|
|
31157b6386 | ||
|
|
6a7d095a47 | ||
|
|
30d8c0bd59 | ||
|
|
95f955b8e0 | ||
|
|
4e931ef4a6 | ||
|
|
c901c12f3e | ||
|
|
0abd9d5658 | ||
|
|
4c670ef3c9 | ||
|
|
db55387598 | ||
|
|
3a36b695eb | ||
|
|
8ff7e53bf0 | ||
|
|
0e0dc93053 | ||
|
|
602b763c1d | ||
|
|
6bd53b2072 | ||
|
|
45c9ba7866 | ||
|
|
1f6e89e307 | ||
|
|
f6f89a1a96 | ||
|
|
a3b6afdcb6 | ||
|
|
53a474e503 |
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -705,6 +705,7 @@ describe("validateNodePairRequestParams", () => {
|
||||
expect(
|
||||
validateNodePairRequestParams({
|
||||
nodeId: "ios-node-1",
|
||||
deviceId: "device-1",
|
||||
commands: ["canvas.snapshot"],
|
||||
permissions: { camera: true, notifications: false },
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -62,6 +62,7 @@ vi.mock("../globals.js", () => ({
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: mocks.callGateway,
|
||||
resolveGatewayCliScopes: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("./plugins-install-record-commit.js", () => ({
|
||||
|
||||
@@ -19,6 +19,7 @@ const { callGateway } = mocks;
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: mocks.callGateway,
|
||||
resolveGatewayCliScopes: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../secrets/runtime-web-tools.js", () => ({
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock("../gateway/call.js", () => ({
|
||||
}),
|
||||
callGateway: callGatewayMock,
|
||||
formatGatewayTransportErrorJson: () => undefined,
|
||||
resolveGatewayCliScopes: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("./progress.js", () => ({
|
||||
|
||||
@@ -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(", ")
|
||||
: "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
261
src/cli/direct-loopback-gateway-auth.test.ts
Normal file
261
src/cli/direct-loopback-gateway-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
169
src/cli/direct-loopback-gateway-auth.ts
Normal file
169
src/cli/direct-loopback-gateway-auth.ts
Normal 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 }))
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
96
src/cli/gateway-cli/call.test.ts
Normal file
96
src/cli/gateway-cli/call.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("../gateway/call.js", () => {
|
||||
loaded.mark("gateway-transport-runtime");
|
||||
return {
|
||||
formatGatewayTransportErrorJson: vi.fn(() => null),
|
||||
resolveGatewayCliScopes: vi.fn(() => []),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>>(
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)]),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {}],
|
||||
|
||||
@@ -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,
|
||||
|
||||
150
src/gateway/node-identity.test.ts
Normal file
150
src/gateway/node-identity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
src/gateway/node-identity.ts
Normal file
47
src/gateway/node-identity.ts
Normal 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;
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
58
ui/src/ui/controllers/exec-approvals.test.ts
Normal file
58
ui/src/ui/controllers/exec-approvals.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user