From d306fcb8a26c2cec6730e597c2e436503c7e782f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 15:12:45 +0000 Subject: [PATCH] fix(macos): validate embedded CLI helper --- apps/macos/Sources/Clawdis/Onboarding.swift | 6 ++- apps/macos/Sources/Clawdis/Utilities.swift | 36 +++++++++++++--- .../ClawdisIPCTests/CLIInstallerTests.swift | 43 +++++++++++++++++++ .../GatewayDiscoveryModelTests.swift | 4 +- .../MacGatewayChatTransportMappingTests.swift | 1 + .../MasterDiscoveryMenuSmokeTests.swift | 43 +++++++++++++------ .../OnboardingViewSmokeTests.swift | 2 +- scripts/restart-mac.sh | 4 +- 8 files changed, 112 insertions(+), 27 deletions(-) create mode 100644 apps/macos/Tests/ClawdisIPCTests/CLIInstallerTests.swift diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 5ca0df788..a1315ce91 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -105,8 +105,10 @@ struct OnboardingView: View { } private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } - private let devLinkCommand = - "ln -sf /Applications/Clawdis.app/Contents/Resources/Relay/clawdis /usr/local/bin/clawdis" + private var devLinkCommand: String { + let bundlePath = Bundle.main.bundlePath + return "ln -sf '\(bundlePath)/Contents/Resources/Relay/clawdis' /usr/local/bin/clawdis" + } private struct LocalGatewayProbe: Equatable { let port: Int diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 1987fde1f..08c9800cb 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -133,18 +133,38 @@ func age(from date: Date, now: Date = .init()) -> String { @MainActor enum CLIInstaller { - static func installedLocation() -> String? { - let fm = FileManager.default + private static func embeddedHelperURL() -> URL { + Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis") + } - for basePath in cliHelperSearchPaths { + static func installedLocation() -> String? { + self.installedLocation( + searchPaths: cliHelperSearchPaths, + embeddedHelper: self.embeddedHelperURL(), + fileManager: .default) + } + + static func installedLocation( + searchPaths: [String], + embeddedHelper: URL, + fileManager: FileManager) -> String? + { + let embedded = embeddedHelper.resolvingSymlinksInPath() + + for basePath in searchPaths { let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path var isDirectory: ObjCBool = false - guard fm.fileExists(atPath: candidate, isDirectory: &isDirectory), !isDirectory.boolValue else { + guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory), + !isDirectory.boolValue + else { continue } - if fm.isExecutableFile(atPath: candidate) { + guard fileManager.isExecutableFile(atPath: candidate) else { continue } + + let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath() + if resolved == embedded { return candidate } } @@ -157,9 +177,11 @@ enum CLIInstaller { } static func install(statusHandler: @escaping @Sendable (String) async -> Void) async { - let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis") + let helper = self.embeddedHelperURL() guard FileManager.default.isExecutableFile(atPath: helper.path) else { - await statusHandler("Helper missing in bundle; rebuild via scripts/package-mac-app.sh") + await statusHandler( + "Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " + + "(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).") return } diff --git a/apps/macos/Tests/ClawdisIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/ClawdisIPCTests/CLIInstallerTests.swift new file mode 100644 index 000000000..fee43f747 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/CLIInstallerTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import Clawdis + +@Suite(.serialized) +@MainActor +struct CLIInstallerTests { + @Test func installedLocationOnlyAcceptsEmbeddedHelper() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent( + "clawdis-cli-installer-\(UUID().uuidString)") + defer { try? fm.removeItem(at: root) } + + let embedded = root.appendingPathComponent("Relay/clawdis") + try fm.createDirectory(at: embedded.deletingLastPathComponent(), withIntermediateDirectories: true) + fm.createFile(atPath: embedded.path, contents: Data()) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: embedded.path) + + let binDir = root.appendingPathComponent("bin") + try fm.createDirectory(at: binDir, withIntermediateDirectories: true) + let link = binDir.appendingPathComponent("clawdis") + try fm.createSymbolicLink(at: link, withDestinationURL: embedded) + + let found = CLIInstaller.installedLocation( + searchPaths: [binDir.path], + embeddedHelper: embedded, + fileManager: fm) + #expect(found == link.path) + + try fm.removeItem(at: link) + let other = root.appendingPathComponent("Other/clawdis") + try fm.createDirectory(at: other.deletingLastPathComponent(), withIntermediateDirectories: true) + fm.createFile(atPath: other.path, contents: Data()) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: other.path) + try fm.createSymbolicLink(at: link, withDestinationURL: other) + + let rejected = CLIInstaller.installedLocation( + searchPaths: [binDir.path], + embeddedHelper: embedded, + fileManager: fm) + #expect(rejected == nil) + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayDiscoveryModelTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayDiscoveryModelTests.swift index 9bd5b5a41..ee6596783 100644 --- a/apps/macos/Tests/ClawdisIPCTests/GatewayDiscoveryModelTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayDiscoveryModelTests.swift @@ -1,7 +1,9 @@ import Testing @testable import Clawdis -@Suite struct GatewayDiscoveryModelTests { +@Suite +@MainActor +struct GatewayDiscoveryModelTests { @Test func localGatewayMatchesLanHost() { let local = GatewayDiscoveryModel.LocalIdentity( hostTokens: ["studio"], diff --git a/apps/macos/Tests/ClawdisIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/ClawdisIPCTests/MacGatewayChatTransportMappingTests.swift index 042a32bdc..4ba889904 100644 --- a/apps/macos/Tests/ClawdisIPCTests/MacGatewayChatTransportMappingTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/MacGatewayChatTransportMappingTests.swift @@ -16,6 +16,7 @@ import Testing server: [:], features: [:], snapshot: snapshot, + canvashosturl: nil, policy: [:]) let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello)) diff --git a/apps/macos/Tests/ClawdisIPCTests/MasterDiscoveryMenuSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/MasterDiscoveryMenuSmokeTests.swift index e91615444..2720b6776 100644 --- a/apps/macos/Tests/ClawdisIPCTests/MasterDiscoveryMenuSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/MasterDiscoveryMenuSmokeTests.swift @@ -6,41 +6,56 @@ import Testing @MainActor struct MasterDiscoveryMenuSmokeTests { @Test func inlineListBuildsBodyWhenEmpty() { - let discovery = MasterDiscoveryModel() + let discovery = GatewayDiscoveryModel() discovery.statusText = "Searching…" - discovery.masters = [] + discovery.gateways = [] - let view = MasterDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in }) + let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in }) _ = view.body } @Test func inlineListBuildsBodyWithMasterAndSelection() { - let discovery = MasterDiscoveryModel() + let discovery = GatewayDiscoveryModel() discovery.statusText = "Found 1" - discovery.masters = [ - .init( + discovery.gateways = [ + GatewayDiscoveryModel.DiscoveredGateway( displayName: "Office Mac", lanHost: "office.local", tailnetDns: "office.tailnet-123.ts.net", sshPort: 2222, - debugID: "office"), + stableID: "office", + debugID: "office", + isLocal: false), ] let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222" - let view = MasterDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in }) + let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in }) _ = view.body } @Test func menuBuildsBodyWithMasters() { - let discovery = MasterDiscoveryModel() + let discovery = GatewayDiscoveryModel() discovery.statusText = "Found 2" - discovery.masters = [ - .init(displayName: "A", lanHost: "a.local", tailnetDns: nil, sshPort: 22, debugID: "a"), - .init(displayName: "B", lanHost: nil, tailnetDns: "b.ts.net", sshPort: 22, debugID: "b"), + discovery.gateways = [ + GatewayDiscoveryModel.DiscoveredGateway( + displayName: "A", + lanHost: "a.local", + tailnetDns: nil, + sshPort: 22, + stableID: "a", + debugID: "a", + isLocal: false), + GatewayDiscoveryModel.DiscoveredGateway( + displayName: "B", + lanHost: nil, + tailnetDns: "b.ts.net", + sshPort: 22, + stableID: "b", + debugID: "b", + isLocal: false), ] - let view = MasterDiscoveryMenu(discovery: discovery, onSelect: { _ in }) + let view = GatewayDiscoveryMenu(discovery: discovery, onSelect: { _ in }) _ = view.body } } - diff --git a/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift index 50a9ab731..2f186bfbe 100644 --- a/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift @@ -10,7 +10,7 @@ struct OnboardingViewSmokeTests { let view = OnboardingView( state: state, permissionMonitor: PermissionMonitor.shared, - discoveryModel: MasterDiscoveryModel()) + discoveryModel: GatewayDiscoveryModel()) _ = view.body } } diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index dec97c860..a6bb20b8a 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -59,8 +59,8 @@ run_step "bundle canvas a2ui" bash -lc "cd '${ROOT_DIR}' && pnpm canvas:a2ui:bun run_step "clean build cache" bash -lc "cd '${ROOT_DIR}/apps/macos' && rm -rf .build .build-swift .swiftpm 2>/dev/null || true" run_step "swift build" bash -lc "cd '${ROOT_DIR}/apps/macos' && swift build -q --product Clawdis" -# 3) Package app (skip TS + gateway staging; rely on global/custom install for gateway JS). -run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=1 SKIP_GATEWAY_PACKAGE=1 '${ROOT_DIR}/scripts/package-mac-app.sh'" +# 3) Package app (default to bundling the embedded gateway + CLI). +run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=${SKIP_TSC:-1} SKIP_GATEWAY_PACKAGE=${SKIP_GATEWAY_PACKAGE:-0} '${ROOT_DIR}/scripts/package-mac-app.sh'" choose_app_bundle() { if [[ -n "${APP_BUNDLE}" && -d "${APP_BUNDLE}" ]]; then