Merge branch 'main' into patch-5

This commit is contained in:
Inventor Yasir 2026-02-20 19:03:12 +05:30 committed by GitHub
commit 6f91865c3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 186 additions and 29 deletions

View File

@ -2140,9 +2140,7 @@ private extension NodeAppModel {
clientId: clientId,
clientMode: "ui",
clientDisplayName: displayName,
// Operator traffic should authenticate via shared gateway auth only.
// Including device identity here can trigger duplicate pairing flows.
includeDeviceIdentity: false)
includeDeviceIdentity: true)
}
func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {

View File

@ -100,6 +100,8 @@ targets:
UIBackgroundModes:
- audio
- remote-notification
BGTaskSchedulerPermittedIdentifiers:
- ai.openclaw.ios.bgrefresh
NSLocalNetworkUsageDescription: OpenClaw discovers and connects to your OpenClaw gateway on the local network.
NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true

View File

@ -85,9 +85,9 @@ public struct GatewayConnectOptions: Sendable {
public var clientId: String
public var clientMode: String
public var clientDisplayName: String?
// When false, the connection omits the signed device identity payload.
// This is useful for secondary "operator" connections where the shared gateway token
// should authorize without triggering device pairing flows.
// When false, the connection omits the signed device identity payload and cannot use
// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
// role/scoped sessions such as operator UI clients.
public var includeDeviceIdentity: Bool
public init(

View File

@ -948,6 +948,101 @@ describe("gateway server auth/connect", () => {
restoreGatewayToken(prevToken);
});
test("single approval captures pending node and operator roles for the same device", async () => {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
ws.close();
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-"));
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
const client = {
id: GATEWAY_CLIENT_NAMES.TEST,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
};
const buildDevice = (role: "operator" | "node", scopes: string[], nonce?: string) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: client.id,
clientMode: client.mode,
role,
scopes,
signedAtMs,
token: "secret",
nonce,
});
return {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
};
const connectWithNonce = async (role: "operator" | "node", scopes: string[]) => {
const socket = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: { host: "gateway.example" },
});
const challengePromise = onceMessage<{
type?: string;
event?: string;
payload?: Record<string, unknown> | null;
}>(socket, (o) => o.type === "event" && o.event === "connect.challenge");
await new Promise<void>((resolve) => socket.once("open", resolve));
const challenge = await challengePromise;
const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce;
expect(typeof nonce).toBe("string");
const result = await connectReq(socket, {
token: "secret",
role,
scopes,
client,
device: buildDevice(role, scopes, String(nonce)),
});
socket.close();
return result;
};
const nodeConnect = await connectWithNonce("node", []);
expect(nodeConnect.ok).toBe(false);
expect(nodeConnect.error?.message ?? "").toContain("pairing required");
const operatorConnect = await connectWithNonce("operator", ["operator.read", "operator.write"]);
expect(operatorConnect.ok).toBe(false);
expect(operatorConnect.error?.message ?? "").toContain("pairing required");
const pending = await listDevicePairing();
expect(pending.pending).toHaveLength(1);
expect(pending.pending[0]?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
expect(pending.pending[0]?.scopes).toEqual(
expect.arrayContaining(["operator.read", "operator.write"]),
);
if (!pending.pending[0]) {
throw new Error("expected pending pairing request");
}
await approveDevicePairing(pending.pending[0].requestId);
const paired = await getPairedDevice(identity.deviceId);
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
expect(paired?.scopes).toEqual(expect.arrayContaining(["operator.read", "operator.write"]));
const approvedOperatorConnect = await connectWithNonce("operator", ["operator.read"]);
expect(approvedOperatorConnect.ok).toBe(true);
const afterApproval = await listDevicePairing();
expect(afterApproval.pending).toEqual([]);
await server.close();
restoreGatewayToken(prevToken);
});
test("allows operator.read connect when device is paired with operator.admin", async () => {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");

View File

@ -55,6 +55,38 @@ describe("device pairing tokens", () => {
expect(second.request.requestId).toBe(first.request.requestId);
});
test("merges pending roles/scopes for the same device before approval", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
const first = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "node",
scopes: [],
},
baseDir,
);
const second = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "operator",
scopes: ["operator.read", "operator.write"],
},
baseDir,
);
expect(second.created).toBe(false);
expect(second.request.requestId).toBe(first.request.requestId);
expect(second.request.roles).toEqual(["node", "operator"]);
expect(second.request.scopes).toEqual(["operator.read", "operator.write"]);
await approveDevicePairing(first.request.requestId, baseDir);
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.roles).toEqual(["node", "operator"]);
expect(paired?.scopes).toEqual(["operator.read", "operator.write"]);
});
test("generates base64url device tokens with 256-bit entropy output length", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);

