mirror of https://github.com/openclaw/openclaw.git
fix(auth): hand off qr bootstrap to bounded device tokens
This commit is contained in:
parent
c4597992ca
commit
a9140abea6
|
|
@ -1816,7 +1816,7 @@ private extension NodeAppModel {
|
|||
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
|
||||
}
|
||||
|
||||
static func shouldStartOperatorGatewayLoop(
|
||||
nonisolated static func shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
|
|
@ -1837,7 +1837,7 @@ private extension NodeAppModel {
|
|||
return hasStoredOperatorToken
|
||||
}
|
||||
|
||||
static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
|
||||
nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
|
||||
guard let config else { return nil }
|
||||
let trimmedBootstrapToken = config.bootstrapToken?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
|
@ -1878,6 +1878,36 @@ private extension NodeAppModel {
|
|||
GatewaySettingsStore.clearGatewayBootstrapToken(instanceId: trimmedInstanceId)
|
||||
}
|
||||
|
||||
private func handleSuccessfulBootstrapGatewayOnboarding(
|
||||
url: URL,
|
||||
stableID: String,
|
||||
token: String?,
|
||||
password: String?,
|
||||
nodeOptions: GatewayConnectOptions,
|
||||
sessionBox: WebSocketSessionBox?) async
|
||||
{
|
||||
self.clearPersistedGatewayBootstrapTokenIfNeeded()
|
||||
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
|
||||
token: token,
|
||||
bootstrapToken: nil,
|
||||
password: password,
|
||||
stableID: stableID)
|
||||
{
|
||||
self.startOperatorGatewayLoop(
|
||||
url: url,
|
||||
stableID: stableID,
|
||||
token: token,
|
||||
bootstrapToken: nil,
|
||||
password: password,
|
||||
nodeOptions: nodeOptions,
|
||||
sessionBox: sessionBox)
|
||||
}
|
||||
|
||||
// QR bootstrap onboarding should surface the system notification permission
|
||||
// prompt immediately so visible APNs alerts work without a second manual step.
|
||||
_ = await self.requestNotificationAuthorizationIfNeeded()
|
||||
}
|
||||
|
||||
func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
|
||||
guard self.isBackgrounded else { return }
|
||||
guard !self.backgroundReconnectSuppressed else { return }
|
||||
|
|
@ -2049,13 +2079,14 @@ private extension NodeAppModel {
|
|||
fallbackToken: token,
|
||||
fallbackBootstrapToken: bootstrapToken,
|
||||
fallbackPassword: password)
|
||||
let connectedOptions = currentOptions
|
||||
GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)")
|
||||
try await self.nodeGateway.connect(
|
||||
url: url,
|
||||
token: reconnectAuth.token,
|
||||
bootstrapToken: reconnectAuth.bootstrapToken,
|
||||
password: reconnectAuth.password,
|
||||
connectOptions: currentOptions,
|
||||
connectOptions: connectedOptions,
|
||||
sessionBox: sessionBox,
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
|
|
@ -2071,24 +2102,13 @@ private extension NodeAppModel {
|
|||
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)
|
||||
}
|
||||
}
|
||||
await self.handleSuccessfulBootstrapGatewayOnboarding(
|
||||
url: url,
|
||||
stableID: stableID,
|
||||
token: reconnectAuth.token,
|
||||
password: reconnectAuth.password,
|
||||
nodeOptions: connectedOptions,
|
||||
sessionBox: sessionBox)
|
||||
}
|
||||
let relayData = await MainActor.run {
|
||||
(
|
||||
|
|
@ -2249,7 +2269,7 @@ private extension NodeAppModel {
|
|||
func makeOperatorConnectOptions(clientId: String, displayName: String?) -> GatewayConnectOptions {
|
||||
GatewayConnectOptions(
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
scopes: ["operator.read", "operator.write", "operator.approvals", "operator.talk.secrets"],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
|
|
@ -3137,11 +3157,18 @@ extension NodeAppModel {
|
|||
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
|
||||
}
|
||||
|
||||
func _test_makeOperatorConnectOptions(
|
||||
clientId: String,
|
||||
displayName: String?
|
||||
) -> GatewayConnectOptions {
|
||||
self.makeOperatorConnectOptions(clientId: clientId, displayName: displayName)
|
||||
}
|
||||
|
||||
static func _test_currentDeepLinkKey() -> String {
|
||||
self.expectedDeepLinkKey()
|
||||
}
|
||||
|
||||
static func _test_shouldStartOperatorGatewayLoop(
|
||||
nonisolated static func _test_shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
|
|
@ -3154,6 +3181,30 @@ extension NodeAppModel {
|
|||
hasStoredOperatorToken: hasStoredOperatorToken)
|
||||
}
|
||||
|
||||
nonisolated static func _test_clearingBootstrapToken(
|
||||
in config: GatewayConnectConfig?
|
||||
) -> GatewayConnectConfig? {
|
||||
self.clearingBootstrapToken(in: config)
|
||||
}
|
||||
|
||||
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
|
||||
await self.handleSuccessfulBootstrapGatewayOnboarding(
|
||||
url: URL(string: "wss://gateway.example")!,
|
||||
stableID: "test-gateway",
|
||||
token: nil,
|
||||
password: nil,
|
||||
nodeOptions: GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios",
|
||||
clientMode: "node",
|
||||
clientDisplayName: nil),
|
||||
sessionBox: nil)
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
// swiftlint:enable type_body_length file_length
|
||||
|
|
|
|||
|
|
@ -70,6 +70,19 @@ import UIKit
|
|||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func operatorConnectOptionsRequestApprovalScope() {
|
||||
let appModel = NodeAppModel()
|
||||
let options = appModel._test_makeOperatorConnectOptions(
|
||||
clientId: "openclaw-ios",
|
||||
displayName: "OpenClaw iOS")
|
||||
|
||||
#expect(options.role == "operator")
|
||||
#expect(options.scopes.contains("operator.read"))
|
||||
#expect(options.scopes.contains("operator.write"))
|
||||
#expect(options.scopes.contains("operator.approvals"))
|
||||
#expect(options.scopes.contains("operator.talk.secrets"))
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReadsSavedValues() {
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
defer {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import OpenClawKit
|
|||
import Foundation
|
||||
import Testing
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
@testable import OpenClaw
|
||||
|
||||
private func makeAgentDeepLinkURL(
|
||||
|
|
@ -68,6 +69,28 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
|||
}
|
||||
}
|
||||
|
||||
private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
var status: NotificationAuthorizationStatus = .notDetermined
|
||||
var requestAuthorizationResult = false
|
||||
var requestAuthorizationCalls = 0
|
||||
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
|
||||
self.requestAuthorizationCalls += 1
|
||||
if self.requestAuthorizationResult {
|
||||
self.status = .authorized
|
||||
} else {
|
||||
self.status = .denied
|
||||
}
|
||||
return self.requestAuthorizationResult
|
||||
}
|
||||
|
||||
func add(_: UNNotificationRequest) async throws {}
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct NodeAppModelInvokeTests {
|
||||
@Test @MainActor func decodeParamsFailsWithoutJSON() {
|
||||
#expect(throws: Error.self) {
|
||||
|
|
@ -127,6 +150,15 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
|||
)
|
||||
}
|
||||
|
||||
@Test @MainActor func successfulBootstrapOnboardingRequestsNotificationAuthorization() async {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
let appModel = NodeAppModel(notificationCenter: center)
|
||||
|
||||
await appModel._test_handleSuccessfulBootstrapGatewayOnboarding()
|
||||
|
||||
#expect(center.requestAuthorizationCalls == 1)
|
||||
}
|
||||
|
||||
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() {
|
||||
let config = GatewayConnectConfig(
|
||||
url: URL(string: "wss://gateway.example")!,
|
||||
|
|
@ -145,7 +177,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
|||
clientMode: "node",
|
||||
clientDisplayName: nil))
|
||||
|
||||
let cleared = NodeAppModel.clearingBootstrapToken(in: config)
|
||||
let cleared = NodeAppModel._test_clearingBootstrapToken(in: config)
|
||||
#expect(cleared?.bootstrapToken == nil)
|
||||
#expect(cleared?.url == config.url)
|
||||
#expect(cleared?.stableID == config.stableID)
|
||||
|
|
|
|||
|
|
@ -542,6 +542,52 @@ public actor GatewayChannelActor {
|
|||
authSource: authSource)
|
||||
}
|
||||
|
||||
private func shouldPersistBootstrapHandoffTokens() -> Bool {
|
||||
guard self.lastAuthSource == .bootstrapToken else { return false }
|
||||
let scheme = self.url.scheme?.lowercased()
|
||||
if scheme == "wss" {
|
||||
return true
|
||||
}
|
||||
if let host = self.url.host, LoopbackHost.isLoopback(host) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func filteredBootstrapHandoffScopes(role: String, scopes: [String]) -> [String]? {
|
||||
let normalizedRole = role.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch normalizedRole {
|
||||
case "node":
|
||||
return []
|
||||
case "operator":
|
||||
let allowedOperatorScopes: Set<String> = [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
]
|
||||
return Array(Set(scopes.filter { allowedOperatorScopes.contains($0) })).sorted()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func persistBootstrapHandoffToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String]
|
||||
) {
|
||||
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
|
||||
return
|
||||
}
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: deviceId,
|
||||
role: role,
|
||||
token: token,
|
||||
scopes: filteredScopes)
|
||||
}
|
||||
|
||||
private func handleConnectResponse(
|
||||
_ res: ResponseFrame,
|
||||
identity: DeviceIdentity?,
|
||||
|
|
@ -572,18 +618,34 @@ public actor GatewayChannelActor {
|
|||
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
|
||||
self.tickIntervalMs = Double(tick)
|
||||
}
|
||||
if let auth = ok.auth,
|
||||
let deviceToken = auth["deviceToken"]?.value as? String {
|
||||
let authRole = auth["role"]?.value as? String ?? role
|
||||
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
|
||||
.compactMap { $0.value as? String } ?? []
|
||||
if let identity {
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
if let auth = ok.auth, let identity, self.shouldPersistBootstrapHandoffTokens() {
|
||||
if let deviceToken = auth["deviceToken"]?.value as? String {
|
||||
let authRole = auth["role"]?.value as? String ?? role
|
||||
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
|
||||
.compactMap { $0.value as? String } ?? []
|
||||
self.persistBootstrapHandoffToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: authRole,
|
||||
token: deviceToken,
|
||||
scopes: scopes)
|
||||
}
|
||||
if let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable] {
|
||||
for entry in tokenEntries {
|
||||
guard let rawEntry = entry.value as? [String: ProtoAnyCodable],
|
||||
let deviceToken = rawEntry["deviceToken"]?.value as? String,
|
||||
let authRole = rawEntry["role"]?.value as? String
|
||||
else {
|
||||
continue
|
||||
}
|
||||
let scopes = (rawEntry["scopes"]?.value as? [ProtoAnyCodable])?
|
||||
.compactMap { $0.value as? String } ?? []
|
||||
self.persistBootstrapHandoffToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: authRole,
|
||||
token: deviceToken,
|
||||
scopes: scopes)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.lastTick = Date()
|
||||
self.tickTask?.cancel()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ private extension NSLock {
|
|||
|
||||
private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let helloAuth: [String: Any]?
|
||||
private var _state: URLSessionTask.State = .suspended
|
||||
private var connectRequestId: String?
|
||||
private var connectAuth: [String: Any]?
|
||||
|
|
@ -20,6 +21,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
|||
private var pendingReceiveHandler:
|
||||
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
|
||||
init(helloAuth: [String: Any]? = nil) {
|
||||
self.helloAuth = helloAuth
|
||||
}
|
||||
|
||||
var state: URLSessionTask.State {
|
||||
get { self.lock.withLock { self._state } }
|
||||
set { self.lock.withLock { self._state = newValue } }
|
||||
|
|
@ -79,11 +84,11 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
|||
for _ in 0..<50 {
|
||||
let id = self.lock.withLock { self.connectRequestId }
|
||||
if let id {
|
||||
return .data(Self.connectOkData(id: id))
|
||||
return .data(Self.connectOkData(id: id, auth: self.helloAuth))
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 1_000_000)
|
||||
}
|
||||
return .data(Self.connectOkData(id: "connect"))
|
||||
return .data(Self.connectOkData(id: "connect", auth: self.helloAuth))
|
||||
}
|
||||
|
||||
func receive(
|
||||
|
|
@ -110,8 +115,8 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
|||
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
|
||||
}
|
||||
|
||||
private static func connectOkData(id: String) -> Data {
|
||||
let payload: [String: Any] = [
|
||||
private static func connectOkData(id: String, auth: [String: Any]? = nil) -> Data {
|
||||
var payload: [String: Any] = [
|
||||
"type": "hello-ok",
|
||||
"protocol": 2,
|
||||
"server": [
|
||||
|
|
@ -137,6 +142,9 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
|||
"tickIntervalMs": 30_000,
|
||||
],
|
||||
]
|
||||
if let auth {
|
||||
payload["auth"] = auth
|
||||
}
|
||||
let frame: [String: Any] = [
|
||||
"type": "res",
|
||||
"id": id,
|
||||
|
|
@ -149,9 +157,14 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
|||
|
||||
private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let helloAuth: [String: Any]?
|
||||
private var tasks: [FakeGatewayWebSocketTask] = []
|
||||
private var makeCount = 0
|
||||
|
||||
init(helloAuth: [String: Any]? = nil) {
|
||||
self.helloAuth = helloAuth
|
||||
}
|
||||
|
||||
func snapshotMakeCount() -> Int {
|
||||
self.lock.withLock { self.makeCount }
|
||||
}
|
||||
|
|
@ -164,7 +177,7 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
|
|||
_ = url
|
||||
return self.lock.withLock {
|
||||
self.makeCount += 1
|
||||
let task = FakeGatewayWebSocketTask()
|
||||
let task = FakeGatewayWebSocketTask(helloAuth: self.helloAuth)
|
||||
self.tasks.append(task)
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
|
|
@ -234,6 +247,145 @@ struct GatewayNodeSessionTests {
|
|||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
func bootstrapHelloStoresAdditionalDeviceTokens() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let session = FakeGatewayWebSocketSession(helloAuth: [
|
||||
"deviceToken": "node-device-token",
|
||||
"role": "node",
|
||||
"scopes": [],
|
||||
"issuedAtMs": 1000,
|
||||
"deviceTokens": [
|
||||
[
|
||||
"deviceToken": "node-device-token",
|
||||
"role": "node",
|
||||
"scopes": ["operator.admin"],
|
||||
"issuedAtMs": 1000,
|
||||
],
|
||||
[
|
||||
"deviceToken": "operator-device-token",
|
||||
"role": "operator",
|
||||
"scopes": [
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
"issuedAtMs": 1001,
|
||||
],
|
||||
],
|
||||
])
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: true)
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "wss://example.invalid")!,
|
||||
token: nil,
|
||||
bootstrapToken: "fresh-bootstrap-token",
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in
|
||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
})
|
||||
|
||||
let nodeEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node"))
|
||||
let operatorEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator"))
|
||||
#expect(nodeEntry.token == "node-device-token")
|
||||
#expect(nodeEntry.scopes == [])
|
||||
#expect(operatorEntry.token == "operator-device-token")
|
||||
#expect(operatorEntry.scopes.contains("operator.approvals"))
|
||||
#expect(!operatorEntry.scopes.contains("operator.admin"))
|
||||
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
func nonBootstrapHelloDoesNotOverwriteStoredDeviceTokens() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let session = FakeGatewayWebSocketSession(helloAuth: [
|
||||
"deviceToken": "server-node-token",
|
||||
"role": "node",
|
||||
"scopes": [],
|
||||
"deviceTokens": [
|
||||
[
|
||||
"deviceToken": "server-operator-token",
|
||||
"role": "operator",
|
||||
"scopes": ["operator.admin"],
|
||||
],
|
||||
],
|
||||
])
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: true)
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "wss://example.invalid")!,
|
||||
token: "shared-token",
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in
|
||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
})
|
||||
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node") == nil)
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") == nil)
|
||||
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
|
||||
let normalized = canonicalizeCanvasHostUrl(
|
||||
|
|
|
|||
|
|
@ -96,6 +96,19 @@ export const HelloOkSchema = Type.Object(
|
|||
role: NonEmptyString,
|
||||
scopes: Type.Array(NonEmptyString),
|
||||
issuedAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
deviceTokens: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object(
|
||||
{
|
||||
deviceToken: NonEmptyString,
|
||||
role: NonEmptyString,
|
||||
scopes: Type.Array(NonEmptyString),
|
||||
issuedAtMs: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -711,9 +711,12 @@ export function registerControlUiAndPairingSuite(): void {
|
|||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("auto-approves fresh node-only bootstrap pairing and revokes the token after connect", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
test("auto-approves fresh node bootstrap pairing from qr setup code", async () => {
|
||||
const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
|
||||
await import("../infra/device-bootstrap.js");
|
||||
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
||||
const { getPairedDevice, listDevicePairing, verifyDeviceToken } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
ws.close();
|
||||
|
||||
|
|
@ -729,12 +732,7 @@ export function registerControlUiAndPairingSuite(): void {
|
|||
};
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
profile: {
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
},
|
||||
});
|
||||
const issued = await issueDeviceBootstrapToken();
|
||||
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const initial = await connectReq(wsBootstrap, {
|
||||
skipDefaultAuth: true,
|
||||
|
|
@ -752,168 +750,84 @@ export function registerControlUiAndPairingSuite(): void {
|
|||
deviceToken?: string;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
deviceTokens?: Array<{
|
||||
deviceToken?: string;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
}>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(initialPayload?.type).toBe("hello-ok");
|
||||
const issuedDeviceToken = initialPayload?.auth?.deviceToken;
|
||||
const issuedOperatorToken = initialPayload?.auth?.deviceTokens?.find(
|
||||
(entry) => entry.role === "operator",
|
||||
)?.deviceToken;
|
||||
expect(issuedDeviceToken).toBeDefined();
|
||||
expect(issuedOperatorToken).toBeDefined();
|
||||
expect(initialPayload?.auth?.role).toBe("node");
|
||||
expect(initialPayload?.auth?.scopes ?? []).toEqual([]);
|
||||
expect(initialPayload?.auth?.deviceTokens?.some((entry) => entry.role === "node")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
initialPayload?.auth?.deviceTokens?.find((entry) => entry.role === "operator")?.scopes,
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
]),
|
||||
);
|
||||
|
||||
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?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
||||
expect(paired?.approvedScopes ?? []).toEqual(
|
||||
expect.arrayContaining([
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
]),
|
||||
);
|
||||
expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken);
|
||||
if (!issuedDeviceToken) {
|
||||
throw new Error("expected hello-ok auth.deviceToken for bootstrap onboarding");
|
||||
expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken);
|
||||
if (!issuedDeviceToken || !issuedOperatorToken) {
|
||||
throw new Error("expected hello-ok auth.deviceTokens 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("keeps setup bootstrap tokens valid until operator approval completes", async () => {
|
||||
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
||||
const { approveDevicePairing, 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-setup-profile-",
|
||||
);
|
||||
const nodeClient = {
|
||||
...client,
|
||||
id: "openclaw-android",
|
||||
mode: "node",
|
||||
};
|
||||
const operatorClient = {
|
||||
...client,
|
||||
id: "openclaw-android",
|
||||
mode: "ui",
|
||||
};
|
||||
const operatorScopes = ["operator.read", "operator.write", "operator.talk.secrets"];
|
||||
|
||||
try {
|
||||
const issued = await issueDeviceBootstrapToken();
|
||||
|
||||
const wsNode = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const nodeConnect = await connectReq(wsNode, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client: nodeClient,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(nodeConnect.ok).toBe(true);
|
||||
wsNode.close();
|
||||
|
||||
const pairedAfterNode = await getPairedDevice(identity.deviceId);
|
||||
expect(pairedAfterNode?.roles).toEqual(expect.arrayContaining(["node"]));
|
||||
|
||||
const wsOperatorPending = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const operatorPending = await connectReq(wsOperatorPending, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "operator",
|
||||
scopes: operatorScopes,
|
||||
client: operatorClient,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(operatorPending.ok).toBe(false);
|
||||
expect((operatorPending.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
||||
);
|
||||
wsOperatorPending.close();
|
||||
|
||||
const pending = (await listDevicePairing()).pending.filter(
|
||||
(entry) => entry.deviceId === identity.deviceId,
|
||||
);
|
||||
expect(pending).toHaveLength(1);
|
||||
const pendingRequest = pending[0];
|
||||
if (!pendingRequest) {
|
||||
throw new Error("expected pending pairing request");
|
||||
}
|
||||
await approveDevicePairing(pendingRequest.requestId, {
|
||||
callerScopes: pendingRequest.scopes ?? ["operator.admin"],
|
||||
await new Promise<void>((resolve) => {
|
||||
if (wsBootstrap.readyState === WebSocket.CLOSED) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
wsBootstrap.once("close", () => resolve());
|
||||
wsBootstrap.close();
|
||||
});
|
||||
|
||||
const wsNodeReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const nodeReconnect = await connectReq(wsNodeReconnect, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
client: nodeClient,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(nodeReconnect.ok).toBe(true);
|
||||
wsNodeReconnect.close();
|
||||
await expect(
|
||||
verifyDeviceBootstrapToken({
|
||||
token: issued.token,
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
role: "node",
|
||||
scopes: [],
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
|
||||
const wsOperatorApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const operatorApproved = await connectReq(wsOperatorApproved, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "operator",
|
||||
scopes: operatorScopes,
|
||||
client: operatorClient,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(operatorApproved.ok).toBe(true);
|
||||
wsOperatorApproved.close();
|
||||
|
||||
const pairedAfterOperator = await getPairedDevice(identity.deviceId);
|
||||
expect(pairedAfterOperator?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
||||
|
||||
const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
||||
const replay = await connectReq(wsReplay, {
|
||||
skipDefaultAuth: true,
|
||||
bootstrapToken: issued.token,
|
||||
role: "operator",
|
||||
scopes: operatorScopes,
|
||||
client: operatorClient,
|
||||
deviceIdentityPath: identityPath,
|
||||
});
|
||||
expect(replay.ok).toBe(false);
|
||||
expect((replay.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
|
||||
);
|
||||
wsReplay.close();
|
||||
await expect(
|
||||
verifyDeviceToken({
|
||||
deviceId: identity.deviceId,
|
||||
token: issuedDeviceToken,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
} finally {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@ import os from "node:os";
|
|||
import type { WebSocket } from "ws";
|
||||
import { loadConfig } from "../../../config/config.js";
|
||||
import {
|
||||
getDeviceBootstrapTokenProfile,
|
||||
redeemDeviceBootstrapTokenProfile,
|
||||
restoreDeviceBootstrapToken,
|
||||
getBoundDeviceBootstrapProfile,
|
||||
revokeDeviceBootstrapToken,
|
||||
restoreDeviceBootstrapToken,
|
||||
verifyDeviceBootstrapToken,
|
||||
} from "../../../infra/device-bootstrap.js";
|
||||
import {
|
||||
|
|
@ -14,6 +13,7 @@ import {
|
|||
normalizeDevicePublicKeyBase64Url,
|
||||
} from "../../../infra/device-identity.js";
|
||||
import {
|
||||
approveBootstrapDevicePairing,
|
||||
approveDevicePairing,
|
||||
ensureDeviceToken,
|
||||
getPairedDevice,
|
||||
|
|
@ -34,6 +34,7 @@ import { upsertPresence } from "../../../infra/system-presence.js";
|
|||
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
||||
import { rawDataToString } from "../../../infra/ws.js";
|
||||
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||
import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js";
|
||||
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
|
||||
import {
|
||||
isBrowserOperatorUiClient,
|
||||
|
|
@ -714,10 +715,8 @@ export function attachGatewayWsMessageHandler(params: {
|
|||
rejectUnauthorized(authResult);
|
||||
return;
|
||||
}
|
||||
const bootstrapProfile =
|
||||
authMethod === "bootstrap-token" && bootstrapTokenCandidate
|
||||
? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate })
|
||||
: null;
|
||||
let bootstrapProfile: DeviceBootstrapProfile | null = null;
|
||||
let shouldConsumeBootstrapTokenAfterHello = false;
|
||||
|
||||
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
|
||||
isControlUi,
|
||||
|
|
@ -824,6 +823,21 @@ export function attachGatewayWsMessageHandler(params: {
|
|||
allowedScopes: pairedScopes,
|
||||
});
|
||||
};
|
||||
if (
|
||||
bootstrapProfile === null &&
|
||||
authMethod === "bootstrap-token" &&
|
||||
reason === "not-paired" &&
|
||||
role === "node" &&
|
||||
scopes.length === 0 &&
|
||||
!existingPairedDevice &&
|
||||
bootstrapTokenCandidate
|
||||
) {
|
||||
bootstrapProfile = await getBoundDeviceBootstrapProfile({
|
||||
token: bootstrapTokenCandidate,
|
||||
deviceId: device.id,
|
||||
publicKey: devicePublicKey,
|
||||
});
|
||||
}
|
||||
const allowSilentLocalPairing = shouldAllowSilentLocalPairing({
|
||||
locality: pairingLocality,
|
||||
hasBrowserOriginHeader,
|
||||
|
|
@ -831,19 +845,26 @@ export function attachGatewayWsMessageHandler(params: {
|
|||
isWebchat,
|
||||
reason,
|
||||
});
|
||||
// Bootstrap setup can silently pair the first fresh node connect. Keep the token alive
|
||||
// until the issued profile is fully redeemed so follow-up operator connects can still
|
||||
// present the same bootstrap token while approval is pending.
|
||||
// QR bootstrap onboarding stays single-use, but only consume the bootstrap token
|
||||
// after the hello-ok path succeeds so reconnects can recover from pre-hello failures.
|
||||
const allowSilentBootstrapPairing =
|
||||
authMethod === "bootstrap-token" &&
|
||||
reason === "not-paired" &&
|
||||
role === "node" &&
|
||||
scopes.length === 0 &&
|
||||
!existingPairedDevice;
|
||||
!existingPairedDevice &&
|
||||
bootstrapProfile !== null;
|
||||
const bootstrapProfileForSilentApproval = allowSilentBootstrapPairing
|
||||
? bootstrapProfile
|
||||
: null;
|
||||
const bootstrapPairingRoles = bootstrapProfileForSilentApproval
|
||||
? Array.from(new Set([role, ...bootstrapProfileForSilentApproval.roles]))
|
||||
: undefined;
|
||||
const pairing = await requestDevicePairing({
|
||||
deviceId: device.id,
|
||||
publicKey: devicePublicKey,
|
||||
...clientPairingMetadata,
|
||||
...(bootstrapPairingRoles ? { roles: bootstrapPairingRoles } : {}),
|
||||
silent:
|
||||
reason === "scope-upgrade"
|
||||
? false
|
||||
|
|
@ -868,10 +889,18 @@ export function attachGatewayWsMessageHandler(params: {
|
|||
return replacementPending?.requestId;
|
||||
};
|
||||
if (pairing.request.silent === true) {
|
||||
approved = await approveDevicePairing(pairing.request.requestId, {
|
||||
callerScopes: scopes,
|
||||
});
|
||||
approved = bootstrapProfileForSilentApproval
|
||||
? await approveBootstrapDevicePairing(
|
||||
pairing.request.requestId,
|
||||
bootstrapProfileForSilentApproval,
|
||||
)
|
||||
: await approveDevicePairing(pairing.request.requestId, {
|
||||
callerScopes: scopes,
|
||||
});
|
||||
if (approved?.status === "approved") {
|
||||
if (allowSilentBootstrapPairing) {
|
||||
shouldConsumeBootstrapTokenAfterHello = true;
|
||||
}
|
||||
logGateway.info(
|
||||
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
|
||||
);
|
||||
|
|
@ -1021,6 +1050,47 @@ export function attachGatewayWsMessageHandler(params: {
|
|||
const deviceToken = device
|
||||
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
|
||||
: null;
|
||||
const bootstrapDeviceTokens: Array<{
|
||||
deviceToken: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
issuedAtMs: number;
|
||||
}> = [];
|
||||
if (deviceToken) {
|
||||
bootstrapDeviceTokens.push({
|
||||
deviceToken: deviceToken.token,
|
||||
role: deviceToken.role,
|
||||
scopes: deviceToken.scopes,
|
||||
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
|
||||
});
|
||||
}
|
||||
const bootstrapProfileForHello: DeviceBootstrapProfile | null = device
|
||||
? bootstrapProfile
|
||||
: null;
|
||||
if (device && bootstrapProfileForHello !== null) {
|
||||
for (const bootstrapRole of bootstrapProfileForHello.roles) {
|
||||
if (bootstrapDeviceTokens.some((entry) => entry.role === bootstrapRole)) {
|
||||
continue;
|
||||
}
|
||||
const bootstrapRoleScopes =
|
||||
bootstrapRole === "operator" ? bootstrapProfileForHello.scopes : [];
|
||||
const extraToken = await ensureDeviceToken({
|
||||
deviceId: device.id,
|
||||
role: bootstrapRole,
|
||||
scopes: bootstrapRoleScopes,
|
||||
});
|
||||
if (!extraToken) {
|
||||
continue;
|
||||
}
|
||||
bootstrapDeviceTokens.push({
|
||||
deviceToken: extraToken.token,
|
||||
role: extraToken.role,
|
||||
scopes: extraToken.scopes,
|
||||
issuedAtMs: extraToken.rotatedAtMs ?? extraToken.createdAtMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (role === "node") {
|
||||
const reconciliation = await reconcileNodePairingOnConnect({
|
||||
cfg: loadConfig(),
|
||||
|
|
@ -1111,6 +1181,9 @@ export function attachGatewayWsMessageHandler(params: {
|
|||
role: deviceToken.role,
|
||||
scopes: deviceToken.scopes,
|
||||
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
|
||||
...(bootstrapDeviceTokens.length > 1
|
||||
? { deviceTokens: bootstrapDeviceTokens.slice(1) }
|
||||
: {}),
|
||||
}
|
||||
: undefined,
|
||||
policy: {
|
||||
|
|
@ -1188,32 +1261,20 @@ export function attachGatewayWsMessageHandler(params: {
|
|||
let consumedBootstrapTokenRecord:
|
||||
| Awaited<ReturnType<typeof revokeDeviceBootstrapToken>>["record"]
|
||||
| undefined;
|
||||
if (
|
||||
authMethod === "bootstrap-token" &&
|
||||
bootstrapProfile &&
|
||||
bootstrapTokenCandidate &&
|
||||
device
|
||||
) {
|
||||
if (shouldConsumeBootstrapTokenAfterHello && bootstrapTokenCandidate && device) {
|
||||
try {
|
||||
const redemption = await redeemDeviceBootstrapTokenProfile({
|
||||
const revoked = await revokeDeviceBootstrapToken({
|
||||
token: bootstrapTokenCandidate,
|
||||
role,
|
||||
scopes,
|
||||
});
|
||||
if (redemption.fullyRedeemed) {
|
||||
const revoked = await revokeDeviceBootstrapToken({
|
||||
token: bootstrapTokenCandidate,
|
||||
});
|
||||
consumedBootstrapTokenRecord = revoked.record;
|
||||
if (!revoked.removed) {
|
||||
logGateway.warn(
|
||||
`bootstrap token revoke skipped after profile redemption device=${device.id}`,
|
||||
);
|
||||
}
|
||||
consumedBootstrapTokenRecord = revoked.record;
|
||||
if (!revoked.removed) {
|
||||
logGateway.warn(
|
||||
`bootstrap token revoke skipped after bootstrap handoff device=${device.id}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logGateway.warn(
|
||||
`bootstrap token redemption bookkeeping failed device=${device.id}: ${formatForLog(err)}`,
|
||||
`bootstrap token consume failed after device-token handoff device=${device.id}: ${formatForLog(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
|||
import {
|
||||
clearDeviceBootstrapTokens,
|
||||
DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
|
||||
getBoundDeviceBootstrapProfile,
|
||||
getDeviceBootstrapTokenProfile,
|
||||
issueDeviceBootstrapToken,
|
||||
redeemDeviceBootstrapTokenProfile,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
revokeDeviceBootstrapToken,
|
||||
verifyDeviceBootstrapToken,
|
||||
} from "./device-bootstrap.js";
|
||||
import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } from "./device-identity.js";
|
||||
|
||||
const tempDirs = createTrackedTempDirs();
|
||||
const createTempDir = () => tempDirs.make("openclaw-device-bootstrap-test-");
|
||||
|
|
@ -68,7 +70,7 @@ describe("device bootstrap tokens", () => {
|
|||
issuedAtMs: Date.now(),
|
||||
profile: {
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -103,7 +105,12 @@ describe("device bootstrap tokens", () => {
|
|||
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual(
|
||||
{
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
|
||||
scopes: [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
},
|
||||
);
|
||||
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull();
|
||||
|
|
@ -129,7 +136,12 @@ describe("device bootstrap tokens", () => {
|
|||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
scopes: [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.talk.secrets",
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
|
|
@ -137,7 +149,12 @@ describe("device bootstrap tokens", () => {
|
|||
baseDir,
|
||||
token: issued.token,
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
scopes: [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.talk.secrets",
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
recorded: true,
|
||||
|
|
@ -214,7 +231,12 @@ describe("device bootstrap tokens", () => {
|
|||
issuedAtMs,
|
||||
profile: {
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
|
||||
scopes: [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -358,6 +380,37 @@ describe("device bootstrap tokens", () => {
|
|||
expect(parsed[issued.token]?.token).toBe(issued.token);
|
||||
});
|
||||
|
||||
it("accepts equivalent public key encodings after binding the bootstrap token", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "device.json"));
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
const rawPublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
|
||||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: identity.publicKeyPem,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
verifyBootstrapToken(baseDir, issued.token, {
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: rawPublicKey,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
getBoundDeviceBootstrapProfile({
|
||||
token: issued.token,
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: rawPublicKey,
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a second device identity after the first verification binds the token", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type DeviceBootstrapProfileInput,
|
||||
} from "../shared/device-bootstrap-profile.js";
|
||||
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
|
||||
import { normalizeDevicePublicKeyBase64Url } from "./device-identity.js";
|
||||
import { resolvePairingPaths } from "./pairing-files.js";
|
||||
import {
|
||||
createAsyncLock,
|
||||
|
|
@ -115,6 +116,17 @@ function bootstrapProfileSatisfiesProfile(params: {
|
|||
return true;
|
||||
}
|
||||
|
||||
function normalizeBootstrapPublicKey(publicKey: string): string {
|
||||
const trimmed = publicKey.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.includes("BEGIN") || /[+/=]/.test(trimmed)) {
|
||||
return normalizeDevicePublicKeyBase64Url(trimmed) ?? trimmed;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function loadState(baseDir?: string): Promise<DeviceBootstrapStateFile> {
|
||||
const bootstrapPath = resolveBootstrapPath(baseDir);
|
||||
const rawState = (await readJsonFile<DeviceBootstrapStateFile>(bootstrapPath)) ?? {};
|
||||
|
|
@ -306,7 +318,7 @@ export async function verifyDeviceBootstrapToken(params: {
|
|||
const [tokenKey, record] = found;
|
||||
|
||||
const deviceId = params.deviceId.trim();
|
||||
const publicKey = params.publicKey.trim();
|
||||
const publicKey = normalizeBootstrapPublicKey(params.publicKey);
|
||||
const role = params.role.trim();
|
||||
if (!deviceId || !publicKey || !role) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
|
|
@ -326,7 +338,10 @@ export async function verifyDeviceBootstrapToken(params: {
|
|||
}
|
||||
|
||||
const boundDeviceId = record.deviceId?.trim();
|
||||
const boundPublicKey = record.publicKey?.trim();
|
||||
const boundPublicKey =
|
||||
typeof record.publicKey === "string"
|
||||
? normalizeBootstrapPublicKey(record.publicKey)
|
||||
: undefined;
|
||||
if (boundDeviceId || boundPublicKey) {
|
||||
if (boundDeviceId !== deviceId || boundPublicKey !== publicKey) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
|
|
@ -353,3 +368,38 @@ export async function verifyDeviceBootstrapToken(params: {
|
|||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBoundDeviceBootstrapProfile(params: {
|
||||
token: string;
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
baseDir?: string;
|
||||
}): Promise<DeviceBootstrapProfile | null> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(params.baseDir);
|
||||
const providedToken = params.token.trim();
|
||||
if (!providedToken) {
|
||||
return null;
|
||||
}
|
||||
const found = Object.entries(state).find(([, candidate]) =>
|
||||
verifyPairingToken(providedToken, candidate.token),
|
||||
);
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
const [, record] = found;
|
||||
const deviceId = params.deviceId.trim();
|
||||
const publicKey = normalizeBootstrapPublicKey(params.publicKey);
|
||||
if (!deviceId || !publicKey) {
|
||||
return null;
|
||||
}
|
||||
const recordPublicKey =
|
||||
typeof record.publicKey === "string"
|
||||
? normalizeBootstrapPublicKey(record.publicKey)
|
||||
: undefined;
|
||||
if (record.deviceId?.trim() !== deviceId || recordPublicKey !== publicKey) {
|
||||
return null;
|
||||
}
|
||||
return resolvePersistedBootstrapProfile(record);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
|
||||
import { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } from "./device-bootstrap.js";
|
||||
import {
|
||||
approveBootstrapDevicePairing,
|
||||
approveDevicePairing,
|
||||
clearDevicePairing,
|
||||
ensureDeviceToken,
|
||||
|
|
@ -96,23 +98,27 @@ async function overwritePairedOperatorTokenScopes(baseDir: string, scopes: strin
|
|||
await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
|
||||
}
|
||||
|
||||
async function mutatePairedOperatorDevice(baseDir: string, mutate: (device: PairedDevice) => void) {
|
||||
async function mutatePairedDevice(
|
||||
baseDir: string,
|
||||
deviceId: string,
|
||||
mutate: (device: PairedDevice) => void,
|
||||
) {
|
||||
const { pairedPath } = resolvePairingPaths(baseDir, "devices");
|
||||
const pairedByDeviceId = JSON.parse(await readFile(pairedPath, "utf8")) as Record<
|
||||
string,
|
||||
PairedDevice
|
||||
>;
|
||||
const device = pairedByDeviceId["device-1"];
|
||||
const device = pairedByDeviceId[deviceId];
|
||||
expect(device).toBeDefined();
|
||||
if (!device) {
|
||||
throw new Error("expected paired operator device");
|
||||
throw new Error(`expected paired device ${deviceId}`);
|
||||
}
|
||||
mutate(device);
|
||||
await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
|
||||
}
|
||||
|
||||
async function clearPairedOperatorApprovalBaseline(baseDir: string) {
|
||||
await mutatePairedOperatorDevice(baseDir, (device) => {
|
||||
await mutatePairedDevice(baseDir, "device-1", (device) => {
|
||||
delete device.approvedScopes;
|
||||
delete device.scopes;
|
||||
});
|
||||
|
|
@ -558,6 +564,68 @@ describe("device pairing tokens", () => {
|
|||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("normalizes legacy node token scopes back to [] on re-approval", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
await setupPairedNodeDevice(baseDir);
|
||||
|
||||
await mutatePairedDevice(baseDir, "node-1", (device) => {
|
||||
const nodeToken = device.tokens?.node;
|
||||
expect(nodeToken).toBeDefined();
|
||||
if (!nodeToken) {
|
||||
throw new Error("expected paired node token");
|
||||
}
|
||||
nodeToken.scopes = ["operator.read"];
|
||||
});
|
||||
|
||||
const repair = await requestDevicePairing(
|
||||
{
|
||||
deviceId: "node-1",
|
||||
publicKey: "public-key-node-1",
|
||||
role: "node",
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
await approveDevicePairing(repair.request.requestId, { callerScopes: [] }, baseDir);
|
||||
|
||||
const paired = await getPairedDevice("node-1", baseDir);
|
||||
expect(paired?.scopes).toEqual([]);
|
||||
expect(paired?.approvedScopes).toEqual([]);
|
||||
expect(paired?.tokens?.node?.scopes).toEqual([]);
|
||||
});
|
||||
|
||||
test("bootstrap pairing seeds node and operator device tokens explicitly", async () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
const request = await requestDevicePairing(
|
||||
{
|
||||
deviceId: "bootstrap-device-1",
|
||||
publicKey: "bootstrap-public-key-1",
|
||||
role: "node",
|
||||
roles: ["node", "operator"],
|
||||
scopes: [],
|
||||
silent: true,
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
await expect(
|
||||
approveBootstrapDevicePairing(
|
||||
request.request.requestId,
|
||||
PAIRING_SETUP_BOOTSTRAP_PROFILE,
|
||||
baseDir,
|
||||
),
|
||||
).resolves.toEqual(expect.objectContaining({ status: "approved" }));
|
||||
|
||||
const paired = await getPairedDevice("bootstrap-device-1", baseDir);
|
||||
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
||||
expect(paired?.approvedScopes).toEqual(
|
||||
expect.arrayContaining(PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes),
|
||||
);
|
||||
expect(paired?.tokens?.node?.scopes).toEqual([]);
|
||||
expect(paired?.tokens?.operator?.scopes).toEqual(
|
||||
expect.arrayContaining(PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes),
|
||||
);
|
||||
});
|
||||
|
||||
test("verifies token and rejects mismatches", async () => {
|
||||
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
|
||||
import type { DeviceBootstrapProfile } from "../shared/device-bootstrap-profile.js";
|
||||
import { resolveMissingRequestedScope, roleScopesAllow } from "../shared/operator-scope-compat.js";
|
||||
import {
|
||||
createAsyncLock,
|
||||
|
|
@ -92,6 +93,7 @@ type DevicePairingStateFile = {
|
|||
};
|
||||
|
||||
const PENDING_TTL_MS = 5 * 60 * 1000;
|
||||
const OPERATOR_ROLE = "operator";
|
||||
const OPERATOR_SCOPE_PREFIX = "operator.";
|
||||
|
||||
const withLock = createAsyncLock();
|
||||
|
|
@ -484,12 +486,14 @@ export async function approveDevicePairing(
|
|||
): Promise<ApproveDevicePairingResult>;
|
||||
export async function approveDevicePairing(
|
||||
requestId: string,
|
||||
options: { callerScopes?: readonly string[] },
|
||||
options: { callerScopes?: readonly string[]; approvedScopesOverride?: readonly string[] },
|
||||
baseDir?: string,
|
||||
): Promise<ApproveDevicePairingResult>;
|
||||
export async function approveDevicePairing(
|
||||
requestId: string,
|
||||
optionsOrBaseDir?: { callerScopes?: readonly string[] } | string,
|
||||
optionsOrBaseDir?:
|
||||
| { callerScopes?: readonly string[]; approvedScopesOverride?: readonly string[] }
|
||||
| string,
|
||||
maybeBaseDir?: string,
|
||||
): Promise<ApproveDevicePairingResult> {
|
||||
const options =
|
||||
|
|
@ -503,10 +507,14 @@ export async function approveDevicePairing(
|
|||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
const requestedRoles = mergeRoles(pending.roles, pending.role) ?? [];
|
||||
const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) =>
|
||||
scope.startsWith(OPERATOR_SCOPE_PREFIX),
|
||||
const approvedScopesOverride = normalizeDeviceAuthScopes(
|
||||
options?.approvedScopesOverride ? [...options.approvedScopesOverride] : undefined,
|
||||
);
|
||||
const requestedRoles = mergeRoles(pending.roles, pending.role) ?? [];
|
||||
const requestedOperatorScopes = normalizeDeviceAuthScopes([
|
||||
...(pending.scopes ?? []),
|
||||
...approvedScopesOverride,
|
||||
]).filter((scope) => scope.startsWith(OPERATOR_SCOPE_PREFIX));
|
||||
if (requestedOperatorScopes.length > 0) {
|
||||
if (!options?.callerScopes) {
|
||||
return {
|
||||
|
|
@ -514,11 +522,11 @@ export async function approveDevicePairing(
|
|||
missingScope: requestedOperatorScopes[0] ?? "callerScopes-required",
|
||||
};
|
||||
}
|
||||
if (!requestedRoles.includes("operator")) {
|
||||
if (!requestedRoles.includes(OPERATOR_ROLE)) {
|
||||
return { status: "forbidden", missingScope: requestedOperatorScopes[0] };
|
||||
}
|
||||
const missingScope = resolveMissingRequestedScope({
|
||||
role: "operator",
|
||||
role: OPERATOR_ROLE,
|
||||
requestedScopes: requestedOperatorScopes,
|
||||
allowedScopes: options.callerScopes,
|
||||
});
|
||||
|
|
@ -532,6 +540,7 @@ export async function approveDevicePairing(
|
|||
const approvedScopes = mergeScopes(
|
||||
existing?.approvedScopes ?? existing?.scopes,
|
||||
pending.scopes,
|
||||
approvedScopesOverride,
|
||||
);
|
||||
const tokens = existing?.tokens ? { ...existing.tokens } : {};
|
||||
for (const roleForToken of requestedRoles) {
|
||||
|
|
@ -578,6 +587,90 @@ export async function approveDevicePairing(
|
|||
});
|
||||
}
|
||||
|
||||
export async function approveBootstrapDevicePairing(
|
||||
requestId: string,
|
||||
bootstrapProfile: DeviceBootstrapProfile,
|
||||
baseDir?: string,
|
||||
): Promise<ApproveDevicePairingResult> {
|
||||
// QR bootstrap handoff is an explicit trust path: it can seed the bounded
|
||||
// node/operator baseline from the verified bootstrap profile without routing
|
||||
// operator scope approval through the generic interactive approval checker.
|
||||
const approvedRoles = mergeRoles(bootstrapProfile.roles) ?? [];
|
||||
const approvedScopes = normalizeDeviceAuthScopes([...bootstrapProfile.scopes]);
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = state.pendingById[requestId];
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
const requestedRoles = resolveRequestedRoles(pending);
|
||||
const missingRole = requestedRoles.find((role) => !approvedRoles.includes(role));
|
||||
if (missingRole) {
|
||||
return { status: "forbidden", missingScope: missingRole };
|
||||
}
|
||||
const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) =>
|
||||
scope.startsWith(OPERATOR_SCOPE_PREFIX),
|
||||
);
|
||||
const missingScope = resolveMissingRequestedScope({
|
||||
role: OPERATOR_ROLE,
|
||||
requestedScopes: requestedOperatorScopes,
|
||||
allowedScopes: approvedScopes,
|
||||
});
|
||||
if (missingScope) {
|
||||
return { status: "forbidden", missingScope };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const existing = state.pairedByDeviceId[pending.deviceId];
|
||||
const roles = mergeRoles(
|
||||
existing?.roles,
|
||||
existing?.role,
|
||||
pending.roles,
|
||||
pending.role,
|
||||
approvedRoles,
|
||||
);
|
||||
const nextApprovedScopes = mergeScopes(
|
||||
existing?.approvedScopes ?? existing?.scopes,
|
||||
pending.scopes,
|
||||
approvedScopes,
|
||||
);
|
||||
const tokens = existing?.tokens ? { ...existing.tokens } : {};
|
||||
for (const roleForToken of approvedRoles) {
|
||||
const existingToken = tokens[roleForToken];
|
||||
const tokenScopes = roleForToken === OPERATOR_ROLE ? approvedScopes : [];
|
||||
tokens[roleForToken] = buildDeviceAuthToken({
|
||||
role: roleForToken,
|
||||
scopes: tokenScopes,
|
||||
existing: existingToken,
|
||||
now,
|
||||
...(existingToken ? { rotatedAtMs: now } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const device: PairedDevice = {
|
||||
deviceId: pending.deviceId,
|
||||
publicKey: pending.publicKey,
|
||||
displayName: pending.displayName,
|
||||
platform: pending.platform,
|
||||
deviceFamily: pending.deviceFamily,
|
||||
clientId: pending.clientId,
|
||||
clientMode: pending.clientMode,
|
||||
role: pending.role,
|
||||
roles,
|
||||
scopes: nextApprovedScopes,
|
||||
approvedScopes: nextApprovedScopes,
|
||||
remoteIp: pending.remoteIp,
|
||||
tokens,
|
||||
createdAtMs: existing?.createdAtMs ?? now,
|
||||
approvedAtMs: now,
|
||||
};
|
||||
delete state.pendingById[requestId];
|
||||
state.pairedByDeviceId[device.deviceId] = device;
|
||||
await persistState(state, baseDir);
|
||||
return { status: "approved", requestId, device };
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectDevicePairing(
|
||||
requestId: string,
|
||||
baseDir?: string,
|
||||
|
|
|
|||
|
|
@ -92,7 +92,12 @@ describe("pairing setup code", () => {
|
|||
expect.objectContaining({
|
||||
profile: {
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
|
||||
scopes: [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export type DeviceBootstrapProfileInput = {
|
|||
|
||||
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
|
||||
roles: ["node", "operator"],
|
||||
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
|
||||
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
|
||||
};
|
||||
|
||||
function normalizeBootstrapRoles(roles: readonly string[] | undefined): string[] {
|
||||
|
|
|
|||
Loading…
Reference in New Issue