Skip to content

Commit c66f3e3

Browse files
committed
fix(gateway): gate talk secret bootstrap handoff
1 parent 8e6ec83 commit c66f3e3

8 files changed

Lines changed: 172 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
1010

1111
### Fixes
1212

13+
- Gateway: require Talk secret authority before setup-code handoff can include Talk secrets.
1314
- Agents/commitments: serialize commitment store load-modify-save writes so concurrent heartbeat and CLI updates no longer lose dismissal, sent, or attempt state. (#81153) Thanks @ai-hpc.
1415
- Gateway/perf: tighten restart and startup benchmark failure handling so long profiling runs, failed probes, and fresh Linux runners no longer produce false passing or `n/a` results.
1516
- Checks: keep intentional Knip unused-file findings optional so full CI and sparse proof workspaces stay aligned.

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
});

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

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,27 @@ type PairingCommandAuthParams = {
77
type PairingCommandAuthState = {
88
isInternalGatewayCaller: boolean;
99
isMissingPairingPrivilege: boolean;
10+
isMissingSetupHandoffPrivilege: boolean;
1011
approvalCallerScopes?: readonly string[];
1112
};
1213

1314
const COMMAND_OWNER_PAIRING_SCOPES = ["operator.pairing"] as const;
15+
const PAIRING_SCOPE = "operator.pairing";
16+
const ADMIN_SCOPE = "operator.admin";
17+
const TALK_SECRETS_SCOPE = "operator.talk.secrets";
1418

1519
function isInternalGatewayPairingCaller(params: PairingCommandAuthParams): boolean {
1620
return params.channel === "webchat" || Array.isArray(params.gatewayClientScopes);
1721
}
1822

23+
function hasPairingPrivilege(scopes: readonly string[]): boolean {
24+
return scopes.includes(PAIRING_SCOPE) || scopes.includes(ADMIN_SCOPE);
25+
}
26+
27+
function hasSetupHandoffPrivilege(scopes: readonly string[]): boolean {
28+
return scopes.includes(TALK_SECRETS_SCOPE) || scopes.includes(ADMIN_SCOPE);
29+
}
30+
1931
export function resolvePairingCommandAuthState(
2032
params: PairingCommandAuthParams,
2133
): PairingCommandAuthState {
@@ -24,13 +36,10 @@ export function resolvePairingCommandAuthState(
2436
const approvalCallerScopes = Array.isArray(params.gatewayClientScopes)
2537
? params.gatewayClientScopes
2638
: [];
27-
const isMissingPairingPrivilege =
28-
!approvalCallerScopes.includes("operator.pairing") &&
29-
!approvalCallerScopes.includes("operator.admin");
30-
3139
return {
3240
isInternalGatewayCaller,
33-
isMissingPairingPrivilege,
41+
isMissingPairingPrivilege: !hasPairingPrivilege(approvalCallerScopes),
42+
isMissingSetupHandoffPrivilege: !hasSetupHandoffPrivilege(approvalCallerScopes),
3443
approvalCallerScopes,
3544
};
3645
}
@@ -39,13 +48,15 @@ export function resolvePairingCommandAuthState(
3948
return {
4049
isInternalGatewayCaller,
4150
isMissingPairingPrivilege: false,
51+
isMissingSetupHandoffPrivilege: false,
4252
approvalCallerScopes: COMMAND_OWNER_PAIRING_SCOPES,
4353
};
4454
}
4555

4656
return {
4757
isInternalGatewayCaller,
4858
isMissingPairingPrivilege: true,
59+
isMissingSetupHandoffPrivilege: true,
4960
approvalCallerScopes: undefined,
5061
};
5162
}
@@ -55,3 +66,9 @@ export function buildMissingPairingScopeReply(): { text: string } {
5566
text: "⚠️ This command requires operator.pairing.",
5667
};
5768
}
69+
70+
export function buildMissingSetupHandoffScopeReply(): { text: string } {
71+
return {
72+
text: "⚠️ Setup code handoff includes Talk secrets and requires operator.talk.secrets.",
73+
};
74+
}

0 commit comments

Comments
 (0)