Skip to content

Commit c791e42

Browse files
authored
fix(gateway): gate talk secret bootstrap handoff (#85690)
Merged via squash. Prepared head SHA: 9247cda Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman
1 parent 35dcd42 commit c791e42

14 files changed

Lines changed: 218 additions & 77 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ Docs: https://docs.openclaw.ai
3535
- Google Vertex: support production ADC modes such as Workload Identity Federation, service-account credentials, and metadata-server ADC for the native Vertex transport. (#83971) Thanks @damianFelixPago.
3636
- Telegram: route normal `[telegram][diag]` polling diagnostics through `runtime.log` while keeping non-diag warnings and persistence failures on `runtime.error`, so healthy polling startup no longer looks like an error. Fixes #82957. (#82958) Thanks @galiniliev.
3737

38+
- Gateway: require Talk secret authority before setup-code handoff can include Talk secrets. (#85690) Thanks @ngutman.
39+
3840
## 2026.5.25
3941

4042
### Fixes

docs/cli/qr.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ openclaw qr --url wss://gateway.example/ws
3636
- `--token` and `--password` are mutually exclusive.
3737
- The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password.
3838
- Built-in setup-code bootstrap returns a primary `node` token with `scopes: []` plus a bounded `operator` handoff token for trusted mobile onboarding.
39-
- The handed-off operator token is limited to `operator.approvals`, `operator.read`, and `operator.write`; `operator.admin`, `operator.pairing`, and `operator.talk.secrets` require a separate approved operator pairing or token flow.
39+
- The handed-off operator token is limited to `operator.approvals`, `operator.read`, `operator.talk.secrets`, and `operator.write`; `operator.admin` and `operator.pairing` require a separate approved operator pairing or token flow.
4040
- Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN addresses and `.local` Bonjour hosts remain supported over `ws://`, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL.
4141
- With `--remote`, OpenClaw requires either `gateway.remote.url` or
4242
`gateway.tailscale.mode=serve|funnel`.

docs/gateway/protocol.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -161,17 +161,19 @@ operator token:
161161
{
162162
"deviceToken": "",
163163
"role": "operator",
164-
"scopes": ["operator.approvals", "operator.read", "operator.write"]
164+
"scopes": ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"]
165165
}
166166
]
167167
}
168168
}
169169
```
170170

171171
The operator handoff is intentionally bounded so QR onboarding can start the
172-
mobile operator loop without granting `operator.admin`, `operator.pairing`, or
173-
`operator.talk.secrets`. Those scopes require a separate approved operator
174-
pairing or token flow. Clients should persist `hello-ok.auth.deviceTokens` only
172+
mobile operator loop without granting `operator.admin` or `operator.pairing`.
173+
It does include `operator.talk.secrets` so the native client can read the Talk
174+
configuration it needs after bootstrap. Broader admin and pairing scopes require
175+
a separate approved operator pairing or token flow. Clients should persist
176+
`hello-ok.auth.deviceTokens` only
175177
when the connect used bootstrap auth on trusted transport such as `wss://` or
176178
loopback/local pairing.
177179

@@ -705,7 +707,8 @@ rather than the pre-handshake defaults.
705707
- Built-in setup-code bootstrap returns the primary node
706708
`hello-ok.auth.deviceToken` plus a bounded operator token in
707709
`hello-ok.auth.deviceTokens` for trusted mobile handoff. The operator token
708-
excludes `operator.admin`, `operator.pairing`, and `operator.talk.secrets`.
710+
includes `operator.talk.secrets` for native Talk configuration reads and
711+
excludes `operator.admin` and `operator.pairing`.
709712
- While a non-baseline setup-code bootstrap is waiting for approval, `PAIRING_REQUIRED`
710713
details include `recommendedNextStep: "wait_then_retry"`, `retryable: true`,
711714
and `pauseReconnect: false`. Clients should keep reconnecting with the same

extensions/device-pair/index.test.ts

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ type ApprovedPairingResult = Extract<
8989
>;
9090
type ApprovedPairingDevice = ApprovedPairingResult["device"];
9191
const INTERNAL_PAIRING_SCOPES = ["operator.write", "operator.pairing"];
92+
const INTERNAL_SETUP_SCOPES = [...INTERNAL_PAIRING_SCOPES, "operator.talk.secrets"];
9293

