Skip to content

Commit 69fe999

Browse files
authored
fix(pairing): restore qr bootstrap onboarding handoff (#58382) (thanks @ngutman)
* fix(pairing): restore qr bootstrap onboarding handoff * fix(pairing): tighten bootstrap handoff follow-ups * fix(pairing): migrate legacy gateway device auth * fix(pairing): narrow qr bootstrap handoff scope * fix(pairing): clear ios tls trust on onboarding reset * fix(pairing): restore qr bootstrap onboarding handoff (#58382) (thanks @ngutman)
1 parent 693d17c commit 69fe999

15 files changed

Lines changed: 694 additions & 48 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ Docs: https://docs.openclaw.ai
9696
- Gateway/auth: reject mismatched browser `Origin` headers on trusted-proxy HTTP operator requests while keeping origin-less headless proxy clients working. Thanks @AntAISecurityLab and @vincentkoc.
9797
- Gateway/device tokens: disconnect active device sessions after token rotation so newly rotated credentials revoke existing live connections immediately instead of waiting for those sockets to close naturally. Thanks @zsxsoft and @vincentkoc.
9898
- Gateway/health: carry webhook-vs-polling account mode from channel descriptors into runtime snapshots so passive channels like LINE and BlueBubbles skip false stale-socket health failures. (#47488) Thanks @karesansui-u.
99+
- Gateway/pairing: restore QR bootstrap onboarding handoff so fresh `/pair qr` iPhone setup can auto-approve the initial node pairing, receive a reusable node device token, and stop retrying with spent bootstrap auth. (#58382) Thanks @ngutman.
99100
- Gateway/OpenAI compatibility: accept flat Responses API function tool definitions on `/v1/responses` and preserve `strict` when normalizing hosted tools into the embedded runner, so spec-compliant clients like Codex no longer fail validation or silently lose strict tool enforcement. Thanks @malaiwah and @vincentkoc.
100101
- Gateway/OpenAI HTTP: restore default operator scopes for bearer-authenticated requests that omit `x-openclaw-scopes`, so headless `/v1/chat/completions` and session-history callers work again after the recent method-scope hardening. (#57596) Thanks @openperf.
101102
- Gateway/plugins: scope plugin-auth HTTP route runtime clients to read-only access and keep gateway-authenticated plugin routes on write scope, so plugin-owned webhook handlers do not inherit write-capable runtime access by default. Thanks @davidluzsilva and @vincentkoc.

apps/ios/Sources/Gateway/GatewaySettingsStore.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ enum GatewaySettingsStore {
6969
account: self.preferredGatewayStableIDAccount)
7070
}
7171

72+
static func clearPreferredGatewayStableID(defaults: UserDefaults = .standard) {
73+
_ = KeychainStore.delete(
74+
service: self.gatewayService,
75+
account: self.preferredGatewayStableIDAccount)
76+
defaults.removeObject(forKey: self.preferredGatewayStableIDDefaultsKey)
77+
}
78+
7279
static func loadLastDiscoveredGatewayStableID() -> String? {
7380
if let value = KeychainStore.loadString(
7481
service: self.gatewayService,
@@ -89,6 +96,13 @@ enum GatewaySettingsStore {
8996
account: self.lastDiscoveredGatewayStableIDAccount)
9097
}
9198

99+
static func clearLastDiscoveredGatewayStableID(defaults: UserDefaults = .standard) {
100+
_ = KeychainStore.delete(
101+
service: self.gatewayService,
102+
account: self.lastDiscoveredGatewayStableIDAccount)
103+
defaults.removeObject(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
104+
}
105+
92106
static func loadGatewayToken(instanceId: String) -> String? {
93107
let account = self.gatewayTokenAccount(instanceId: instanceId)
94108
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
@@ -119,6 +133,12 @@ enum GatewaySettingsStore {
119133
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
120134
}
121135

136+
static func clearGatewayBootstrapToken(instanceId: String) {
137+
_ = KeychainStore.delete(
138+
service: self.gatewayService,
139+
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
140+
}
141+
122142
static func loadGatewayPassword(instanceId: String) -> String? {
123143
KeychainStore.loadString(
124144
service: self.gatewayService,

apps/ios/Sources/Model/NodeAppModel.swift

Lines changed: 149 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1697,14 +1697,24 @@ extension NodeAppModel {
16971697
password: password,
16981698
nodeOptions: connectOptions)
16991699
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
1700-
self.startOperatorGatewayLoop(
1701-
url: url,
1702-
stableID: effectiveStableID,
1700+
if self.shouldStartOperatorGatewayLoop(
17031701
token: token,
17041702
bootstrapToken: bootstrapToken,
17051703
password: password,
1706-
nodeOptions: connectOptions,
1707-
sessionBox: sessionBox)
1704+
stableID: effectiveStableID)
1705+
{
1706+
self.startOperatorGatewayLoop(
1707+
url: url,
1708+
stableID: effectiveStableID,
1709+
token: token,
1710+
bootstrapToken: bootstrapToken,
1711+
password: password,
1712+
nodeOptions: connectOptions,
1713+
sessionBox: sessionBox)
1714+
} else {
1715+
self.operatorGatewayTask = nil
1716+
Task { await self.operatorGateway.disconnect() }
1717+
}
17081718
self.startNodeGatewayLoop(
17091719
url: url,
17101720
stableID: effectiveStableID,
@@ -1785,6 +1795,86 @@ private extension NodeAppModel {
17851795
self.apnsLastRegisteredTokenHex = nil
17861796
}
17871797

1798+
func shouldStartOperatorGatewayLoop(
1799+
token: String?,
1800+
bootstrapToken: String?,
1801+
password: String?,
1802+
stableID _: String) -> Bool
1803+
{
1804+
Self.shouldStartOperatorGatewayLoop(
1805+
token: token,
1806+
bootstrapToken: bootstrapToken,
1807+
password: password,
1808+
hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator"))
1809+
}
1810+
1811+
func hasStoredGatewayRoleToken(_ role: String) -> Bool {
1812+
let identity = DeviceIdentityStore.loadOrCreate()
1813+
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
1814+
}
1815+
1816+
static func shouldStartOperatorGatewayLoop(
1817+
token: String?,
1818+
bootstrapToken: String?,
1819+
password: String?,
1820+
hasStoredOperatorToken: Bool) -> Bool
1821+
{
1822+
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1823+
if !trimmedToken.isEmpty {
1824+
return true
1825+
}
1826+
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1827+
if !trimmedPassword.isEmpty {
1828+
return true
1829+
}
1830+
let trimmedBootstrapToken = bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1831+
if !trimmedBootstrapToken.isEmpty {
1832+
return false
1833+
}
1834+
return hasStoredOperatorToken
1835+
}
1836+
1837+
static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
1838+
guard let config else { return nil }
1839+
let trimmedBootstrapToken = config.bootstrapToken?
1840+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1841+
guard !trimmedBootstrapToken.isEmpty else { return config }
1842+
return GatewayConnectConfig(
1843+
url: config.url,
1844+
stableID: config.stableID,
1845+
tls: config.tls,
1846+
token: config.token,
1847+
bootstrapToken: nil,
1848+
password: config.password,
1849+
nodeOptions: config.nodeOptions)
1850+
}
1851+
1852+
func currentGatewayReconnectAuth(
1853+
fallbackToken: String?,
1854+
fallbackBootstrapToken: String?,
1855+
fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?)
1856+
{
1857+
if let cfg = self.activeGatewayConnectConfig {
1858+
return (cfg.token, cfg.bootstrapToken, cfg.password)
1859+
}
1860+
return (fallbackToken, fallbackBootstrapToken, fallbackPassword)
1861+
}
1862+
1863+
func clearPersistedGatewayBootstrapTokenIfNeeded() {
1864+
// Always drop the in-memory bootstrap token after the first successful
1865+
// bootstrap connect so reconnect loops cannot reuse a spent token.
1866+
self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig)
1867+
1868+
let trimmedInstanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
1869+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1870+
guard !trimmedInstanceId.isEmpty else { return }
1871+
guard
1872+
GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: trimmedInstanceId) != nil
1873+
else { return }
1874+
1875+
GatewaySettingsStore.clearGatewayBootstrapToken(instanceId: trimmedInstanceId)
1876+
}
1877+
17881878
func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
17891879
guard self.isBackgrounded else { return }
17901880
guard !self.backgroundReconnectSuppressed else { return }
@@ -1841,11 +1931,15 @@ private extension NodeAppModel {
18411931
displayName: nodeOptions.clientDisplayName)
18421932

18431933
do {
1934+
let reconnectAuth = self.currentGatewayReconnectAuth(
1935+
fallbackToken: token,
1936+
fallbackBootstrapToken: bootstrapToken,
1937+
fallbackPassword: password)
18441938
try await self.operatorGateway.connect(
18451939
url: url,
1846-
token: token,
1847-
bootstrapToken: bootstrapToken,
1848-
password: password,
1940+
token: reconnectAuth.token,
1941+
bootstrapToken: reconnectAuth.bootstrapToken,
1942+
password: reconnectAuth.password,
18491943
connectOptions: operatorOptions,
18501944
sessionBox: sessionBox,
18511945
onConnected: { [weak self] in
@@ -1948,12 +2042,16 @@ private extension NodeAppModel {
19482042

19492043
do {
19502044
let epochMs = Int(Date().timeIntervalSince1970 * 1000)
2045+
let reconnectAuth = self.currentGatewayReconnectAuth(
2046+
fallbackToken: token,
2047+
fallbackBootstrapToken: bootstrapToken,
2048+
fallbackPassword: password)
19512049
GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)")
19522050
try await self.nodeGateway.connect(
19532051
url: url,
1954-
token: token,
1955-
bootstrapToken: bootstrapToken,
1956-
password: password,
2052+
token: reconnectAuth.token,
2053+
bootstrapToken: reconnectAuth.bootstrapToken,
2054+
password: reconnectAuth.password,
19572055
connectOptions: currentOptions,
19582056
sessionBox: sessionBox,
19592057
onConnected: { [weak self] in
@@ -1965,6 +2063,30 @@ private extension NodeAppModel {
19652063
self.screen.errorText = nil
19662064
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
19672065
}
2066+
let usedBootstrapToken =
2067+
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
2068+
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
2069+
.isEmpty == false
2070+
if usedBootstrapToken {
2071+
await MainActor.run {
2072+
self.clearPersistedGatewayBootstrapTokenIfNeeded()
2073+
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
2074+
token: reconnectAuth.token,
2075+
bootstrapToken: nil,
2076+
password: reconnectAuth.password,
2077+
stableID: stableID)
2078+
{
2079+
self.startOperatorGatewayLoop(
2080+
url: url,
2081+
stableID: stableID,
2082+
token: reconnectAuth.token,
2083+
bootstrapToken: nil,
2084+
password: reconnectAuth.password,
2085+
nodeOptions: currentOptions,
2086+
sessionBox: sessionBox)
2087+
}
2088+
}
2089+
}
19682090
let relayData = await MainActor.run {
19692091
(
19702092
sessionKey: self.mainSessionKey,
@@ -1975,8 +2097,8 @@ private extension NodeAppModel {
19752097
ShareGatewayRelaySettings.saveConfig(
19762098
ShareGatewayRelayConfig(
19772099
gatewayURLString: url.absoluteString,
1978-
token: token,
1979-
password: password,
2100+
token: reconnectAuth.token,
2101+
password: reconnectAuth.password,
19802102
sessionKey: relayData.sessionKey,
19812103
deliveryChannel: relayData.deliveryChannel,
19822104
deliveryTo: relayData.deliveryTo))
@@ -3015,6 +3137,20 @@ extension NodeAppModel {
30153137
static func _test_currentDeepLinkKey() -> String {
30163138
self.expectedDeepLinkKey()
30173139
}
3140+
3141+
static func _test_shouldStartOperatorGatewayLoop(
3142+
token: String?,
3143+
bootstrapToken: String?,
3144+
password: String?,
3145+
hasStoredOperatorToken: Bool) -> Bool
3146+
{
3147+
self.shouldStartOperatorGatewayLoop(
3148+
token: token,
3149+
bootstrapToken: bootstrapToken,
3150+
password: password,
3151+
hasStoredOperatorToken: hasStoredOperatorToken)
3152+
}
3153+
30183154
}
30193155
#endif
30203156
// swiftlint:enable type_body_length file_length

apps/ios/Sources/Settings/SettingsTab.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,11 @@ struct SettingsTab: View {
10081008

10091009
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
10101010
GatewaySettingsStore.clearLastGatewayConnection()
1011+
GatewaySettingsStore.clearPreferredGatewayStableID()
1012+
GatewaySettingsStore.clearLastDiscoveredGatewayStableID()
1013+
// Resetting onboarding should also forget trusted gateway TLS fingerprints.
1014+
// Otherwise a restarted dev gateway can stay stuck in a local TLS cancel loop.
1015+
GatewayTLSStore.clearAllFingerprints()
10111016
OnboardingStateStore.reset()
10121017

10131018
// RootCanvas also short-circuits onboarding when these are true.

apps/ios/Tests/GatewayConnectionSecurityTests.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Testing
55
@testable import OpenClaw
66

77
@Suite(.serialized) struct GatewayConnectionSecurityTests {
8+
@MainActor
89
private func makeController() -> GatewayConnectionController {
910
GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
1011
}
@@ -32,8 +33,7 @@ import Testing
3233
}
3334

3435
private func clearTLSFingerprint(stableID: String) {
35-
let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
36-
suite.removeObject(forKey: "gateway.tls.\(stableID)")
36+
GatewayTLSStore.clearFingerprint(stableID: stableID)
3737
}
3838

3939
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
@@ -126,4 +126,21 @@ import Testing
126126
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net.", port: 0, useTLS: true) == 443)
127127
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 18789, useTLS: true) == 18789)
128128
}
129+
130+
@Test @MainActor func clearAllTLSFingerprints_removesStoredPins() async {
131+
let stableID1 = "test|\(UUID().uuidString)"
132+
let stableID2 = "test|\(UUID().uuidString)"
133+
defer { GatewayTLSStore.clearAllFingerprints() }
134+
135+
GatewayTLSStore.saveFingerprint("11", stableID: stableID1)
136+
GatewayTLSStore.saveFingerprint("22", stableID: stableID2)
137+
138+
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == "11")
139+
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == "22")
140+
141+
GatewayTLSStore.clearAllFingerprints()
142+
143+
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == nil)
144+
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == nil)
145+
}
129146
}

apps/ios/Tests/NodeAppModelInvokeTests.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,64 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
9696
#expect(appModel.mainSessionKey == "agent:agent-123:main")
9797
}
9898

99+
@Test func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() {
100+
#expect(
101+
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
102+
token: nil,
103+
bootstrapToken: "fresh-bootstrap-token",
104+
password: nil,
105+
hasStoredOperatorToken: true)
106+
)
107+
#expect(
108+
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
109+
token: nil,
110+
bootstrapToken: nil,
111+
password: nil,
112+
hasStoredOperatorToken: false)
113+
)
114+
#expect(
115+
NodeAppModel._test_shouldStartOperatorGatewayLoop(
116+
token: nil,
117+
bootstrapToken: nil,
118+
password: nil,
119+
hasStoredOperatorToken: true)
120+
)
121+
#expect(
122+
NodeAppModel._test_shouldStartOperatorGatewayLoop(
123+
token: "shared-token",
124+
bootstrapToken: "fresh-bootstrap-token",
125+
password: nil,
126+
hasStoredOperatorToken: false)
127+
)
128+
}
129+
130+
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() {
131+
let config = GatewayConnectConfig(
132+
url: URL(string: "wss://gateway.example")!,
133+
stableID: "test-gateway",
134+
tls: nil,
135+
token: nil,
136+
bootstrapToken: "spent-bootstrap-token",
137+
password: nil,
138+
nodeOptions: GatewayConnectOptions(
139+
role: "node",
140+
scopes: [],
141+
caps: [],
142+
commands: [],
143+
permissions: [:],
144+
clientId: "openclaw-ios",
145+
clientMode: "node",
146+
clientDisplayName: nil))
147+
148+
let cleared = NodeAppModel.clearingBootstrapToken(in: config)
149+
#expect(cleared?.bootstrapToken == nil)
150+
#expect(cleared?.url == config.url)
151+
#expect(cleared?.stableID == config.stableID)
152+
#expect(cleared?.token == config.token)
153+
#expect(cleared?.password == config.password)
154+
#expect(cleared?.nodeOptions.role == config.nodeOptions.role)
155+
}
156+
99157
@Test @MainActor func handleInvokeRejectsBackgroundCommands() async {
100158
let appModel = NodeAppModel()
101159
appModel.setScenePhase(.background)

0 commit comments

Comments
 (0)