fix(macos): validate embedded CLI helper

This commit is contained in:
Peter Steinberger
2025-12-20 15:12:45 +00:00
parent 44339a6447
commit d306fcb8a2
8 changed files with 112 additions and 27 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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"],

View File

@@ -16,6 +16,7 @@ import Testing
server: [:],
features: [:],
snapshot: snapshot,
canvashosturl: nil,
policy: [:])
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))

View File

@@ -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
}
}

View File

@@ -10,7 +10,7 @@ struct OnboardingViewSmokeTests {
let view = OnboardingView(
state: state,
permissionMonitor: PermissionMonitor.shared,
discoveryModel: MasterDiscoveryModel())
discoveryModel: GatewayDiscoveryModel())
_ = view.body
}
}

View File

@@ -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