Skip to content

RFC: one gated actions core for GUI + MCP (ticket-minted policy, parse-once reports, frozen wire format) #51

Description

@caezium

Problem

The same user-facing operation — "clean caches, maybe elevated, report freed bytes" — exists as two parallel implementations in two processes:

  • GUI path: CleanView/OptimizeViewguarded() FDA checks + confirm dialogs + pendingRun closure parking → CommandRunnerTaskReport.parseOperationCenter.begin/detail/end. SoftwareView hand-rolls the uninstall flow (confirm sheet, dry-run match preflight, "y\n" stdin answers) separately.
  • MCP path (separate process): ToolCatalog.callClean/callOptimize/callUninstall re-spell the argv/stdin/timeouts, gate via realActionAllowed() (confirm && Store.mcpActionsEnabled), and return raw unparsed text. Purge/installer are preview-only with a hand-built redirect note.

Consequences:

  • The argv tables, stdin scripts, and timeouts are spelled independently per surface and have already drifted (GUI uninstall 300 s vs MCP 600 s).
  • Gating folklore lives in scattered ifs and comments: "root bypasses TCC for cache walks but not for Downloads/Desktop", "optimize's auth prompt is the consent", "uninstall needs the second opt-in switch". None of the GUI policy is unit-testable — it's trapped in SwiftUI closures.
  • The uninstall match preflight (dry-run → pin mo's matched set → fail closed on divergence) exists twice and can half-regress.
  • The stream parser runs only in the GUI; agents get text. The freed-bytes summary the GUI banner shows and the text MCP returns can disagree.
  • The MCP wire format (command/dry_run/ran/blocked/reason/interactive_only/note) is hand-rolled dictionaries with no owner and no golden tests, yet agents already depend on it.

Proposed Interface

One actions core, shared code (not shared runtime state — each process builds its own instance over its own engine). Spine: a pure gate that is the only mint for run tickets, and a runner that only accepts tickets — nothing in either process can execute mo without passing policy.

// What — the catalog as data (argv, stdin, timeouts, severity, elevation, interactive-only)
public enum MoAction: Equatable, Sendable {
    case clean, optimize
    case uninstall(apps: [String], permanent: Bool = false)
    case purge, installer
    public var spec: ActionSpec { get }     // one switch; THE place per-action facts live
}
public struct ActionSpec: Sendable, Equatable {
    public let severity: Severity            // .recoverable / .irreversible (uninstall)
    public let interactiveOnly: Bool         // purge/installer real runs are TUI-only
    public let elevation: ElevationProfile   // .oneAuthAdmin (clean/optimize real) / .userOnly
    public let previewNeedsFDA: Bool
    public let adminBypassesTCC: Bool        // true for cache walks; false for purge/installer
    public let needsExplicitConfirm: Bool    // clean/uninstall yes; optimize: the auth prompt IS consent
    public let requiresMatchPreflight: Bool  // uninstall
    public func command(_ mode: RunMode, privilege: Privilege) -> MoCommand   // argv/stdin/timeout, once
}
public enum RunMode: String, Sendable { case preview, real }

// May we — pure policy; consent arrives as DATA, so the core never imports Store/Privacy/AppKit
public enum Gate: Equatable, Sendable {
    case gui(hasFullDiskAccess: Bool, userConfirmed: Bool = false, elevationGranted: Bool = false)
    case agent(actionsOptIn: Bool, irreversibleOptIn: Bool)
}
public enum Verdict: Equatable, Sendable {
    case run(RunTicket)                      // possibly a DOWNGRADE: agent real purge → preview ticket + note
    case needsConfirmation                   // GUI: show the dialog, re-decide with userConfirmed: true
    case needsFullDiskAccess(elevationEscape: Bool)
    case interactiveFlow                     // GUI real purge/installer → existing PTY checklist UI
    case blocked(BlockedReason)              // agent opt-in missing; canonical message owned here
}
public struct RunTicket: Equatable, Sendable {   // minted ONLY by decide (internal init)
    public let action: MoAction
    public let mode: RunMode
    public let command: MoCommand                // ready to run (RFC #48 type)
    public let preflight: Preflight?             // .verifyUninstallMatch(expected:) — fail closed
    public let note: String?                     // interactive-only redirect text
}

public struct MoActions: Sendable {
    public init(engine: any MoRunning,           // capture+stream slice of the RFC #48 engine
                observer: (any ActionObserver)? = nil)
    public static func decide(_ action: MoAction, _ mode: RunMode, _ gate: Gate) -> Verdict   // ① pure
    public func run(_ ticket: RunTicket) -> ActionRun                                          // ② execute+parse
    public func deletionLog(limit: Int) async -> DeletionLog                                   // ③ mo's deletion log
    public var blocking: Blocking { get }        // sync mirror for the MCP stdio loop
}

public struct ActionRun: Sendable {
    public let canCancel: Bool                   // false for .admin runs (root child survives SIGTERM)
    public let events: AsyncStream<ActionEvent>  // coalesced ≤ ~8/s: progress(groups, summary, latestLine) / finished
    public func cancel()
    public var report: ActionReport { get async }
}
public struct ActionReport: Equatable, Sendable {
    public let outcome: Outcome                  // completed(exit)/cancelled/blocked/abortedPreflight/failed
    public let groups: [ReportGroup]             // parsed ONCE, here — never in views or MCP
    public let summary: SpaceSummary?            // freed bytes / items / categories / free-now
    public let rawOutput: String                 // ANSI-stripped transcript
    public func wireText() -> String             // the FROZEN MCP JSON contract, golden-tested
}

public protocol ActionObserver: Sendable {       // GUI injects an OperationCenter sink; MCP injects nothing
    func actionBegan(id: UUID, action: MoAction, mode: RunMode)
    func actionProgressed(id: UUID, latestLine: String)
    func actionEnded(id: UUID, report: ActionReport)
}

Representative usage:

// CleanView — policy says real clean needs confirmation; dialogs STAY in the view (no parked continuations):
guard case .needsConfirmation = MoActions.decide(.clean, .real, guiGate()) else { return }
guard runCleanConfirmAlert() else { return }
guard case .run(let ticket) = MoActions.decide(.clean, .real, guiGate(confirmed: true)) else { return }
model.start(actions.run(ticket))     // ~30-line ActionRunModel replaces CommandRunner's published surface

// MCP — every action handler becomes the same three lines:
switch MoActions.decide(action, confirm ? .real : .preview,
                        .agent(actionsOptIn: Store.mcpActionsEnabled,
                               irreversibleOptIn: Store.mcpIrreversibleEnabled)) {
case .run(let ticket):     return actions.blocking.run(ticket).wireText()
case .blocked(let reason): return ActionReport.blocked(action, reason).wireText()
default:                   return ActionReport.blocked(action, .agentCleanupsOptInOff).wireText()
}

// SoftwareView uninstall — the match preflight now lives in the core, one implementation for both surfaces.
// MCP purge confirm:true — decide returns a DOWNGRADED preview ticket with the redirect note; wireText
// renders today's exact {"interactive_only":true,"note":…} contract.

What it hides: the two-surface gate matrix as one truth table; TCC folklore as ActionSpec data; per-surface privilege/stdin/timeout minting; the uninstall preflight interlock; parse-once structured progress with coalescing; canCancel for elevated runs; the frozen MCP wire schema with its canonical refusal copy; OperationCenter plumbing behind the observer; deletion-log location + parsing.

Deliberately outside: the purge/installer interactive TUI (SelectionSession + engine.interactive) — the core only routes (.interactiveFlow) or downgrades with a note; GUI dialog copy and TaskReportText localization stay view-side.

Dependency Strategy

In-process orchestration on top of the execution engine port (RFC #48); consent as injected data.

Seam GUI binding MCP binding Test double
MoRunning (capture+stream) the app's engine instance the MCP process's engine ScriptedEngine — canned line scripts per expected MoCommand; records argv/stdin/privilege/timeout
Gate values views read Privacy.hasFullDiskAccess() + dialog results handler reads Store.mcp*Enabled (fresh cfprefsd read per call) literal Gate values
ActionObserver OperationCenterSink (hops to @mainactor) none RecordingObserver
readFile (deletion log) String(contentsOfFile:) same fixture closure

A third surface (CLI burrow clean --yes, URL-scheme automation) is a new Gate source + a renderer — zero core changes.

Testing Strategy

New boundary tests:

  • decide truth table: every (action × mode × gate) cell, including the irreversible second switch, the FDA/elevation-escape branches, the optimize no-confirm rule, and the agent purge/installer downgrade-with-note.
  • Runner with ScriptedEngine: parsed groups/summary from a canned clean transcript; coalescing; preflight abort on matched-set divergence with the real uninstall never spawned; cancellation; MoFailure → outcome mapping; observer event ordering.
  • wireText() golden tests pinned to today's exact JSON (including refusal prose and the redirect note) — the MCP contract gets an owner.
  • One parametrized GUI≡MCP equivalence test: same ticket through both gates, diff everything but the gate and rendering.

Old tests to delete/migrate: MCPTests' action-gating tests (realActionAllowed, blocked-uninstall, blocked-clean) → the decide truth table, dropping their scratch-UserDefaults plumbing; TaskReportTests retarget the relocated parser unchanged; the duplicated uninstall-guard expectations collapse to one suite.

Test environment needs: none beyond fixtures — the core unit-tests with zero subprocesses and zero UI.

Implementation Recommendations

  • The core owns: the action catalog, gate evaluation, ticket minting, preflight, stream parsing, the result type, and the agent wire format. It never imports SwiftUI/AppKit/Store/Privacy — consent is data in, verdicts out.
  • Keep the ticket invariant absolute: decide is the only mint; run accepts only tickets. That property — not discipline — is what makes "GUI and MCP behave identically" provable.
  • Treat the wire format as a frozen contract with golden tests; additive fields only (a structured summary for agents is a good first addition — they currently parse raw text).
  • Unify constants deliberately and visibly: the GUI-uninstall timeout change (300→600 s) is a flagged behavior change, not an accident — that's the point of one catalog.
  • Migration order: land the module + ActionRunModel with CommandRunner delegating internally, then flip CleanView, OptimizeView, SoftwareView, and the five MCP handlers one per commit; delete CommandRunner last.
  • Depends on RFC RFC: deepen mo execution into one engine (capture / stream / PTY / elevated behind ports) #48 (engine). Cancellation honesty is inherited: an .admin run's root child survives SIGTERM, so canCancel == false and UIs hide Stop — surface the truth, don't pretend.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions