Skip to content

Commit 67d2026

Browse files
committed
feat(cli): show pairing access upgrades
1 parent 9de39ac commit 67d2026

3 files changed

Lines changed: 298 additions & 54 deletions

File tree

docs/cli/devices.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ openclaw devices list
2121
openclaw devices list --json
2222
```
2323

24-
Pending request output includes the requested role and scopes so approvals can
25-
be reviewed before you approve.
24+
Pending request output shows the requested access next to the device's current
25+
approved access when the device is already paired. This makes scope/role
26+
upgrades explicit instead of looking like the pairing was lost.
2627

2728
### `openclaw devices remove <deviceId>`
2829

@@ -59,6 +60,12 @@ key), OpenClaw supersedes the previous pending entry and issues a new
5960
`requestId`. Run `openclaw devices list` right before approval to use the
6061
current ID.
6162

63+
If the device is already paired and asks for broader scopes or a broader role,
64+
OpenClaw keeps the existing approval in place and creates a new pending upgrade
65+
request. Review the `Requested` vs `Approved` columns in `openclaw devices list`
66+
or use `openclaw devices approve --latest` to preview the exact upgrade before
67+
approving it.
68+
6269
```
6370
openclaw devices approve
6471
openclaw devices approve <requestId>

src/cli/devices-cli.test.ts

Lines changed: 155 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ describe("devices cli approve", () => {
9797
ts: 1000,
9898
},
9999
],
100+
paired: [
101+
{
102+
deviceId: "device-9",
103+
displayName: "Device Nine",
104+
roles: ["operator"],
105+
scopes: ["operator.read"],
106+
},
107+
],
100108
});
101109

