mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
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)
This commit is contained in:
@@ -96,6 +96,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -69,6 +69,13 @@ enum GatewaySettingsStore {
|
||||
account: self.preferredGatewayStableIDAccount)
|
||||
}
|
||||
|
||||
static func clearPreferredGatewayStableID(defaults: UserDefaults = .standard) {
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.preferredGatewayStableIDAccount)
|
||||
defaults.removeObject(forKey: self.preferredGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
static func loadLastDiscoveredGatewayStableID() -> String? {
|
||||
if let value = KeychainStore.loadString(
|
||||
service: self.gatewayService,
|
||||
@@ -89,6 +96,13 @@ enum GatewaySettingsStore {
|
||||
account: self.lastDiscoveredGatewayStableIDAccount)
|
||||
}
|
||||
|
||||
static func clearLastDiscoveredGatewayStableID(defaults: UserDefaults = .standard) {
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.lastDiscoveredGatewayStableIDAccount)
|
||||
defaults.removeObject(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
static func loadGatewayToken(instanceId: String) -> String? {
|
||||
let account = self.gatewayTokenAccount(instanceId: instanceId)
|
||||
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
|
||||
@@ -119,6 +133,12 @@ enum GatewaySettingsStore {
|
||||
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
|
||||
}
|
||||
|
||||
static func clearGatewayBootstrapToken(instanceId: String) {
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
|
||||
}
|
||||
|
||||
static func loadGatewayPassword(instanceId: String) -> String? {
|
||||
KeychainStore.loadString(
|
||||
service: self.gatewayService,
|
||||
|
||||
@@ -1697,14 +1697,24 @@ extension NodeAppModel {
|
||||
password: password,
|
||||
nodeOptions: connectOptions)
|
||||
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
|
||||
self.startOperatorGatewayLoop(
|
||||
url: url,
|
||||
stableID: effectiveStableID,
|
||||
if self.shouldStartOperatorGatewayLoop(
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
nodeOptions: connectOptions,
|
||||
sessionBox: sessionBox)
|
||||
stableID: effectiveStableID)
|
||||
{
|
||||
self.startOperatorGatewayLoop(
|
||||
url: url,
|
||||
stableID: effectiveStableID,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
nodeOptions: connectOptions,
|
||||
sessionBox: sessionBox)
|
||||
} else {
|
||||
self.operatorGatewayTask = nil
|
||||
Task { await self.operatorGateway.disconnect() }
|
||||
}
|
||||
self.startNodeGatewayLoop(
|
||||
url: url,
|
||||
stableID: effectiveStableID,
|
||||
@@ -1785,6 +1795,86 @@ private extension NodeAppModel {
|
||||
self.apnsLastRegisteredTokenHex = nil
|
||||
}
|
||||
|
||||
func shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
stableID _: String) -> Bool
|
||||
{
|
||||
Self.shouldStartOperatorGatewayLoop(
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator"))
|
||||
}
|
||||
|
||||
func hasStoredGatewayRoleToken(_ role: String) -> Bool {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
|
||||
}
|
||||
|
||||
static func shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
hasStoredOperatorToken: Bool) -> Bool
|
||||
{
|
||||
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedToken.isEmpty {
|
||||
return true
|
||||
}
|
||||
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedPassword.isEmpty {
|
||||
return true
|
||||
}
|
||||
let trimmedBootstrapToken = bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedBootstrapToken.isEmpty {
|
||||
return false
|
||||
}
|
||||
return hasStoredOperatorToken
|
||||
}
|
||||
|
||||
static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
|
||||
guard let config else { return nil }
|
||||
let trimmedBootstrapToken = config.bootstrapToken?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmedBootstrapToken.isEmpty else { return config }
|
||||
return GatewayConnectConfig(
|
||||
url: config.url,
|
||||
stableID: config.stableID,
|
||||
tls: config.tls,
|
||||
token: config.token,
|
||||
bootstrapToken: nil,
|
||||
password: config.password,
|
||||
nodeOptions: config.nodeOptions)
|
||||
}
|
||||
|
||||
func currentGatewayReconnectAuth(
|
||||
fallbackToken: String?,
|
||||
fallbackBootstrapToken: String?,
|
||||
fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?)
|
||||
{
|
||||
if let cfg = self.activeGatewayConnectConfig {
|
||||
return (cfg.token, cfg.bootstrapToken, cfg.password)
|
||||
}
|
||||
return (fallbackToken, fallbackBootstrapToken, fallbackPassword)
|
||||
}
|
||||
|
||||
func clearPersistedGatewayBootstrapTokenIfNeeded() {
|
||||
// Always drop the in-memory bootstrap token after the first successful
|
||||
// bootstrap connect so reconnect loops cannot reuse a spent token.
|
||||
self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig)
|
||||
|
||||
let trimmedInstanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmedInstanceId.isEmpty else { return }
|
||||
guard
|
||||
GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: trimmedInstanceId) != nil
|
||||
else { return }
|
||||
|
||||
GatewaySettingsStore.clearGatewayBootstrapToken(instanceId: trimmedInstanceId)
|
||||
}
|
||||
|
||||
func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
|
||||
guard self.isBackgrounded else { return }
|
||||
guard !self.backgroundReconnectSuppressed else { return }
|
||||
@@ -1841,11 +1931,15 @@ private extension NodeAppModel {
|
||||
displayName: nodeOptions.clientDisplayName)
|
||||
|
||||
do {
|
||||
let reconnectAuth = self.currentGatewayReconnectAuth(
|
||||
fallbackToken: token,
|
||||
fallbackBootstrapToken: bootstrapToken,
|
||||
fallbackPassword: password)
|
||||
try await self.operatorGateway.connect(
|
||||
url: url,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
token: reconnectAuth.token,
|
||||
bootstrapToken: reconnectAuth.bootstrapToken,
|
||||
password: reconnectAuth.password,
|
||||
connectOptions: operatorOptions,
|
||||
sessionBox: sessionBox,
|
||||
onConnected: { [weak self] in
|
||||
@@ -1948,12 +2042,16 @@ private extension NodeAppModel {
|
||||
|
||||
do {
|
||||
let epochMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let reconnectAuth = self.currentGatewayReconnectAuth(
|
||||
fallbackToken: token,
|
||||
fallbackBootstrapToken: bootstrapToken,
|
||||
fallbackPassword: password)
|
||||
GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)")
|
||||
try await self.nodeGateway.connect(
|
||||
url: url,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
token: reconnectAuth.token,
|
||||
bootstrapToken: reconnectAuth.bootstrapToken,
|
||||
password: reconnectAuth.password,
|
||||
connectOptions: currentOptions,
|
||||
sessionBox: sessionBox,
|
||||
onConnected: { [weak self] in
|
||||
@@ -1965,6 +2063,30 @@ private extension NodeAppModel {
|
||||
self.screen.errorText = nil
|
||||
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
|
||||
}
|
||||
let usedBootstrapToken =
|
||||
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
|
||||
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty == false
|
||||
if usedBootstrapToken {
|
||||
await MainActor.run {
|
||||
self.clearPersistedGatewayBootstrapTokenIfNeeded()
|
||||
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
|
||||
token: reconnectAuth.token,
|
||||
bootstrapToken: nil,
|
||||
password: reconnectAuth.password,
|
||||
stableID: stableID)
|
||||
{
|
||||
self.startOperatorGatewayLoop(
|
||||
url: url,
|
||||
stableID: stableID,
|
||||
token: reconnectAuth.token,
|
||||
bootstrapToken: nil,
|
||||
password: reconnectAuth.password,
|
||||
nodeOptions: currentOptions,
|
||||
sessionBox: sessionBox)
|
||||
}
|
||||
}
|
||||
}
|
||||
let relayData = await MainActor.run {
|
||||
(
|
||||
sessionKey: self.mainSessionKey,
|
||||
@@ -1975,8 +2097,8 @@ private extension NodeAppModel {
|
||||
ShareGatewayRelaySettings.saveConfig(
|
||||
ShareGatewayRelayConfig(
|
||||
gatewayURLString: url.absoluteString,
|
||||
token: token,
|
||||
password: password,
|
||||
token: reconnectAuth.token,
|
||||
password: reconnectAuth.password,
|
||||
sessionKey: relayData.sessionKey,
|
||||
deliveryChannel: relayData.deliveryChannel,
|
||||
deliveryTo: relayData.deliveryTo))
|
||||
@@ -3015,6 +3137,20 @@ extension NodeAppModel {
|
||||
static func _test_currentDeepLinkKey() -> String {
|
||||
self.expectedDeepLinkKey()
|
||||
}
|
||||
|
||||
static func _test_shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
hasStoredOperatorToken: Bool) -> Bool
|
||||
{
|
||||
self.shouldStartOperatorGatewayLoop(
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
hasStoredOperatorToken: hasStoredOperatorToken)
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
// swiftlint:enable type_body_length file_length
|
||||
|
||||
@@ -1008,6 +1008,11 @@ struct SettingsTab: View {
|
||||
|
||||
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
|
||||
GatewaySettingsStore.clearLastGatewayConnection()
|
||||
GatewaySettingsStore.clearPreferredGatewayStableID()
|
||||
GatewaySettingsStore.clearLastDiscoveredGatewayStableID()
|
||||
// Resetting onboarding should also forget trusted gateway TLS fingerprints.
|
||||
// Otherwise a restarted dev gateway can stay stuck in a local TLS cancel loop.
|
||||
GatewayTLSStore.clearAllFingerprints()
|
||||
OnboardingStateStore.reset()
|
||||
|
||||
// RootCanvas also short-circuits onboarding when these are true.
|
||||
|
||||
@@ -5,6 +5,7 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionSecurityTests {
|
||||
@MainActor
|
||||
private func makeController() -> GatewayConnectionController {
|
||||
GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
|
||||
}
|
||||
@@ -32,8 +33,7 @@ import Testing
|
||||
}
|
||||
|
||||
private func clearTLSFingerprint(stableID: String) {
|
||||
let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
|
||||
suite.removeObject(forKey: "gateway.tls.\(stableID)")
|
||||
GatewayTLSStore.clearFingerprint(stableID: stableID)
|
||||
}
|
||||
|
||||
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
|
||||
@@ -126,4 +126,21 @@ import Testing
|
||||
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net.", port: 0, useTLS: true) == 443)
|
||||
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 18789, useTLS: true) == 18789)
|
||||
}
|
||||
|
||||
@Test @MainActor func clearAllTLSFingerprints_removesStoredPins() async {
|
||||
let stableID1 = "test|\(UUID().uuidString)"
|
||||
let stableID2 = "test|\(UUID().uuidString)"
|
||||
defer { GatewayTLSStore.clearAllFingerprints() }
|
||||
|
||||
GatewayTLSStore.saveFingerprint("11", stableID: stableID1)
|
||||
GatewayTLSStore.saveFingerprint("22", stableID: stableID2)
|
||||
|
||||
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == "11")
|
||||
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == "22")
|
||||
|
||||
GatewayTLSStore.clearAllFingerprints()
|
||||
|
||||
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID1) == nil)
|
||||
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,64 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
#expect(appModel.mainSessionKey == "agent:agent-123:main")
|
||||
}
|
||||
|
||||
@Test func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() {
|
||||
#expect(
|
||||
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
|
||||
token: nil,
|
||||
bootstrapToken: "fresh-bootstrap-token",
|
||||
password: nil,
|
||||
hasStoredOperatorToken: true)
|
||||
)
|
||||
#expect(
|
||||
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
hasStoredOperatorToken: false)
|
||||
)
|
||||
#expect(
|
||||
NodeAppModel._test_shouldStartOperatorGatewayLoop(
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
hasStoredOperatorToken: true)
|
||||
)
|
||||
#expect(
|
||||
NodeAppModel._test_shouldStartOperatorGatewayLoop(
|
||||
token: "shared-token",
|
||||
bootstrapToken: "fresh-bootstrap-token",
|
||||
password: nil,
|
||||
hasStoredOperatorToken: false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() {
|
||||
let config = GatewayConnectConfig(
|
||||
url: URL(string: "wss://gateway.example")!,
|
||||
stableID: "test-gateway",
|
||||
tls: nil,
|
||||
token: nil,
|
||||
bootstrapToken: "spent-bootstrap-token",
|
||||
password: nil,
|
||||
nodeOptions: GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios",
|
||||
clientMode: "node",
|
||||
clientDisplayName: nil))
|
||||
|
||||
let cleared = NodeAppModel.clearingBootstrapToken(in: config)
|
||||
#expect(cleared?.bootstrapToken == nil)
|
||||
#expect(cleared?.url == config.url)
|
||||
#expect(cleared?.stableID == config.stableID)
|
||||
#expect(cleared?.token == config.token)
|
||||
#expect(cleared?.password == config.password)
|
||||
#expect(cleared?.nodeOptions.role == config.nodeOptions.role)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeRejectsBackgroundCommands() async {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.setScenePhase(.background)
|
||||
|
||||
@@ -35,6 +35,25 @@ public enum GatewayTLSStore {
|
||||
_ = GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func clearFingerprint(stableID: String) -> Bool {
|
||||
let removedKeychain = GenericPasswordKeychainStore.delete(
|
||||
service: self.keychainService,
|
||||
account: stableID)
|
||||
self.clearLegacyFingerprint(stableID: stableID)
|
||||
return removedKeychain
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func clearAllFingerprints() -> Bool {
|
||||
let removedKeychain = SecItemDelete([
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.keychainService,
|
||||
] as CFDictionary)
|
||||
self.clearAllLegacyFingerprints()
|
||||
return removedKeychain == errSecSuccess || removedKeychain == errSecItemNotFound
|
||||
}
|
||||
|
||||
// MARK: - Migration
|
||||
|
||||
/// On first Keychain read for a given stableID, move any legacy UserDefaults
|
||||
@@ -53,6 +72,18 @@ public enum GatewayTLSStore {
|
||||
}
|
||||
defaults.removeObject(forKey: legacyKey)
|
||||
}
|
||||
|
||||
private static func clearLegacyFingerprint(stableID: String) {
|
||||
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return }
|
||||
defaults.removeObject(forKey: self.legacyKeyPrefix + stableID)
|
||||
}
|
||||
|
||||
private static func clearAllLegacyFingerprints() {
|
||||
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return }
|
||||
for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(self.legacyKeyPrefix) {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, @unchecked Sendable {
|
||||
|
||||
@@ -80,6 +80,10 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
return device;
|
||||
};
|
||||
|
||||
const REMOTE_BOOTSTRAP_HEADERS = {
|
||||
"x-forwarded-for": "10.0.0.14",
|
||||
};
|
||||
|
||||
const expectStatusAndHealthOk = async (ws: WebSocket) => {
|
||||
const status = await rpcReq(ws, "status");
|
||||
expect(status.ok).toBe(true);
|
||||
@@ -706,6 +710,215 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("auto-approves fresh node bootstrap pairing from qr setup code", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
ws.close();
|
||||
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-node-",
|
||||
);
|
||||
const client = {
|
||||
id: "openclaw-ios",
|
||||
version: "2026.3.30",
|
||||
platform: "iOS 26.3.1",
|
||||
mode: "node",
|
||||
deviceFamily: "iPhone",
|
||||
};
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken();
|
||||
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(initial.ok).toBe(true);
|
||||
const initialPayload = initial.payload as
|
||||
| {
|
||||
type?: string;
|
||||
auth?: {
|
||||
deviceToken?: string;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(initialPayload?.type).toBe("hello-ok");
|
||||
const issuedDeviceToken = initialPayload?.auth?.deviceToken;
|
||||
expect(issuedDeviceToken).toBeDefined();
|
||||
expect(initialPayload?.auth?.role).toBe("node");
|
||||
expect(initialPayload?.auth?.scopes ?? []).toEqual([]);
|
||||
|
||||
const afterBootstrap = await listDevicePairing();
|
||||
expect(
|
||||
afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId),
|
||||
).toEqual([]);
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.roles).toEqual(expect.arrayContaining(["node"]));
|
||||
expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken);
|
||||
if (!issuedDeviceToken) {
|
||||
throw new Error("expected hello-ok auth.deviceToken for bootstrap onboarding");
|
||||
}
|
||||
|
||||
wsBootstrap.close();
|
||||
|
||||
const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const replay = await connectReq(wsReplay, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(replay.ok).toBe(false);
|
||||
expect((replay.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
|
||||
);
|
||||
wsReplay.close();
|
||||
|
||||
const wsReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const reconnect = await connectReq(wsReconnect, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: issuedDeviceToken,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(reconnect.ok).toBe(true);
|
||||
wsReconnect.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("requires approval for bootstrap-auth role upgrades on already-paired devices", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
ws.close();
|
||||
|
||||
const { identityPath, identity } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-role-upgrade-",
|
||||
);
|
||||
const client = {
|
||||
id: "openclaw-ios",
|
||||
version: "2026.3.30",
|
||||
platform: "iOS 26.3.1",
|
||||
mode: "node",
|
||||
deviceFamily: "iPhone",
|
||||
};
|
||||
|
||||
try {
|
||||
const seededRequest = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
clientId: client.id,
|
||||
clientMode: client.mode,
|
||||
platform: client.platform,
|
||||
deviceFamily: client.deviceFamily,
|
||||
});
|
||||
await approveDevicePairing(seededRequest.request.requestId, {
|
||||
callerScopes: ["operator.read"],
|
||||
});
|
||||
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
},
|
||||
});
|
||||
const wsUpgrade = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const upgrade = await connectReq(wsUpgrade, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(upgrade.ok).toBe(false);
|
||||
expect(upgrade.error?.message ?? "").toContain("pairing required");
|
||||
expect((upgrade.error?.details as { code?: string; reason?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
);
|
||||
expect(
|
||||
(upgrade.error?.details as { code?: string; reason?: string } | undefined)?.reason,
|
||||
).toBe("role-upgrade");
|
||||
|
||||
const pending = (await listDevicePairing()).pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]?.role).toBe("node");
|
||||
expect(pending[0]?.roles).toEqual(["node"]);
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
expect(paired?.roles).toEqual(expect.arrayContaining(["operator"]));
|
||||
wsUpgrade.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("requires approval for bootstrap-auth operator pairing outside the qr baseline profile", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
ws.close();
|
||||
|
||||
const { identityPath, identity, client } = await createOperatorIdentityFixture(
|
||||
"openclaw-bootstrap-operator-",
|
||||
);
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: {
|
||||
roles: ["operator"],
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
});
|
||||
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
client,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(initial.ok).toBe(false);
|
||||
expect(initial.error?.message ?? "").toContain("pairing required");
|
||||
expect((initial.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
);
|
||||
|
||||
const pending = (await listDevicePairing()).pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]?.role).toBe("operator");
|
||||
expect(pending[0]?.scopes ?? []).toEqual(expect.arrayContaining(["operator.read"]));
|
||||
expect(await getPairedDevice(identity.deviceId)).toBeNull();
|
||||
wsBootstrap.close();
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
}
|
||||
});
|
||||
|
||||
test("merges remote node/operator pairing requests for the same unpaired device", async () => {
|
||||
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { IncomingMessage } from "node:http";
|
||||
import os from "node:os";
|
||||
import type { WebSocket } from "ws";
|
||||
import { loadConfig } from "../../../config/config.js";
|
||||
import { verifyDeviceBootstrapToken } from "../../../infra/device-bootstrap.js";
|
||||
import {
|
||||
revokeDeviceBootstrapToken,
|
||||
verifyDeviceBootstrapToken,
|
||||
} from "../../../infra/device-bootstrap.js";
|
||||
import {
|
||||
deriveDeviceIdFromPublicKey,
|
||||
normalizeDevicePublicKeyBase64Url,
|
||||
@@ -752,6 +755,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
};
|
||||
const requirePairing = async (
|
||||
reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade",
|
||||
existingPairedDevice: Awaited<ReturnType<typeof getPairedDevice>> | null = null,
|
||||
) => {
|
||||
const pairingStateAllowsRequestedAccess = (
|
||||
pairedCandidate: Awaited<ReturnType<typeof getPairedDevice>>,
|
||||
@@ -786,11 +790,23 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
isWebchat,
|
||||
reason,
|
||||
});
|
||||
// QR bootstrap onboarding is node-only and single-use. When a fresh device presents
|
||||
// a valid bootstrap token for the baseline node profile, complete pairing in the same
|
||||
// handshake so iOS does not get stuck retrying with an already-consumed bootstrap token.
|
||||
const allowSilentBootstrapPairing =
|
||||
authMethod === "bootstrap-token" &&
|
||||
reason === "not-paired" &&
|
||||
role === "node" &&
|
||||
scopes.length === 0 &&
|
||||
!existingPairedDevice;
|
||||
const pairing = await requestDevicePairing({
|
||||
deviceId: device.id,
|
||||
publicKey: devicePublicKey,
|
||||
...clientPairingMetadata,
|
||||
silent: reason === "scope-upgrade" ? false : allowSilentLocalPairing,
|
||||
silent:
|
||||
reason === "scope-upgrade"
|
||||
? false
|
||||
: allowSilentLocalPairing || allowSilentBootstrapPairing,
|
||||
});
|
||||
const context = buildRequestContext();
|
||||
let approved: Awaited<ReturnType<typeof approveDevicePairing>> | undefined;
|
||||
@@ -815,6 +831,16 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
callerScopes: scopes,
|
||||
});
|
||||
if (approved?.status === "approved") {
|
||||
if (allowSilentBootstrapPairing && bootstrapTokenCandidate) {
|
||||
const revoked = await revokeDeviceBootstrapToken({
|
||||
token: bootstrapTokenCandidate,
|
||||
});
|
||||
if (!revoked.removed) {
|
||||
logGateway.warn(
|
||||
`bootstrap token revoke skipped after silent auto-approval device=${approved.device.deviceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
logGateway.info(
|
||||
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
|
||||
);
|
||||
@@ -879,7 +905,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const paired = await getPairedDevice(device.id);
|
||||
const isPaired = paired?.publicKey === devicePublicKey;
|
||||
if (!isPaired) {
|
||||
const ok = await requirePairing("not-paired");
|
||||
const ok = await requirePairing("not-paired", paired);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
@@ -899,7 +925,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
logGateway.warn(
|
||||
`security audit: device metadata upgrade requested reason=metadata-upgrade device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} payload=${deviceAuthPayloadVersion ?? "unknown"} claimedPlatform=${claimedPlatform ?? "<none>"} pinnedPlatform=${pairedPlatform ?? "<none>"} claimedDeviceFamily=${claimedDeviceFamily ?? "<none>"} pinnedDeviceFamily=${pairedDeviceFamily ?? "<none>"} client=${connectParams.client.id} conn=${connId}`,
|
||||
);
|
||||
const ok = await requirePairing("metadata-upgrade");
|
||||
const ok = await requirePairing("metadata-upgrade", paired);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
@@ -920,13 +946,13 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const allowedRoles = new Set(pairedRoles);
|
||||
if (allowedRoles.size === 0) {
|
||||
logUpgradeAudit("role-upgrade", pairedRoles, pairedScopes);
|
||||
const ok = await requirePairing("role-upgrade");
|
||||
const ok = await requirePairing("role-upgrade", paired);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
} else if (!allowedRoles.has(role)) {
|
||||
logUpgradeAudit("role-upgrade", pairedRoles, pairedScopes);
|
||||
const ok = await requirePairing("role-upgrade");
|
||||
const ok = await requirePairing("role-upgrade", paired);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
@@ -935,7 +961,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
if (scopes.length > 0) {
|
||||
if (pairedScopes.length === 0) {
|
||||
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
|
||||
const ok = await requirePairing("scope-upgrade");
|
||||
const ok = await requirePairing("scope-upgrade", paired);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
@@ -947,7 +973,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
});
|
||||
if (!scopesAllowed) {
|
||||
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
|
||||
const ok = await requirePairing("scope-upgrade");
|
||||
const ok = await requirePairing("scope-upgrade", paired);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
24
src/gateway/test-helpers.server.test.ts
Normal file
24
src/gateway/test-helpers.server.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { testOnlyResolveAuthTokenForSignature } from "./test-helpers.server.js";
|
||||
|
||||
describe("testOnlyResolveAuthTokenForSignature", () => {
|
||||
it("matches connect auth precedence for bootstrap tokens", () => {
|
||||
expect(
|
||||
testOnlyResolveAuthTokenForSignature({
|
||||
token: undefined,
|
||||
bootstrapToken: "bootstrap-token",
|
||||
deviceToken: "device-token",
|
||||
}),
|
||||
).toBe("bootstrap-token");
|
||||
});
|
||||
|
||||
it("still prefers the shared token when present", () => {
|
||||
expect(
|
||||
testOnlyResolveAuthTokenForSignature({
|
||||
token: "shared-token",
|
||||
bootstrapToken: "bootstrap-token",
|
||||
deviceToken: "device-token",
|
||||
}),
|
||||
).toBe("shared-token");
|
||||
});
|
||||
});
|
||||
@@ -684,10 +684,27 @@ export async function readConnectChallengeNonce(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAuthTokenForSignature(opts?: {
|
||||
token?: string;
|
||||
bootstrapToken?: string;
|
||||
deviceToken?: string;
|
||||
}) {
|
||||
return opts?.token ?? opts?.bootstrapToken ?? opts?.deviceToken;
|
||||
}
|
||||
|
||||
export function testOnlyResolveAuthTokenForSignature(opts?: {
|
||||
token?: string;
|
||||
bootstrapToken?: string;
|
||||
deviceToken?: string;
|
||||
}) {
|
||||
return resolveAuthTokenForSignature(opts);
|
||||
}
|
||||
|
||||
export async function connectReq(
|
||||
ws: WebSocket,
|
||||
opts?: {
|
||||
token?: string;
|
||||
bootstrapToken?: string;
|
||||
deviceToken?: string;
|
||||
password?: string;
|
||||
skipDefaultAuth?: boolean;
|
||||
@@ -742,9 +759,14 @@ export async function connectReq(
|
||||
? ((testState.gatewayAuth as { password?: string }).password ?? undefined)
|
||||
: process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
const token = opts?.token ?? defaultToken;
|
||||
const bootstrapToken = opts?.bootstrapToken?.trim() || undefined;
|
||||
const deviceToken = opts?.deviceToken?.trim() || undefined;
|
||||
const password = opts?.password ?? defaultPassword;
|
||||
const authTokenForSignature = token ?? deviceToken;
|
||||
const authTokenForSignature = resolveAuthTokenForSignature({
|
||||
token,
|
||||
bootstrapToken,
|
||||
deviceToken,
|
||||
});
|
||||
const requestedScopes = Array.isArray(opts?.scopes)
|
||||
? opts.scopes
|
||||
: role === "operator"
|
||||
@@ -811,9 +833,10 @@ export async function connectReq(
|
||||
role,
|
||||
scopes: requestedScopes,
|
||||
auth:
|
||||
token || password || deviceToken
|
||||
token || bootstrapToken || password || deviceToken
|
||||
? {
|
||||
token,
|
||||
bootstrapToken,
|
||||
deviceToken,
|
||||
password,
|
||||
}
|
||||
|
||||
@@ -70,23 +70,27 @@ describe("device bootstrap tokens", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies valid bootstrap tokens without consuming them before expiry", async () => {
|
||||
it("verifies valid bootstrap tokens and binds them to the first device identity", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
||||
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
||||
|
||||
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
||||
expect(raw).toContain(issued.token);
|
||||
const parsed = JSON.parse(raw) as Record<
|
||||
string,
|
||||
{
|
||||
token: string;
|
||||
deviceId?: string;
|
||||
publicKey?: string;
|
||||
}
|
||||
>;
|
||||
expect(parsed[issued.token]).toMatchObject({
|
||||
token: issued.token,
|
||||
deviceId: "device-123",
|
||||
publicKey: "public-key-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears outstanding bootstrap tokens on demand", async () => {
|
||||
@@ -125,7 +129,7 @@ describe("device bootstrap tokens", () => {
|
||||
await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("verifies bootstrap tokens by the persisted map key without deleting them", async () => {
|
||||
it("verifies bootstrap tokens by the persisted map key and binds them", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
const issuedAtMs = Date.now();
|
||||
@@ -153,7 +157,15 @@ describe("device bootstrap tokens", () => {
|
||||
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
||||
|
||||
const raw = await fs.readFile(bootstrapPath, "utf8");
|
||||
expect(raw).toContain(issued.token);
|
||||
const parsed = JSON.parse(raw) as Record<
|
||||
string,
|
||||
{ token: string; deviceId?: string; publicKey?: string }
|
||||
>;
|
||||
expect(parsed["legacy-key"]).toMatchObject({
|
||||
token: issued.token,
|
||||
deviceId: "device-123",
|
||||
publicKey: "public-key-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the token when required verification fields are blank", async () => {
|
||||
@@ -225,7 +237,7 @@ describe("device bootstrap tokens", () => {
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("accepts trimmed bootstrap tokens without consuming them", async () => {
|
||||
it("accepts trimmed bootstrap tokens and binds them", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
||||
@@ -234,7 +246,8 @@ describe("device bootstrap tokens", () => {
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
||||
expect(raw).toContain(issued.token);
|
||||
const parsed = JSON.parse(raw) as Record<string, { deviceId?: string }>;
|
||||
expect(parsed[issued.token]?.deviceId).toBe("device-123");
|
||||
});
|
||||
|
||||
it("rejects blank or unknown tokens", async () => {
|
||||
@@ -275,6 +288,19 @@ describe("device bootstrap tokens", () => {
|
||||
expect(parsed[issued.token]?.token).toBe(issued.token);
|
||||
});
|
||||
|
||||
it("rejects a second device identity after the first verification binds the token", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
||||
await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
deviceId: "device-456",
|
||||
publicKey: "public-key-456",
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
});
|
||||
|
||||
it("fails closed for unbound legacy records and prunes expired tokens", async () => {
|
||||
vi.useFakeTimers();
|
||||
const baseDir = await createTempDir();
|
||||
|
||||
@@ -189,7 +189,7 @@ export async function verifyDeviceBootstrapToken(params: {
|
||||
if (!found) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
const [, record] = found;
|
||||
const [tokenKey, record] = found;
|
||||
|
||||
const deviceId = params.deviceId.trim();
|
||||
const publicKey = params.publicKey.trim();
|
||||
@@ -198,8 +198,8 @@ export async function verifyDeviceBootstrapToken(params: {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
const allowedProfile = resolvePersistedBootstrapProfile(record);
|
||||
// Fail closed for unbound legacy setup codes and for any attempt to redeem
|
||||
// the token outside the issued role/scope allowlist.
|
||||
// Fail closed for any attempt to redeem the token outside the issued
|
||||
// role/scope allowlist before binding it to a concrete device identity.
|
||||
if (
|
||||
allowedProfile.roles.length === 0 ||
|
||||
!bootstrapProfileAllowsRequest({
|
||||
@@ -211,9 +211,31 @@ export async function verifyDeviceBootstrapToken(params: {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
|
||||
// Keep valid setup codes alive until they expire or are explicitly revoked.
|
||||
// Approval happens after bootstrap verification, so consuming the token here
|
||||
// makes post-approval reconnect impossible.
|
||||
const boundDeviceId = record.deviceId?.trim();
|
||||
const boundPublicKey = record.publicKey?.trim();
|
||||
if (boundDeviceId || boundPublicKey) {
|
||||
if (boundDeviceId !== deviceId || boundPublicKey !== publicKey) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
state[tokenKey] = {
|
||||
...record,
|
||||
profile: allowedProfile,
|
||||
deviceId,
|
||||
publicKey,
|
||||
lastUsedAtMs: Date.now(),
|
||||
};
|
||||
await persistState(state, params.baseDir);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
state[tokenKey] = {
|
||||
...record,
|
||||
profile: allowedProfile,
|
||||
deviceId,
|
||||
publicKey,
|
||||
lastUsedAtMs: Date.now(),
|
||||
};
|
||||
await persistState(state, params.baseDir);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,6 +34,19 @@ async function setupPairedOperatorDevice(baseDir: string, scopes: string[]) {
|
||||
await approveDevicePairing(request.request.requestId, { callerScopes: scopes }, baseDir);
|
||||
}
|
||||
|
||||
async function setupPairedNodeDevice(baseDir: string) {
|
||||
const request = await requestDevicePairing(
|
||||
{
|
||||
deviceId: "node-1",
|
||||
publicKey: "public-key-node-1",
|
||||
role: "node",
|
||||
scopes: [],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
await approveDevicePairing(request.request.requestId, { callerScopes: [] }, baseDir);
|
||||
}
|
||||
|
||||
async function setupOperatorToken(scopes: string[]) {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
await setupPairedOperatorDevice(baseDir, scopes);
|
||||
@@ -55,7 +68,7 @@ function verifyOperatorToken(params: { baseDir: string; token: string; scopes: s
|
||||
function requireToken(token: string | undefined): string {
|
||||
expect(typeof token).toBe("string");
|
||||
if (typeof token !== "string") {
|
||||
throw new Error("expected operator token to be issued");
|
||||
throw new Error("expected device token to be issued");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
@@ -389,6 +402,35 @@ describe("device pairing tokens", () => {
|
||||
expect(after?.approvedScopes).toEqual(["operator.read"]);
|
||||
});
|
||||
|
||||
test("preserves explicit empty scope baselines for node device tokens", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
await setupPairedNodeDevice(baseDir);
|
||||
|
||||
const paired = await getPairedDevice("node-1", baseDir);
|
||||
expect(paired?.scopes).toEqual([]);
|
||||
expect(paired?.approvedScopes).toEqual([]);
|
||||
|
||||
const seededToken = requireToken(paired?.tokens?.node?.token);
|
||||
await expect(
|
||||
ensureDeviceToken({
|
||||
deviceId: "node-1",
|
||||
role: "node",
|
||||
scopes: [],
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toEqual(expect.objectContaining({ token: seededToken, scopes: [] }));
|
||||
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: "node-1",
|
||||
token: seededToken,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("verifies token and rejects mismatches", async () => {
|
||||
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
|
||||
|
||||
|
||||
@@ -188,10 +188,12 @@ export function hasEffectivePairedDeviceRole(
|
||||
|
||||
function mergeScopes(...items: Array<string[] | undefined>): string[] | undefined {
|
||||
const scopes = new Set<string>();
|
||||
let sawExplicitScopeList = false;
|
||||
for (const item of items) {
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
sawExplicitScopeList = true;
|
||||
for (const scope of item) {
|
||||
const trimmed = scope.trim();
|
||||
if (trimmed) {
|
||||
@@ -200,7 +202,7 @@ function mergeScopes(...items: Array<string[] | undefined>): string[] | undefine
|
||||
}
|
||||
}
|
||||
if (scopes.size === 0) {
|
||||
return undefined;
|
||||
return sawExplicitScopeList ? [] : undefined;
|
||||
}
|
||||
return [...scopes];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user