fix(gateway): auto-approve loopback scope upgrades
Co-authored-by: Marcus Widing <245375637+widingmarcus-cyber@users.noreply.github.com>
This commit is contained in:
@@ -157,6 +157,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Models/Config: default missing Anthropic provider/model `api` fields to `anthropic-messages` during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123.
|
||||
- Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07.
|
||||
- Gateway/Pairing: treat `operator.admin` as satisfying other `operator.*` scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt.
|
||||
- Gateway/Pairing: auto-approve loopback `scope-upgrade` pairing requests (including device-token reconnects) so local clients do not disconnect on pairing-required scope elevation. (#23708) Thanks @widingmarcus-cyber.
|
||||
- Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit `scopes`, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81.
|
||||
- Gateway/Scopes: include `operator.read` and `operator.write` in default operator connect scope bundles across CLI, Control UI, and macOS clients so write-scoped announce/sub-agent follow-up calls no longer hit `pairing required` disconnects on loopback gateways. (#22582) thanks @YuzuruS.
|
||||
- Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl.
|
||||
|
||||
@@ -1166,6 +1166,72 @@ describe("gateway server auth/connect", () => {
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("auto-approves loopback scope upgrades for control ui clients", async () => {
|
||||
const { mkdtemp } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { buildDeviceAuthPayload } = await import("./device-auth.js");
|
||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
||||
await import("../infra/device-identity.js");
|
||||
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-token-scope-"));
|
||||
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
|
||||
const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
const buildDevice = (scopes: string[], nonce: string) => {
|
||||
const signedAtMs = Date.now();
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: identity.deviceId,
|
||||
clientId: CONTROL_UI_CLIENT.id,
|
||||
clientMode: CONTROL_UI_CLIENT.mode,
|
||||
role: "operator",
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: "secret",
|
||||
nonce,
|
||||
});
|
||||
return {
|
||||
id: identity.deviceId,
|
||||
publicKey: devicePublicKey,
|
||||
signature: signDevicePayload(identity.privateKeyPem, payload),
|
||||
signedAt: signedAtMs,
|
||||
nonce,
|
||||
};
|
||||
};
|
||||
const seeded = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: devicePublicKey,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
clientId: CONTROL_UI_CLIENT.id,
|
||||
clientMode: CONTROL_UI_CLIENT.mode,
|
||||
displayName: "loopback-control-ui-upgrade",
|
||||
platform: CONTROL_UI_CLIENT.platform,
|
||||
});
|
||||
await approveDevicePairing(seeded.request.requestId);
|
||||
|
||||
ws.close();
|
||||
|
||||
const ws2 = await openWs(port, { origin: originForPort(port) });
|
||||
const nonce2 = await readConnectChallengeNonce(ws2);
|
||||
const upgraded = await connectReq(ws2, {
|
||||
token: "secret",
|
||||
scopes: ["operator.admin"],
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
device: buildDevice(["operator.admin"], nonce2),
|
||||
});
|
||||
expect(upgraded.ok).toBe(true);
|
||||
const pending = await listDevicePairing();
|
||||
expect(pending.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]);
|
||||
const updated = await getPairedDevice(identity.deviceId);
|
||||
expect(updated?.tokens?.operator?.scopes).toContain("operator.admin");
|
||||
|
||||
ws2.close();
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("still requires node pairing while operator shared auth succeeds for the same device", async () => {
|
||||
const { mkdtemp } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
@@ -604,7 +604,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
deviceId: device.id,
|
||||
publicKey: devicePublicKey,
|
||||
...clientAccessMetadata,
|
||||
silent: isLocalClient && reason === "not-paired",
|
||||
silent: isLocalClient && (reason === "not-paired" || reason === "scope-upgrade"),
|
||||
});
|
||||
const context = buildRequestContext();
|
||||
if (pairing.request.silent === true) {
|
||||
|
||||
Reference in New Issue
Block a user