Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ let package = Package(
.linkedFramework("CoreGraphics"),
.linkedFramework("ApplicationServices"),
.linkedFramework("Network"),
.linkedFramework("Security"),
]
),
// Thin CLI: flag parsing + wiring. No logic worth testing lives here.
Expand Down
84 changes: 75 additions & 9 deletions Sources/InstanceMCPCore/AttachManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,46 @@ public actor AttachManager {
private let log: @Sendable (String) -> Void
private var grants: [String: Grant] = [:]
private var clients: [String: ReverseAttachClient] = [:]
/// Attach secrets by grant id, held only so live grants can be persisted (#12).
private var secrets: [String: String] = [:]
/// nil ⇒ grants live in memory only (tests, `--no-grant-persistence`).
private let store: GrantStore?
/// Seam for tests: mint against the runtime's admin plane.
private let mint: @Sendable (URL, String, String, TimeInterval) async throws -> (secret: String, expiresIn: TimeInterval)

public init(server: MCPServer,
mint: (@Sendable (URL, String, String, TimeInterval) async throws -> (secret: String, expiresIn: TimeInterval))? = nil,
store: GrantStore? = nil,
log: @escaping @Sendable (String) -> Void = { _ in }) {
self.baseServer = server
self.log = log
self.mint = mint ?? AttachManager.mintAtRuntime
self.store = store
}

/// Re-dial every persisted grant still inside its deadline, under its original
/// id. Call once at start. A runtime that has forgotten the grant (pod replaced)
/// answers the handshake with 401 and the grant ends through the normal
/// disposition. Returns how many were resumed.
@discardableResult
public func resume() async -> Int {
guard let store else { return 0 }
let persisted = store.load()
var resumed = 0
for p in persisted where grants[p.id] == nil {
guard let profile = ToolProfile(rawValue: p.profile) else {
log("grant \(p.id.prefix(8)) has unknown profile \(p.profile); dropping it")
continue
}
let grant = Grant(id: p.id, runtime: p.runtime, session: p.session, profile: profile,
principal: p.principal, createdAt: p.createdAt, expiresAt: p.expiresAt, state: .idle)
log("grant \(p.id.prefix(8)) resumed: \(profile.rawValue) → \(p.runtime.host ?? "?")/\(p.session), \(Int(p.expiresAt.timeIntervalSinceNow))s left")
await startClient(grant, secret: p.secret)
resumed += 1
}
// Rewrite without whatever expired or failed to load while we were down.
persist()
return resumed
}

// MARK: API
Expand Down Expand Up @@ -115,41 +146,76 @@ public actor AttachManager {
}

let id = UUID().uuidString.lowercased()
var grant = Grant(id: id, runtime: req.runtime, session: req.session, profile: req.profile,
let grant = Grant(id: id, runtime: req.runtime, session: req.session, profile: req.profile,
principal: principal, createdAt: Date(), expiresAt: expiresAt, state: .idle)
let instructions = Self.sandboxInstructions(profile: req.profile, base: baseServer.instructions)
let scoped = baseServer.scoped(to: req.profile, instructions: instructions)
let config = ReverseAttachClient.Config(runtime: req.runtime, session: req.session, secret: secret,
profile: req.profile, deadline: expiresAt)
log("grant \(id.prefix(8)) by \(principal): \(req.profile.rawValue) → \(req.runtime.host ?? "?")/\(req.session) for \(Int(req.ttl))s")
let started = await startClient(grant, secret: secret)
persist()
return started
}

/// Register `grant` and start dialling. Shared by `create` and `resume`.
@discardableResult
private func startClient(_ grant: Grant, secret: String) async -> Grant {
let id = grant.id
let instructions = Self.sandboxInstructions(profile: grant.profile, base: baseServer.instructions)
let scoped = baseServer.scoped(to: grant.profile, instructions: instructions)
let config = ReverseAttachClient.Config(runtime: grant.runtime, session: grant.session, secret: secret,
profile: grant.profile, deadline: grant.expiresAt)
let client = ReverseAttachClient(
config: config, server: scoped,
onStateChange: { [weak self] s in Task { await self?.update(id, state: s) } },
log: log)
grants[id] = grant
clients[id] = client
log("grant \(id.prefix(8)) by \(principal): \(req.profile.rawValue) → \(req.runtime.host ?? "?")/\(req.session) for \(Int(req.ttl))s")
secrets[id] = secret
await client.start()
grant.state = await client.state
return grant
var started = grant
started.state = await client.state
return started
}

public func revoke(_ id: String, reason: String = "revoked") async {
guard let g = grants.removeValue(forKey: id) else { return }
secrets.removeValue(forKey: id)
if let c = clients.removeValue(forKey: id) { await c.cancel() }
log("grant \(id.prefix(8)) \(reason) (\(g.session))")
persist()
}

/// Drop grants whose client has reached a terminal state or whose deadline
/// passed. Called from the HTTP layer opportunistically; nothing depends on it.
public func sweep() async {
var changed = false
for (id, g) in grants {
if case .ended = g.state { grants.removeValue(forKey: id); clients.removeValue(forKey: id); continue }
if case .ended = g.state {
grants.removeValue(forKey: id); clients.removeValue(forKey: id); secrets.removeValue(forKey: id)
changed = true
continue
}
if g.expiresAt < Date() { await revoke(id, reason: "expired") }
}
if changed { persist() }
}

private func update(_ id: String, state: ReverseAttachClient.State) {
grants[id]?.state = state
// A terminal grant must not be resumed after a restart.
if case .ended = state { persist() }
}

/// Write the live grants (not ended, not expired) to the store, if any.
private func persist() {
guard let store else { return }
let now = Date()
let live: [PersistedGrant] = grants.values.compactMap { g in
if case .ended = g.state { return nil }
guard g.expiresAt > now, let secret = secrets[g.id] else { return nil }
return PersistedGrant(id: g.id, runtime: g.runtime, session: g.session, profile: g.profile.rawValue,
principal: g.principal, createdAt: g.createdAt, expiresAt: g.expiresAt,
secret: secret)
}
store.save(live.sorted { $0.createdAt < $1.createdAt })
}

// MARK: helpers
Expand Down
202 changes: 202 additions & 0 deletions Sources/InstanceMCPCore/GrantStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import Foundation
import Security

/// Grants survive a daemon restart (#12).
///
/// A restart — deploy, crash, logout/login, `launchctl kickstart -k` — used to drop
/// every grant, and the human had to lend the Mac again from Connect/Remote. Live
/// grants are now persisted and re-dialled at start under their original id.
///
/// Split by sensitivity:
/// - the **record** (id, runtime, session, profile, principal, dates) is JSON in
/// `~/Library/Application Support/oab-instance-mcp/grants.json`, mode 600 in a
/// 0700 directory — inspectable, no secret in it;
/// - the **attach secret** is a generic password in the login Keychain, service
/// `dev.openab.instance-mcp.grant`, account = grant id, accessible only after
/// first unlock on this device. The Keychain ACL binds it to this code-signing
/// identity, like the TCC grants.
///
/// Ended, revoked and expired grants are never written; a secret whose record is
/// gone is deleted on the next save.
public struct PersistedGrant: Codable, Sendable, Equatable {
public var id: String
public var runtime: URL
public var session: String
public var profile: String
public var principal: String
public var createdAt: Date
public var expiresAt: Date
/// Not encoded into the record file; carried to and from the secret store.
public var secret: String

enum CodingKeys: String, CodingKey { case id, runtime, session, profile, principal, createdAt, expiresAt }

public init(id: String, runtime: URL, session: String, profile: String, principal: String,
createdAt: Date, expiresAt: Date, secret: String) {
self.id = id; self.runtime = runtime; self.session = session; self.profile = profile
self.principal = principal; self.createdAt = createdAt; self.expiresAt = expiresAt
self.secret = secret
}

public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
runtime = try c.decode(URL.self, forKey: .runtime)
session = try c.decode(String.self, forKey: .session)
profile = try c.decode(String.self, forKey: .profile)
principal = try c.decode(String.self, forKey: .principal)
createdAt = try c.decode(Date.self, forKey: .createdAt)
expiresAt = try c.decode(Date.self, forKey: .expiresAt)
secret = ""
}

public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id); try c.encode(runtime, forKey: .runtime)
try c.encode(session, forKey: .session); try c.encode(profile, forKey: .profile)
try c.encode(principal, forKey: .principal)
try c.encode(createdAt, forKey: .createdAt); try c.encode(expiresAt, forKey: .expiresAt)
}
}