102110
await runDevicesApprove([]);
@@ -108,6 +116,8 @@ describe("devices cli approve", () => {
108116
const logOutput = runtime.log.mock.calls.map((c) => readRuntimeCallText(c)).join("\n");
109117
expect(logOutput).toContain("req-abc");
110118
expect(logOutput).toContain("Device Nine");
119+
expect(logOutput).toContain("Approved: roles: operator; scopes: operator.read");
120+
expect(logOutput).toContain("Requested scopes exceed the current approval");
111121
expect(runtime.error).toHaveBeenCalledWith(
112122
expect.stringContaining("openclaw devices approve req-abc"),
113123
);
@@ -117,6 +127,36 @@ describe("devices cli approve", () => {
117127
);
118128
});
119129

130+
it("sanitizes preview ip output for implicit approval", async () => {
131+
callGateway.mockResolvedValueOnce({
132+
pending: [
133+
{
134+
requestId: "req-abc",
135+
deviceId: "device-9",
136+
displayName: "Device Nine",
137+
role: "operator",
138+
scopes: ["operator.admin"],
139+
remoteIp: "10.0.0.9\rspoof",
140+
ts: 1000,
141+
},
142+
],
143+
paired: [
144+
{
145+
deviceId: "device-9",
146+
displayName: "Device Nine",
147+
roles: ["operator"],
148+
scopes: ["operator.read"],
149+
},
150+
],
151+
});
152+
153+
await runDevicesApprove([]);
154+
155+
const logOutput = runtime.log.mock.calls.map((c) => readRuntimeCallText(c)).join("\n");
156+
expect(logOutput).not.toContain("\r");
157+
expect(logOutput).toContain("IP: 10.0.0.9spoof");
158+
});
159+
120160
it.each([
121161
{
122162
name: "id is omitted",
@@ -208,6 +248,7 @@ describe("devices cli approve", () => {
208248
it("returns JSON for implicit approval preview in JSON mode", async () => {
209249
callGateway.mockResolvedValueOnce({
210250
pending: [{ requestId: "req-json", deviceId: "device-json", ts: 1000 }],
251+
paired: [],
211252
});
212253

213254
await runDevicesApprove(["--latest", "--json", "--url", "ws://gateway.example:18789"]);
@@ -216,6 +257,11 @@ describe("devices cli approve", () => {
216257
expect(runtime.error).not.toHaveBeenCalled();
217258
expect(runtime.writeJson).toHaveBeenCalledWith({
218259
selected: { requestId: "req-json", deviceId: "device-json", ts: 1000 },
260+
approvalState: {
261+
kind: "new-pairing",
262+
requested: { roles: [], scopes: [] },
263+
approved: null,
264+
},
219265
approveCommand: "openclaw devices approve req-json --url ws://gateway.example:18789 --json",
220266
requiresAuthFlags: {
221267
token: false,
@@ -404,7 +450,7 @@ describe("devices cli local fallback", () => {
404450
});
405451

406452
describe("devices cli list", () => {
407-
it("renders pending scopes when present", async () => {
453+
it("renders requested versus approved access for pending upgrades", async () => {
408454
callGateway.mockResolvedValueOnce({
409455
pending: [
410456
{
@@ -416,14 +462,119 @@ describe("devices cli list", () => {
416462
ts: 1,
417463
},
418464
],
419-
paired: [],
465+
paired: [
466+
{
467+
deviceId: "device-1",
468+
displayName: "Device One",
469+
roles: ["operator"],
470+
scopes: ["operator.read"],
471+
},
472+
],
473+
});
474+
475+
await runDevicesCommand(["list"]);
476+
477+
const output = runtime.log.mock.calls.map((entry) => readRuntimeCallText(entry)).join("\n");
478+
expect(output).toContain("Requested");
479+
expect(output).toContain("Approved");
480+
expect(output).toContain("operator.write");
481+
expect(output).toContain("operator.read");
482+
expect(output).toContain("scope upgrade");
483+
});
484+
485+
it("normalizes pending device ids before matching paired approvals", async () => {
486+
callGateway.mockResolvedValueOnce({
487+
pending: [
488+
{
489+
requestId: "req-1",
490+
deviceId: " device-1 ",
491+
displayName: "Device One",
492+
role: "operator",
493+
scopes: ["operator.admin"],
494+
ts: 1,
495+
},
496+
],
497+
paired: [
498+
{
499+
deviceId: "device-1",
500+
displayName: "Device One",
501+
roles: ["operator"],
502+
scopes: ["operator.read"],
503+
},
504+
],
505+
});
506+
507+
await runDevicesCommand(["list"]);
508+
509+
const output = runtime.log.mock.calls.map((entry) => readRuntimeCallText(entry)).join("\n");
510+
expect(output).toContain("scope upgrade");
511+
expect(output).toContain("operator.read");
512+
});
513+
514+
it("does not show upgrade context for key-mismatched pending requests", async () => {
515+
callGateway.mockResolvedValueOnce({
516+
pending: [
517+
{
518+
requestId: "req-1",
519+
deviceId: "device-1",
520+
publicKey: "new-key",
521+
displayName: "Device One",
522+
role: "operator",
523+
scopes: ["operator.admin"],
524+
ts: 1,
525+
},
526+
],
527+
paired: [
528+
{
529+
deviceId: "device-1",
530+
publicKey: "old-key",
531+
displayName: "Device One",
532+
roles: ["operator"],
533+
scopes: ["operator.read"],
534+
},
535+
],
536+
});
537+
538+
await runDevicesCommand(["list"]);
539+
540+
const output = runtime.log.mock.calls.map((entry) => readRuntimeCallText(entry)).join("\n");
541+
expect(output).toContain("new pairing");
542+
expect(output).not.toContain("scope upgrade");
543+
expect(output).not.toContain("roles: operator; scopes: operator.read");
544+
});
545+
546+
it("sanitizes device-controlled terminal output", async () => {
547+
callGateway.mockResolvedValueOnce({
548+
pending: [
549+
{
550+
requestId: "req-1",
551+
deviceId: "device-1",
552+
displayName: "Bad\u001b[2J\nName",
553+
role: "operator",
554+
scopes: ["operator.admin"],
555+
remoteIp: "10.0.0.9\rspoof",
556+
ts: 1,
557+
},
558+
],
559+
paired: [
560+
{
561+
deviceId: "device-1",
562+
displayName: "Pair\u001b]8;;https://evil.example\u001b\\ed",
563+
roles: ["operator"],
564+
scopes: ["operator.read"],
565+
remoteIp: "10.0.0.1\u007f",
566+
},
567+
],
420568
});
421569

422570
await runDevicesCommand(["list"]);
423571

424572
const output = runtime.log.mock.calls.map((entry) => readRuntimeCallText(entry)).join("\n");
425-
expect(output).toContain("Scopes");
426-
expect(output).toContain("operator.admin, operator.read");
573+
expect(output).not.toContain("\u001b");
574+
expect(output).not.toContain("\r");
575+
expect(output).toContain("BadName");
576+
expect(output).toContain("spoof");
577+
expect(output).toContain("Paired");
427578
});
428579
});
429580

0 commit comments

Comments
 (0)