Skip to content
Open
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
20 changes: 20 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,24 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security list-keychains -d user -s build.keychain

- name: Import provisioning profile
if: steps.guard_release_assets.outputs.skip_all != 'true'
env:
APPLE_PROVISION_PROFILE_BASE64: ${{ secrets.APPLE_PROVISION_PROFILE_BASE64 }}
run: |
# Optional secret: only set once a Developer ID provisioning profile exists for
# a restricted entitlement (e.g. CloudKit). See plans/golden-tumbling-gray.md M3
# for the one-time Developer portal setup this unblocks. Absent today by design —
# when unset, this step is a no-op and the release pipeline (and the signing
# script it feeds) behaves exactly as it does today: no profile, no iCloud.
if [ -z "$APPLE_PROVISION_PROFILE_BASE64" ]; then
echo "No APPLE_PROVISION_PROFILE_BASE64 secret set; skipping profile embedding."
echo "PROGRAMA_PROVISION_PROFILE=" >> "$GITHUB_ENV"
exit 0
fi
echo "$APPLE_PROVISION_PROFILE_BASE64" | base64 --decode > /tmp/programa.provisionprofile
echo "PROGRAMA_PROVISION_PROFILE=/tmp/programa.provisionprofile" >> "$GITHUB_ENV"

- name: Codesign app
if: steps.guard_release_assets.outputs.skip_all != 'true'
env:
Expand All @@ -407,6 +425,7 @@ jobs:
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
./scripts/sign-release-app.sh "$APP_PATH" "$APPLE_SIGNING_IDENTITY" programa.entitlements
./scripts/verify-release-entitlements.sh "$APP_PATH" "$CLI_PATH" "$HELPER_PATH"
./scripts/verify-provision-profile.sh "$APP_PATH"

