Merge branch 'main' into patch-5
This commit is contained in:
commit
6f91865c3f
@ -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? {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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"]);
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user