public protocol GrantStore: Sendable {
/// Grants still inside their deadline, with their secrets.
func load() -> [PersistedGrant]
/// Replace the persisted set with exactly `grants`.
func save(_ grants: [PersistedGrant])
}

/// Where attach secrets live. The Keychain in production; a dictionary in tests
/// (CI runners have no usable login keychain).
public protocol SecretStore: Sendable {
func set(_ secret: String, account: String) -> Bool
func get(account: String) -> String?
func delete(account: String)
}

public final class FileGrantStore: GrantStore, @unchecked Sendable {
public let url: URL
private let secrets: SecretStore
private let log: @Sendable (String) -> Void
private let lock = NSLock()

public static var defaultURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/oab-instance-mcp/grants.json")
}

public init(url: URL = FileGrantStore.defaultURL, secrets: SecretStore = KeychainSecretStore(),
log: @escaping @Sendable (String) -> Void = { _ in }) {
self.url = url; self.secrets = secrets; self.log = log
}

private struct File: Codable { var version: Int; var grants: [PersistedGrant] }

private func readRecords() -> [PersistedGrant] {
guard let data = try? Data(contentsOf: url) else { return [] }
guard let file = try? Self.decoder.decode(File.self, from: data), file.version == 1 else {
log("grants: ignoring unreadable \(url.path)")
return []
}
return file.grants
}

public func load() -> [PersistedGrant] {
lock.lock(); defer { lock.unlock() }
narrowPermissions()
let now = Date()
return readRecords().compactMap { record in
guard record.expiresAt > now else { return nil }
guard let secret = secrets.get(account: record.id) else {
log("grants: \(record.id.prefix(8)) has no secret in the Keychain; dropping it")
return nil
}
var g = record; g.secret = secret
return g
}
}

public func save(_ grants: [PersistedGrant]) {
lock.lock(); defer { lock.unlock() }
let previous = Set(readRecords().map(\.id))
let current = Set(grants.map(\.id))
for g in grants where !secrets.set(g.secret, account: g.id) {
log("grants: could not store the secret for \(g.id.prefix(8)) in the Keychain")
}
do {
let dir = url.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
let data = try Self.encoder.encode(File(version: 1, grants: grants))
let tmp = dir.appendingPathComponent(url.lastPathComponent + ".tmp")
try? FileManager.default.removeItem(at: tmp)
guard FileManager.default.createFile(atPath: tmp.path, contents: data,
attributes: [.posixPermissions: 0o600]) else {
throw CocoaError(.fileWriteUnknown)
}
_ = try FileManager.default.replaceItemAt(url, withItemAt: tmp)
} catch {
log("grants: could not persist to \(url.path): \(error)")
return
}
for gone in previous.subtracting(current) { secrets.delete(account: gone) }
}

private func narrowPermissions() {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let mode = (attrs[.posixPermissions] as? NSNumber)?.intValue, mode & 0o077 != 0 else { return }
log("grants: \(url.path) was mode \(String(mode, radix: 8)); resetting to 600")
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
}

private static let encoder: JSONEncoder = {
let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; e.outputFormatting = [.sortedKeys]; return e
}()
private static let decoder: JSONDecoder = {
let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d
}()
}

public struct KeychainSecretStore: SecretStore {
public static let service = "dev.openab.instance-mcp.grant"
public init() {}

private func query(_ account: String) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: account]
}