- name: Verify embedded Sparkle artifact
if: steps.guard_release_assets.outputs.skip_all != 'true'
Expand Down Expand Up @@ -610,3 +629,4 @@ jobs:
run: |
security delete-keychain build.keychain >/dev/null 2>&1 || true
rm -f /tmp/cert.p12
rm -f /tmp/programa.provisionprofile
29 changes: 27 additions & 2 deletions CLI/CLI+Hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -465,11 +465,36 @@ extension ProgramaCLI {
// case that leaves them stuck on.
do {
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(

// Deliberately not `try`, for the same reason as the surfaceId resolution
// further down -- and this was the remaining half of that bug. A throw here
// aborted the hook before any of the three clears below, and the catch at the
// bottom treats teardown-shaped errors as benign: it prints "OK", so Claude
// Code sees a perfectly healthy hook while the red "blocked" badge stays lit
// with nothing left to turn it off.
//
// Nothing recovers from that. There is no TTL or watchdog on agent state, and
// AgentScreenDetectionEngine deliberately refuses to touch a surface a hook has
// claimed (its `hooksOwned` guard), so the terminal can be visibly running a
// command while the sidebar still reads "Claude needs your permission" until the
// session ends.
//
// Falling back to the identifiers the Notification hook recorded is the whole
// point: those are the exact workspace and surface it marked blocked, so they
// are the right things to clear even when a live re-resolution is unavailable.
let resolvedWorkspaceId = (try? resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
))
?? nonEmptyClaudeHookIdentifier(mappedSession?.workspaceId).flatMap { isUUID($0) ? $0 : nil }
?? nonEmptyClaudeHookIdentifier(workspaceArg).flatMap { isUUID($0) ? $0 : nil }

// Only when there is genuinely no workspace to name is there nothing to clear.
guard let workspaceId = resolvedWorkspaceId else {
print("OK")
return
}
let claudePid = mappedSession?.pid

// AskUserQuestion means Claude is about to ask the user something.
Expand Down
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
MOBB0003 /* MobileBridgeStreamSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0004 /* MobileBridgeStreamSupport.swift */; };
MOBB0005 /* MobileBridgeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0006 /* MobileBridgeListener.swift */; };
MOBB0007 /* MobileBridgeSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0008 /* MobileBridgeSession.swift */; };
MOBB0009 /* MobileBridgePush.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0010 /* MobileBridgePush.swift */; };
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; };
MOBB1003 /* IrohLib in Frameworks */ = {isa = PBXBuildFile; productRef = MOBB1002 /* IrohLib */; };
Expand Down Expand Up @@ -604,6 +605,7 @@
MOBB0004 /* MobileBridgeStreamSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeStreamSupport.swift; sourceTree = "<group>"; };
MOBB0006 /* MobileBridgeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeListener.swift; sourceTree = "<group>"; };
MOBB0008 /* MobileBridgeSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeSession.swift; sourceTree = "<group>"; };
MOBB0010 /* MobileBridgePush.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgePush.swift; sourceTree = "<group>"; };
A5001661 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = "<group>"; };
A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = "<group>"; };
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1015,6 +1017,7 @@
MOBB0004 /* MobileBridgeStreamSupport.swift */,
MOBB0006 /* MobileBridgeListener.swift */,
MOBB0008 /* MobileBridgeSession.swift */,
MOBB0010 /* MobileBridgePush.swift */,
RVPN00000000000000000001 /* ReviewComment.swift */,
RVPN00000000000000000003 /* ReviewCommentSerializer.swift */,
RVPN00000000000000000005 /* ReviewDiffParser.swift */,
Expand Down Expand Up @@ -1500,6 +1503,7 @@
MOBB0003 /* MobileBridgeStreamSupport.swift in Sources */,
MOBB0005 /* MobileBridgeListener.swift in Sources */,
MOBB0007 /* MobileBridgeSession.swift in Sources */,
MOBB0009 /* MobileBridgePush.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
244 changes: 244 additions & 0 deletions Sources/MobileBridge/MobileBridgePush.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import Bonsplit
import CloudKit
import Foundation

/// M3: publishes a small, low-sensitivity agent-activity summary to the user's own iCloud
/// private database via CloudKit, so a `CKQuerySubscription` on the paired iPhone can wake it
/// with a push while the mobile bridge's iroh connection is dead (backgrounded/suspended --
/// see `MobileBridgeListener`). No server Programa operates: each user's Mac writes only to
/// *their own* private database, under container `iCloud.com.darkroom.programa`. The record
/// carries nothing sensitive beyond counts and a workspace title -- never prompt/output text,
/// mirroring the mobile bridge's own "never put prompt or output text in a notification
/// payload" rule (see `plans/golden-tumbling-gray.md`'s "Security implications" section).
///
/// Gated on:
/// - `MobileBridgeSettings` being anything other than `.off` -- users who never turned on the
/// phone companion generate zero CloudKit traffic.
/// - `CKContainer.accountStatus == .available` -- silently no-ops (logs once) otherwise, e.g.
/// not signed into iCloud on this Mac, or (today) the container entitlement not yet present
/// -- see the macOS entitlements note in this milestone's report.
///
/// Trigger point: called directly from `Workspace.updatePanelAgentState` /
/// `clearPanelAgentState` / `resetSidebarContext` (`Workspace+SidebarTelemetry.swift`),
/// alongside the existing `SocketEventBroadcaster.shared.publishAgentState(...)` calls at each
/// site. Chosen over adding an observer mechanism to `SocketEventBroadcaster` itself: that
/// class is shared, hot-path infrastructure for the entire v2 subscription fan-out, and giving
/// it a second consumer channel is a materially bigger, riskier change than three one-line
/// calls at the existing telemetry funnel that already fires on every transition.
final class MobileBridgePush: @unchecked Sendable {
static let shared = MobileBridgePush()

static let containerIdentifier = "iCloud.com.darkroom.programa"
static let recordType = "AgentStatus"
/// Stable record name (default zone, no custom `recordID.zoneID`) so every write updates
/// the same record in place rather than accumulating new ones in the user's iCloud quota.
static let recordName = "agent-status-summary"

/// Never write more than once every this many seconds -- every CloudKit write is a push
/// delivered to the user's phone.
private static let minimumWriteInterval: TimeInterval = 5

struct Summary: Equatable {
var blockedCount: Int
var workingCount: Int
var mostRecentBlockedWorkspaceTitle: String?
}

private let container: CKContainer

/// Serializes all mutable state below (`trackedBlockedTitle` through
/// `didLogAccountUnavailable`). Every access to that state -- including from CloudKit's
/// own completion-handler callbacks, which run on an arbitrary system queue -- is
/// dispatched through this queue rather than guarded by a lock, since the coalescing
/// timer (`asyncAfter`) is native to `DispatchQueue` and this avoids mixing a lock with
/// queue-hopping.
private let queue = DispatchQueue(label: "com.darkroom.programa.mobileBridgePush")

private var trackedBlockedTitle: String?
private var lastWrittenSummary: Summary?
private var pendingSummary: Summary?
private var lastWriteAt: Date = .distantPast
private var coalesceScheduled = false
private var didLogAccountUnavailable = false

/// **Hard build-time kill switch for the CloudKit entitlement. Set to `true` on
/// 2026-07-28, together with the entitlement itself, once the release pipeline could
/// actually carry a provisioning profile that grants it.**
///
/// What made it safe to flip: App ID `com.darkroom.programa` was registered with the
/// iCloud capability and container `iCloud.com.darkroom.programa`, a Developer ID
/// Application profile (`Programa Developer ID CloudKit`) was generated against it, and
/// that profile is supplied to CI as `APPLE_PROVISION_PROFILE_BASE64` and embedded by
/// `scripts/sign-release-app.sh` before the app is signed. Verify with
/// `scripts/verify-provision-profile.sh <app>`.
///
/// If the entitlement is ever removed from `programa.entitlements`, or the profile secret
/// is dropped, set this back to `false` in the same change -- the two must move together.
/// The historical reason, still the reason:
///
/// Adding `com.apple.developer.icloud-container-identifiers` /
/// `com.apple.developer.icloud-services` to `programa.entitlements` is *not* done as part
/// of this milestone: those are Apple-restricted, App-ID-level capability entitlements
/// that must be present in an embedded provisioning profile to survive AMFI's launch-time
/// check. `scripts/sign-release-app.sh` signs the notarized release build with a bare
/// `codesign --entitlements` pass and embeds no provisioning profile at all -- the exact
/// gap that bricked launch for every user in the 2026-07-14 incident (POSIX 163, see
/// `memory/restricted-entitlements-brick-app.md`) over a different restricted entitlement.
/// `main` auto-ships every green CI run, so there is no safe way to "try it and see";
/// notarization does not catch this class of failure, only a real launch does.
///
/// This flag is the second, independent gate (on top of `MobileBridgeSettings` and
/// `CKContainer.accountStatus`) that keeps this file inert in a shipped build even though
/// it already compiles and links against CloudKit: `CKContainer.accountStatus` alone would
/// still report `.available` on any Mac signed into iCloud, entitlement or not, so relying
/// on that gate alone is not sufficient to keep this dark.
static let releaseProvisioningComplete = true

private init(container: CKContainer = CKContainer(identifier: MobileBridgePush.containerIdentifier)) {
self.container = container
}

/// Call on every agent-state transition (set or clear) that already calls
/// `SocketEventBroadcaster.shared.publishAgentState`. Must be called from the main thread
/// -- it reads `TabManager.tabs` / `Workspace.aggregateAgentState` synchronously, both of
/// which ARE main-actor isolated (the compiler rejects reading them from a nonisolated
/// context). Annotated `@MainActor` to match; the call sites in
/// `Workspace+SidebarTelemetry.swift` already run there, alongside the existing
/// `publishAgentState` calls, so this adds no hop. Only the derived counts cross to the
/// background queue below -- plain Ints and an optional String.
@MainActor
func noteAgentStateChanged(workspaceId: UUID, workspaceTitle: String, changedState: AgentActivityState?) {
guard Self.releaseProvisioningComplete else { return }
guard Self.bridgeEnabled else { return }

let workspaces = TerminalController.shared.tabManager?.tabs ?? []
var blockedCount = 0
var workingCount = 0
for workspace in workspaces {
switch workspace.aggregateAgentState {
case .blocked: blockedCount += 1
case .working: workingCount += 1
case .idle, nil: break
}
}
let newlyBlockedWorkspaceTitle = (changedState == .blocked) ? workspaceTitle : nil

queue.async { [weak self] in
self?.handleChange(
blockedCount: blockedCount,
workingCount: workingCount,
newlyBlockedWorkspaceTitle: newlyBlockedWorkspaceTitle
)
}
}

private static var bridgeEnabled: Bool {
let raw = UserDefaults.standard.string(forKey: MobileBridgeSettings.appStorageKey)
?? MobileBridgeSettings.defaultMode.rawValue
return MobileBridgeSettings.mode(for: raw) != .off
}

// MARK: - `queue`-confined

private func handleChange(blockedCount: Int, workingCount: Int, newlyBlockedWorkspaceTitle: String?) {
if let newlyBlockedWorkspaceTitle {
trackedBlockedTitle = newlyBlockedWorkspaceTitle
}
if blockedCount == 0 {
trackedBlockedTitle = nil
}

let summary = Summary(
blockedCount: blockedCount,
workingCount: workingCount,
mostRecentBlockedWorkspaceTitle: trackedBlockedTitle
)
guard summary != lastWrittenSummary, summary != pendingSummary else { return }
pendingSummary = summary
scheduleCoalescedWriteIfNeeded()
}

private func scheduleCoalescedWriteIfNeeded() {
guard !coalesceScheduled else { return }
coalesceScheduled = true
let elapsed = Date().timeIntervalSince(lastWriteAt)
let delay = max(0, Self.minimumWriteInterval - elapsed)
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.flushPendingWrite()
}
}

private func flushPendingWrite() {
coalesceScheduled = false
guard let summary = pendingSummary, summary != lastWrittenSummary else {
pendingSummary = nil
return
}
pendingSummary = nil
lastWriteAt = Date()
checkAccountStatusAndSave(summary)
}

private func checkAccountStatusAndSave(_ summary: Summary) {
container.accountStatus { [weak self] status, error in
guard let self else { return }
self.queue.async {
guard status == .available, error == nil else {
if !self.didLogAccountUnavailable {
self.didLogAccountUnavailable = true
#if DEBUG
dlog(
"mobileBridge.push.accountUnavailable status=\(status.rawValue) " +
"error=\(String(describing: error))"
)
#endif
}
return
}
self.didLogAccountUnavailable = false
self.performSave(summary)
}
}
}

/// Runs on `queue`. Always constructs a fresh `CKRecord` (never fetches first) and saves
/// with `.changedKeys` -- correct for this record's single-writer-per-account semantics
/// (only this Mac, under this iCloud account, ever writes `agent-status-summary`), and
/// avoids the `.ifServerRecordUnchanged` default's change-tag conflict since we never hold
/// a server-issued tag.
private func performSave(_ summary: Summary) {
let recordID = CKRecord.ID(recordName: Self.recordName)
let record = CKRecord(recordType: Self.recordType, recordID: recordID)
record["blockedCount"] = summary.blockedCount as CKRecordValue
record["workingCount"] = summary.workingCount as CKRecordValue
if let title = summary.mostRecentBlockedWorkspaceTitle {
record["mostRecentBlockedWorkspaceTitle"] = title as CKRecordValue
} else {
// Explicit nil clears the field server-side (and counts as a "changed key") --
// omitting the key entirely would leave a stale title from a previous block.
record["mostRecentBlockedWorkspaceTitle"] = nil
}

let operation = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil)
operation.savePolicy = .changedKeys
operation.qualityOfService = .utility
operation.modifyRecordsResultBlock = { [weak self] result in
guard let self else { return }
self.queue.async {
switch result {
case .success:
self.lastWrittenSummary = summary
#if DEBUG
dlog(
"mobileBridge.push.wrote blocked=\(summary.blockedCount) " +
"working=\(summary.workingCount)"
)
#endif
case let .failure(error):
NSLog("MobileBridgePush: CloudKit write failed: %@", "\(error)")
}
}
}
container.privateCloudDatabase.add(operation)
}
}
Loading
Loading