From 104e02063586cd9c3e3bf1e5628f371159fe8b17 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 20 Jul 2026 03:20:51 -0400 Subject: [PATCH 1/5] fix(rpc): do not block startup on undetermined Contacts auth imsg rpc used ContactResolver.create() with the default requestIfNeeded policy, so a headless LaunchAgent could hang forever waiting on CNContactStore.requestAccess before the JSON-RPC server started. Use skipIfNotDetermined (same as chats/history) so RPC starts with optional name enrichment disabled until Contacts is already authorized. Closes #186 Signed-off-by: Sebastien Tardif --- CHANGELOG.md | 3 +++ Sources/imsg/Commands/RpcCommand.swift | 17 ++++++++++++++++- .../RpcCommandContactsPolicyTests.swift | 11 +++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 Tests/imsgTests/RpcCommandContactsPolicyTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 25905a6f..cacde936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ - perf: fetch history reactions in one pass while preserving cross-chat reaction associations (#189, thanks @zachwinter). - perf: reuse ISO-8601 date formatting safely across high-volume output (#193, thanks @zachwinter). +### JSON-RPC +- fix: start `imsg rpc` without blocking on undetermined Contacts authorization so headless LaunchAgents accept JSON-RPC methods before any permission prompt resolves (#186, thanks @SebTardif). + ### Advanced IMCore - fix: restore group participant add/remove on macOS 26 by using fallback-capable handle lookup and probing both current and legacy IMChat selectors (#185, thanks @oficiallyAkshay). diff --git a/Sources/imsg/Commands/RpcCommand.swift b/Sources/imsg/Commands/RpcCommand.swift index bff73ee1..2f7c15dd 100644 --- a/Sources/imsg/Commands/RpcCommand.swift +++ b/Sources/imsg/Commands/RpcCommand.swift @@ -3,6 +3,11 @@ import Foundation import IMsgCore enum RpcCommand { + /// RPC often runs headless (LaunchAgent / automation). Do not block server + /// startup on a Contacts prompt that may never resolve when authorization is + /// still `.notDetermined`. Display-name enrichment stays optional. + static let startupContactsAccessPolicy: ContactsAccessPolicy = .skipIfNotDetermined + static let spec = CommandSpec( name: "rpc", abstract: "Run JSON-RPC over stdin/stdout", @@ -15,6 +20,16 @@ enum RpcCommand { "imsg rpc --db ~/Library/Messages/chat.db", ] ) { values, runtime in + try await run(values: values, runtime: runtime) + } + + static func run( + values: ParsedValues, + runtime: RuntimeOptions, + contactResolverFactory: @escaping () async -> any ContactResolving = { + await ContactResolver.create(accessPolicy: startupContactsAccessPolicy) + } + ) async throws { let dbPath = values.option("db") ?? MessageStore.defaultPath let store: MessageStore do { @@ -23,7 +38,7 @@ enum RpcCommand { await RPCStartupErrorServer(error: error).run() throw CommandOutputEmittedError() } - let contacts = await ContactResolver.create() + let contacts = await contactResolverFactory() let server = RPCServer(store: store, verbose: runtime.verbose, contactResolver: contacts) try await server.run() } diff --git a/Tests/imsgTests/RpcCommandContactsPolicyTests.swift b/Tests/imsgTests/RpcCommandContactsPolicyTests.swift new file mode 100644 index 00000000..946072e4 --- /dev/null +++ b/Tests/imsgTests/RpcCommandContactsPolicyTests.swift @@ -0,0 +1,11 @@ +import Testing + +@testable import IMsgCore +@testable import imsg + +@Test +func rpcCommandUsesFailOpenContactsPolicyAtStartup() { + // Headless RPC must not await CNContactStore.requestAccess when auth is + // still undetermined (issue #186). Match chats/history fail-open policy. + #expect(RpcCommand.startupContactsAccessPolicy == .skipIfNotDetermined) +} From 34f12642871c0428f1f32d36f9d2ac739cab2e09 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 20 Jul 2026 17:17:23 -0400 Subject: [PATCH 2/5] docs: refresh headless rpc proof for ClawSweeper Signed-off-by: Sebastien Tardif From 6e3634848e13e3ac9974c9536d7c5f853d40759d Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 26 Jul 2026 09:09:18 -0400 Subject: [PATCH 3/5] fix(rpc): scope Contacts fail-open to noninteractive stdin ClawSweeper P1: unconditional skipIfNotDetermined changed the RPC authorization contract for interactive callers. Keep requestIfNeeded when stdin is a TTY; use skipIfNotDetermined only for headless pipes/LaunchAgents. Adds pure policy tests for both modes. Signed-off-by: Sebastien Tardif --- CHANGELOG.md | 2 +- Sources/imsg/Commands/RpcCommand.swift | 19 ++++++++++++++---- .../RpcCommandContactsPolicyTests.swift | 20 ++++++++++++------- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cacde936..7090ad63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ - perf: reuse ISO-8601 date formatting safely across high-volume output (#193, thanks @zachwinter). ### JSON-RPC -- fix: start `imsg rpc` without blocking on undetermined Contacts authorization so headless LaunchAgents accept JSON-RPC methods before any permission prompt resolves (#186, thanks @SebTardif). +- fix: start headless `imsg rpc` without blocking on undetermined Contacts authorization (interactive TTY still prompts); LaunchAgents accept JSON-RPC before any permission prompt resolves (#186, thanks @SebTardif). ### Advanced IMCore - fix: restore group participant add/remove on macOS 26 by using fallback-capable handle lookup and probing both current and legacy IMChat selectors (#185, thanks @oficiallyAkshay). diff --git a/Sources/imsg/Commands/RpcCommand.swift b/Sources/imsg/Commands/RpcCommand.swift index 2f7c15dd..9c84fb6b 100644 --- a/Sources/imsg/Commands/RpcCommand.swift +++ b/Sources/imsg/Commands/RpcCommand.swift @@ -1,12 +1,23 @@ import Commander +import Darwin import Foundation import IMsgCore enum RpcCommand { - /// RPC often runs headless (LaunchAgent / automation). Do not block server - /// startup on a Contacts prompt that may never resolve when authorization is - /// still `.notDetermined`. Display-name enrichment stays optional. - static let startupContactsAccessPolicy: ContactsAccessPolicy = .skipIfNotDetermined + /// Contacts policy for RPC startup. + /// + /// Headless RPC (stdin not a TTY: LaunchAgent, pipes, automation) must not + /// block on a Contacts prompt that will never resolve while authorization + /// remains `.notDetermined`. Interactive terminals keep the prompt-capable + /// path so Contacts-backed name resolution still works. + static var startupContactsAccessPolicy: ContactsAccessPolicy { + contactsAccessPolicy(stdinIsTTY: isatty(STDIN_FILENO) != 0) + } + + /// Pure policy helper for tests and callers that already know interactivity. + static func contactsAccessPolicy(stdinIsTTY: Bool) -> ContactsAccessPolicy { + stdinIsTTY ? .requestIfNeeded : .skipIfNotDetermined + } static let spec = CommandSpec( name: "rpc", diff --git a/Tests/imsgTests/RpcCommandContactsPolicyTests.swift b/Tests/imsgTests/RpcCommandContactsPolicyTests.swift index 946072e4..16c4bbcd 100644 --- a/Tests/imsgTests/RpcCommandContactsPolicyTests.swift +++ b/Tests/imsgTests/RpcCommandContactsPolicyTests.swift @@ -1,11 +1,17 @@ +import Foundation +import IMsgCore import Testing - -@testable import IMsgCore @testable import imsg -@Test -func rpcCommandUsesFailOpenContactsPolicyAtStartup() { - // Headless RPC must not await CNContactStore.requestAccess when auth is - // still undetermined (issue #186). Match chats/history fail-open policy. - #expect(RpcCommand.startupContactsAccessPolicy == .skipIfNotDetermined) +@Suite("RpcCommand Contacts policy") +struct RpcCommandContactsPolicyTests { + @Test("headless stdin uses skipIfNotDetermined") + func headlessSkipsUndetermined() { + #expect(RpcCommand.contactsAccessPolicy(stdinIsTTY: false) == .skipIfNotDetermined) + } + + @Test("interactive stdin keeps requestIfNeeded") + func interactiveRequestsIfNeeded() { + #expect(RpcCommand.contactsAccessPolicy(stdinIsTTY: true) == .requestIfNeeded) + } } From b8bcf52b8f944f34121f0e8ae4ee90d6cc71a8f6 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 26 Jul 2026 21:19:39 -0400 Subject: [PATCH 4/5] fix(rpc): import Darwin/Glibc for Linux CI isatty Unconditional import Darwin breaks linux-read-core (no Darwin module). Match SecurePath and use canImport Darwin/Glibc for isatty/STDIN_FILENO. Signed-off-by: Sebastien Tardif --- Sources/imsg/Commands/RpcCommand.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/imsg/Commands/RpcCommand.swift b/Sources/imsg/Commands/RpcCommand.swift index 9c84fb6b..c94dcae4 100644 --- a/Sources/imsg/Commands/RpcCommand.swift +++ b/Sources/imsg/Commands/RpcCommand.swift @@ -1,8 +1,13 @@ import Commander -import Darwin import Foundation import IMsgCore +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + enum RpcCommand { /// Contacts policy for RPC startup. /// From af95a7c9c9938679d4593c1a3a5254d3a4a902d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 22:26:33 -0700 Subject: [PATCH 5/5] fix(rpc): fail closed when contacts are unavailable --- CHANGELOG.md | 3 --- Sources/imsg/ChatTargetResolver.swift | 5 +++++ Tests/imsgTests/ContactResolutionTests.swift | 8 +++++++ Tests/imsgTests/RPCServerTests.swift | 22 ++++++++++++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7090ad63..25905a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,6 @@ - perf: fetch history reactions in one pass while preserving cross-chat reaction associations (#189, thanks @zachwinter). - perf: reuse ISO-8601 date formatting safely across high-volume output (#193, thanks @zachwinter). -### JSON-RPC -- fix: start headless `imsg rpc` without blocking on undetermined Contacts authorization (interactive TTY still prompts); LaunchAgents accept JSON-RPC before any permission prompt resolves (#186, thanks @SebTardif). - ### Advanced IMCore - fix: restore group participant add/remove on macOS 26 by using fallback-capable handle lookup and probing both current and legacy IMChat selectors (#185, thanks @oficiallyAkshay). diff --git a/Sources/imsg/ChatTargetResolver.swift b/Sources/imsg/ChatTargetResolver.swift index af8b9665..628ede0e 100644 --- a/Sources/imsg/ChatTargetResolver.swift +++ b/Sources/imsg/ChatTargetResolver.swift @@ -114,6 +114,11 @@ enum ChatTargetResolver { contacts: any ContactResolving ) throws -> String { guard looksLikeContactName(recipient) else { return recipient } + guard !contacts.contactsUnavailable else { + throw IMsgError.invalidChatTarget( + "Contacts access is unavailable; specify a phone number or email instead." + ) + } let matches = contacts.searchByName(recipient) switch matches.count { case 0: diff --git a/Tests/imsgTests/ContactResolutionTests.swift b/Tests/imsgTests/ContactResolutionTests.swift index 2d5865e3..df3cb4e2 100644 --- a/Tests/imsgTests/ContactResolutionTests.swift +++ b/Tests/imsgTests/ContactResolutionTests.swift @@ -25,6 +25,14 @@ func contactNameResolutionPassesThroughUnknownNames() throws { #expect(resolved == "Unknown Person") } +@Test +func contactNameResolutionRejectsNamesWhenContactsAreUnavailable() { + let resolver = MockContactResolver(contactsUnavailable: true) + #expect(throws: (any Error).self) { + try ChatTargetResolver.resolveRecipientName("Unknown Person", contacts: resolver) + } +} + @Test func contactNameResolutionReturnsUniqueMatch() throws { let resolver = MockContactResolver( diff --git a/Tests/imsgTests/RPCServerTests.swift b/Tests/imsgTests/RPCServerTests.swift index 3ca80d15..ceb30ef6 100644 --- a/Tests/imsgTests/RPCServerTests.swift +++ b/Tests/imsgTests/RPCServerTests.swift @@ -222,6 +222,28 @@ func rpcSendRejectsAmbiguousContactName() async throws { #expect(int64Value(error?["code"]) == -32602) } +@Test +func rpcSendRejectsContactNameWhenContactsAreUnavailable() async throws { + let store = try CommandTestDatabase.makeStoreForRPC() + let output = TestRPCOutput() + let resolver = MockContactResolver(contactsUnavailable: true) + var didSend = false + let server = RPCServer( + store: store, + verbose: false, + output: output, + sendMessage: { _ in didSend = true }, + contactResolver: resolver + ) + + let line = #"{"jsonrpc":"2.0","id":"3u","method":"send","params":{"to":"Alice","text":"yo"}}"# + await server.handleLineForTesting(line) + + let error = output.errors.first?["error"] as? [String: Any] + #expect(int64Value(error?["code"]) == -32602) + #expect(didSend == false) +} + @Test func rpcSendReturnsSentMessageIdentifiersWhenResolved() async throws { let store = try CommandTestDatabase.makeStoreForRPC()