public func set(_ secret: String, account: String) -> Bool {
SecItemDelete(query(account) as CFDictionary)
var add = query(account)
add[kSecValueData as String] = Data(secret.utf8)
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
add[kSecAttrLabel as String] = "oab-instance-mcp attach grant"
return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
}

public func get(account: String) -> String? {
var q = query(account)
q[kSecReturnData as String] = true
q[kSecMatchLimit as String] = kSecMatchLimitOne
var out: CFTypeRef?
guard SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess, let data = out as? Data else { return nil }
return String(decoding: data, as: UTF8.self)
}

public func delete(account: String) {
SecItemDelete(query(account) as CFDictionary)
}
}

/// Test double, and a fallback nothing in production uses.
public final class InMemorySecretStore: SecretStore, @unchecked Sendable {
private var values: [String: String] = [:]
private let lock = NSLock()
public init() {}
public func set(_ secret: String, account: String) -> Bool { lock.lock(); values[account] = secret; lock.unlock(); return true }
public func get(account: String) -> String? { lock.lock(); defer { lock.unlock() }; return values[account] }
public func delete(account: String) { lock.lock(); values[account] = nil; lock.unlock() }
public var accounts: [String] { lock.lock(); defer { lock.unlock() }; return values.keys.sorted() }
}
18 changes: 17 additions & 1 deletion Sources/oab-instance-mcp/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct Options {
var menuBar = false
var publicURL: String? = nil
var attach = true
var persistGrants = true
/// name=url pairs; each is a loopback MCP server whose tools are re-served.
var upstreams: [(String, URL)] = []
}
Expand All @@ -44,6 +45,10 @@ func usage() -> Never {
--no-attach Disable the reverse-attach plane (POST/GET /attach, DELETE /attach/{id}):
the human-credentialed endpoint through which Connect / Remote lends this
Mac to one openab-pty session (this Mac dials the pod; see the ADR).
--no-grant-persistence
Keep reverse-attach grants in memory only. By default live grants are
saved (record: ~/Library/Application Support/oab-instance-mcp/grants.json,
mode 600; secret: login Keychain) and re-dialled after a restart.
--menu-bar Show a status item in the menu bar (permissions, activity, restart/quit).
--public-url The URL clients use (shown/copied from the menu); defaults to the local one.

Expand Down Expand Up @@ -73,6 +78,7 @@ while !args.isEmpty {
case "--insecure-local": opts.insecureLocal = true
case "--quiet": opts.quiet = true
case "--menu-bar": opts.menuBar = true
case "--no-grant-persistence": opts.persistGrants = false
case "--public-url": opts.publicURL = next(a)
case "--no-attach": opts.attach = false
case "--upstream":
Expand Down Expand Up @@ -134,7 +140,11 @@ let server = MCPServer(
tools: [SysInfoTool(agentVersion: version), ExecTool(), ExecStartTool(), ExecPollTool(), ExecListTool(), ExecCancelTool(), ScreenshotTool(), MouseTool(), KeyTool(), OsascriptTool()],
upstreams: opts.upstreams.map { UpstreamMCP(name: $0.0, url: $0.1, log: log) }
)
let attachManager: AttachManager? = opts.attach ? AttachManager(server: server, log: log) : nil
let attachManager: AttachManager? = opts.attach
? AttachManager(server: server,
store: opts.persistGrants ? FileGrantStore(log: log) : nil,
log: log)
: nil
let endpoint = MCPHTTPEndpoint(path: opts.path, server: server, auth: auth, attach: attachManager, log: log)

// Must be a global: a `let` inside `do {}` is released after the block and the
Expand All @@ -153,6 +163,12 @@ log("oab-instance-mcp \(version) starting on http://\(opts.host):\(opts.port)\(o
"accessibility=\(startupPermissions.accessibility.isGranted) " +
"full_disk_access=\(startupPermissions.fullDiskAccess.isGranted)")
http.start()
if let attachManager {
Task {
let n = await attachManager.resume()
if n > 0 { log("resumed \(n) reverse-attach grant(s) from the previous run") }
}
}

signal(SIGPIPE, SIG_IGN)
let stop = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
Expand Down
Loading
Loading