feat: add device token auth and devices cli
This commit is contained in:
@@ -75,6 +75,7 @@ public struct HelloOk: Codable, Sendable {
|
|||||||
public let features: [String: AnyCodable]
|
public let features: [String: AnyCodable]
|
||||||
public let snapshot: Snapshot
|
public let snapshot: Snapshot
|
||||||
public let canvashosturl: String?
|
public let canvashosturl: String?
|
||||||
|
public let auth: [String: AnyCodable]?
|
||||||
public let policy: [String: AnyCodable]
|
public let policy: [String: AnyCodable]
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -84,6 +85,7 @@ public struct HelloOk: Codable, Sendable {
|
|||||||
features: [String: AnyCodable],
|
features: [String: AnyCodable],
|
||||||
snapshot: Snapshot,
|
snapshot: Snapshot,
|
||||||
canvashosturl: String?,
|
canvashosturl: String?,
|
||||||
|
auth: [String: AnyCodable]?,
|
||||||
policy: [String: AnyCodable]
|
policy: [String: AnyCodable]
|
||||||
) {
|
) {
|
||||||
self.type = type
|
self.type = type
|
||||||
@@ -92,6 +94,7 @@ public struct HelloOk: Codable, Sendable {
|
|||||||
self.features = features
|
self.features = features
|
||||||
self.snapshot = snapshot
|
self.snapshot = snapshot
|
||||||
self.canvashosturl = canvashosturl
|
self.canvashosturl = canvashosturl
|
||||||
|
self.auth = auth
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
@@ -101,6 +104,7 @@ public struct HelloOk: Codable, Sendable {
|
|||||||
case features
|
case features
|
||||||
case snapshot
|
case snapshot
|
||||||
case canvashosturl = "canvasHostUrl"
|
case canvashosturl = "canvasHostUrl"
|
||||||
|
case auth
|
||||||
case policy
|
case policy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1950,6 +1954,44 @@ public struct DevicePairRejectParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct DeviceTokenRotateParams: Codable, Sendable {
|
||||||
|
public let deviceid: String
|
||||||
|
public let role: String
|
||||||
|
public let scopes: [String]?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
deviceid: String,
|
||||||
|
role: String,
|
||||||
|
scopes: [String]?
|
||||||
|
) {
|
||||||
|
self.deviceid = deviceid
|
||||||
|
self.role = role
|
||||||
|
self.scopes = scopes
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case deviceid = "deviceId"
|
||||||
|
case role
|
||||||
|
case scopes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DeviceTokenRevokeParams: Codable, Sendable {
|
||||||
|
public let deviceid: String
|
||||||
|
public let role: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
deviceid: String,
|
||||||
|
role: String
|
||||||
|
) {
|
||||||
|
self.deviceid = deviceid
|
||||||
|
self.role = role
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case deviceid = "deviceId"
|
||||||
|
case role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct DevicePairRequestedEvent: Codable, Sendable {
|
public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||||
public let requestid: String
|
public let requestid: String
|
||||||
public let deviceid: String
|
public let deviceid: String
|
||||||
|
|||||||
66
docs/cli/devices.md
Normal file
66
docs/cli/devices.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot devices` (device pairing + token rotation/revocation)"
|
||||||
|
read_when:
|
||||||
|
- You are approving device pairing requests
|
||||||
|
- You need to rotate or revoke device tokens
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot devices`
|
||||||
|
|
||||||
|
Manage device pairing requests and device-scoped tokens.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `clawdbot devices list`
|
||||||
|
|
||||||
|
List pending pairing requests and paired devices.
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot devices list
|
||||||
|
clawdbot devices list --json
|
||||||
|
```
|
||||||
|
|
||||||
|
### `clawdbot devices approve <requestId>`
|
||||||
|
|
||||||
|
Approve a pending device pairing request.
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot devices approve <requestId>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `clawdbot devices reject <requestId>`
|
||||||
|
|
||||||
|
Reject a pending device pairing request.
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot devices reject <requestId>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `clawdbot devices rotate --device <id> --role <role> [--scope <scope...>]`
|
||||||
|
|
||||||
|
Rotate a device token for a specific role (optionally updating scopes).
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot devices rotate --device <deviceId> --role operator --scope operator.read --scope operator.write
|
||||||
|
```
|
||||||
|
|
||||||
|
### `clawdbot devices revoke --device <id> --role <role>`
|
||||||
|
|
||||||
|
Revoke a device token for a specific role.
|
||||||
|
|
||||||
|
```
|
||||||
|
clawdbot devices revoke --device <deviceId> --role node
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common options
|
||||||
|
|
||||||
|
- `--url <url>`: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured).
|
||||||
|
- `--token <token>`: Gateway token (if required).
|
||||||
|
- `--password <password>`: Gateway password (password auth).
|
||||||
|
- `--timeout <ms>`: RPC timeout.
|
||||||
|
- `--json`: JSON output (recommended for scripting).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Token rotation returns a new token (sensitive). Treat it like a secret.
|
||||||
|
- These commands require `operator.pairing` (or `operator.admin`) scope.
|
||||||
@@ -116,17 +116,18 @@ clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
|
|||||||
|
|
||||||
## Discover gateways (Bonjour)
|
## Discover gateways (Bonjour)
|
||||||
|
|
||||||
`gateway discover` scans for Gateway bridge beacons (`_clawdbot-bridge._tcp`).
|
`gateway discover` scans for Gateway beacons (`_clawdbot-gateway._tcp`).
|
||||||
|
|
||||||
- Multicast DNS-SD: `local.`
|
- Multicast DNS-SD: `local.`
|
||||||
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
|
- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
|
||||||
|
|
||||||
Only gateways with the **bridge enabled** will advertise the discovery beacon.
|
Only gateways with Bonjour discovery enabled (default) advertise the beacon.
|
||||||
|
|
||||||
Wide-Area discovery records include (TXT):
|
Wide-Area discovery records include (TXT):
|
||||||
- `gatewayPort` (WebSocket port, usually `18789`)
|
- `gatewayPort` (WebSocket port, usually `18789`)
|
||||||
- `sshPort` (SSH port; defaults to `22` if not present)
|
- `sshPort` (SSH port; defaults to `22` if not present)
|
||||||
- `tailnetDns` (MagicDNS hostname, when available)
|
- `tailnetDns` (MagicDNS hostname, when available)
|
||||||
|
- `gatewayTls` / `gatewayTlsSha256` (TLS enabled + cert fingerprint)
|
||||||
- `cliPath` (optional hint for remote installs)
|
- `cliPath` (optional hint for remote installs)
|
||||||
|
|
||||||
### `gateway discover`
|
### `gateway discover`
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
- [`nodes`](/cli/nodes)
|
- [`nodes`](/cli/nodes)
|
||||||
|
- [`devices`](/cli/devices)
|
||||||
- [`node`](/cli/node)
|
- [`node`](/cli/node)
|
||||||
- [`approvals`](/cli/approvals)
|
- [`approvals`](/cli/approvals)
|
||||||
- [`sandbox`](/cli/sandbox)
|
- [`sandbox`](/cli/sandbox)
|
||||||
@@ -188,6 +189,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
runs
|
runs
|
||||||
run
|
run
|
||||||
nodes
|
nodes
|
||||||
|
devices
|
||||||
node
|
node
|
||||||
start
|
start
|
||||||
daemon
|
daemon
|
||||||
|
|||||||
@@ -2677,7 +2677,7 @@ Notes:
|
|||||||
|
|
||||||
Auth and Tailscale:
|
Auth and Tailscale:
|
||||||
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
||||||
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
|
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing).
|
||||||
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
||||||
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
||||||
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
|
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
|
||||||
@@ -2686,6 +2686,9 @@ Auth and Tailscale:
|
|||||||
`true`, Serve requests do not need a token/password; set `false` to require
|
`true`, Serve requests do not need a token/password; set `false` to require
|
||||||
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
|
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
|
||||||
auth mode is not `password`.
|
auth mode is not `password`.
|
||||||
|
- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes.
|
||||||
|
These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them
|
||||||
|
instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`.
|
||||||
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
|
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
|
||||||
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
||||||
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ Gateway → Client:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When a device token is issued, `hello-ok` also includes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"deviceToken": "…",
|
||||||
|
"role": "operator",
|
||||||
|
"scopes": ["operator.read", "operator.write"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Node example
|
### Node example
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -141,6 +153,11 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
|||||||
|
|
||||||
- If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token`
|
- If `CLAWDBOT_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token`
|
||||||
must match or the socket is closed.
|
must match or the socket is closed.
|
||||||
|
- After pairing, the Gateway issues a **device token** scoped to the connection
|
||||||
|
role + scopes. It is returned in `hello-ok.auth.deviceToken` and should be
|
||||||
|
persisted by the client for future connects.
|
||||||
|
- Device tokens can be rotated/revoked via `device.token.rotate` and
|
||||||
|
`device.token.revoke` (requires `operator.pairing` scope).
|
||||||
|
|
||||||
## Device identity + pairing
|
## Device identity + pairing
|
||||||
|
|
||||||
|
|||||||
225
src/cli/devices-cli.ts
Normal file
225
src/cli/devices-cli.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import type { Command } from "commander";
|
||||||
|
|
||||||
|
import { callGateway } from "../gateway/call.js";
|
||||||
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
|
import { defaultRuntime } from "./runtime.js";
|
||||||
|
import { withProgress } from "./progress.js";
|
||||||
|
|
||||||
|
type DevicesRpcOpts = {
|
||||||
|
url?: string;
|
||||||
|
token?: string;
|
||||||
|
password?: string;
|
||||||
|
timeout?: string;
|
||||||
|
json?: boolean;
|
||||||
|
device?: string;
|
||||||
|
role?: string;
|
||||||
|
scope?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeviceTokenSummary = {
|
||||||
|
role: string;
|
||||||
|
scopes?: string[];
|
||||||
|
revokedAtMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingDevice = {
|
||||||
|
requestId: string;
|
||||||
|
deviceId: string;
|
||||||
|
displayName?: string;
|
||||||
|
role?: string;
|
||||||
|
remoteIp?: string;
|
||||||
|
isRepair?: boolean;
|
||||||
|
ts?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PairedDevice = {
|
||||||
|
deviceId: string;
|
||||||
|
displayName?: string;
|
||||||
|
roles?: string[];
|
||||||
|
scopes?: string[];
|
||||||
|
remoteIp?: string;
|
||||||
|
tokens?: DeviceTokenSummary[];
|
||||||
|
createdAtMs?: number;
|
||||||
|
approvedAtMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DevicePairingList = {
|
||||||
|
pending?: PendingDevice[];
|
||||||
|
paired?: PairedDevice[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAge(msAgo: number) {
|
||||||
|
const s = Math.max(0, Math.floor(msAgo / 1000));
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
return `${d}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
|
||||||
|
cmd
|
||||||
|
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
|
||||||
|
.option("--token <token>", "Gateway token (if required)")
|
||||||
|
.option("--password <password>", "Gateway password (password auth)")
|
||||||
|
.option("--timeout <ms>", "Timeout in ms", String(defaults?.timeoutMs ?? 10_000))
|
||||||
|
.option("--json", "Output JSON", false);
|
||||||
|
|
||||||
|
const callGatewayCli = async (method: string, opts: DevicesRpcOpts, params?: unknown) =>
|
||||||
|
withProgress(
|
||||||
|
{
|
||||||
|
label: `Devices ${method}`,
|
||||||
|
indeterminate: true,
|
||||||
|
enabled: opts.json !== true,
|
||||||
|
},
|
||||||
|
async () =>
|
||||||
|
await callGateway({
|
||||||
|
url: opts.url,
|
||||||
|
token: opts.token,
|
||||||
|
password: opts.password,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
timeoutMs: Number(opts.timeout ?? 10_000),
|
||||||
|
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||||
|
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
function parseDevicePairingList(value: unknown): DevicePairingList {
|
||||||
|
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
||||||
|
return {
|
||||||
|
pending: Array.isArray(obj.pending) ? (obj.pending as PendingDevice[]) : [],
|
||||||
|
paired: Array.isArray(obj.paired) ? (obj.paired as PairedDevice[]) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) {
|
||||||
|
if (!tokens || tokens.length === 0) return "tokens: none";
|
||||||
|
const parts = tokens
|
||||||
|
.map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
return `tokens: ${parts.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerDevicesCli(program: Command) {
|
||||||
|
const devices = program.command("devices").description("Device pairing and auth tokens");
|
||||||
|
|
||||||
|
devicesCallOpts(
|
||||||
|
devices
|
||||||
|
.command("list")
|
||||||
|
.description("List pending and paired devices")
|
||||||
|
.action(async (opts: DevicesRpcOpts) => {
|
||||||
|
const result = await callGatewayCli("device.pair.list", opts, {});
|
||||||
|
const list = parseDevicePairingList(result);
|
||||||
|
if (opts.json) {
|
||||||
|
defaultRuntime.log(JSON.stringify(list, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (list.pending?.length) {
|
||||||
|
defaultRuntime.log("Pending:");
|
||||||
|
for (const req of list.pending) {
|
||||||
|
const name = req.displayName || req.deviceId;
|
||||||
|
const repair = req.isRepair ? " (repair)" : "";
|
||||||
|
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
|
||||||
|
const age = typeof req.ts === "number" ? ` · ${formatAge(Date.now() - req.ts)} ago` : "";
|
||||||
|
const role = req.role ? ` · role: ${req.role}` : "";
|
||||||
|
defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (list.paired?.length) {
|
||||||
|
defaultRuntime.log("Paired:");
|
||||||
|
for (const device of list.paired) {
|
||||||
|
const name = device.displayName || device.deviceId;
|
||||||
|
const roles = device.roles?.length ? `roles: ${device.roles.join(", ")}` : "roles: -";
|
||||||
|
const scopes = device.scopes?.length ? `scopes: ${device.scopes.join(", ")}` : "scopes: -";
|
||||||
|
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
|
||||||
|
const tokens = formatTokenSummary(device.tokens);
|
||||||
|
defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!list.pending?.length && !list.paired?.length) {
|
||||||
|
defaultRuntime.log("No device pairing entries.");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
devicesCallOpts(
|
||||||
|
devices
|
||||||
|
.command("approve")
|
||||||
|
.description("Approve a pending device pairing request")
|
||||||
|
.argument("<requestId>", "Pending request id")
|
||||||
|
.action(async (requestId: string, opts: DevicesRpcOpts) => {
|
||||||
|
const result = await callGatewayCli("device.pair.approve", opts, { requestId });
|
||||||
|
if (opts.json) {
|
||||||
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId;
|
||||||
|
defaultRuntime.log(`device approved: ${deviceId ?? "ok"}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
devicesCallOpts(
|
||||||
|
devices
|
||||||
|
.command("reject")
|
||||||
|
.description("Reject a pending device pairing request")
|
||||||
|
.argument("<requestId>", "Pending request id")
|
||||||
|
.action(async (requestId: string, opts: DevicesRpcOpts) => {
|
||||||
|
const result = await callGatewayCli("device.pair.reject", opts, { requestId });
|
||||||
|
if (opts.json) {
|
||||||
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deviceId = (result as { deviceId?: string })?.deviceId;
|
||||||
|
defaultRuntime.log(`device rejected: ${deviceId ?? "ok"}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
devicesCallOpts(
|
||||||
|
devices
|
||||||
|
.command("rotate")
|
||||||
|
.description("Rotate a device token for a role")
|
||||||
|
.requiredOption("--device <id>", "Device id")
|
||||||
|
.requiredOption("--role <role>", "Role name")
|
||||||
|
.option("--scope <scope...>", "Scopes to attach to the token (repeatable)")
|
||||||
|
.action(async (opts: DevicesRpcOpts) => {
|
||||||
|
const deviceId = String(opts.device ?? "").trim();
|
||||||
|
const role = String(opts.role ?? "").trim();
|
||||||
|
if (!deviceId || !role) {
|
||||||
|
defaultRuntime.error("--device and --role required");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await callGatewayCli("device.token.rotate", opts, {
|
||||||
|
deviceId,
|
||||||
|
role,
|
||||||
|
scopes: Array.isArray(opts.scope) ? opts.scope : undefined,
|
||||||
|
});
|
||||||
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
devicesCallOpts(
|
||||||
|
devices
|
||||||
|
.command("revoke")
|
||||||
|
.description("Revoke a device token for a role")
|
||||||
|
.requiredOption("--device <id>", "Device id")
|
||||||
|
.requiredOption("--role <role>", "Role name")
|
||||||
|
.action(async (opts: DevicesRpcOpts) => {
|
||||||
|
const deviceId = String(opts.device ?? "").trim();
|
||||||
|
const role = String(opts.role ?? "").trim();
|
||||||
|
if (!deviceId || !role) {
|
||||||
|
defaultRuntime.error("--device and --role required");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await callGatewayCli("device.token.revoke", opts, {
|
||||||
|
deviceId,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,7 +64,8 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
|
|||||||
|
|
||||||
const host = pickBeaconHost(beacon);
|
const host = pickBeaconHost(beacon);
|
||||||
const gatewayPort = pickGatewayPort(beacon);
|
const gatewayPort = pickGatewayPort(beacon);
|
||||||
const wsUrl = host ? `ws://${host}:${gatewayPort}` : null;
|
const scheme = beacon.gatewayTls ? "wss" : "ws";
|
||||||
|
const wsUrl = host ? `${scheme}://${host}:${gatewayPort}` : null;
|
||||||
|
|
||||||
const lines = [`- ${title} ${domain}`];
|
const lines = [`- ${title} ${domain}`];
|
||||||
|
|
||||||
@@ -81,6 +82,12 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean):
|
|||||||
if (wsUrl) {
|
if (wsUrl) {
|
||||||
lines.push(` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`);
|
lines.push(` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`);
|
||||||
}
|
}
|
||||||
|
if (beacon.gatewayTls) {
|
||||||
|
const fingerprint = beacon.gatewayTlsFingerprintSha256
|
||||||
|
? `sha256 ${beacon.gatewayTlsFingerprintSha256}`
|
||||||
|
: "enabled";
|
||||||
|
lines.push(` ${colorize(rich, theme.muted, "tls")}: ${fingerprint}`);
|
||||||
|
}
|
||||||
if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) {
|
if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) {
|
||||||
const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`;
|
const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`;
|
||||||
lines.push(` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`);
|
lines.push(` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`);
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ const entries: SubCliEntry[] = [
|
|||||||
mod.registerNodesCli(program);
|
mod.registerNodesCli(program);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "devices",
|
||||||
|
description: "Device pairing + token management",
|
||||||
|
register: async (program) => {
|
||||||
|
const mod = await import("../devices-cli.js");
|
||||||
|
mod.registerDevicesCli(program);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "node",
|
name: "node",
|
||||||
description: "Node control",
|
description: "Node control",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type ResolvedGatewayAuth = {
|
|||||||
|
|
||||||
export type GatewayAuthResult = {
|
export type GatewayAuthResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
method?: "none" | "token" | "password" | "tailscale";
|
method?: "none" | "token" | "password" | "tailscale" | "device-token";
|
||||||
user?: string;
|
user?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
publicKeyRawBase64UrlFromPem,
|
publicKeyRawBase64UrlFromPem,
|
||||||
signDevicePayload,
|
signDevicePayload,
|
||||||
} from "../infra/device-identity.js";
|
} from "../infra/device-identity.js";
|
||||||
|
import { loadDeviceAuthToken, storeDeviceAuthToken } from "../infra/device-auth-store.js";
|
||||||
import {
|
import {
|
||||||
GATEWAY_CLIENT_MODES,
|
GATEWAY_CLIENT_MODES,
|
||||||
GATEWAY_CLIENT_NAMES,
|
GATEWAY_CLIENT_NAMES,
|
||||||
@@ -146,36 +147,39 @@ export class GatewayClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sendConnect() {
|
private sendConnect() {
|
||||||
|
const role = this.opts.role ?? "operator";
|
||||||
|
const storedToken = this.opts.deviceIdentity
|
||||||
|
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
|
||||||
|
: null;
|
||||||
|
const authToken = this.opts.token ?? storedToken ?? undefined;
|
||||||
const auth =
|
const auth =
|
||||||
this.opts.token || this.opts.password
|
authToken || this.opts.password
|
||||||
? {
|
? {
|
||||||
token: this.opts.token,
|
token: authToken,
|
||||||
password: this.opts.password,
|
password: this.opts.password,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const signedAtMs = Date.now();
|
const signedAtMs = Date.now();
|
||||||
const role = this.opts.role ?? "operator";
|
|
||||||
const scopes = this.opts.scopes ?? ["operator.admin"];
|
const scopes = this.opts.scopes ?? ["operator.admin"];
|
||||||
const identity = this.opts.deviceIdentity;
|
const device = (() => {
|
||||||
if (!identity) {
|
if (!this.opts.deviceIdentity) return undefined;
|
||||||
throw new Error("gateway client requires a device identity");
|
|
||||||
}
|
|
||||||
const payload = buildDeviceAuthPayload({
|
const payload = buildDeviceAuthPayload({
|
||||||
deviceId: identity.deviceId,
|
deviceId: this.opts.deviceIdentity.deviceId,
|
||||||
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||||
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
signedAtMs,
|
signedAtMs,
|
||||||
token: this.opts.token ?? null,
|
token: authToken ?? null,
|
||||||
});
|
});
|
||||||
const signature = signDevicePayload(identity.privateKeyPem, payload);
|
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
|
||||||
const device = {
|
return {
|
||||||
id: identity.deviceId,
|
id: this.opts.deviceIdentity.deviceId,
|
||||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
publicKey: publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem),
|
||||||
signature,
|
signature,
|
||||||
signedAt: signedAtMs,
|
signedAt: signedAtMs,
|
||||||
};
|
};
|
||||||
|
})();
|
||||||
const params: ConnectParams = {
|
const params: ConnectParams = {
|
||||||
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
||||||
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||||
@@ -201,6 +205,15 @@ export class GatewayClient {
|
|||||||
|
|
||||||
void this.request<HelloOk>("connect", params)
|
void this.request<HelloOk>("connect", params)
|
||||||
.then((helloOk) => {
|
.then((helloOk) => {
|
||||||
|
const authInfo = helloOk?.auth;
|
||||||
|
if (authInfo?.deviceToken && this.opts.deviceIdentity) {
|
||||||
|
storeDeviceAuthToken({
|
||||||
|
deviceId: this.opts.deviceIdentity.deviceId,
|
||||||
|
role: authInfo.role ?? role,
|
||||||
|
token: authInfo.deviceToken,
|
||||||
|
scopes: authInfo.scopes ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
this.backoffMs = 1000;
|
this.backoffMs = 1000;
|
||||||
this.tickIntervalMs =
|
this.tickIntervalMs =
|
||||||
typeof helloOk.policy?.tickIntervalMs === "number"
|
typeof helloOk.policy?.tickIntervalMs === "number"
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ import {
|
|||||||
DevicePairListParamsSchema,
|
DevicePairListParamsSchema,
|
||||||
type DevicePairRejectParams,
|
type DevicePairRejectParams,
|
||||||
DevicePairRejectParamsSchema,
|
DevicePairRejectParamsSchema,
|
||||||
|
type DeviceTokenRevokeParams,
|
||||||
|
DeviceTokenRevokeParamsSchema,
|
||||||
|
type DeviceTokenRotateParams,
|
||||||
|
DeviceTokenRotateParamsSchema,
|
||||||
type ExecApprovalsGetParams,
|
type ExecApprovalsGetParams,
|
||||||
ExecApprovalsGetParamsSchema,
|
ExecApprovalsGetParamsSchema,
|
||||||
type ExecApprovalsNodeGetParams,
|
type ExecApprovalsNodeGetParams,
|
||||||
@@ -270,6 +274,12 @@ export const validateDevicePairApproveParams = ajv.compile<DevicePairApprovePara
|
|||||||
export const validateDevicePairRejectParams = ajv.compile<DevicePairRejectParams>(
|
export const validateDevicePairRejectParams = ajv.compile<DevicePairRejectParams>(
|
||||||
DevicePairRejectParamsSchema,
|
DevicePairRejectParamsSchema,
|
||||||
);
|
);
|
||||||
|
export const validateDeviceTokenRotateParams = ajv.compile<DeviceTokenRotateParams>(
|
||||||
|
DeviceTokenRotateParamsSchema,
|
||||||
|
);
|
||||||
|
export const validateDeviceTokenRevokeParams = ajv.compile<DeviceTokenRevokeParams>(
|
||||||
|
DeviceTokenRevokeParamsSchema,
|
||||||
|
);
|
||||||
export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>(
|
export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>(
|
||||||
ExecApprovalsGetParamsSchema,
|
ExecApprovalsGetParamsSchema,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,23 @@ export const DevicePairRejectParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const DeviceTokenRotateParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
deviceId: NonEmptyString,
|
||||||
|
role: NonEmptyString,
|
||||||
|
scopes: Type.Optional(Type.Array(NonEmptyString)),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DeviceTokenRevokeParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
deviceId: NonEmptyString,
|
||||||
|
role: NonEmptyString,
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
export const DevicePairRequestedEventSchema = Type.Object(
|
export const DevicePairRequestedEventSchema = Type.Object(
|
||||||
{
|
{
|
||||||
requestId: NonEmptyString,
|
requestId: NonEmptyString,
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ export const HelloOkSchema = Type.Object(
|
|||||||
),
|
),
|
||||||
snapshot: SnapshotSchema,
|
snapshot: SnapshotSchema,
|
||||||
canvasHostUrl: Type.Optional(NonEmptyString),
|
canvasHostUrl: Type.Optional(NonEmptyString),
|
||||||
|
auth: Type.Optional(
|
||||||
|
Type.Object(
|
||||||
|
{
|
||||||
|
deviceToken: NonEmptyString,
|
||||||
|
role: NonEmptyString,
|
||||||
|
scopes: Type.Array(NonEmptyString),
|
||||||
|
issuedAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
),
|
||||||
|
),
|
||||||
policy: Type.Object(
|
policy: Type.Object(
|
||||||
{
|
{
|
||||||
maxPayload: Type.Integer({ minimum: 1 }),
|
maxPayload: Type.Integer({ minimum: 1 }),
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ import {
|
|||||||
DevicePairRejectParamsSchema,
|
DevicePairRejectParamsSchema,
|
||||||
DevicePairRequestedEventSchema,
|
DevicePairRequestedEventSchema,
|
||||||
DevicePairResolvedEventSchema,
|
DevicePairResolvedEventSchema,
|
||||||
|
DeviceTokenRevokeParamsSchema,
|
||||||
|
DeviceTokenRotateParamsSchema,
|
||||||
} from "./devices.js";
|
} from "./devices.js";
|
||||||
import {
|
import {
|
||||||
ConnectParamsSchema,
|
ConnectParamsSchema,
|
||||||
@@ -206,6 +208,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
DevicePairListParams: DevicePairListParamsSchema,
|
DevicePairListParams: DevicePairListParamsSchema,
|
||||||
DevicePairApproveParams: DevicePairApproveParamsSchema,
|
DevicePairApproveParams: DevicePairApproveParamsSchema,
|
||||||
DevicePairRejectParams: DevicePairRejectParamsSchema,
|
DevicePairRejectParams: DevicePairRejectParamsSchema,
|
||||||
|
DeviceTokenRotateParams: DeviceTokenRotateParamsSchema,
|
||||||
|
DeviceTokenRevokeParams: DeviceTokenRevokeParamsSchema,
|
||||||
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
|
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
|
||||||
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
|
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
|
||||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ import type {
|
|||||||
DevicePairApproveParamsSchema,
|
DevicePairApproveParamsSchema,
|
||||||
DevicePairListParamsSchema,
|
DevicePairListParamsSchema,
|
||||||
DevicePairRejectParamsSchema,
|
DevicePairRejectParamsSchema,
|
||||||
|
DeviceTokenRevokeParamsSchema,
|
||||||
|
DeviceTokenRotateParamsSchema,
|
||||||
} from "./devices.js";
|
} from "./devices.js";
|
||||||
import type {
|
import type {
|
||||||
ConnectParamsSchema,
|
ConnectParamsSchema,
|
||||||
@@ -195,6 +197,8 @@ export type ExecApprovalResolveParams = Static<typeof ExecApprovalResolveParamsS
|
|||||||
export type DevicePairListParams = Static<typeof DevicePairListParamsSchema>;
|
export type DevicePairListParams = Static<typeof DevicePairListParamsSchema>;
|
||||||
export type DevicePairApproveParams = Static<typeof DevicePairApproveParamsSchema>;
|
export type DevicePairApproveParams = Static<typeof DevicePairApproveParamsSchema>;
|
||||||
export type DevicePairRejectParams = Static<typeof DevicePairRejectParamsSchema>;
|
export type DevicePairRejectParams = Static<typeof DevicePairRejectParamsSchema>;
|
||||||
|
export type DeviceTokenRotateParams = Static<typeof DeviceTokenRotateParamsSchema>;
|
||||||
|
export type DeviceTokenRevokeParams = Static<typeof DeviceTokenRevokeParamsSchema>;
|
||||||
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||||
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
||||||
export type ChatEvent = Static<typeof ChatEventSchema>;
|
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const BASE_METHODS = [
|
|||||||
"device.pair.list",
|
"device.pair.list",
|
||||||
"device.pair.approve",
|
"device.pair.approve",
|
||||||
"device.pair.reject",
|
"device.pair.reject",
|
||||||
|
"device.token.rotate",
|
||||||
|
"device.token.revoke",
|
||||||
"node.rename",
|
"node.rename",
|
||||||
"node.list",
|
"node.list",
|
||||||
"node.describe",
|
"node.describe",
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ const PAIRING_METHODS = new Set([
|
|||||||
"device.pair.list",
|
"device.pair.list",
|
||||||
"device.pair.approve",
|
"device.pair.approve",
|
||||||
"device.pair.reject",
|
"device.pair.reject",
|
||||||
|
"device.token.rotate",
|
||||||
|
"device.token.revoke",
|
||||||
"node.rename",
|
"node.rename",
|
||||||
]);
|
]);
|
||||||
const ADMIN_METHOD_PREFIXES = ["exec.approvals."];
|
const ADMIN_METHOD_PREFIXES = ["exec.approvals."];
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
approveDevicePairing,
|
approveDevicePairing,
|
||||||
listDevicePairing,
|
listDevicePairing,
|
||||||
|
type DeviceAuthToken,
|
||||||
rejectDevicePairing,
|
rejectDevicePairing,
|
||||||
|
revokeDeviceToken,
|
||||||
|
rotateDeviceToken,
|
||||||
|
summarizeDeviceTokens,
|
||||||
} from "../../infra/device-pairing.js";
|
} from "../../infra/device-pairing.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
@@ -10,9 +14,19 @@ import {
|
|||||||
validateDevicePairApproveParams,
|
validateDevicePairApproveParams,
|
||||||
validateDevicePairListParams,
|
validateDevicePairListParams,
|
||||||
validateDevicePairRejectParams,
|
validateDevicePairRejectParams,
|
||||||
|
validateDeviceTokenRevokeParams,
|
||||||
|
validateDeviceTokenRotateParams,
|
||||||
} from "../protocol/index.js";
|
} from "../protocol/index.js";
|
||||||
import type { GatewayRequestHandlers } from "./types.js";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
|
|
||||||
|
function redactPairedDevice(device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>) {
|
||||||
|
const { tokens, ...rest } = device;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
tokens: summarizeDeviceTokens(tokens),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const deviceHandlers: GatewayRequestHandlers = {
|
export const deviceHandlers: GatewayRequestHandlers = {
|
||||||
"device.pair.list": async ({ params, respond }) => {
|
"device.pair.list": async ({ params, respond }) => {
|
||||||
if (!validateDevicePairListParams(params)) {
|
if (!validateDevicePairListParams(params)) {
|
||||||
@@ -29,7 +43,14 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const list = await listDevicePairing();
|
const list = await listDevicePairing();
|
||||||
respond(true, list, undefined);
|
respond(
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
pending: list.pending,
|
||||||
|
paired: list.paired.map((device) => redactPairedDevice(device)),
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
"device.pair.approve": async ({ params, respond, context }) => {
|
"device.pair.approve": async ({ params, respond, context }) => {
|
||||||
if (!validateDevicePairApproveParams(params)) {
|
if (!validateDevicePairApproveParams(params)) {
|
||||||
@@ -61,7 +82,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
|||||||
},
|
},
|
||||||
{ dropIfSlow: true },
|
{ dropIfSlow: true },
|
||||||
);
|
);
|
||||||
respond(true, approved, undefined);
|
respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined);
|
||||||
},
|
},
|
||||||
"device.pair.reject": async ({ params, respond, context }) => {
|
"device.pair.reject": async ({ params, respond, context }) => {
|
||||||
if (!validateDevicePairRejectParams(params)) {
|
if (!validateDevicePairRejectParams(params)) {
|
||||||
@@ -95,4 +116,66 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
respond(true, rejected, undefined);
|
respond(true, rejected, undefined);
|
||||||
},
|
},
|
||||||
|
"device.token.rotate": async ({ params, respond }) => {
|
||||||
|
if (!validateDeviceTokenRotateParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid device.token.rotate params: ${formatValidationErrors(
|
||||||
|
validateDeviceTokenRotateParams.errors,
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { deviceId, role, scopes } = params as {
|
||||||
|
deviceId: string;
|
||||||
|
role: string;
|
||||||
|
scopes?: string[];
|
||||||
|
};
|
||||||
|
const entry = await rotateDeviceToken({ deviceId, role, scopes });
|
||||||
|
if (!entry) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
respond(
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
deviceId,
|
||||||
|
role: entry.role,
|
||||||
|
token: entry.token,
|
||||||
|
scopes: entry.scopes,
|
||||||
|
rotatedAtMs: entry.rotatedAtMs ?? entry.createdAtMs,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"device.token.revoke": async ({ params, respond }) => {
|
||||||
|
if (!validateDeviceTokenRevokeParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid device.token.revoke params: ${formatValidationErrors(
|
||||||
|
validateDeviceTokenRevokeParams.errors,
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { deviceId, role } = params as { deviceId: string; role: string };
|
||||||
|
const entry = await revokeDeviceToken({ deviceId, role });
|
||||||
|
if (!entry) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
respond(
|
||||||
|
true,
|
||||||
|
{ deviceId, role: entry.role, revokedAtMs: entry.revokedAtMs ?? Date.now() },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("rejects invalid token", async () => {
|
test("rejects invalid token", async () => {
|
||||||
const { server, ws, prevToken } = await startServerWithClient("secret");
|
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||||
const res = await connectReq(ws, { token: "wrong" });
|
const res = await connectReq(ws, { token: "wrong" });
|
||||||
expect(res.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
expect(res.error?.message ?? "").toContain("unauthorized");
|
expect(res.error?.message ?? "").toContain("unauthorized");
|
||||||
@@ -100,6 +100,81 @@ describe("gateway server auth/connect", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts device token auth for paired device", async () => {
|
||||||
|
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||||
|
const { approveDevicePairing, getPairedDevice, listDevicePairing } = await import(
|
||||||
|
"../infra/device-pairing.js"
|
||||||
|
);
|
||||||
|
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||||
|
const res = await connectReq(ws, { token: "secret" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const list = await listDevicePairing();
|
||||||
|
const pending = list.pending.at(0);
|
||||||
|
expect(pending?.requestId).toBeDefined();
|
||||||
|
if (pending?.requestId) {
|
||||||
|
await approveDevicePairing(pending.requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = loadOrCreateDeviceIdentity();
|
||||||
|
const paired = await getPairedDevice(identity.deviceId);
|
||||||
|
const deviceToken = paired?.tokens?.operator?.token;
|
||||||
|
expect(deviceToken).toBeDefined();
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
|
||||||
|
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
|
await new Promise<void>((resolve) => ws2.once("open", resolve));
|
||||||
|
const res2 = await connectReq(ws2, { token: deviceToken });
|
||||||
|
expect(res2.ok).toBe(true);
|
||||||
|
|
||||||
|
ws2.close();
|
||||||
|
await server.close();
|
||||||
|
if (prevToken === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects revoked device token", async () => {
|
||||||
|
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||||
|
const { approveDevicePairing, getPairedDevice, listDevicePairing, revokeDeviceToken } =
|
||||||
|
await import("../infra/device-pairing.js");
|
||||||
|
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||||
|
const res = await connectReq(ws, { token: "secret" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const list = await listDevicePairing();
|
||||||
|
const pending = list.pending.at(0);
|
||||||
|
expect(pending?.requestId).toBeDefined();
|
||||||
|
if (pending?.requestId) {
|
||||||
|
await approveDevicePairing(pending.requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = loadOrCreateDeviceIdentity();
|
||||||
|
const paired = await getPairedDevice(identity.deviceId);
|
||||||
|
const deviceToken = paired?.tokens?.operator?.token;
|
||||||
|
expect(deviceToken).toBeDefined();
|
||||||
|
|
||||||
|
await revokeDeviceToken({ deviceId: identity.deviceId, role: "operator" });
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
|
||||||
|
const ws2 = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
|
await new Promise<void>((resolve) => ws2.once("open", resolve));
|
||||||
|
const res2 = await connectReq(ws2, { token: deviceToken });
|
||||||
|
expect(res2.ok).toBe(false);
|
||||||
|
|
||||||
|
ws2.close();
|
||||||
|
await server.close();
|
||||||
|
if (prevToken === undefined) {
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects invalid password", async () => {
|
test("rejects invalid password", async () => {
|
||||||
testState.gatewayAuth = { mode: "password", password: "secret" };
|
testState.gatewayAuth = { mode: "password", password: "secret" };
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||||
|
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
import {
|
import {
|
||||||
connectOk,
|
connectOk,
|
||||||
@@ -223,8 +224,9 @@ describe("gateway server health/presence", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const presenceRes = await presenceP;
|
const presenceRes = await presenceP;
|
||||||
|
const identity = loadOrCreateDeviceIdentity();
|
||||||
const entries = presenceRes.payload as Array<Record<string, unknown>>;
|
const entries = presenceRes.payload as Array<Record<string, unknown>>;
|
||||||
const clientEntry = entries.find((e) => e.instanceId === "abc");
|
const clientEntry = entries.find((e) => e.instanceId === identity.deviceId);
|
||||||
expect(clientEntry?.host).toBe(GATEWAY_CLIENT_NAMES.FINGERPRINT);
|
expect(clientEntry?.host).toBe(GATEWAY_CLIENT_NAMES.FINGERPRINT);
|
||||||
expect(clientEntry?.version).toBe("9.9.9");
|
expect(clientEntry?.version).toBe("9.9.9");
|
||||||
expect(clientEntry?.mode).toBe("ui");
|
expect(clientEntry?.mode).toBe("ui");
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ describe("gateway node command allowlist", () => {
|
|||||||
role: "node",
|
role: "node",
|
||||||
client: {
|
client: {
|
||||||
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||||
|
displayName: "node-empty",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
platform: "ios",
|
platform: "ios",
|
||||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||||
@@ -94,6 +95,7 @@ describe("gateway node command allowlist", () => {
|
|||||||
role: "node",
|
role: "node",
|
||||||
client: {
|
client: {
|
||||||
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||||
|
displayName: "node-allowed",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
platform: "ios",
|
platform: "ios",
|
||||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
} from "../../../infra/device-identity.js";
|
} from "../../../infra/device-identity.js";
|
||||||
import {
|
import {
|
||||||
approveDevicePairing,
|
approveDevicePairing,
|
||||||
|
ensureDeviceToken,
|
||||||
getPairedDevice,
|
getPairedDevice,
|
||||||
requestDevicePairing,
|
requestDevicePairing,
|
||||||
updatePairedDeviceMetadata,
|
updatePairedDeviceMetadata,
|
||||||
|
verifyDeviceToken,
|
||||||
} from "../../../infra/device-pairing.js";
|
} from "../../../infra/device-pairing.js";
|
||||||
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
|
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
|
||||||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||||
@@ -218,42 +220,6 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authResult = await authorizeGatewayConnect({
|
|
||||||
auth: resolvedAuth,
|
|
||||||
connectAuth: connectParams.auth,
|
|
||||||
req: upgradeReq,
|
|
||||||
});
|
|
||||||
if (!authResult.ok) {
|
|
||||||
setHandshakeState("failed");
|
|
||||||
logWsControl.warn(
|
|
||||||
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
|
|
||||||
);
|
|
||||||
const authProvided = connectParams.auth?.token
|
|
||||||
? "token"
|
|
||||||
: connectParams.auth?.password
|
|
||||||
? "password"
|
|
||||||
: "none";
|
|
||||||
setCloseCause("unauthorized", {
|
|
||||||
authMode: resolvedAuth.mode,
|
|
||||||
authProvided,
|
|
||||||
authReason: authResult.reason,
|
|
||||||
allowTailscale: resolvedAuth.allowTailscale,
|
|
||||||
client: connectParams.client.id,
|
|
||||||
clientDisplayName: connectParams.client.displayName,
|
|
||||||
mode: connectParams.client.mode,
|
|
||||||
version: connectParams.client.version,
|
|
||||||
});
|
|
||||||
send({
|
|
||||||
type: "res",
|
|
||||||
id: frame.id,
|
|
||||||
ok: false,
|
|
||||||
error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"),
|
|
||||||
});
|
|
||||||
close(1008, "unauthorized");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const authMethod = authResult.method ?? "none";
|
|
||||||
|
|
||||||
const roleRaw = connectParams.role ?? "operator";
|
const roleRaw = connectParams.role ?? "operator";
|
||||||
const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null;
|
const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null;
|
||||||
if (!role) {
|
if (!role) {
|
||||||
@@ -385,6 +351,55 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authResult = await authorizeGatewayConnect({
|
||||||
|
auth: resolvedAuth,
|
||||||
|
connectAuth: connectParams.auth,
|
||||||
|
req: upgradeReq,
|
||||||
|
});
|
||||||
|
let authOk = authResult.ok;
|
||||||
|
let authMethod = authResult.method ?? "none";
|
||||||
|
if (!authOk && connectParams.auth?.token) {
|
||||||
|
const tokenCheck = await verifyDeviceToken({
|
||||||
|
deviceId: device.id,
|
||||||
|
token: connectParams.auth.token,
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
});
|
||||||
|
if (tokenCheck.ok) {
|
||||||
|
authOk = true;
|
||||||
|
authMethod = "device-token";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!authOk) {
|
||||||
|
setHandshakeState("failed");
|
||||||
|
logWsControl.warn(
|
||||||
|
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
|
||||||
|
);
|
||||||
|
const authProvided = connectParams.auth?.token
|
||||||
|
? "token"
|
||||||
|
: connectParams.auth?.password
|
||||||
|
? "password"
|
||||||
|
: "none";
|
||||||
|
setCloseCause("unauthorized", {
|
||||||
|
authMode: resolvedAuth.mode,
|
||||||
|
authProvided,
|
||||||
|
authReason: authResult.reason,
|
||||||
|
allowTailscale: resolvedAuth.allowTailscale,
|
||||||
|
client: connectParams.client.id,
|
||||||
|
clientDisplayName: connectParams.client.displayName,
|
||||||
|
mode: connectParams.client.mode,
|
||||||
|
version: connectParams.client.version,
|
||||||
|
});
|
||||||
|
send({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"),
|
||||||
|
});
|
||||||
|
close(1008, "unauthorized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (device && devicePublicKey) {
|
if (device && devicePublicKey) {
|
||||||
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
||||||
const pairing = await requestDevicePairing({
|
const pairing = await requestDevicePairing({
|
||||||
@@ -445,7 +460,11 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
} else {
|
} else {
|
||||||
const allowedRoles = new Set(
|
const allowedRoles = new Set(
|
||||||
Array.isArray(paired.roles) ? paired.roles : paired.role ? [paired.role] : [],
|
Array.isArray(paired.roles)
|
||||||
|
? paired.roles
|
||||||
|
: paired.role
|
||||||
|
? [paired.role]
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
if (allowedRoles.size === 0) {
|
if (allowedRoles.size === 0) {
|
||||||
const ok = await requirePairing("role-upgrade", paired);
|
const ok = await requirePairing("role-upgrade", paired);
|
||||||
@@ -482,6 +501,10 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deviceToken = device
|
||||||
|
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
|
||||||
|
: null;
|
||||||
|
|
||||||
if (role === "node") {
|
if (role === "node") {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const allowlist = resolveNodeCommandAllowlist(cfg, {
|
const allowlist = resolveNodeCommandAllowlist(cfg, {
|
||||||
@@ -552,6 +575,14 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
features: { methods: gatewayMethods, events },
|
features: { methods: gatewayMethods, events },
|
||||||
snapshot,
|
snapshot,
|
||||||
canvasHostUrl,
|
canvasHostUrl,
|
||||||
|
auth: deviceToken
|
||||||
|
? {
|
||||||
|
deviceToken: deviceToken.token,
|
||||||
|
role: deviceToken.role,
|
||||||
|
scopes: deviceToken.scopes,
|
||||||
|
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
policy: {
|
policy: {
|
||||||
maxPayload: MAX_PAYLOAD_BYTES,
|
maxPayload: MAX_PAYLOAD_BYTES,
|
||||||
maxBufferedBytes: MAX_BUFFERED_BYTES,
|
maxBufferedBytes: MAX_BUFFERED_BYTES,
|
||||||
|
|||||||
104
src/infra/device-auth-store.ts
Normal file
104
src/infra/device-auth-store.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
|
|
||||||
|
export type DeviceAuthEntry = {
|
||||||
|
token: string;
|
||||||
|
role: string;
|
||||||
|
scopes: string[];
|
||||||
|
updatedAtMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeviceAuthStore = {
|
||||||
|
version: 1;
|
||||||
|
deviceId: string;
|
||||||
|
tokens: Record<string, DeviceAuthEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEVICE_AUTH_FILE = "device-auth.json";
|
||||||
|
|
||||||
|
function resolveDeviceAuthPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
return path.join(resolveStateDir(env), "identity", DEVICE_AUTH_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRole(role: string): string {
|
||||||
|
return role.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScopes(scopes: string[] | undefined): string[] {
|
||||||
|
if (!Array.isArray(scopes)) return [];
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const scope of scopes) {
|
||||||
|
const trimmed = scope.trim();
|
||||||
|
if (trimmed) out.add(trimmed);
|
||||||
|
}
|
||||||
|
return [...out].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStore(filePath: string): DeviceAuthStore | null {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as DeviceAuthStore;
|
||||||
|
if (parsed?.version !== 1 || typeof parsed.deviceId !== "string") return null;
|
||||||
|
if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStore(filePath: string, store: DeviceAuthStore): void {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
||||||
|
try {
|
||||||
|
fs.chmodSync(filePath, 0o600);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadDeviceAuthToken(params: {
|
||||||
|
deviceId: string;
|
||||||
|
role: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): DeviceAuthEntry | null {
|
||||||
|
const filePath = resolveDeviceAuthPath(params.env);
|
||||||
|
const store = readStore(filePath);
|
||||||
|
if (!store) return null;
|
||||||
|
if (store.deviceId !== params.deviceId) return null;
|
||||||
|
const role = normalizeRole(params.role);
|
||||||
|
const entry = store.tokens[role];
|
||||||
|
if (!entry || typeof entry.token !== "string") return null;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeDeviceAuthToken(params: {
|
||||||
|
deviceId: string;
|
||||||
|
role: string;
|
||||||
|
token: string;
|
||||||
|
scopes?: string[];
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): DeviceAuthEntry {
|
||||||
|
const filePath = resolveDeviceAuthPath(params.env);
|
||||||
|
const existing = readStore(filePath);
|
||||||
|
const role = normalizeRole(params.role);
|
||||||
|
const next: DeviceAuthStore = {
|
||||||
|
version: 1,
|
||||||
|
deviceId: params.deviceId,
|
||||||
|
tokens:
|
||||||
|
existing && existing.deviceId === params.deviceId && existing.tokens
|
||||||
|
? { ...existing.tokens }
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
const entry: DeviceAuthEntry = {
|
||||||
|
token: params.token,
|
||||||
|
role,
|
||||||
|
scopes: normalizeScopes(params.scopes),
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
next.tokens[role] = entry;
|
||||||
|
writeStore(filePath, next);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
43
src/infra/device-pairing.test.ts
Normal file
43
src/infra/device-pairing.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { mkdtemp } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
approveDevicePairing,
|
||||||
|
getPairedDevice,
|
||||||
|
requestDevicePairing,
|
||||||
|
rotateDeviceToken,
|
||||||
|
} from "./device-pairing.js";
|
||||||
|
|
||||||
|
describe("device pairing tokens", () => {
|
||||||
|
test("preserves existing token scopes when rotating without scopes", async () => {
|
||||||
|
const baseDir = await mkdtemp(join(tmpdir(), "clawdbot-device-pairing-"));
|
||||||
|
const request = await requestDevicePairing(
|
||||||
|
{
|
||||||
|
deviceId: "device-1",
|
||||||
|
publicKey: "public-key-1",
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
},
|
||||||
|
baseDir,
|
||||||
|
);
|
||||||
|
await approveDevicePairing(request.request.requestId, baseDir);
|
||||||
|
|
||||||
|
await rotateDeviceToken({
|
||||||
|
deviceId: "device-1",
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.read"],
|
||||||
|
baseDir,
|
||||||
|
});
|
||||||
|
let paired = await getPairedDevice("device-1", baseDir);
|
||||||
|
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
|
||||||
|
|
||||||
|
await rotateDeviceToken({
|
||||||
|
deviceId: "device-1",
|
||||||
|
role: "operator",
|
||||||
|
baseDir,
|
||||||
|
});
|
||||||
|
paired = await getPairedDevice("device-1", baseDir);
|
||||||
|
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,25 @@ export type DevicePairingPendingRequest = {
|
|||||||
ts: number;
|
ts: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeviceAuthToken = {
|
||||||
|
token: string;
|
||||||
|
role: string;
|
||||||
|
scopes: string[];
|
||||||
|
createdAtMs: number;
|
||||||
|
rotatedAtMs?: number;
|
||||||
|
revokedAtMs?: number;
|
||||||
|
lastUsedAtMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeviceAuthTokenSummary = {
|
||||||
|
role: string;
|
||||||
|
scopes: string[];
|
||||||
|
createdAtMs: number;
|
||||||
|
rotatedAtMs?: number;
|
||||||
|
revokedAtMs?: number;
|
||||||
|
lastUsedAtMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type PairedDevice = {
|
export type PairedDevice = {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
@@ -31,6 +50,7 @@ export type PairedDevice = {
|
|||||||
roles?: string[];
|
roles?: string[];
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
|
tokens?: Record<string, DeviceAuthToken>;
|
||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
approvedAtMs: number;
|
approvedAtMs: number;
|
||||||
};
|
};
|
||||||
@@ -136,6 +156,11 @@ function normalizeDeviceId(deviceId: string) {
|
|||||||
return deviceId.trim();
|
return deviceId.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRole(role: string | undefined): string | null {
|
||||||
|
const trimmed = role?.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
function mergeRoles(...items: Array<string | string[] | undefined>): string[] | undefined {
|
function mergeRoles(...items: Array<string | string[] | undefined>): string[] | undefined {
|
||||||
const roles = new Set<string>();
|
const roles = new Set<string>();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -167,6 +192,27 @@ function mergeScopes(...items: Array<string[] | undefined>): string[] | undefine
|
|||||||
return [...scopes];
|
return [...scopes];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeScopes(scopes: string[] | undefined): string[] {
|
||||||
|
if (!Array.isArray(scopes)) return [];
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const scope of scopes) {
|
||||||
|
const trimmed = scope.trim();
|
||||||
|
if (trimmed) out.add(trimmed);
|
||||||
|
}
|
||||||
|
return [...out].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopesAllow(requested: string[], allowed: string[]): boolean {
|
||||||
|
if (requested.length === 0) return true;
|
||||||
|
if (allowed.length === 0) return false;
|
||||||
|
const allowedSet = new Set(allowed);
|
||||||
|
return requested.every((scope) => allowedSet.has(scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
function newToken() {
|
||||||
|
return randomUUID().replaceAll("-", "");
|
||||||
|
}
|
||||||
|
|
||||||
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
|
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
|
||||||
const state = await loadState(baseDir);
|
const state = await loadState(baseDir);
|
||||||
const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts);
|
const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts);
|
||||||
@@ -237,6 +283,22 @@ export async function approveDevicePairing(
|
|||||||
const existing = state.pairedByDeviceId[pending.deviceId];
|
const existing = state.pairedByDeviceId[pending.deviceId];
|
||||||
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
|
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
|
||||||
const scopes = mergeScopes(existing?.scopes, pending.scopes);
|
const scopes = mergeScopes(existing?.scopes, pending.scopes);
|
||||||
|
const tokens = existing?.tokens ? { ...existing.tokens } : {};
|
||||||
|
const roleForToken = normalizeRole(pending.role);
|
||||||
|
if (roleForToken) {
|
||||||
|
const nextScopes = normalizeScopes(pending.scopes);
|
||||||
|
const existingToken = tokens[roleForToken];
|
||||||
|
const now = Date.now();
|
||||||
|
tokens[roleForToken] = {
|
||||||
|
token: newToken(),
|
||||||
|
role: roleForToken,
|
||||||
|
scopes: nextScopes,
|
||||||
|
createdAtMs: existingToken?.createdAtMs ?? now,
|
||||||
|
rotatedAtMs: existingToken ? now : undefined,
|
||||||
|
revokedAtMs: undefined,
|
||||||
|
lastUsedAtMs: existingToken?.lastUsedAtMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
const device: PairedDevice = {
|
const device: PairedDevice = {
|
||||||
deviceId: pending.deviceId,
|
deviceId: pending.deviceId,
|
||||||
publicKey: pending.publicKey,
|
publicKey: pending.publicKey,
|
||||||
@@ -248,6 +310,7 @@ export async function approveDevicePairing(
|
|||||||
roles,
|
roles,
|
||||||
scopes,
|
scopes,
|
||||||
remoteIp: pending.remoteIp,
|
remoteIp: pending.remoteIp,
|
||||||
|
tokens,
|
||||||
createdAtMs: existing?.createdAtMs ?? now,
|
createdAtMs: existing?.createdAtMs ?? now,
|
||||||
approvedAtMs: now,
|
approvedAtMs: now,
|
||||||
};
|
};
|
||||||
@@ -296,3 +359,142 @@ export async function updatePairedDeviceMetadata(
|
|||||||
await persistState(state, baseDir);
|
await persistState(state, baseDir);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function summarizeDeviceTokens(
|
||||||
|
tokens: Record<string, DeviceAuthToken> | undefined,
|
||||||
|
): DeviceAuthTokenSummary[] | undefined {
|
||||||
|
if (!tokens) return undefined;
|
||||||
|
const summaries = Object.values(tokens)
|
||||||
|
.map((token) => ({
|
||||||
|
role: token.role,
|
||||||
|
scopes: token.scopes,
|
||||||
|
createdAtMs: token.createdAtMs,
|
||||||
|
rotatedAtMs: token.rotatedAtMs,
|
||||||
|
revokedAtMs: token.revokedAtMs,
|
||||||
|
lastUsedAtMs: token.lastUsedAtMs,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.role.localeCompare(b.role));
|
||||||
|
return summaries.length > 0 ? summaries : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyDeviceToken(params: {
|
||||||
|
deviceId: string;
|
||||||
|
token: string;
|
||||||
|
role: string;
|
||||||
|
scopes: string[];
|
||||||
|
baseDir?: string;
|
||||||
|
}): Promise<{ ok: boolean; reason?: string }> {
|
||||||
|
return await withLock(async () => {
|
||||||
|
const state = await loadState(params.baseDir);
|
||||||
|
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
|
||||||
|
if (!device) return { ok: false, reason: "device-not-paired" };
|
||||||
|
const role = normalizeRole(params.role);
|
||||||
|
if (!role) return { ok: false, reason: "role-missing" };
|
||||||
|
const entry = device.tokens?.[role];
|
||||||
|
if (!entry) return { ok: false, reason: "token-missing" };
|
||||||
|
if (entry.revokedAtMs) return { ok: false, reason: "token-revoked" };
|
||||||
|
if (entry.token !== params.token) return { ok: false, reason: "token-mismatch" };
|
||||||
|
const requestedScopes = normalizeScopes(params.scopes);
|
||||||
|
if (!scopesAllow(requestedScopes, entry.scopes)) {
|
||||||
|
return { ok: false, reason: "scope-mismatch" };
|
||||||
|
}
|
||||||
|
entry.lastUsedAtMs = Date.now();
|
||||||
|
device.tokens = { ...(device.tokens ?? {}), [role]: entry };
|
||||||
|
state.pairedByDeviceId[device.deviceId] = device;
|
||||||
|
await persistState(state, params.baseDir);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDeviceToken(params: {
|
||||||
|
deviceId: string;
|
||||||
|
role: string;
|
||||||
|
scopes: string[];
|
||||||
|
baseDir?: string;
|
||||||
|
}): Promise<DeviceAuthToken | null> {
|
||||||
|
return await withLock(async () => {
|
||||||
|
const state = await loadState(params.baseDir);
|
||||||
|
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
|
||||||
|
if (!device) return null;
|
||||||
|
const role = normalizeRole(params.role);
|
||||||
|
if (!role) return null;
|
||||||
|
const requestedScopes = normalizeScopes(params.scopes);
|
||||||
|
const tokens = device.tokens ? { ...device.tokens } : {};
|
||||||
|
const existing = tokens[role];
|
||||||
|
if (existing && !existing.revokedAtMs) {
|
||||||
|
if (scopesAllow(requestedScopes, existing.scopes)) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const next: DeviceAuthToken = {
|
||||||
|
token: newToken(),
|
||||||
|
role,
|
||||||
|
scopes: requestedScopes,
|
||||||
|
createdAtMs: existing?.createdAtMs ?? now,
|
||||||
|
rotatedAtMs: existing ? now : undefined,
|
||||||
|
revokedAtMs: undefined,
|
||||||
|
lastUsedAtMs: existing?.lastUsedAtMs,
|
||||||
|
};
|
||||||
|
tokens[role] = next;
|
||||||
|
device.tokens = tokens;
|
||||||
|
state.pairedByDeviceId[device.deviceId] = device;
|
||||||
|
await persistState(state, params.baseDir);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateDeviceToken(params: {
|
||||||
|
deviceId: string;
|
||||||
|
role: string;
|
||||||
|
scopes?: string[];
|
||||||
|
baseDir?: string;
|
||||||
|
}): Promise<DeviceAuthToken | null> {
|
||||||
|
return await withLock(async () => {
|
||||||
|
const state = await loadState(params.baseDir);
|
||||||
|
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
|
||||||
|
if (!device) return null;
|
||||||
|
const role = normalizeRole(params.role);
|
||||||
|
if (!role) return null;
|
||||||
|
const tokens = device.tokens ? { ...device.tokens } : {};
|
||||||
|
const existing = tokens[role];
|
||||||
|
const requestedScopes = normalizeScopes(params.scopes ?? existing?.scopes ?? device.scopes);
|
||||||
|
const now = Date.now();
|
||||||
|
const next: DeviceAuthToken = {
|
||||||
|
token: newToken(),
|
||||||
|
role,
|
||||||
|
scopes: requestedScopes,
|
||||||
|
createdAtMs: existing?.createdAtMs ?? now,
|
||||||
|
rotatedAtMs: now,
|
||||||
|
revokedAtMs: undefined,
|
||||||
|
lastUsedAtMs: existing?.lastUsedAtMs,
|
||||||
|
};
|
||||||
|
tokens[role] = next;
|
||||||
|
device.tokens = tokens;
|
||||||
|
state.pairedByDeviceId[device.deviceId] = device;
|
||||||
|
await persistState(state, params.baseDir);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeDeviceToken(params: {
|
||||||
|
deviceId: string;
|
||||||
|
role: string;
|
||||||
|
baseDir?: string;
|
||||||
|
}): Promise<DeviceAuthToken | null> {
|
||||||
|
return await withLock(async () => {
|
||||||
|
const state = await loadState(params.baseDir);
|
||||||
|
const device = state.pairedByDeviceId[normalizeDeviceId(params.deviceId)];
|
||||||
|
if (!device) return null;
|
||||||
|
const role = normalizeRole(params.role);
|
||||||
|
if (!role) return null;
|
||||||
|
if (!device.tokens?.[role]) return null;
|
||||||
|
const tokens = { ...device.tokens };
|
||||||
|
const entry = { ...tokens[role], revokedAtMs: Date.now() };
|
||||||
|
tokens[role] = entry;
|
||||||
|
device.tokens = tokens;
|
||||||
|
state.pairedByDeviceId[device.deviceId] = device;
|
||||||
|
await persistState(state, params.baseDir);
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lookup } from "node:dns/promises";
|
import { lookup } from "node:dns/promises";
|
||||||
|
import { logWarn } from "../logger.js";
|
||||||
|
|
||||||
type CanvasModule = typeof import("@napi-rs/canvas");
|
type CanvasModule = typeof import("@napi-rs/canvas");
|
||||||
type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs");
|
type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||||
@@ -289,7 +290,14 @@ async function extractPdfContent(params: {
|
|||||||
return { text, images: [] };
|
return { text, images: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { createCanvas } = await loadCanvasModule();
|
let canvasModule: CanvasModule;
|
||||||
|
try {
|
||||||
|
canvasModule = await loadCanvasModule();
|
||||||
|
} catch (err) {
|
||||||
|
logWarn(`media: PDF image extraction skipped; ${String(err)}`);
|
||||||
|
return { text, images: [] };
|
||||||
|
}
|
||||||
|
const { createCanvas } = canvasModule;
|
||||||
const images: InputImageContent[] = [];
|
const images: InputImageContent[] = [];
|
||||||
for (let pageNum = 1; pageNum <= maxPages; pageNum += 1) {
|
for (let pageNum = 1; pageNum <= maxPages; pageNum += 1) {
|
||||||
const page = await pdf.getPage(pageNum);
|
const page = await pdf.getPage(pageNum);
|
||||||
|
|||||||
Reference in New Issue
Block a user