mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 09:31:42 +00:00
* fix(crestodian): keep onboarding RPCs restart-safe * fix(profiles): isolate approval state migrations * fix(crestodian): bypass configured gateway setup * test(crestodian): type onboarding mocks * fix(onboarding): require inference before Crestodian * fix(onboarding): enforce verified inference handoff * fix(macos): reset setup on gateway endpoint edits * chore(i18n): refresh native source inventory * fix(gateway): keep socket on request cancellation * test(packaging): require workspace templates * fix(onboarding): bind setup to verified inference * fix(onboarding): align inference gate contracts * fix(crestodian): classify concurrent policy rejection * test(crestodian): expect registry restoration * fix(onboarding): bind setup to configured gateways * fix(codex): preserve startup phase deadlines * test(crestodian): match fail-closed policy ordering * test(onboarding): assert bound gateway handoff * fix(codex): bind runtime resolution to spawn cwd * test(crestodian): assert policy rejection order * fix(cli): preserve gateway routing across restarts * fix(macos): fail closed during gateway edits * test(macos): cover gateway route generation races * chore: keep release notes out of onboarding PR * fix(ci): refresh onboarding generated checks * style(swift): align gateway channel formatting * fix(ci): refresh plugin SDK surface budgets * fix(ci): resync native string inventory * refactor(swift): split gateway channel support * test(doctor): isolate plugin compatibility registry * test(macos): isolate gateway onboarding fixtures * test(macos): assert gateway lease health ordering * fix(codex): reconcile computer-use startup changes
483 lines
20 KiB
Swift
483 lines
20 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import OpenClawDiscovery
|
|
import OpenClawIPC
|
|
import OpenClawKit
|
|
import SwiftUI
|
|
import Testing
|
|
@testable import OpenClaw
|
|
|
|
private struct OnboardingStoredGatewayPreference {
|
|
let stableID: String?
|
|
let routeBinding: String?
|
|
}
|
|
|
|
private func captureOnboardingGatewayPreference() -> OnboardingStoredGatewayPreference {
|
|
OnboardingStoredGatewayPreference(
|
|
stableID: GatewayDiscoveryPreferences.preferredStableID(),
|
|
routeBinding: GatewayDiscoveryPreferences.preferredRouteBinding())
|
|
}
|
|
|
|
private func restoreOnboardingGatewayPreference(_ preference: OnboardingStoredGatewayPreference) {
|
|
GatewayDiscoveryPreferences.setPreferredStableID(
|
|
preference.stableID,
|
|
routeBinding: preference.routeBinding)
|
|
}
|
|
|
|
@Suite(.serialized)
|
|
@MainActor
|
|
struct OnboardingViewSmokeTests {
|
|
@Test func `onboarding view builds body`() {
|
|
let state = AppState(preview: true)
|
|
let view = OnboardingView(
|
|
state: state,
|
|
permissionMonitor: PermissionMonitor.shared,
|
|
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
|
|
_ = view.body
|
|
}
|
|
|
|
@Test func `onboarding window resizes vertically and gives the page the extra height`() {
|
|
#expect(OnboardingController.windowStyleMask.contains(.resizable))
|
|
|
|
let baseline = OnboardingView.contentHeight(
|
|
for: OnboardingView.windowHeight,
|
|
usesCompactHero: false)
|
|
let taller = OnboardingView.contentHeight(
|
|
for: OnboardingView.windowHeight + 200,
|
|
usesCompactHero: false)
|
|
|
|
#expect(taller - baseline == 200)
|
|
}
|
|
|
|
@Test func `page order delegates setup after inference to Crestodian`() {
|
|
let order = OnboardingView.pageOrder(
|
|
for: .local,
|
|
requiresCLIInstall: false)
|
|
#expect(!order.contains(4))
|
|
#expect(!order.contains(7))
|
|
#expect(!order.contains(8))
|
|
#expect(order.contains(3))
|
|
}
|
|
|
|
@Test func `fresh local setup installs CLI before inference setup`() {
|
|
let order = OnboardingView.pageOrder(
|
|
for: .local,
|
|
requiresCLIInstall: true)
|
|
|
|
#expect(order.firstIndex(of: 2) == 2)
|
|
#expect(order.firstIndex(of: 3) == 3)
|
|
}
|
|
|
|
@Test func `configured local setup skips CLI install page`() {
|
|
let order = OnboardingView.pageOrder(
|
|
for: .local,
|
|
requiresCLIInstall: false)
|
|
|
|
#expect(!order.contains(2))
|
|
}
|
|
|
|
@Test func `fresh onboarding defaults to this Mac`() {
|
|
let state = AppState(preview: true)
|
|
state.onboardingSeen = false
|
|
state.connectionMode = .unconfigured
|
|
let view = OnboardingView(state: state)
|
|
|
|
#expect(view.selectedConnectionMode == .local)
|
|
#expect(view.isConnectionSelectionBlocking)
|
|
#expect(state.connectionMode == .unconfigured)
|
|
}
|
|
|
|
@Test func `reopened onboarding preserves configure later selection`() {
|
|
let state = AppState(preview: true)
|
|
state.onboardingSeen = true
|
|
state.connectionMode = .unconfigured
|
|
let view = OnboardingView(state: state)
|
|
|
|
#expect(view.selectedConnectionMode == .unconfigured)
|
|
#expect(!view.isConnectionSelectionBlocking)
|
|
#expect(state.connectionMode == .unconfigured)
|
|
}
|
|
|
|
@Test func `advancing from recommended this Mac commits local mode`() {
|
|
let state = AppState(preview: true)
|
|
state.onboardingSeen = false
|
|
state.connectionMode = .unconfigured
|
|
let view = OnboardingView(state: state)
|
|
|
|
view.commitRecommendedConnectionIfNeeded(for: view.connectionPageIndex)
|
|
|
|
#expect(state.connectionMode == .local)
|
|
}
|
|
|
|
@Test func `automatic CLI setup waits for the initial status probe`() {
|
|
#expect(!OnboardingView.shouldAutoInstallCLI(
|
|
onCLIPage: true,
|
|
isLocal: true,
|
|
visible: true,
|
|
statusKnown: false,
|
|
executableReady: false,
|
|
installed: false,
|
|
installing: false))
|
|
#expect(OnboardingView.shouldAutoInstallCLI(
|
|
onCLIPage: true,
|
|
isLocal: true,
|
|
visible: true,
|
|
statusKnown: true,
|
|
executableReady: false,
|
|
installed: false,
|
|
installing: false))
|
|
#expect(!OnboardingView.shouldAutoInstallCLI(
|
|
onCLIPage: true,
|
|
isLocal: true,
|
|
visible: false,
|
|
statusKnown: true,
|
|
executableReady: false,
|
|
installed: false,
|
|
installing: false))
|
|
#expect(!OnboardingView.shouldAutoInstallCLI(
|
|
onCLIPage: true,
|
|
isLocal: true,
|
|
visible: true,
|
|
statusKnown: true,
|
|
executableReady: true,
|
|
installed: false,
|
|
installing: false))
|
|
}
|
|
|
|
@Test func `detected CLI starts its gateway after this Mac is selected`() {
|
|
#expect(!OnboardingView.shouldStartExistingCLIActivation(
|
|
isLocal: false,
|
|
executableReady: true,
|
|
installing: false))
|
|
#expect(OnboardingView.shouldStartExistingCLIActivation(
|
|
isLocal: true,
|
|
executableReady: true,
|
|
installing: false))
|
|
#expect(!OnboardingView.shouldStartExistingCLIActivation(
|
|
isLocal: true,
|
|
executableReady: true,
|
|
installing: true))
|
|
}
|
|
|
|
@Test func `connection mode change restarts full page monitoring`() {
|
|
let state = AppState(preview: true)
|
|
let view = OnboardingView(state: state)
|
|
var monitoredPage: Int?
|
|
let previousCrestodianChat = view.crestodianState.chat
|
|
view.aiSetup.manualKey = "route-bound"
|
|
view.crestodianState.isPresented = true
|
|
|
|
view.handleConnectionModeChange { pageIndex in
|
|
monitoredPage = pageIndex
|
|
}
|
|
|
|
#expect(view.aiSetup.manualKey.isEmpty)
|
|
#expect(!view.crestodianState.isPresented)
|
|
#expect(view.crestodianState.chat !== previousCrestodianChat)
|
|
#expect(monitoredPage == view.activePageIndex)
|
|
}
|
|
|
|
@Test func `gateway route reset returns later pages to inference setup`() throws {
|
|
let order = OnboardingView.pageOrder(
|
|
for: .remote,
|
|
requiresCLIInstall: false)
|
|
let permissionsCursor = try #require(order.firstIndex(of: 5))
|
|
let aiCursor = try #require(order.firstIndex(of: 3))
|
|
let resetCursor = OnboardingView.pageCursorAfterGatewayReset(
|
|
currentPage: permissionsCursor,
|
|
pageOrder: order,
|
|
aiPageIndex: 3)
|
|
|
|
#expect(resetCursor == aiCursor)
|
|
#expect(OnboardingView.shouldBlockAISetup(
|
|
currentPage: resetCursor,
|
|
pageOrder: order,
|
|
aiPageIndex: 3,
|
|
connectionMode: .remote,
|
|
connected: false))
|
|
}
|
|
|
|
@Test func `select remote gateway clears stale ssh target when endpoint unresolved`() async {
|
|
let override = FileManager().temporaryDirectory
|
|
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
|
|
.appendingPathComponent("openclaw.json")
|
|
.path
|
|
|
|
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
|
let state = AppState(preview: true)
|
|
state.remoteTransport = .ssh
|
|
state.remoteTarget = "user@old-host:2222"
|
|
let view = OnboardingView(
|
|
state: state,
|
|
permissionMonitor: PermissionMonitor.shared,
|
|
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
|
|
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
|
displayName: "Unresolved",
|
|
serviceHost: nil,
|
|
servicePort: nil,
|
|
lanHost: "txt-host.local",
|
|
tailnetDns: "txt-host.ts.net",
|
|
sshPort: 22,
|
|
gatewayPort: 18789,
|
|
cliPath: "/tmp/openclaw",
|
|
stableID: UUID().uuidString,
|
|
debugID: UUID().uuidString,
|
|
isLocal: false)
|
|
|
|
view.selectRemoteGateway(gateway)
|
|
#expect(state.remoteTarget.isEmpty)
|
|
}
|
|
}
|
|
|
|
@Test func `different remote selection resets UI but preserves prior activation lease`() async {
|
|
let override = FileManager().temporaryDirectory
|
|
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
|
|
.appendingPathComponent("openclaw.json")
|
|
.path
|
|
let previousGatewayPreference = captureOnboardingGatewayPreference()
|
|
let previousPending = UserDefaults.standard.object(forKey: onboardingCrestodianPendingKey)
|
|
defer {
|
|
restoreOnboardingGatewayPreference(previousGatewayPreference)
|
|
if let previousPending {
|
|
UserDefaults.standard.set(previousPending, forKey: onboardingCrestodianPendingKey)
|
|
} else {
|
|
OnboardingCrestodianResumeStore.clear()
|
|
}
|
|
}
|
|
GatewayDiscoveryPreferences.setPreferredStableID("gateway-a")
|
|
OnboardingCrestodianResumeStore.markPending(routeIdentity: "remote:id:gateway-a")
|
|
|
|
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
|
let state = AppState(preview: true)
|
|
state.connectionMode = .remote
|
|
let view = OnboardingView(
|
|
state: state,
|
|
permissionMonitor: PermissionMonitor.shared,
|
|
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
|
|
let priorChat = view.crestodianState.chat
|
|
view.aiSetup.manualKey = "route-a-secret"
|
|
view.crestodianState.isPresented = true
|
|
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
|
displayName: "Gateway B",
|
|
serviceHost: nil,
|
|
servicePort: nil,
|
|
lanHost: "gateway-b.local",
|
|
tailnetDns: "gateway-b.ts.net",
|
|
sshPort: 22,
|
|
gatewayPort: 18789,
|
|
cliPath: "/tmp/openclaw",
|
|
stableID: "gateway-b",
|
|
debugID: "gateway-b",
|
|
isLocal: false)
|
|
|
|
view.selectRemoteGateway(gateway)
|
|
|
|
#expect(state.connectionMode == .remote)
|
|
#expect(view.aiSetup.manualKey.isEmpty)
|
|
#expect(!view.crestodianState.isPresented)
|
|
#expect(view.crestodianState.chat !== priorChat)
|
|
#expect(!OnboardingCrestodianResumeStore.isPending(for: "remote:id:gateway-b"))
|
|
#expect(OnboardingCrestodianResumeStore.isPending(for: "remote:id:gateway-a"))
|
|
}
|
|
}
|
|
|
|
@Test func `manual remote endpoint edit clears stale discovery identity`() throws {
|
|
let previousGatewayPreference = captureOnboardingGatewayPreference()
|
|
let previousPending = UserDefaults.standard.object(forKey: onboardingCrestodianPendingKey)
|
|
defer {
|
|
restoreOnboardingGatewayPreference(previousGatewayPreference)
|
|
if let previousPending {
|
|
UserDefaults.standard.set(previousPending, forKey: onboardingCrestodianPendingKey)
|
|
} else {
|
|
OnboardingCrestodianResumeStore.clear()
|
|
}
|
|
}
|
|
GatewayDiscoveryPreferences.setPreferredStableID("gateway-a")
|
|
OnboardingCrestodianResumeStore.markPending(routeIdentity: "remote:id:gateway-a")
|
|
let state = AppState(preview: true)
|
|
state.connectionMode = .remote
|
|
state.remoteTransport = .direct
|
|
state.remoteUrl = "wss://gateway-a.example.test"
|
|
let gatewaySession = GatewayTestWebSocketSession()
|
|
let gatewayURL = try #require(URL(string: "wss://gateway-a.example.test"))
|
|
let gateway = GatewayConnection(
|
|
configProvider: { (url: gatewayURL, token: nil, password: nil) },
|
|
sessionBox: WebSocketSessionBox(session: gatewaySession))
|
|
let view = OnboardingView(state: state, aiSetupGateway: gateway)
|
|
view.preferredGatewayID = "gateway-a"
|
|
view.aiSetup.manualKey = "route-a-secret"
|
|
view.aiSetup.resumeConfiguredInference(modelRef: "openai/gpt-5.5")
|
|
view.aiSetup.acceptVerifiedPendingInference(modelRef: "openai/gpt-5.5")
|
|
let priorChat = view.crestodianState.chat
|
|
view.crestodianState.isPresented = true
|
|
view.remoteProbeState = .ok(RemoteGatewayProbeSuccess(authSource: .sharedToken))
|
|
view.remoteAuthIssue = .tokenMismatch
|
|
|
|
view.updateManualRemoteURL("wss://gateway-b.example.test")
|
|
|
|
let editedRouteIdentity = OnboardingCrestodianResumeStore.selectedRouteIdentity(
|
|
state: state,
|
|
preferredGatewayID: view.preferredGatewayID ?? GatewayDiscoveryPreferences.preferredStableID())
|
|
#expect(view.preferredGatewayID == nil)
|
|
#expect(GatewayDiscoveryPreferences.preferredStableID() == nil)
|
|
#expect(editedRouteIdentity?.hasPrefix("remote:direct:") == true)
|
|
#expect(editedRouteIdentity != "remote:id:gateway-a")
|
|
#expect(OnboardingCrestodianResumeStore.isPending(for: "remote:id:gateway-a"))
|
|
#expect(!OnboardingCrestodianResumeStore.isPending(for: editedRouteIdentity))
|
|
#expect(view.aiSetup.phase == .idle)
|
|
#expect(!view.aiSetup.connected)
|
|
#expect(view.aiSetup.manualKey.isEmpty)
|
|
#expect(!view.crestodianState.isPresented)
|
|
#expect(view.crestodianState.chat !== priorChat)
|
|
#expect(view.remoteProbeState == .idle)
|
|
#expect(view.remoteAuthIssue == nil)
|
|
#expect(gatewaySession.snapshotMakeCount() == 0)
|
|
}
|
|
|
|
@Test func `same persisted remote selection preserves pending gateway setup state`() async {
|
|
let override = FileManager().temporaryDirectory
|
|
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
|
|
.appendingPathComponent("openclaw.json")
|
|
.path
|
|
let previousGatewayPreference = captureOnboardingGatewayPreference()
|
|
let previousPending = UserDefaults.standard.object(forKey: onboardingCrestodianPendingKey)
|
|
defer {
|
|
restoreOnboardingGatewayPreference(previousGatewayPreference)
|
|
if let previousPending {
|
|
UserDefaults.standard.set(previousPending, forKey: onboardingCrestodianPendingKey)
|
|
} else {
|
|
OnboardingCrestodianResumeStore.clear()
|
|
}
|
|
}
|
|
GatewayDiscoveryPreferences.setPreferredStableID("gateway-a")
|
|
OnboardingCrestodianResumeStore.markPending(routeIdentity: "remote:id:gateway-a")
|
|
|
|
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
|
let state = AppState(preview: true)
|
|
state.connectionMode = .remote
|
|
let view = OnboardingView(
|
|
state: state,
|
|
permissionMonitor: PermissionMonitor.shared,
|
|
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
|
|
let priorChat = view.crestodianState.chat
|
|
view.aiSetup.manualKey = "pending-secret"
|
|
view.crestodianState.isPresented = true
|
|
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
|
displayName: "Gateway A",
|
|
serviceHost: nil,
|
|
servicePort: nil,
|
|
lanHost: "gateway-a.local",
|
|
tailnetDns: "gateway-a.ts.net",
|
|
sshPort: 22,
|
|
gatewayPort: 18789,
|
|
cliPath: "/tmp/openclaw",
|
|
stableID: "gateway-a",
|
|
debugID: "gateway-a",
|
|
isLocal: false)
|
|
|
|
view.selectRemoteGateway(gateway)
|
|
|
|
#expect(view.aiSetup.manualKey == "pending-secret")
|
|
#expect(view.crestodianState.isPresented)
|
|
#expect(view.crestodianState.chat === priorChat)
|
|
#expect(OnboardingCrestodianResumeStore.isPending(for: "remote:id:gateway-a"))
|
|
}
|
|
}
|
|
|
|
@Test func `remote to local selection preserves prior activation lease`() {
|
|
let previousGatewayPreference = captureOnboardingGatewayPreference()
|
|
let previousPending = UserDefaults.standard.object(forKey: onboardingCrestodianPendingKey)
|
|
defer {
|
|
restoreOnboardingGatewayPreference(previousGatewayPreference)
|
|
if let previousPending {
|
|
UserDefaults.standard.set(previousPending, forKey: onboardingCrestodianPendingKey)
|
|
} else {
|
|
OnboardingCrestodianResumeStore.clear()
|
|
}
|
|
}
|
|
GatewayDiscoveryPreferences.setPreferredStableID("gateway-a")
|
|
OnboardingCrestodianResumeStore.markPending(routeIdentity: "remote:id:gateway-a")
|
|
let state = AppState(preview: true)
|
|
state.connectionMode = .remote
|
|
let view = OnboardingView(state: state)
|
|
let priorChat = view.crestodianState.chat
|
|
view.aiSetup.manualKey = "route-a-secret"
|
|
view.crestodianState.isPresented = true
|
|
|
|
view.selectLocalGateway()
|
|
|
|
#expect(state.connectionMode == .local)
|
|
#expect(view.aiSetup.manualKey.isEmpty)
|
|
#expect(!view.crestodianState.isPresented)
|
|
#expect(view.crestodianState.chat !== priorChat)
|
|
#expect(!OnboardingCrestodianResumeStore.isPending(for: "local"))
|
|
#expect(OnboardingCrestodianResumeStore.isPending(for: "remote:id:gateway-a"))
|
|
}
|
|
|
|
@Test func `same local selection preserves pending gateway setup state`() {
|
|
let previousGatewayPreference = captureOnboardingGatewayPreference()
|
|
let previousPending = UserDefaults.standard.object(forKey: onboardingCrestodianPendingKey)
|
|
defer {
|
|
restoreOnboardingGatewayPreference(previousGatewayPreference)
|
|
if let previousPending {
|
|
UserDefaults.standard.set(previousPending, forKey: onboardingCrestodianPendingKey)
|
|
} else {
|
|
OnboardingCrestodianResumeStore.clear()
|
|
}
|
|
}
|
|
OnboardingCrestodianResumeStore.markPending(routeIdentity: "local")
|
|
let state = AppState(preview: true)
|
|
state.connectionMode = .local
|
|
let view = OnboardingView(state: state)
|
|
let priorChat = view.crestodianState.chat
|
|
view.aiSetup.manualKey = "pending-secret"
|
|
view.crestodianState.isPresented = true
|
|
|
|
view.selectLocalGateway()
|
|
|
|
#expect(view.aiSetup.manualKey == "pending-secret")
|
|
#expect(view.crestodianState.isPresented)
|
|
#expect(view.crestodianState.chat === priorChat)
|
|
#expect(OnboardingCrestodianResumeStore.isPending(for: "local"))
|
|
}
|
|
|
|
@Test func `configure later preserves in flight activation lease`() {
|
|
let previousGatewayPreference = captureOnboardingGatewayPreference()
|
|
let previousPending = UserDefaults.standard.object(forKey: onboardingCrestodianPendingKey)
|
|
defer {
|
|
restoreOnboardingGatewayPreference(previousGatewayPreference)
|
|
if let previousPending {
|
|
UserDefaults.standard.set(previousPending, forKey: onboardingCrestodianPendingKey)
|
|
} else {
|
|
OnboardingCrestodianResumeStore.clear()
|
|
}
|
|
}
|
|
OnboardingCrestodianResumeStore.markPending(routeIdentity: "local")
|
|
let state = AppState(preview: true)
|
|
state.connectionMode = .local
|
|
let view = OnboardingView(state: state)
|
|
let priorChat = view.crestodianState.chat
|
|
view.aiSetup.manualKey = "local-secret"
|
|
view.crestodianState.isPresented = true
|
|
|
|
view.selectUnconfiguredGateway()
|
|
|
|
#expect(state.connectionMode == .unconfigured)
|
|
#expect(view.aiSetup.manualKey.isEmpty)
|
|
#expect(!view.crestodianState.isPresented)
|
|
#expect(view.crestodianState.chat !== priorChat)
|
|
#expect(OnboardingCrestodianResumeStore.isPending(for: "local"))
|
|
}
|
|
|
|
@Test
|
|
func `permission list covers every capability in importance order`() {
|
|
#expect(Set(Capability.importanceOrdered) == Set(Capability.allCases))
|
|
#expect(Capability.importanceOrdered.count == Capability.allCases.count)
|
|
// App control and context capture lead; location stays last.
|
|
#expect(Capability.importanceOrdered.first == .appleScript)
|
|
#expect(Array(Capability.importanceOrdered.prefix(3))
|
|
== [.appleScript, .accessibility, .screenRecording])
|
|
#expect(Capability.importanceOrdered.last == Capability.location)
|
|
}
|
|
}
|