View File

@ -6,7 +6,6 @@ import {
pruneExpiredPending,
readJsonFile,
resolvePairingPaths,
upsertPendingPairingRequest,
writeJsonAtomic,
} from "./pairing-files.js";
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
@ -153,6 +152,30 @@ function mergeScopes(...items: Array<string[] | undefined>): string[] | undefine
return [...scopes];
}
function mergePendingDevicePairingRequest(
existing: DevicePairingPendingRequest,
incoming: Omit<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair">,
isRepair: boolean,
): DevicePairingPendingRequest {
const existingRole = normalizeRole(existing.role);
const incomingRole = normalizeRole(incoming.role);
return {
...existing,
displayName: incoming.displayName ?? existing.displayName,
platform: incoming.platform ?? existing.platform,
clientId: incoming.clientId ?? existing.clientId,
clientMode: incoming.clientMode ?? existing.clientMode,
role: existingRole ?? incomingRole ?? undefined,
roles: mergeRoles(existing.roles, existing.role, incoming.role),
scopes: mergeScopes(existing.scopes, incoming.scopes),
remoteIp: incoming.remoteIp ?? existing.remoteIp,
// If either request is interactive, keep the pending request visible for approval.
silent: Boolean(existing.silent && incoming.silent),
isRepair: existing.isRepair || isRepair,
ts: Date.now(),
};
}
function newToken() {
return generatePairingToken();
}
@ -217,29 +240,36 @@ export async function requestDevicePairing(
if (!deviceId) {
throw new Error("deviceId required");
}
const isRepair = Boolean(state.pairedByDeviceId[deviceId]);
const existing = Object.values(state.pendingById).find(
(pending) => pending.deviceId === deviceId,
);
if (existing) {
const merged = mergePendingDevicePairingRequest(existing, req, isRepair);
state.pendingById[existing.requestId] = merged;
await persistState(state, baseDir);
return { status: "pending" as const, request: merged, created: false };
}
return await upsertPendingPairingRequest({
pendingById: state.pendingById,
isExisting: (pending) => pending.deviceId === deviceId,
isRepair: Boolean(state.pairedByDeviceId[deviceId]),
createRequest: (isRepair) => ({
requestId: randomUUID(),
deviceId,
publicKey: req.publicKey,
displayName: req.displayName,
platform: req.platform,
clientId: req.clientId,
clientMode: req.clientMode,
role: req.role,
roles: req.role ? [req.role] : undefined,
scopes: req.scopes,
remoteIp: req.remoteIp,
silent: req.silent,
isRepair,
ts: Date.now(),
}),
persist: async () => await persistState(state, baseDir),
});
const request: DevicePairingPendingRequest = {
requestId: randomUUID(),
deviceId,
publicKey: req.publicKey,
displayName: req.displayName,
platform: req.platform,
clientId: req.clientId,
clientMode: req.clientMode,
role: req.role,
roles: req.role ? [req.role] : undefined,
scopes: req.scopes,
remoteIp: req.remoteIp,
silent: req.silent,
isRepair,
ts: Date.now(),
};
state.pendingById[request.requestId] = request;
await persistState(state, baseDir);
return { status: "pending" as const, request, created: true };
});
}