mirror of https://github.com/openclaw/openclaw.git
fix: bound bootstrap handoff token scopes
This commit is contained in:
parent
7c0752f834
commit
7d22a16adb
|
|
@ -103,7 +103,9 @@ Docs: https://docs.openclaw.ai
|
|||
- Synology Chat/security: default low-level HTTPS helper TLS verification to on so helper/API defaults match the shipped safe account default, and only explicit `allowInsecureSsl: true` opts out.
|
||||
- Android/canvas security: require exact normalized A2UI URL matches before forwarding canvas bridge actions, rejecting query mismatches and descendant paths while still allowing fragment-only A2UI navigation.
|
||||
- Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit `failureDestination` is configured. (#60622) Thanks @artwalker.
|
||||
- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147.- Doctor/config: compare normalized `talk` configs by deep structural equality instead of key-order-sensitive serialization so `openclaw doctor --fix` stops repeatedly reporting/applying no-op `talk.provider/providers` normalization. (#59911) Thanks @ejames-dev.
|
||||
- Mobile pairing/bootstrap: keep QR bootstrap handoff tokens bounded to the mobile-safe contract so node handoff stays unscoped and operator handoff drops mixed `node.*`, `operator.admin`, and `operator.pairing` scopes.
|
||||
- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147.
|
||||
- Doctor/config: compare normalized `talk` configs by deep structural equality instead of key-order-sensitive serialization so `openclaw doctor --fix` stops repeatedly reporting/applying no-op `talk.provider/providers` normalization. (#59911) Thanks @ejames-dev.
|
||||
- Gateway/device auth: reuse cached device-token scopes only for cached-token reconnects, while keeping explicit `deviceToken` scope requests and empty-cache fallbacks intact so reconnects preserve `operator.read` without breaking explicit auth flows. (#46032) Thanks @caicongyang.
|
||||
|
||||
## 2026.4.2
|
||||
|
|
|
|||
|
|
@ -275,8 +275,10 @@ struct GatewayNodeSessionTests {
|
|||
"deviceToken": "operator-device-token",
|
||||
"role": "operator",
|
||||
"scopes": [
|
||||
"node.exec",
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
|
|
@ -315,8 +317,12 @@ struct GatewayNodeSessionTests {
|
|||
#expect(nodeEntry.token == "node-device-token")
|
||||
#expect(nodeEntry.scopes == [])
|
||||
#expect(operatorEntry.token == "operator-device-token")
|
||||
#expect(operatorEntry.scopes.contains("operator.approvals"))
|
||||
#expect(!operatorEntry.scopes.contains("operator.admin"))
|
||||
#expect(operatorEntry.scopes == [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
])
|
||||
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@ bounded role entries in `deviceTokens`:
|
|||
}
|
||||
```
|
||||
|
||||
For the built-in node/operator bootstrap flow, the primary node token stays
|
||||
`scopes: []` and any handed-off operator token stays bounded to the bootstrap
|
||||
operator allowlist (`operator.approvals`, `operator.read`,
|
||||
`operator.talk.secrets`, `operator.write`).
|
||||
|
||||
### Node example
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -780,6 +780,14 @@ export function registerControlUiAndPairingSuite(): void {
|
|||
"operator.write",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
initialPayload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes,
|
||||
).not.toEqual(
|
||||
expect.arrayContaining(["node.camera", "node.display", "node.exec", "node.voice"]),
|
||||
);
|
||||
expect(
|
||||
initialPayload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes,
|
||||
).not.toEqual(expect.arrayContaining(["operator.admin", "operator.pairing"]));
|
||||
|
||||
const afterBootstrap = await listDevicePairing();
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ import { upsertPresence } from "../../../infra/system-presence.js";
|
|||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||
import { rawDataToString } from "../../../infra/ws.js";
|
||||
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||
import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js";
|
||||
import {
|
||||
resolveBootstrapProfileScopesForRole,
|
||||
type DeviceBootstrapProfile,
|
||||
} from "../../../shared/device-bootstrap-profile.js";
|
||||
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
|
||||
import {
|
||||
isBrowserOperatorUiClient,
|
||||
|
|
@ -1071,7 +1074,12 @@ export function attachGatewayWsMessageHandler(params: {
|
|||
continue;
|
||||
}
|
||||
const bootstrapRoleScopes =
|
||||
bootstrapRole === "operator" ? bootstrapProfileForHello.scopes : [];
|
||||
bootstrapRole === "operator"
|
||||
? resolveBootstrapProfileScopesForRole(
|
||||
bootstrapRole,
|
||||
bootstrapProfileForHello.scopes,
|
||||
)
|
||||
: [];
|
||||
const extraToken = await ensureDeviceToken({
|
||||
deviceId: device.id,
|
||||
role: bootstrapRole,
|
||||
|
|
|
|||
|
|
@ -626,6 +626,36 @@ describe("device pairing tokens", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("bootstrap pairing keeps operator token scopes operator-only", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
const request = await requestDevicePairing(
|
||||
{
|
||||
deviceId: "bootstrap-device-operator-scope",
|
||||
publicKey: "bootstrap-public-key-operator-scope",
|
||||
role: "node",
|
||||
roles: ["node", "operator"],
|
||||
scopes: [],
|
||||
silent: true,
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
await expect(
|
||||
approveBootstrapDevicePairing(
|
||||
request.request.requestId,
|
||||
{
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["node.exec", "operator.pairing", "operator.read", "operator.write"],
|
||||
},
|
||||
baseDir,
|
||||
),
|
||||
).resolves.toEqual(expect.objectContaining({ status: "approved" }));
|
||||
|
||||
const paired = await getPairedDevice("bootstrap-device-operator-scope", baseDir);
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read", "operator.write"]);
|
||||
expect(paired?.tokens?.node?.scopes).toEqual([]);
|
||||
});
|
||||
|
||||
test("verifies token and rejects mismatches", async () => {
|
||||
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
|
||||
import type { DeviceBootstrapProfile } from "../shared/device-bootstrap-profile.js";
|
||||
import {
|
||||
resolveBootstrapProfileScopesForRole,
|
||||
type DeviceBootstrapProfile,
|
||||
} from "../shared/device-bootstrap-profile.js";
|
||||
import { resolveMissingRequestedScope, roleScopesAllow } from "../shared/operator-scope-compat.js";
|
||||
import {
|
||||
createAsyncLock,
|
||||
|
|
@ -643,7 +646,10 @@ export async function approveBootstrapDevicePairing(
|
|||
const tokens = existing?.tokens ? { ...existing.tokens } : {};
|
||||
for (const roleForToken of approvedRoles) {
|
||||
const existingToken = tokens[roleForToken];
|
||||
const tokenScopes = roleForToken === OPERATOR_ROLE ? approvedScopes : [];
|
||||
const tokenScopes =
|
||||
roleForToken === OPERATOR_ROLE
|
||||
? resolveBootstrapProfileScopesForRole(roleForToken, approvedScopes)
|
||||
: [];
|
||||
tokens[roleForToken] = buildDeviceAuthToken({
|
||||
role: roleForToken,
|
||||
scopes: tokenScopes,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
|
||||
resolveBootstrapProfileScopesForRole,
|
||||
} from "./device-bootstrap-profile.js";
|
||||
|
||||
describe("device bootstrap profile", () => {
|
||||
test("bounds bootstrap handoff scopes by role", () => {
|
||||
expect(
|
||||
resolveBootstrapProfileScopesForRole("operator", [
|
||||
"node.exec",
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
]),
|
||||
).toEqual(["operator.approvals", "operator.read", "operator.write"]);
|
||||
|
||||
expect(
|
||||
resolveBootstrapProfileScopesForRole("node", ["node.exec", "operator.approvals"]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("bootstrap handoff operator allowlist stays aligned with pairing setup profile", () => {
|
||||
expect([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]).toEqual([
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -10,11 +10,32 @@ export type DeviceBootstrapProfileInput = {
|
|||
scopes?: readonly string[];
|
||||
};
|
||||
|
||||
export const BOOTSTRAP_HANDOFF_OPERATOR_SCOPES = [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
] as const;
|
||||
|
||||
const BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET = new Set<string>(BOOTSTRAP_HANDOFF_OPERATOR_SCOPES);
|
||||
|
||||
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES],
|
||||
};
|
||||
|
||||
export function resolveBootstrapProfileScopesForRole(
|
||||
role: string,
|
||||
scopes: readonly string[],
|
||||
): string[] {
|
||||
const normalizedRole = normalizeDeviceAuthRole(role);
|
||||
const normalizedScopes = normalizeDeviceAuthScopes(Array.from(scopes));
|
||||
if (normalizedRole === "operator") {
|
||||
return normalizedScopes.filter((scope) => BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET.has(scope));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {
|
||||
if (!Array.isArray(roles)) {
|
||||
return [];
|
||||
|
|
|
|||
Loading…
Reference in New Issue