mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 04:21:38 +00:00
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.
|
- 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 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: 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/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.
|
- 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.
|
- 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);
|
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 () => {
|
test("still requires node pairing while operator shared auth succeeds for the same device", async () => {
|
||||||
const { mkdtemp } = await import("node:fs/promises");
|
const { mkdtemp } = await import("node:fs/promises");
|
||||||
const { tmpdir } = await import("node:os");
|
const { tmpdir } = await import("node:os");
|
||||||
|
|||||||
@@ -604,7 +604,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
publicKey: devicePublicKey,
|
publicKey: devicePublicKey,
|
||||||
...clientAccessMetadata,
|
...clientAccessMetadata,
|
||||||
silent: isLocalClient && reason === "not-paired",
|
silent: isLocalClient && (reason === "not-paired" || reason === "scope-upgrade"),
|
||||||
});
|
});
|
||||||
const context = buildRequestContext();
|
const context = buildRequestContext();
|
||||||
if (pairing.request.silent === true) {
|
if (pairing.request.silent === true) {
|
||||||
|
|||||||
Reference in New Issue
Block a user