refactor(cli): unify on clawdis CLI + node permissions

This commit is contained in:
Peter Steinberger
2025-12-20 02:08:04 +00:00
parent 479720c169
commit 849446ae17
49 changed files with 1205 additions and 2735 deletions
@@ -60,9 +60,10 @@ final class MacNodeModeCoordinator {
retryDelay = 1_000_000_000
do {
let hello = await self.makeHello()
try await self.session.connect(
endpoint: endpoint,
hello: self.makeHello(),
hello: hello,
onConnected: { [weak self] serverName in
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
},
@@ -86,10 +87,11 @@ final class MacNodeModeCoordinator {
}
}
private func makeHello() -> BridgeHello {
private func makeHello() async -> BridgeHello {
let token = MacNodeTokenStore.loadToken()
let caps = self.currentCaps()
let commands = self.currentCommands(caps: caps)
let permissions = await self.currentPermissions()
return BridgeHello(
nodeId: Self.nodeId(),
displayName: InstanceIdentity.displayName,
@@ -99,7 +101,8 @@ final class MacNodeModeCoordinator {
deviceFamily: "Mac",
modelIdentifier: InstanceIdentity.modelIdentifier,
caps: caps,
commands: commands)
commands: commands,
permissions: permissions)
}
private func currentCaps() -> [String] {
@@ -110,6 +113,11 @@ final class MacNodeModeCoordinator {
return caps
}
private func currentPermissions() async -> [String: Bool] {
let statuses = await PermissionManager.status()
return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) })
}
private func currentCommands(caps: [String]) -> [String] {
var commands: [String] = [
ClawdisCanvasCommand.present.rawValue,
@@ -121,6 +129,8 @@ final class MacNodeModeCoordinator {
ClawdisCanvasA2UICommand.pushJSONL.rawValue,
ClawdisCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.record.rawValue,
ClawdisSystemCommand.run.rawValue,
ClawdisSystemCommand.notify.rawValue,
]
let capsSet = Set(caps)
@@ -140,9 +150,10 @@ final class MacNodeModeCoordinator {
let shouldSilent = await MainActor.run {
AppStateStore.shared.connectionMode == .remote
}
let hello = await self.makeHello()
let token = try await MacNodeBridgePairingClient().pairAndHello(
endpoint: endpoint,
hello: self.makeHello(),
hello: hello,
silent: shouldSilent,
onStatus: { [weak self] status in
self?.logger.info("mac node pairing: \(status, privacy: .public)")
@@ -185,6 +185,12 @@ actor MacNodeRuntime {
hasAudio: res.hasAudio))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdisSystemCommand.run.rawValue:
return try await self.handleSystemRun(req)
case ClawdisSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req)
default:
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
}
@@ -249,6 +255,89 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
}
private func handleSystemRun(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdisSystemRunParams.self, from: req.paramsJSON)
let command = params.command
guard !command.isEmpty else {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
}
if params.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
return Self.errorResponse(
req,
code: .unavailable,
message: "PERMISSION_MISSING: screenRecording")
}
}
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
let result = await ShellExecutor.runDetailed(
command: command,
cwd: params.cwd,
env: params.env,
timeout: timeoutSec)
struct RunPayload: Encodable {
var exitCode: Int?
var timedOut: Bool
var success: Bool
var stdout: String
var stderr: String
var error: String?
}
let payload = try Self.encodePayload(RunPayload(
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdisSystemNotifyParams.self, from: req.paramsJSON)
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
if title.isEmpty && body.isEmpty {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: empty notification")
}
let priority = params.priority.flatMap { NotificationPriority(rawValue: $0.rawValue) }
let delivery = params.delivery.flatMap { NotificationDelivery(rawValue: $0.rawValue) } ?? .system
let manager = NotificationManager()
switch delivery {
case .system:
let ok = await manager.send(
title: title,
body: body,
sound: params.sound,
priority: priority)
return ok
? BridgeInvokeResponse(id: req.id, ok: true)
: Self.errorResponse(req, code: .unavailable, message: "NOT_AUTHORIZED: notifications")
case .overlay:
await NotifyOverlayController.shared.present(title: title, body: body)
return BridgeInvokeResponse(id: req.id, ok: true)
case .auto:
let ok = await manager.send(
title: title,
body: body,
sound: params.sound,
priority: priority)
if ok {
return BridgeInvokeResponse(id: req.id, ok: true)
}
await NotifyOverlayController.shared.present(title: title, body: body)
return BridgeInvokeResponse(id: req.id, ok: true)
}
}
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
guard let json, let data = json.data(using: .utf8) else {
throw NSError(domain: "Bridge", code: 20, userInfo: [