diff --git a/slab/bin/prox-mcp.mjs b/slab/bin/prox-mcp.mjs index 993146638..d500e5bec 100755 --- a/slab/bin/prox-mcp.mjs +++ b/slab/bin/prox-mcp.mjs @@ -63,15 +63,6 @@ async function readJson(path) { } } -async function loopboyContactsBySession() { - const cfg = await readJson(LOOPBOY_CONFIG); - const contacts = new Map(); - for (const [contact, loop] of Object.entries(cfg?.loops || {})) { - if (loop?.sessionId) contacts.set(String(loop.sessionId), contact); - } - return contacts; -} - // Every ledger this machine knows about: its own local one first, then each // cached peer. Returns [{host, ip, updatedAt, entries, self}]. async function allLedgers() { @@ -93,7 +84,6 @@ async function allLedgers() { // where to go. Sorted newest-activity-first within each host. async function allRocks(ledgers = null) { const rows = []; - const loopboyContacts = await loopboyContactsBySession(); for (const led of ledgers || await allLedgers()) { for (const e of led.entries || []) { rows.push({ @@ -102,7 +92,10 @@ async function allRocks(ledgers = null) { ip: led.ip, self: led.self, ledgerUpdatedAt: led.updatedAt, - loopboyContact: e.loopboyContact || (led.self ? loopboyContacts.get(e.id) || "" : ""), + // Trust only the immutable launch identity advertised by the live + // marker. The mutable route registry cannot turn an ordinary process + // into a guarded Loopboy after startup. + loopboyContact: e.loopboyContact || "", }); } } @@ -549,6 +542,17 @@ async function toolBindNotification({ handle, contact, event = "imessage" }) { if (!contactKey) throw new Error("`contact` is required (the key from ~/.config/slab/imsg.json)"); const r = actionResolution(await allRocks(), handle, "bind"); if (!r.self) throw new Error("iMessage notification wake targets must be a local prox on this machine"); + if (!r.loopboyContact) { + throw new Error( + `${canonicalHandle(r)} was not launched as a guarded Loopboy; ` + + `start a dedicated session with prox_launch and loopboyContact=${contactKey}`, + ); + } + if (String(r.loopboyContact).toLowerCase() !== contactKey) { + throw new Error( + `${canonicalHandle(r)} was launched for ${r.loopboyContact}, not ${contactKey}`, + ); + } const loop = { event: "imessage", contact: contactKey, @@ -747,7 +751,7 @@ const TOOLS = [ { name: "prox_bind_notification", description: - "Create or replace one contact-keyed Loopboy route from iMessage to a stable local prox. Events are delivered only through that session's isolated inbox and prox_loopboy_wait; this never types into Terminal or other user-space UI.", + "Register or repair the route for a local prox that was already launched with the same guarded Loopboy contact identity. Refuses ordinary sessions because route JSON cannot retrofit the listener identity or reduced tool surface; use prox_launch with loopboyContact to create a new Loopboy.", inputSchema: { type: "object", properties: { diff --git a/slab/menubar-swift/README.md b/slab/menubar-swift/README.md index f30f15a6d..8282be4b6 100644 --- a/slab/menubar-swift/README.md +++ b/slab/menubar-swift/README.md @@ -110,8 +110,12 @@ prox session. New inbound messages poke and optionally wake only that contact's rock. Heartbeats and messages are written to a private, per-session inbox and consumed by `prox_loopboy_wait`; they never type into Terminal, use the clipboard, or move focus. Loopboy never replies on its own. Armed Loopboy rocks spin faster, wear a -pink glow, and identify themselves in their hover bubble. The Slab menu lists -all active client loops and their prox targets. +pink glow, show their verified contact beside the current phase, and identify +themselves in their hover bubble. A saved route becomes active only when it +matches the contact identity placed in the live session marker by the guarded +launcher; editing `loopboy.json` cannot retrofit an ordinary prompt. The Slab +menu counts verified client loops and labels stale or mismatched saved routes +as inactive. ## ZZZ — resumable prompt parking diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 1171a90d3..c99b224d8 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -863,12 +863,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// never type into Terminal, touch the clipboard, or use foreground UI. private func bumpBoundProx(contact: String, displayLabel: String, message: String, fromMe: Bool = false, heartbeat: Bool = false) { - guard let data = FileManager.default.contents(atPath: Paths.loopboyConfig), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let loops = obj["loops"] as? [String: Any], - let loop = loops[contact] as? [String: Any], - let sid = loop["sessionId"] as? String, !sid.isEmpty else { return } - let routeHost = ((loop["host"] as? String) ?? "") + guard let route = LoopboyRoutes.all()[contact], + let boundSession = state.claudeSessions.first(where: { + $0.sessionId == route.sessionId + && LoopboyRoutes.verifiedContact(for: $0) == contact + }) else { + NSLog("💬 [loopboy] %@ route has no matching guarded live session; refusing delivery", + contact) + return + } + let sid = route.sessionId + let routeHost = route.host .lowercased().replacingOccurrences(of: ".local", with: "") let localHost = ProcessInfo.processInfo.hostName.lowercased() .replacingOccurrences(of: ".local", with: "") @@ -877,14 +882,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { contact, routeHost, localHost) return } - let autoRespond = (loop["autoRespond"] as? Bool) ?? false + let data = FileManager.default.contents(atPath: Paths.loopboyConfig) + let obj = data.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + let loops = obj?["loops"] as? [String: Any] + let loop = loops?[contact] as? [String: Any] + let autoRespond = (loop?["autoRespond"] as? Bool) ?? false LedgerStore.shared.pokeLocal(sessionId: sid, by: "loopboy:\(contact)") let clean = message.replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) let excerpt = String(clean.prefix(240)) let direction = fromMe ? "outgoing to" : "incoming from" - let boundSession = state.claudeSessions.first(where: { $0.sessionId == sid }) let responsePolicy = autoRespond ? " This route explicitly authorizes automatic responses: after completing and validating any work, reread the newest thread context, discard stale drafts, compose concise Markdown with paragraphs, bullets, links, and restrained emphasis, then send one appropriate reply using `node slab/bin/imsg.mjs send --rich --to \(contact)`. Verify it appears outbound. Never duplicate a response." : " Do not send or react automatically." @@ -895,8 +903,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // the prompt hook's inferred title so each heartbeat names this // prox's actual mission instead of asking a context-free "what's // next?" every minute. - let topic = boundSession?.titleString - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let topic = boundSession.titleString + .trimmingCharacters(in: .whitespacesAndNewlines) let continuation = topic.isEmpty || topic == "(no subject)" ? "Re-read the latest thread with \(displayLabel), infer the next concrete action, and do it." : "Continue \(topic) for \(displayLabel): infer the next concrete action from the latest thread, then do it." @@ -1041,16 +1049,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// visually different from an ordinary manually-launched prox even while /// both agents share the same working/awaiting state. private func loopboySessionLabels() -> [String: String] { - guard let data = FileManager.default.contents(atPath: Paths.loopboyConfig), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let loops = obj["loops"] as? [String: Any] else { return [:] } - var labels: [String: String] = [:] - for (contact, value) in loops { - guard let loop = value as? [String: Any], - let sid = loop["sessionId"] as? String, !sid.isEmpty else { continue } - labels[sid] = contact - } - return labels + LoopboyRoutes.verifiedBySession(state.claudeSessions) } private func loopboyAutoRespondContacts() -> Set { diff --git a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift index b95e31ead..cba9bf1f4 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift @@ -92,6 +92,9 @@ struct ClaudeSession { /// Native provider thread id. Claude uses `sessionId`; Codex's tracked /// wrapper has its own rock id, so the watcher records the rollout id here. var providerSessionId: String = "" + /// Immutable contact identity supplied by the guarded launcher. A saved + /// route is trusted only when it agrees with this marker field. + var loopboyContact: String = "" var loopboyState: String = "" var loopboyResponse: String = "" var nudgeScreen: String = "" @@ -334,6 +337,7 @@ enum ClaudeSessionReader { session.providerSessionId = (obj["provider_session_id"] as? String) ?? (obj["codex_session_id"] as? String) ?? (session.agentType == "claude" ? session.sessionId : "") + session.loopboyContact = (obj["loopboy_contact"] as? String) ?? "" session.loopboyState = (obj["loopboy_state"] as? String) ?? "" session.loopboyResponse = (obj["loopboy_response"] as? String) ?? "" session.nudgeScreen = (obj["nudge_screen"] as? String) ?? "" diff --git a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift index 2685993ad..bd3fee030 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift @@ -45,6 +45,9 @@ struct LedgerEntry: Codable, Equatable { /// platform-target-awareness-identifier-badge. Optional preserves rolling /// compatibility with fleet ledgers published before target awareness. var platformTarget: String? + /// Guarded launch identity. Optional keeps older fleet ledgers readable; + /// unlike the route registry, this originates in the live marker. + var loopboyContact: String? } struct Ledger: Codable { @@ -363,7 +366,8 @@ final class LedgerStore { started: s.started.timeIntervalSince1970 * 1000, memoir: ProxMemoirs.shared.text(for: s.sessionId), agentType: s.agentType, - platformTarget: s.platformTarget.isEmpty ? nil : s.platformTarget) + platformTarget: s.platformTarget.isEmpty ? nil : s.platformTarget, + loopboyContact: s.loopboyContact.isEmpty ? nil : s.loopboyContact) } entries.append(contentsOf: advertisedAgents()) @@ -409,7 +413,8 @@ final class LedgerStore { started: (obj["started"] as? Double), memoir: (obj["memoir"] as? String), agentType: (obj["agent_type"] as? String), - platformTarget: (obj["platform_target"] as? String))) + platformTarget: (obj["platform_target"] as? String), + loopboyContact: (obj["loopboy_contact"] as? String))) } return out } diff --git a/slab/menubar-swift/Sources/SlabMenubar/LoopboyRoutes.swift b/slab/menubar-swift/Sources/SlabMenubar/LoopboyRoutes.swift new file mode 100644 index 000000000..5b18a0dfd --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/LoopboyRoutes.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Saved Loopboy routing and the live launch identity are deliberately +/// separate. A route is operational only when both agree: editing the JSON +/// registry cannot retrofit the environment, MCP headers, or reduced tool +/// surface that a guarded Loopboy receives at process launch. +struct LoopboyRoute { + let contact: String + let sessionId: String + let host: String + let name: String + let wake: Bool +} + +enum LoopboyRoutes { + static func all() -> [String: LoopboyRoute] { + guard let data = FileManager.default.contents(atPath: Paths.loopboyConfig), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let loops = obj["loops"] as? [String: Any] else { return [:] } + var routes: [String: LoopboyRoute] = [:] + for (rawContact, value) in loops { + guard let loop = value as? [String: Any], + let sid = loop["sessionId"] as? String, !sid.isEmpty else { continue } + let contact = rawContact.lowercased() + routes[contact] = LoopboyRoute( + contact: contact, + sessionId: sid, + host: (loop["host"] as? String) ?? "?", + name: (loop["name"] as? String) ?? "?", + wake: (loop["wake"] as? Bool) ?? false) + } + return routes + } + + /// Return the contact only when the saved route and immutable launch-time + /// marker agree on this exact session. + static func verifiedContact(for session: ClaudeSession, + routes: [String: LoopboyRoute]? = nil) -> String? { + let contact = session.loopboyContact.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !contact.isEmpty, + let route = (routes ?? all())[contact], + route.sessionId == session.sessionId else { return nil } + return contact + } + + static func verifiedBySession(_ sessions: [ClaudeSession]) -> [String: String] { + let routes = all() + return Dictionary(uniqueKeysWithValues: sessions.compactMap { session in + verifiedContact(for: session, routes: routes).map { (session.sessionId, $0) } + }) + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift index e47709690..586dbc52f 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -78,7 +78,7 @@ enum MenuBuilder { : "Inbox: clear" menu.addItem(section(inboxTitle, symbol: "tray.full.fill", submenu: inbox)) - menu.addItem(buildLoopboy(target: target)) + menu.addItem(buildLoopboy(state: state, target: target)) let work = NSMenu() work.addItem(buildAsana(state: asana, target: target)) @@ -172,27 +172,32 @@ enum MenuBuilder { return it } - private static func buildLoopboy(target: AppDelegate) -> NSMenuItem { + private static func buildLoopboy(state: StateSnapshot, target: AppDelegate) -> NSMenuItem { let sub = NSMenu() - var count = 0 - if let data = FileManager.default.contents(atPath: Paths.loopboyConfig), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let loops = obj["loops"] as? [String: Any] { - for key in loops.keys.sorted() { - guard let loop = loops[key] as? [String: Any] else { continue } - let host = (loop["host"] as? String) ?? "?" - let name = (loop["name"] as? String) ?? "?" - let wake = (loop["wake"] as? Bool) ?? false - sub.addItem(info("\(wake ? "↻" : "◌") \(key) → \(host):\(name)")) - count += 1 + let routes = LoopboyRoutes.all() + let verified = LoopboyRoutes.verifiedBySession(state.claudeSessions) + let activeContacts = Set(verified.values) + for key in routes.keys.sorted() where activeContacts.contains(key) { + guard let route = routes[key] else { continue } + sub.addItem(info("\(route.wake ? "↻" : "◌") \(key) → \(route.host):\(route.name)")) + } + if activeContacts.isEmpty { sub.addItem(info("No active client loops")) } + let inactive = routes.keys.filter { !activeContacts.contains($0) }.sorted() + if !inactive.isEmpty { + sub.addItem(.separator()) + for key in inactive { + guard let route = routes[key] else { continue } + sub.addItem(info("⚠ inactive route: \(key) → \(route.host):\(route.name)")) } } - if count == 0 { sub.addItem(info("No client loops")) } sub.addItem(.separator()) sub.addItem(item("Open Loopboy routes…", selector: #selector(AppDelegate.openLoopboyConfig), target: target)) - return section(count == 1 ? "Loopboy: 1 client loop" : "Loopboy: \(count) client loops", + let title = inactive.isEmpty + ? "Loopboy: \(activeContacts.count) active" + : "Loopboy: \(activeContacts.count) active · \(inactive.count) inactive" + return section(title, symbol: "arrow.triangle.2.circlepath", submenu: sub) } diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift index af4d728cf..79a150b67 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -785,7 +785,9 @@ final class PromptSigilOverlay { private func applyStateLabel(_ state: String) { let color: NSColor - switch state { + let phase = state.split(separator: "·").last? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? state + switch phase { case "READING": color = NSColor(deviceRed: 0.28, green: 0.92, blue: 1.0, alpha: 1) case "WORKING": color = NSColor(deviceRed: 1.0, green: 0.92, blue: 0.22, alpha: 1) case "RESPONDING": color = NSColor(deviceRed: 1.0, green: 0.42, blue: 0.72, alpha: 1) @@ -2024,18 +2026,14 @@ final class PromptSigilOverlayController { } } - private func loopboySessions() -> Set { - guard let data = FileManager.default.contents(atPath: Paths.loopboyConfig), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let loops = obj["loops"] as? [String: Any] else { return [] } - return Set(loops.values.compactMap { - ($0 as? [String: Any])?["sessionId"] as? String - }) - } + /// Updated by `sync` from sessions whose immutable launch identity agrees + /// with their saved route. Registry-only or dead bindings never become + /// gems and cannot receive heartbeat animation. + private var verifiedLoopboyContacts: [String: String] = [:] func pulseLoopboy(sessionId: String) { - let ids = loopboySessions() - guard ids.contains(sessionId), let overlay = overlays[sessionId] else { return } + guard verifiedLoopboyContacts[sessionId] != nil, + let overlay = overlays[sessionId] else { return } let beat = CACurrentMediaTime() + 0.08 overlay.heartbeatPulse(beginTime: beat) overlay.resetHeartbeatCountdown() @@ -2226,7 +2224,8 @@ final class PromptSigilOverlayController { let live = sessions.filter { !$0.tty.isEmpty && $0.remoteHost.isEmpty } let liveIds = Set(live.map { $0.sessionId }) - let loopIds = loopboySessions() + let loopContacts = LoopboyRoutes.verifiedBySession(live) + verifiedLoopboyContacts = loopContacts let dark = AppDelegate.isDarkAppearance() var liveParticleTtys = Set() @@ -2266,7 +2265,8 @@ final class PromptSigilOverlayController { // moves to a new prompt. let seed = SigilRenderer.seed(for: s.sessionId + "\u{1}" + s.subject) // Re-render the sprite sheet only when the rock or the sun moved. - let loopboy = loopIds.contains(s.sessionId) + let loopboyContact = loopContacts[s.sessionId] + let loopboy = loopboyContact != nil let key = "\(seed):\(dark):\(sunMinute):\(loopboy)" if ov.frameKey != key { ov.frameKey = key @@ -2294,7 +2294,7 @@ final class PromptSigilOverlayController { : terminalThemeColor) ov.setShining(loopboy, color: loopboyGlow) ov.setLoopboyStyle(loopboy, active: loopboyActive, pending: false, dark: dark) - if loopboy { + if let loopboyContact { let phase: String if s.loopboyState == "responding" { phase = "RESPONDING" @@ -2307,7 +2307,7 @@ final class PromptSigilOverlayController { case .blank, .complete, .interrupted, .stale: phase = "IDLE" } } - ov.setLoopboyState(phase) + ov.setLoopboyState("\(loopboyContact!.uppercased()) · \(phase)") } ov.setLighting(drop: sun.drop) // Name + hover copy. The name belongs to the session/thread and @@ -2319,7 +2319,9 @@ final class PromptSigilOverlayController { ov.setName(SigilRenderer.name(for: s), dark: dark) ov.setPlatformTarget(s.platformTarget) let title = s.emoji.isEmpty ? ov.name : "\(s.emoji) \(ov.name)" - ov.tooltipTitle = loopboy ? "↻ Loopboy · \(title)" : title + ov.tooltipTitle = loopboyContact.map { + "↻ Loopboy · \($0) · \(title)" + } ?? title let story = (s.loopboyResponse.isEmpty ? nil : s.loopboyResponse) ?? ProxMemoirs.shared.text(for: s.sessionId) ?? Self.fallbackBody(summary: s.titleString, subject: s.shortSubject) diff --git a/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift b/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift index 40462f769..0d729ecc8 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift @@ -59,16 +59,10 @@ enum SigilRenderer { /// wrapper is reopened around the same provider thread. Ordinary prompts /// still derive their name from the ephemeral Slab session id. static func name(for session: ClaudeSession) -> String { - if let data = FileManager.default.contents(atPath: Paths.loopboyConfig), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let loops = obj["loops"] as? [String: Any] { - for value in loops.values { - guard let loop = value as? [String: Any], - (loop["sessionId"] as? String) == session.sessionId, - let routeName = loop["name"] as? String, - !routeName.isEmpty else { continue } - return routeName - } + if let contact = LoopboyRoutes.verifiedContact(for: session), + let routeName = LoopboyRoutes.all()[contact]?.name, + !routeName.isEmpty, routeName != "?" { + return routeName } return name(forSessionId: session.sessionId) } diff --git a/slab/menubar-swift/Sources/SlabMenubar/ZzzManager.swift b/slab/menubar-swift/Sources/SlabMenubar/ZzzManager.swift index a2e5f9f3d..a79e563c1 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ZzzManager.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ZzzManager.swift @@ -165,7 +165,7 @@ final class ZzzManager { if session.agentType == "codex" && session.providerSessionId.isEmpty { return "Codex provider thread is not known yet" } - if loopboySessionIds().contains(session.sessionId) { return "Loopboy-bound prompt" } + if LoopboyRoutes.verifiedContact(for: session) != nil { return "Loopboy-bound prompt" } if let seconds = minimumIdle, Date().timeIntervalSince(session.updated) < seconds { return "idle timer has not elapsed" } return nil @@ -223,15 +223,6 @@ final class ZzzManager { return "zzz'd \(session.shortSubject). Resume with: zzz resume \(selector)" } - private func loopboySessionIds() -> Set { - guard let data = FileManager.default.contents(atPath: Paths.loopboyConfig), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let loops = obj["loops"] as? [String: Any] else { return [] } - return Set(loops.values.compactMap { - ($0 as? [String: Any])?["sessionId"] as? String - }) - } - private static func pidAlive(_ pid: pid_t) -> Bool { guard pid > 0 else { return false } return kill(pid, 0) == 0 || errno == EPERM diff --git a/slab/menubar-swift/install.sh b/slab/menubar-swift/install.sh index c3d4ad0b2..8576451e9 100755 --- a/slab/menubar-swift/install.sh +++ b/slab/menubar-swift/install.sh @@ -68,7 +68,9 @@ slab_sign_hash() { # a GUI Terminal. When it fails we run the exact same sign INSIDE the logged-in # Aqua session via a one-shot launchd job — the same gui-bootstrap trick the # frame capture uses to reach the WindowServer. Returns 1 if neither path -# produces a "${SIGN_CN}" signature (caller then falls back to ad-hoc). +# produces a "${SIGN_CN}" signature. The caller refuses an implicit ad-hoc +# fallback because launching a new cdhash silently revokes this app's TCC +# grants (Accessibility, Screen Recording, and Full Disk Access). codesign_app() { local app="$1" # Sign by the cert's SHA-1 HASH, never by CN. If a same-CN cert also lives in @@ -81,11 +83,6 @@ codesign_app() { # falling through to the gui-bootstrap path which creates it. local hash="" hash="$(slab_sign_hash)" - # Only try a direct sign when we're already in an Aqua session (install.sh - # run from a GUI Terminal) AND we have a hash. Over SSH a direct sign fails - # with errSecInternalComponent AND can pop a keychain dialog on the machine's - # screen, so skip straight to the gui-bootstrap path below. - # # Prep the dedicated keychain FIRST, non-interactively (every command below # authenticates with the known "${SIGN_KC_PASS}", so none can prompt). This # is what keeps SUBSEQUENT rebuilds silent: without it, a re-locked @@ -95,16 +92,20 @@ codesign_app() { # • set-keychain-settings (no flags) — drop the auto-lock timeout so it STAYS unlocked # • set-key-partition-list apple-tool:,apple: — pre-authorize Apple signing # tools for the key, suppressing the "codesign wants to use key" prompt - if [ -n "${SECURITYSESSIONID:-}" ] && [ -n "${hash}" ] && [ -f "${SIGN_KEYCHAIN}" ]; then + if [ -n "${hash}" ] && [ -f "${SIGN_KEYCHAIN}" ]; then security unlock-keychain -p "${SIGN_KC_PASS}" "${SIGN_KEYCHAIN}" 2>/dev/null || true security set-keychain-settings "${SIGN_KEYCHAIN}" 2>/dev/null || true security set-key-partition-list -S apple-tool:,apple: -s \ -k "${SIGN_KC_PASS}" "${SIGN_KEYCHAIN}" >/dev/null 2>&1 || true - fi - if [ -n "${SECURITYSESSIONID:-}" ] && [ -n "${hash}" ] && \ - codesign --force --deep --sign "${hash}" \ - --identifier computer.slab.menubar "${app}" >/dev/null 2>&1; then - return 0 + # Once the private key has an explicit apple-tool partition grant, + # direct signing is non-interactive even when this shell lacks a + # SECURITYSESSIONID (as Codex terminals commonly do). Try it before + # paying for the Aqua launchd helper; a headless failure simply falls + # through to that helper. + if codesign --force --deep --sign "${hash}" \ + --identifier computer.slab.menubar "${app}" >/dev/null 2>&1; then + return 0 + fi fi local uid label script plist log i uid="$(id -u)"; label="ac.slabsign.$$" @@ -344,12 +345,17 @@ if codesign_app "${APP_DIR}"; then SIGN_OK=1 ok "signed with '${SIGN_CN}' (stable identity — TCC grants persist across rebuilds)" else - warn "stable signing unavailable; using ad-hoc (TCC grants reset each rebuild)" + warn "stable signing unavailable" fi if [[ "${SIGN_OK}" -eq 0 ]]; then - codesign --force --deep --sign - \ - --identifier computer.slab.menubar \ - "${APP_DIR}" >/dev/null 2>&1 || warn "ad-hoc codesign also failed" + if [[ "${SLAB_ALLOW_ADHOC:-0}" != 1 ]]; then + echo "Refusing an ad-hoc fallback: it would revoke Slab Menubar's TCC grants." >&2 + echo "Repair the stable signing identity, or explicitly set SLAB_ALLOW_ADHOC=1 on a first install." >&2 + exit 1 + fi + warn "SLAB_ALLOW_ADHOC=1: signing ad-hoc; TCC grants will need approval" + codesign --force --deep --sign - --identifier computer.slab.menubar \ + "${APP_DIR}" >/dev/null 2>&1 || { warn "ad-hoc codesign failed"; exit 1; } fi ok "app bundle installed" diff --git a/slab/test/prox-mcp.test.mjs b/slab/test/prox-mcp.test.mjs index c2173da5e..a7adc0dba 100644 --- a/slab/test/prox-mcp.test.mjs +++ b/slab/test/prox-mcp.test.mjs @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { once } from "node:events"; -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -79,3 +79,60 @@ test("prox_list includes stale empty ledger snapshots", async () => { assert.match(text, /mac: .*stale\), 0 rock\(s\)/); assert.match(text, /\(no prompt rocks match\)/); }); + +test("registry-only routes neither badge nor retrofit an ordinary session", async () => { + const home = await mkdtemp(join(tmpdir(), "prox-mcp-test-")); + const slabDir = join(home, ".config", "slab"); + const ledgerDir = join(slabDir, "ledger"); + await mkdir(join(ledgerDir, "peers"), { recursive: true }); + const now = Date.now(); + const id = "aaaaaaaa-1111-2222-3333-444444444444"; + await writeFile(join(ledgerDir, "local.json"), JSON.stringify({ + host: "neo", ip: "127.0.0.1", updatedAt: now, + entries: [{ + id, host: "neo", name: "fotos", subject: "ordinary prompt", + status: "working", kind: "session", seed: "1234", cwd: home, + updated: now, started: now - 5_000, + }], + })); + await writeFile(join(slabDir, "loopboy.json"), JSON.stringify({ + version: 1, + loops: { alex: { contact: "alex", sessionId: id, host: "neo", name: "fotos" } }, + })); + + const list = await callProx(home, "prox_list", { host: "neo" }); + assert.doesNotMatch(list, /loopboy:alex/); + const bound = await callProx(home, "prox_bind_notification", { + handle: "neo:fotos#aaaaaaaa", contact: "alex", + }); + assert.match(bound, /was not launched as a guarded Loopboy/); +}); + +test("binding accepts a live marker identity only for its own contact", async () => { + const home = await mkdtemp(join(tmpdir(), "prox-mcp-test-")); + const slabDir = join(home, ".config", "slab"); + const ledgerDir = join(slabDir, "ledger"); + await mkdir(join(ledgerDir, "peers"), { recursive: true }); + const now = Date.now(); + const id = "bbbbbbbb-1111-2222-3333-444444444444"; + await writeFile(join(ledgerDir, "local.json"), JSON.stringify({ + host: "neo", ip: "127.0.0.1", updatedAt: now, + entries: [{ + id, host: "neo", name: "nimef", subject: "guarded prompt", + status: "working", kind: "session", seed: "5678", cwd: home, + updated: now, started: now - 5_000, loopboyContact: "alex", + }], + })); + + const wrong = await callProx(home, "prox_bind_notification", { + handle: "neo:nimef#bbbbbbbb", contact: "fia", + }); + assert.match(wrong, /was launched for alex, not fia/); + + const bound = await callProx(home, "prox_bind_notification", { + handle: "neo:nimef#bbbbbbbb", contact: "alex", + }); + assert.match(bound, /Loopboy bound alex/); + const config = JSON.parse(await readFile(join(slabDir, "loopboy.json"), "utf8")); + assert.equal(config.loops.alex.sessionId, id); +});