9394
function createApi(params?: {
9495
config?: OpenClawPluginApi["config"];
@@ -286,7 +287,7 @@ describe("device-pair /pair qr", () => {
286287
const result = await command.handler(
287288
createCommandContext({
288289
channel: "webchat",
289-
gatewayClientScopes: ["operator.write", "operator.pairing"],
290+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
290291
}),
291292
);
292293
const payload = result as { text?: string; mediaUrl?: string; sensitiveMedia?: boolean };
@@ -342,6 +343,23 @@ describe("device-pair /pair qr", () => {
342343
});
343344
});
344345

346+
it("rejects qr setup for internal callers without Talk secret scope", async () => {
347+
const command = registerPairCommand();
348+
const result = await command.handler(
349+
createCommandContext({
350+
channel: "webchat",
351+
args: "qr",
352+
commandBody: "/pair qr",
353+
gatewayClientScopes: INTERNAL_PAIRING_SCOPES,
354+
}),
355+
);
356+
357+
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
358+
expect(result).toEqual({
359+
text: "⚠️ Setup code handoff includes Talk secrets and requires operator.talk.secrets.",
360+
});
361+
});
362+
345363
it("reissues the bootstrap token if webchat QR rendering fails before falling back", async () => {
346364
pluginApiMocks.issueDeviceBootstrapToken
347365
.mockResolvedValueOnce({
@@ -358,7 +376,7 @@ describe("device-pair /pair qr", () => {
358376
const result = await command.handler(
359377
createCommandContext({
360378
channel: "webchat",
361-
gatewayClientScopes: ["operator.write", "operator.pairing"],
379+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
362380
}),
363381
);
364382
const text = requireText(result);
@@ -478,7 +496,7 @@ describe("device-pair /pair qr", () => {
478496
const result = await command.handler(
479497
createCommandContext({
480498
...testCase.ctx,
481-
gatewayClientScopes: INTERNAL_PAIRING_SCOPES,
499+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
482500
}),
483501
);
484502
const text = requireText(result);
@@ -538,7 +556,7 @@ describe("device-pair /pair qr", () => {
538556
createCommandContext({
539557
channel: "discord",
540558
senderId: "123",
541-
gatewayClientScopes: INTERNAL_PAIRING_SCOPES,
559+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
542560
}),
543561
);
544562
const text = requireText(result);
@@ -557,7 +575,7 @@ describe("device-pair /pair qr", () => {
557575
createCommandContext({
558576
channel: "msteams",
559577
senderId: "8:orgid:123",
560-
gatewayClientScopes: INTERNAL_PAIRING_SCOPES,
578+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
561579
}),
562580
);
563581
const text = requireText(result);
@@ -678,6 +696,23 @@ describe("device-pair /pair default setup code", () => {
678696
});
679697
});
680698

699+
it("rejects setup code issuance for internal callers without Talk secret scope", async () => {
700+
const command = registerPairCommand();
701+
const result = await command.handler(
702+
createCommandContext({
703+
channel: "webchat",
704+
args: "",
705+
commandBody: "/pair",
706+
gatewayClientScopes: INTERNAL_PAIRING_SCOPES,
707+
}),
708+
);
709+
710+
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
711+
expect(result).toEqual({
712+
text: "⚠️ Setup code handoff includes Talk secrets and requires operator.talk.secrets.",
713+
});
714+
});
715+
681716
it("fails closed for webchat setup code issuance when scopes are absent", async () => {
682717
const command = registerPairCommand();
683718
const result = await command.handler(
@@ -749,7 +784,7 @@ describe("device-pair /pair default setup code", () => {
749784
channel: "webchat",
750785
args: "",
751786
commandBody: "/pair",
752-
gatewayClientScopes: ["operator.write", "operator.pairing"],
787+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
753788
}),
754789
);
755790
const text = requireText(result);
@@ -769,7 +804,7 @@ describe("device-pair /pair default setup code", () => {
769804
channel: "webchat",
770805
args: "",
771806
commandBody: "/pair",
772-
gatewayClientScopes: ["operator.write", "operator.pairing"],
807+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
773808
}),
774809
);
775810
const text = requireText(result);
@@ -789,7 +824,7 @@ describe("device-pair /pair default setup code", () => {
789824
channel: "webchat",
790825
args: "",
791826
commandBody: "/pair",
792-
gatewayClientScopes: ["operator.write", "operator.pairing"],
827+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
793828
}),
794829
);
795830

@@ -808,7 +843,7 @@ describe("device-pair /pair default setup code", () => {
808843
channel: "webchat",
809844
args: "",
810845
commandBody: "/pair",
811-
gatewayClientScopes: ["operator.write", "operator.pairing"],
846+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
812847
}),
813848
);
814849

@@ -827,7 +862,7 @@ describe("device-pair /pair default setup code", () => {
827862
channel: "webchat",
828863
args: "",
829864
commandBody: "/pair",
830-
gatewayClientScopes: ["operator.write", "operator.pairing"],
865+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
831866
}),
832867
);
833868

@@ -861,7 +896,7 @@ describe("device-pair /pair default setup code", () => {
861896
channel: "webchat",
862897
args: "",
863898
commandBody: "/pair",
864-
gatewayClientScopes: ["operator.write", "operator.pairing"],
899+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
865900
}),
866901
);
867902

@@ -890,7 +925,7 @@ describe("device-pair /pair default setup code", () => {
890925
channel: "webchat",
891926
args: "",
892927
commandBody: "/pair",
893-
gatewayClientScopes: ["operator.write", "operator.pairing"],
928+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
894929
}),
895930
);
896931
const text = requireText(result);
@@ -910,7 +945,7 @@ describe("device-pair /pair default setup code", () => {
910945
channel: "webchat",
911946
args: "",
912947
commandBody: "/pair",
913-
gatewayClientScopes: ["operator.write", "operator.pairing"],
948+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
914949
}),
915950
);
916951

@@ -940,7 +975,7 @@ describe("device-pair /pair default setup code", () => {
940975
channel: "webchat",
941976
args: "",
942977
commandBody: "/pair",
943-
gatewayClientScopes: ["operator.write", "operator.pairing"],
978+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
944979
}),
945980
);
946981

@@ -967,7 +1002,7 @@ describe("device-pair /pair default setup code", () => {
9671002
channel: "webchat",
9681003
args: "",
9691004
commandBody: "/pair",
970-
gatewayClientScopes: ["operator.write", "operator.pairing"],
1005+
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
9711006
}),
9721007
);
9731008

extensions/device-pair/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -672,8 +672,11 @@ export default definePluginEntry({
672672
const gatewayClientScopes = Array.isArray(ctx.gatewayClientScopes)
673673
? ctx.gatewayClientScopes
674674
: undefined;
675-
const { buildMissingPairingScopeReply, resolvePairingCommandAuthState } =
676-
await loadPairCommandAuthModule();
675+
const {
676+
buildMissingPairingScopeReply,
677+
buildMissingSetupHandoffScopeReply,
678+
resolvePairingCommandAuthState,
679+
} = await loadPairCommandAuthModule();
677680
const authState = resolvePairingCommandAuthState({
678681
channel: ctx.channel,
679682
gatewayClientScopes,
@@ -742,6 +745,10 @@ export default definePluginEntry({
742745
};
743746
}
744747

748+
if (authState.isMissingSetupHandoffPrivilege) {
749+
return buildMissingSetupHandoffScopeReply();
750+
}
751+
745752
const authLabelResult = resolveAuthLabel(api.config);
746753
if (authLabelResult.error) {
747754
return { text: `Error: ${authLabelResult.error}` };

extensions/device-pair/pair-command-auth.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ describe("device-pair pairing command auth", () => {
1111
).toEqual({
1212
isInternalGatewayCaller: false,
1313
isMissingPairingPrivilege: true,
14+
isMissingSetupHandoffPrivilege: true,
1415
approvalCallerScopes: undefined,
1516
});
1617
});
@@ -25,6 +26,7 @@ describe("device-pair pairing command auth", () => {
2526
).toEqual({
2627
isInternalGatewayCaller: false,
2728
isMissingPairingPrivilege: false,
29+
isMissingSetupHandoffPrivilege: false,
2830
approvalCallerScopes: ["operator.pairing"],
2931
});
3032
});
@@ -38,11 +40,12 @@ describe("device-pair pairing command auth", () => {
3840
).toEqual({
3941
isInternalGatewayCaller: true,
4042
isMissingPairingPrivilege: true,
43+
isMissingSetupHandoffPrivilege: true,
4144
approvalCallerScopes: [],
4245
});
4346
});
4447

45-
it("accepts pairing and admin scopes for internal callers", () => {
48+
it("tracks pairing and setup-handoff privileges independently for internal callers", () => {
4649
expect(
4750
resolvePairingCommandAuthState({
4851
channel: "webchat",
@@ -51,8 +54,20 @@ describe("device-pair pairing command auth", () => {
5154
).toEqual({
5255
isInternalGatewayCaller: true,
5356
isMissingPairingPrivilege: false,
57+
isMissingSetupHandoffPrivilege: true,
5458
approvalCallerScopes: ["operator.write", "operator.pairing"],
5559
});
60+
expect(
61+
resolvePairingCommandAuthState({
62+
channel: "webchat",
63+
gatewayClientScopes: ["operator.write", "operator.pairing", "operator.talk.secrets"],
64+
}),
65+
).toEqual({
66+
isInternalGatewayCaller: true,
67+
isMissingPairingPrivilege: false,
68+
isMissingSetupHandoffPrivilege: false,
69+
approvalCallerScopes: ["operator.write", "operator.pairing", "operator.talk.secrets"],
70+
});
5671
expect(
5772
resolvePairingCommandAuthState({
5873
channel: "webchat",
@@ -61,6 +76,7 @@ describe("device-pair pairing command auth", () => {
6176
).toEqual({
6277
isInternalGatewayCaller: true,
6378
isMissingPairingPrivilege: false,
79+
isMissingSetupHandoffPrivilege: false,
6480
approvalCallerScopes: ["operator.admin"],
6581
});
6682
});
@@ -75,6 +91,7 @@ describe("device-pair pairing command auth", () => {
7591
).toEqual({
7692
isInternalGatewayCaller: true,
7793
isMissingPairingPrivilege: false,
94+
isMissingSetupHandoffPrivilege: true,
7895
approvalCallerScopes: ["operator.write", "operator.pairing"],
7996
});
8097
});

0 commit comments

Comments
 (0)