Compare commits
3 Commits
main
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db3f25ae75 | ||
|
|
7b3630e310 | ||
|
|
c71fb8cda0 |
@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
||||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||||
|
- Device pairing/setup codes: bind setup-code pairing to the intended node role and scope set so approval keeps the expected device profile. Thanks @vincentkoc.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
@ -407,7 +407,12 @@ export default function register(api: OpenClawPluginApi) {
|
|||||||
|
|
||||||
const payload: SetupPayload = {
|
const payload: SetupPayload = {
|
||||||
url: urlResult.url,
|
url: urlResult.url,
|
||||||
bootstrapToken: (await issueDeviceBootstrapToken()).token,
|
bootstrapToken: (
|
||||||
|
await issueDeviceBootstrapToken({
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
})
|
||||||
|
).token,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (action === "qr") {
|
if (action === "qr") {
|
||||||
|
|||||||
@ -43,6 +43,22 @@ describe("device bootstrap tokens", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists an intended role and scopes when requested", async () => {
|
||||||
|
const baseDir = await createTempDir();
|
||||||
|
const issued = await issueDeviceBootstrapToken({
|
||||||
|
baseDir,
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, { roles?: string[]; scopes?: string[] }>;
|
||||||
|
expect(parsed[issued.token]).toMatchObject({
|
||||||
|
roles: ["node"],
|
||||||
|
scopes: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("verifies valid bootstrap tokens once and deletes them after success", async () => {
|
it("verifies valid bootstrap tokens once and deletes them after success", async () => {
|
||||||
const baseDir = await createTempDir();
|
const baseDir = await createTempDir();
|
||||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||||
@ -201,4 +217,64 @@ describe("device bootstrap tokens", () => {
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a role that does not match the issued pairing profile", async () => {
|
||||||
|
const baseDir = await createTempDir();
|
||||||
|
const issued = await issueDeviceBootstrapToken({
|
||||||
|
baseDir,
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
verifyDeviceBootstrapToken({
|
||||||
|
token: issued.token,
|
||||||
|
deviceId: "device-123",
|
||||||
|
publicKey: "public-key-123",
|
||||||
|
role: "operator",
|
||||||
|
scopes: [],
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts constrained tokens when the requested role and scopes match", async () => {
|
||||||
|
const baseDir = await createTempDir();
|
||||||
|
const issued = await issueDeviceBootstrapToken({
|
||||||
|
baseDir,
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
verifyDeviceBootstrapToken({
|
||||||
|
token: issued.token,
|
||||||
|
deviceId: "device-123",
|
||||||
|
publicKey: "public-key-123",
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects scopes that do not match the issued pairing profile", async () => {
|
||||||
|
const baseDir = await createTempDir();
|
||||||
|
const issued = await issueDeviceBootstrapToken({
|
||||||
|
baseDir,
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
verifyDeviceBootstrapToken({
|
||||||
|
token: issued.token,
|
||||||
|
deviceId: "device-123",
|
||||||
|
publicKey: "public-key-123",
|
||||||
|
role: "node",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "../shared/device-auth.js";
|
||||||
import { resolvePairingPaths } from "./pairing-files.js";
|
import { resolvePairingPaths } from "./pairing-files.js";
|
||||||
import {
|
import {
|
||||||
createAsyncLock,
|
createAsyncLock,
|
||||||
@ -63,16 +64,24 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string):
|
|||||||
export async function issueDeviceBootstrapToken(
|
export async function issueDeviceBootstrapToken(
|
||||||
params: {
|
params: {
|
||||||
baseDir?: string;
|
baseDir?: string;
|
||||||
|
role?: string;
|
||||||
|
scopes?: readonly string[];
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<{ token: string; expiresAtMs: number }> {
|
): Promise<{ token: string; expiresAtMs: number }> {
|
||||||
return await withLock(async () => {
|
return await withLock(async () => {
|
||||||
const state = await loadState(params.baseDir);
|
const state = await loadState(params.baseDir);
|
||||||
const token = generatePairingToken();
|
const token = generatePairingToken();
|
||||||
const issuedAtMs = Date.now();
|
const issuedAtMs = Date.now();
|
||||||
|
const role = params.role?.trim();
|
||||||
|
const scopes = normalizeDeviceAuthScopes(
|
||||||
|
Array.isArray(params.scopes) ? [...params.scopes] : undefined,
|
||||||
|
);
|
||||||
state[token] = {
|
state[token] = {
|
||||||
token,
|
token,
|
||||||
ts: issuedAtMs,
|
ts: issuedAtMs,
|
||||||
issuedAtMs,
|
issuedAtMs,
|
||||||
|
...(role ? { roles: [normalizeDeviceAuthRole(role)] } : {}),
|
||||||
|
...(scopes.length > 0 || Array.isArray(params.scopes) ? { scopes } : {}),
|
||||||
};
|
};
|
||||||
await persistState(state, params.baseDir);
|
await persistState(state, params.baseDir);
|
||||||
return { token, expiresAtMs: issuedAtMs + DEVICE_BOOTSTRAP_TOKEN_TTL_MS };
|
return { token, expiresAtMs: issuedAtMs + DEVICE_BOOTSTRAP_TOKEN_TTL_MS };
|
||||||
@ -102,10 +111,28 @@ export async function verifyDeviceBootstrapToken(params: {
|
|||||||
|
|
||||||
const deviceId = params.deviceId.trim();
|
const deviceId = params.deviceId.trim();
|
||||||
const publicKey = params.publicKey.trim();
|
const publicKey = params.publicKey.trim();
|
||||||
const role = params.role.trim();
|
const role = normalizeDeviceAuthRole(params.role);
|
||||||
|
const requestedScopes = normalizeDeviceAuthScopes([...params.scopes]);
|
||||||
if (!deviceId || !publicKey || !role) {
|
if (!deviceId || !publicKey || !role) {
|
||||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||||
}
|
}
|
||||||
|
const allowedRoles = Array.isArray(entry.roles)
|
||||||
|
? entry.roles.map((value) => normalizeDeviceAuthRole(String(value))).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (allowedRoles.length > 0 && !allowedRoles.includes(role)) {
|
||||||
|
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||||
|
}
|
||||||
|
if (Array.isArray(entry.scopes)) {
|
||||||
|
const allowedScopes = normalizeDeviceAuthScopes(entry.scopes);
|
||||||
|
// Both arrays are normalized through normalizeDeviceAuthScopes, which
|
||||||
|
// sorts and deduplicates them before comparison.
|
||||||
|
if (
|
||||||
|
allowedScopes.length !== requestedScopes.length ||
|
||||||
|
allowedScopes.some((value, index) => value !== requestedScopes[index])
|
||||||
|
) {
|
||||||
|
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bootstrap setup codes are single-use. Consume the record before returning
|
// Bootstrap setup codes are single-use. Consume the record before returning
|
||||||
// success so the same token cannot be replayed to mutate a pending request.
|
// success so the same token cannot be replayed to mutate a pending request.
|
||||||
|
|||||||
@ -400,6 +400,8 @@ export async function resolvePairingSetupFromConfig(
|
|||||||
bootstrapToken: (
|
bootstrapToken: (
|
||||||
await issueDeviceBootstrapToken({
|
await issueDeviceBootstrapToken({
|
||||||
baseDir: options.pairingBaseDir,
|
baseDir: options.pairingBaseDir,
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
})
|
})
|
||||||
).token,
|
).token,
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user