diff --git a/Package.swift b/Package.swift index 764200c..125f6a1 100644 --- a/Package.swift +++ b/Package.swift @@ -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. diff --git a/Sources/InstanceMCPCore/AttachManager.swift b/Sources/InstanceMCPCore/AttachManager.swift index 4c9fdcb..bf0a99a 100644 --- a/Sources/InstanceMCPCore/AttachManager.swift +++ b/Sources/InstanceMCPCore/AttachManager.swift @@ -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 @@ -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 diff --git a/Sources/InstanceMCPCore/GrantStore.swift b/Sources/InstanceMCPCore/GrantStore.swift new file mode 100644 index 0000000..b289b61 --- /dev/null +++ b/Sources/InstanceMCPCore/GrantStore.swift @@ -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() } +} diff --git a/Sources/oab-instance-mcp/main.swift b/Sources/oab-instance-mcp/main.swift index 842629a..328c6ff 100644 --- a/Sources/oab-instance-mcp/main.swift +++ b/Sources/oab-instance-mcp/main.swift @@ -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)] = [] } @@ -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. @@ -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": @@ -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 @@ -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) diff --git a/Tests/InstanceMCPCoreTests/GrantPersistenceTests.swift b/Tests/InstanceMCPCoreTests/GrantPersistenceTests.swift new file mode 100644 index 0000000..c55ce3a --- /dev/null +++ b/Tests/InstanceMCPCoreTests/GrantPersistenceTests.swift @@ -0,0 +1,146 @@ +import Foundation +import XCTest +@testable import InstanceMCPCore + +/// #12: grants survive a daemon restart. These use `FileGrantStore` against a temp +/// directory with an in-memory `SecretStore` — CI has no usable login Keychain, +/// and the Keychain backend is exercised live on the Mac instead. +final class GrantPersistenceTests: XCTestCase { + private var dir: URL! + + override func setUp() { + dir = FileManager.default.temporaryDirectory + .appendingPathComponent("grants-\(UUID().uuidString)", isDirectory: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: dir) + } + + private func store(_ secrets: InMemorySecretStore) -> FileGrantStore { + FileGrantStore(url: dir.appendingPathComponent("sub/grants.json"), secrets: secrets) + } + + private func grant(_ id: String, expiresIn: TimeInterval) -> PersistedGrant { + PersistedGrant(id: id, runtime: URL(string: "ws://127.0.0.1:9")!, session: "s", profile: "sandbox", + principal: "a@b", createdAt: Date(), expiresAt: Date().addingTimeInterval(expiresIn), + secret: "secret-\(id)") + } + + private func mode(_ path: String) -> Int { + ((try? FileManager.default.attributesOfItem(atPath: path))?[.posixPermissions] as? NSNumber)?.intValue ?? -1 + } + + // MARK: store + + func testRoundTripKeepsTheSecretOutOfTheFile() throws { + let secrets = InMemorySecretStore() + let s = store(secrets) + s.save([grant("a", expiresIn: 600), grant("b", expiresIn: 600)]) + + XCTAssertEqual(mode(s.url.path) & 0o777, 0o600) + XCTAssertEqual(mode(s.url.deletingLastPathComponent().path) & 0o777, 0o700) + let onDisk = try String(contentsOf: s.url, encoding: .utf8) + XCTAssertFalse(onDisk.contains("secret-a"), "secret must not be in the record file") + XCTAssertEqual(secrets.accounts, ["a", "b"]) + + let loaded = s.load() + XCTAssertEqual(loaded.map(\.id), ["a", "b"]) + XCTAssertEqual(loaded.map(\.secret), ["secret-a", "secret-b"]) + } + + func testRemovedGrantsLoseTheirSecrets() { + let secrets = InMemorySecretStore() + let s = store(secrets) + s.save([grant("a", expiresIn: 600), grant("b", expiresIn: 600)]) + s.save([grant("b", expiresIn: 600)]) + XCTAssertEqual(secrets.accounts, ["b"]) + XCTAssertEqual(s.load().map(\.id), ["b"]) + } + + func testExpiredOrSecretlessRecordsAreDroppedOnLoad() { + let secrets = InMemorySecretStore() + let s = store(secrets) + s.save([grant("live", expiresIn: 600), grant("stale", expiresIn: -1), grant("orphan", expiresIn: 600)]) + secrets.delete(account: "orphan") + XCTAssertEqual(s.load().map(\.id), ["live"]) + } + + func testAWidenedFileIsNarrowedBeforeItIsRead() { + let s = store(InMemorySecretStore()) + s.save([grant("a", expiresIn: 600)]) + try? FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: s.url.path) + XCTAssertEqual(s.load().count, 1) + XCTAssertEqual(mode(s.url.path) & 0o777, 0o600) + } + + func testMissingOrCorruptFileIsEmpty() throws { + let s = store(InMemorySecretStore()) + XCTAssertTrue(s.load().isEmpty) + try FileManager.default.createDirectory(at: s.url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("not json".utf8).write(to: s.url) + XCTAssertTrue(s.load().isEmpty) + } + + // MARK: manager + + private func request(_ session: String, secret: String = "abc", ttl: TimeInterval = 60) -> AttachManager.Request { + // Unreachable runtime: the grant exists and its client keeps redialling. + AttachManager.Request(runtime: URL(string: "ws://127.0.0.1:9")!, session: session, profile: .sandbox, + ttl: ttl, secret: secret, adminCredential: nil) + } + + func testAGrantIsResumedUnderTheSameIdAfterARestart() async throws { + let secrets = InMemorySecretStore() + let first = AttachManager(server: fullServer(), store: store(secrets)) + let created = try await first.create(request("laptop", secret: "k1"), principal: "a@b") + XCTAssertEqual(secrets.get(account: created.id), "k1") + + // "Restart": a fresh manager over the same store. The old one is simply + // abandoned, as a killed process would be. + let second = AttachManager(server: fullServer(), store: store(secrets)) + let resumed = await second.resume() + XCTAssertEqual(resumed, 1) + let grants = await second.list() + XCTAssertEqual(grants.map(\.id), [created.id]) + XCTAssertEqual(grants.first?.session, "laptop") + XCTAssertEqual(grants.first?.profile, .sandbox) + XCTAssertEqual(grants.first?.principal, "a@b") + XCTAssertEqual(grants.first?.expiresAt.timeIntervalSince1970 ?? 0, + created.expiresAt.timeIntervalSince1970, accuracy: 1) + + await second.revoke(created.id) + await first.revoke(created.id) + } + + func testARevokedGrantIsNotResumed() async throws { + let secrets = InMemorySecretStore() + let mgr = AttachManager(server: fullServer(), store: store(secrets)) + let g = try await mgr.create(request("laptop"), principal: "a@b") + await mgr.revoke(g.id) + XCTAssertTrue(secrets.accounts.isEmpty, "revoke deletes the Keychain secret") + let next = AttachManager(server: fullServer(), store: store(secrets)) + let resumed = await next.resume() + XCTAssertEqual(resumed, 0) + let remaining = await next.list() + XCTAssertTrue(remaining.isEmpty) + } + + func testAReplacedGrantLeavesOnlyTheNewOnePersisted() async throws { + let secrets = InMemorySecretStore() + let mgr = AttachManager(server: fullServer(), store: store(secrets)) + let old = try await mgr.create(request("laptop", secret: "old"), principal: "a@b") + let new = try await mgr.create(request("laptop", secret: "new"), principal: "a@b") + XCTAssertEqual(secrets.accounts, [new.id]) + XCTAssertNil(secrets.get(account: old.id)) + await mgr.revoke(new.id) + } + + func testWithoutAStoreNothingIsPersisted() async throws { + let mgr = AttachManager(server: fullServer()) + let g = try await mgr.create(request("laptop"), principal: "a@b") + let resumed = await mgr.resume() + XCTAssertEqual(resumed, 0) + await mgr.revoke(g.id) + } +}