diff --git a/README.md b/README.md index f9948bd..5b13e3f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Listen to the SwiftASB Codex apps promo clip: ### Status -SwiftASB is actively maintained and supported by Gale. Our current API is v1, and `v1.6.0` is the current and latest release. +SwiftASB is actively maintained and supported by Gale. Our current API is v1, and `v1.7.0` is the current and latest release. ### What This Project Is @@ -38,7 +38,7 @@ I built SwiftASB because I saw so many others building and forking existing Apps Add SwiftASB to your `Package.swift` dependencies: ```swift -.package(url: "https://github.com/gaelic-ghost/SwiftASB", from: "1.6.0"), +.package(url: "https://github.com/gaelic-ghost/SwiftASB", from: "1.7.0"), ``` Then add the library product to your target dependencies: diff --git a/ROADMAP.md b/ROADMAP.md index 6838d70..4e7252b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,7 +50,7 @@ | Typed protocol notification decoding | `Partially shipped` | The protocol layer now maps a broader batch of app, thread, turn, item, reasoning, hook, MCP-status, config-warning, deprecation, remote-control, and reroute notifications, plus the item lifecycle needed to drive the current observable tool, MCP, file-edit, hook, and compaction summaries. | | Public owning client actor | `Shipped` | `CodexAppServer` owns transport plus protocol and exposes startup, shutdown, initialize, thread start, and turn start. | | Public value-typed request and result models | `Shipped` | Public API uses hand-owned Swift value types rather than exposing `CodexWire...` directly. | -| App-wide capability surfaces | `Partially shipped` | `CodexAppServer.makeInventory()` now provides observable model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes with SwiftASB-owned refresh from app-server inventory notifications. Direct methods such as `listModels(...)`, `readModelCapabilities()`, `listHooks(...)`, `mcp.statusSnapshot()`, and `mcp.readResource(...)` remain available for one-off reads and inspector-style detail. The deprecated `listMcpServerStatuses(...)` remains compatibility-only while consumers move to owned snapshots. Broader app-wide settings and actions still need deliberate public models before promotion. | +| App-wide capability surfaces | `Partially shipped` | `CodexExtensions.Inventory` now provides observable model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes with SwiftASB-owned refresh from app-server inventory notifications. Direct methods such as `listModels(...)`, `readModelCapabilities()`, `listHooks(...)`, `extensions.mcp.statusSnapshot()`, and `extensions.mcp.readResource(...)` remain available for one-off reads and inspector-style detail. Deprecated app-server inventory and MCP conveniences remain compatibility-only while consumers move to the top-level extension owner. Broader app-wide settings and actions still need deliberate public models before promotion. | | Initialize handshake | `Shipped` | `initialize(...)` automatically sends the follow-up `initialized` notification. | | Thread start flow | `Shipped` | `startThread(...)` returns `CodexThread`, which carries thread metadata plus a back-reference to the shared app-server owner. | | Stored thread list flow | `Shipped` | `listThreads(...)` wraps `thread/list`, returns typed stored-thread pages, and now reconciles local thread metadata plus explicit archived or unarchived list results back into the internal history store. | @@ -58,9 +58,9 @@ | Stored thread resume flow | `Shipped` | `resumeThread(...)` wraps `thread/resume`, returns a normal `CodexThread`, restores thread defaults, clears stale archived state for the reopened thread, and hydrates any resumed persisted turns into the same local history store without resetting completeness to a fresh-thread state. Callers can set `excludeTurns` when they plan to page history separately through `thread/turns/list`. | | Stored thread fork flow | `Shipped` | `forkThread(...)` wraps `thread/fork`, returns a normal `CodexThread`, persists copied fork history into thread-scoped local turn rows, and records explicit fork lineage through the source thread id plus the last shared turn id. Callers can set `excludeTurns` when they want the fork metadata first and copied turn history through paged reads afterward. | | Thread management actions | `Partially shipped` | `CodexThread.setName(...)` wraps `thread/name/set`, `CodexThread.archive()` wraps `thread/archive`, `CodexThread.unarchive()` wraps `thread/unarchive`, `CodexThread.updateMetadata(...)` wraps `thread/metadata/update`, and `CodexThread.rollbackLastTurns(...)` wraps `thread/rollback`. Metadata patches use an explicit replace/clear/unchanged field model so callers can express upstream null-vs-omitted semantics. Rollback reconciles visible local history to the app-server response, records a rollback marker, and now has opt-in live coverage against a disposable non-ephemeral thread, but it does not preserve full removed turn payloads as forensic archive data yet. | -| App-server filesystem reads and watches | `Partially shipped` | `CodexAppServer.fs` now exposes the `CodexFS` namespace for app-server-routed metadata, directory listing, file-byte reads, bounded file discovery, SwiftASB-owned fuzzy ranking over app-server-returned entries, UI-ready discovery match metadata, and filesystem watch notifications. This gives sandboxed clients a Codex-owned path for basic filesystem facts and picker/search views instead of requiring direct local disk reads. File mutations and repository-root discovery remain separate schema families for later promotion decisions. | +| App-server filesystem reads and watches | `Partially shipped` | `CodexAppServer.fs` now exposes the `CodexFS` namespace for app-server-routed metadata, directory listing, file-byte reads, bounded file discovery, SwiftASB-owned fuzzy ranking over app-server-returned entries, UI-ready discovery match metadata, and filesystem watch notifications. This gives sandboxed clients a Codex-owned path for basic filesystem facts and picker/search views instead of requiring direct local disk reads. File mutations are now internally promoted for protocol work; public mutation and repository-root surfaces still need deliberate shape decisions. | | App-server config reads | `Partially shipped` | `CodexAppServer.config` now exposes `CodexConfig` for effective config and requirements reads through the app-server. Effective config stays JSON-shaped for now so SwiftASB does not turn unstable config keys into long-lived public Swift fields too early. | -| App-server extension inventory and maintenance | `Partially shipped` | Routine app, skill, plugin, and collaboration-mode inventory now flows through `CodexAppServer.Inventory`; `CodexAppServer.extensions` remains the direct escape hatch for custom pagination, selected plugin-detail reads, and `upgradeMarketplace(_:)` for upgrading already-configured plugin marketplaces through app-server `command/exec` under the `extensionMaintenance` feature category. Plugin installs, removals, sharing changes, and skills config writes remain unpromoted until their permission and review model is clearer. | +| App-server extension inventory and maintenance | `Partially shipped` | Routine app, skill, plugin, MCP, and collaboration-mode inventory now flows through top-level `CodexExtensions`. `CodexAppServer.extensions` exposes family surfaces for custom pagination, selected plugin-detail reads, MCP status/resource reads, unified MCP installs, and plugin marketplace upgrades through app-server `command/exec` under the `extensionMaintenance` feature category. Plugin installs, removals, marketplace reads/writes, sharing changes, and skills config writes remain high-priority promotion candidates once their permission and review model is clearer. | | SwiftASB feature permission policy | `Fifth slice shipped` | `SwiftASBFeaturePolicy`, `SwiftASBFeatureCategory`, and `SwiftASBHostAccess` now describe feature-category defaults and host access declarations, and `CodexAppServer.Configuration` accepts the app-wide feature policy. SwiftASB also has an internal `command/exec` protocol/executor path for future typed Git/GitHub helper intents, `CodexAppServer.Library` selected-worktree Git status refresh through the default-enabled `gitObservability` category, `CodexAppServer.featureOperationEvents()` for human-readable SwiftASB-owned mutation records, and a typed marketplace-upgrade maintenance intent. Maintainer planning targets quiet read-only Git/config/extension inventory by default, one-time mutation-category enablement, and human-readable mutation events instead of repeated prompts. See [`docs/maintainers/feature-permission-policy-plan.md`](docs/maintainers/feature-permission-policy-plan.md). | | Thread goals | `Partially shipped` | `CodexThread.readGoal()`, `setGoal(...)`, and `clearGoal()` wrap `thread/goal/get`, `thread/goal/set`, and `thread/goal/clear`, thread event streams now surface goal updated and cleared notifications, and `CodexThread.Agenda` provides UI-friendly `setGoal(...)`, `pauseGoal()`, `resumeGoal()`, and `clearGoal()` actions. | | Thread shell commands | `Partially shipped` | `CodexThread.sendShellCommand(_:)` wraps app-server `thread/shellCommand` as a thread-scoped, literal shell-string action. This is deliberately separate from SwiftASB's internal `command/exec` helper path because `thread/shellCommand` preserves shell syntax and is documented upstream as unsandboxed full-user shell access. The public method is gated behind the disabled-by-default high-impact `shellCommandExecution` feature category. | @@ -75,16 +75,16 @@ | Thread-scoped recent-turn observable | `Partially shipped` | `CodexThread.makeRecentTurns(limit:)` now vends a bounded recent-turn observable that prewarms from the local history store, supports explicit older/newer whole-turn window expansion, seeds upstream paging cursors even when the visible initial window came from local history, and falls back to `thread/turns/list` when needed. Live probing showed that upstream turn paging is available only after a non-ephemeral thread has materialized at least one user turn, so recent observable startup now degrades to an empty local-only view for the known ephemeral and pre-materialized live runtime responses instead of surfacing raw protocol text. `RecentTurns` now ships named cache-policy presets for chat UIs, full inspectors, and compact history rails; tracks both resident item counts and weighted resident item cost; slims low-value payloads out of older non-visible completed turns before evicting whole turns; rehydrates slimmed turns when they become visible again; and uses scroll-position, visibility, phase, and velocity signals to drive protected residency plus earlier prefetch. Richer weighting heuristics and deeper policy tuning are still open. | | Thread-scoped recent-file observable | `Partially shipped` | `CodexThread.makeRecentFiles(limit:)` and `makeRecentFiles(_:)` now vend a file-centric recent-files observable that hydrates from persisted file-change items, keeps one resident entry per file-change item, enriches live entries from `item/fileChange/outputDelta` and `item/fileChange/patchUpdated`, can load older file entries from the same turn before stepping farther back through older turns, and supports selection-aware shell-versus-payload slimming with automatic payload rehydration for protected files. `CodexThread.RecentFilesQD` gives callers a repeatable descriptor for the initial resident file window and cache policy. Live probing exercises a real create/edit/delete scenario, and recent-file startup now inherits the same empty local-only degradation as recent-turns for the known live history-unavailable responses. The current weighting now accounts for diff structure and line volume, and shell summaries prefer concise edit summaries over raw terminal status when sealed payload is available. The remaining open work is better payload-cost calibration at the margins and richer structured patch presentation beyond the current text preview. | | Thread-scoped recent-command observable | `Partially shipped` | `CodexThread.makeRecentCommands(limit:)` and `makeRecentCommands(_:)` now vend a command-centric recent-commands observable that hydrates from persisted `commandExecution` items, keeps one resident entry per command item, enriches live entries from `item/commandExecution/outputDelta`, can load older command entries from the same turn before stepping farther back through older turns, and supports selection-aware shell-versus-output slimming with automatic output rehydration for protected commands. `CodexThread.RecentCommandsQD` gives callers a repeatable descriptor for the initial resident command window and cache policy. Recent-command startup now inherits the same empty local-only degradation as recent-turns for the known live history-unavailable responses. Current output weighting accounts for output size and line structure, and shell summaries prefer concise command and output summaries over raw transport detail. The remaining open work is better output-cost calibration and sharper shell-summary heuristics. | -| App-wide observable companions | `Partially shipped` | `CodexAppServer.makeInventory()` now exposes the app-wide observable for routine model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes. `CodexAppServer.makeLibrary()` and `CodexAppServer.Library` expose Core Data-backed stored-thread lists, cwd and repository grouping, stable worktree groups, repository/worktree thread filters, project/worktree identity, bindable sort/grouping policies, scoped refresh actions, library-local selection, selected worktree Git status, and optional model/MCP/hook snapshots beside thread lists. Broader app-wide settings/actions still need deliberate public models before promotion. | +| App-wide observable companions | `Partially shipped` | `CodexExtensions.makeInventory()` now exposes the app-wide observable for routine model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes, while `CodexAppServer.makeInventory()` remains a deprecated forwarding convenience until the next major version. `CodexAppServer.makeLibrary()` and `CodexAppServer.Library` expose Core Data-backed stored-thread lists, cwd and repository grouping, stable worktree groups, repository/worktree thread filters, project/worktree identity, bindable sort/grouping policies, scoped refresh actions, library-local selection, selected worktree Git status, and optional model/MCP/hook snapshots beside thread lists. Broader app-wide settings/actions still need deliberate public models before promotion. | | Public query descriptors | `Partially shipped` | `CodexAppServer.ThreadListQD` now provides repeatable thread-list intent for direct app-server `thread/list` reads and app-wide `Library` loading, `CodexFS.FileDiscoveryQD` provides repeatable bounded file-discovery intent over app-server `fs/readDirectory` reads, `CodexThread.HistoryWindowQD` provides repeatable local completed-turn window intent for recent, older, newer, turn-centered, and item-centered reads, and `CodexThread.RecentFilesQD` plus `CodexThread.RecentCommandsQD` describe recent-activity companion startup. Repository grouping now uses `CodexWorkspace.ProjectInfo`, and per-thread UI state can read `CodexWorkspace.WorktreeSnapshot`, both of which identify a project by Codex-reported Git origin when available and fall back to cwd. Remaining descriptor work includes broader public cursor semantics, selection-centered reads if a concrete caller needs them, and later search-hit hydration. | | Non-UI local history-reading helpers | `Partially shipped` | `CodexThread` now exposes a lightweight `HistoryWindow` page shape for recent local history, older or newer local windows around a known boundary turn id, centered `windowAroundTurn(...)` reads, centered `windowAroundItem(...)` reads, direct `ClosedTurn` reads for one turn, and convenience array helpers over those same windows. This gives non-UI callers an intentional path into the local history store without binding a UI-oriented observable, while still deferring a broader public cursor model, transcript search surface, and richer history-query helpers. | | Public API curation | `Shipped / ongoing` | The source-organization pass has split app-wide model, MCP, thread-management, history, and observable companion values into focused public files while preserving `CodexAppServer`, `CodexThread`, and `CodexTurnHandle` as the three real owners. The connected public-surface review closed the v1 ownership model; post-v1 curation now includes app-server-owned project identity and thread source facts for launcher UI without exposing generated wire models. Future curation should stay tied to concrete public API additions. | | DocC documentation | `Shipped / ongoing` | `Sources/SwiftASB/SwiftASB.docc/` contains a package landing page, public-handle extension pages, conceptual articles for app-wide capabilities, interactive lifecycle, thread management, history/observable companions, generated-wire boundary notes, and copy-pasteable walkthroughs for startup, progress/approval handling, diagnostics/history, and SwiftUI observable companions. The catalog is validated through Xcode `docbuild`; future work is ordinary stale-link, prose, and symbol-comment refinement as the public API grows. | -| Swift Package Index readiness | `Shipped` | `.spi.yml` declares `SwiftASB` as the documentation target, and Swift Package Index lists `gaelic-ghost/SwiftASB` with a documentation link, compatibility/build results, Package ID `9B5839D9-9551-473F-A939-841534A3FC55`, and a 2026-05-06 update timestamp for the latest confirmed indexed release. Recheck SPI after the `v1.6.0` tag is published. | +| Swift Package Index readiness | `Shipped` | `.spi.yml` declares `SwiftASB` as the documentation target, and Swift Package Index lists `gaelic-ghost/SwiftASB` with a documentation link, compatibility/build results, Package ID `9B5839D9-9551-473F-A939-841534A3FC55`, and a 2026-05-06 update timestamp for the latest confirmed indexed release. Recheck SPI after the `v1.7.0` tag is published. | | Contributor documentation split | `Shipped` | `README.md` is now focused on Swift and SwiftUI package users, while `CONTRIBUTING.md` owns contributor setup, validation, DocC, live-test flags, generated-wire refresh, and PR expectations. | | `CodexTurnHandle` live observable companion | `Partially shipped` | `CodexTurnHandle` owns a live `Minimap` companion that is attached when the handle is created and maintains current-state call snapshots for command, file-edit, dynamic-tool, collab-tool, and MCP item activity. It also now mirrors whether thread context compaction is active for the turn and supports explicit `complete()` handoff into a caller-owned sealed turn snapshot. | | Additional turn event mapping | `Partially shipped` | The public event layer covers the current interactive lifecycle plus the item-start and item-complete events needed for observable call-state mirrors. Raw command-output and file-change-output deltas now stay internal as transport detail but drive the shipped `RecentCommands` and `RecentFiles` companions, and streamed or patch-updated payloads are preserved when later completed snapshots are thinner. Richer MCP-progress detail still remains internal, while warning, guardian-warning, config-warning, deprecation, MCP-server-status, remote-control-status, model-reroute, and model-verification notifications now surface through hand-owned diagnostic events. | -| Server request / approval handling | `Partially shipped` | Typed approval and elicitation request models now surface on thread and turn event streams, explicit response APIs exist on `CodexThread` and `CodexTurnHandle`, request resolution is tracked by JSON-RPC request id, and deterministic command-approval plus permissions-approval completion are covered through the real app-server with a mock Responses provider. Diagnostics are now separated from control flows: passive warning/model/guardian signals are public diagnostics, while guardian denied-action approval remains internal until SwiftASB owns a stable request/response model for it. | +| Server request / approval handling | `Partially shipped` | Typed approval and elicitation request models now surface on thread and turn event streams, explicit response APIs exist on `CodexThread` and `CodexTurnHandle`, request resolution is tracked by JSON-RPC request id, and deterministic command-approval plus permissions-approval completion are covered through the real app-server with a mock Responses provider. Diagnostics are now separated from control flows: passive warning/model/guardian signals are public diagnostics, while guardian denied-action approvals now flow through SwiftASB's existing approval-needed model so auto-review denial cases can be answered through the same consumer path. | | Internal thread history persistence | `Partially shipped` | The package now has a Core Data-backed `ThreadHistoryStore` that persists live-built thread and turn history, hydrates stored turns from `thread/read`, `thread/resume`, `thread/fork`, and `thread/turns/list`, seeds previously unknown local threads from paged history, widens persisted turn identity to stay thread-scoped across forks, and records explicit fork lineage while preserving conservative reconciliation that keeps richer local detail when upstream stored history is thinner. Public history paging/search helpers and archive-retention policy are still open. | | Convenience run API | `Not started` | No `run(...)` or one-shot text convenience layer yet. | | Binary discovery and compatibility policy | `Partially shipped` | Explicit binary override exists, the docs now define a current-reviewed Codex CLI support window of `0.135.x`, transport startup checks PATH, common Homebrew paths, and the npm global prefix on macOS, and `cliExecutableDiagnostics()` now exposes the resolved binary, version string, and documented support-window assessment. Any further diagnostics work is now expansion rather than a missing baseline surface. | @@ -108,7 +108,7 @@ The next meaningful package step is no longer proving the v1 interactive lifecycle, SPI visibility, basic history hydration, first-pass reconciliation, or command-approval completion. Those slices now exist and shipped in the -`v1.6.0` baseline. +`v1.7.0` baseline. The next meaningful work is to widen the reviewed app-server schema and protocol coverage before adding more public query descriptors. Descriptors should compile @@ -116,10 +116,10 @@ against Codex-owned workspace, Git, file, and thread facts wherever possible, rather than making SwiftASB or a sandboxed client infer repository identity by walking the local filesystem. -The 2026-05-11 repository-wide security audit adds two patch-sized hardening -items that should land before broadening more protocol surface: preserve or -reject out-of-range numeric JSON-RPC IDs instead of narrowing through -`NSNumber.intValue`, and fail closed for unknown network-policy amendment +The 2026-05-11 repository-wide security audit added two patch-sized hardening +items that landed before broadening more protocol surface: preserving or +rejecting out-of-range numeric JSON-RPC IDs instead of narrowing through +`NSNumber.intValue`, and failing closed for unknown network-policy amendment actions instead of representing them as `allow`. See [`docs/security-audits/82ea49d_20260511T213956-0400/report.md`](docs/security-audits/82ea49d_20260511T213956-0400/report.md). @@ -142,12 +142,16 @@ The package can now: through `CodexThread` - archive and unarchive stored threads through `CodexThread` - publish app-wide model, MCP-summary, hook, app, skill, plugin, and - collaboration-mode inventory through `CodexAppServer.Inventory` -- expose MCP full status and resource detail through `CodexAppServer.mcp` + collaboration-mode inventory through `CodexExtensions.Inventory` +- expose MCP full status, resource detail, and unified MCP install through + `CodexAppServer.extensions` - document the supported lifecycle in the README without sending consumers into the tests -After those audit hardening items, the current broader priority order is: +The current broader priority order is to complete app-server command families +that SwiftASB has already started wrapping, then turn the coherent families into +"we do it for you" surfaces that are observable, permission-aware, and easy for +consuming apps to adopt: 1. Implement the feature permission policy described in [`docs/maintainers/feature-permission-policy-plan.md`](docs/maintainers/feature-permission-policy-plan.md): @@ -155,26 +159,53 @@ After those audit hardening items, the current broader priority order is: categories are enabled once by the consuming app, and every write/mutation emits human-readable observable operation events instead of recurring prompts. -2. Review the currently bundled app-server schema families that are not yet - promoted through SwiftASB's hand-shaped protocol/public surfaces, with - special attention to workspace, filesystem, Git/repository, and app-server - action families that let sandboxed clients ask Codex for facts instead of - reading disk directly. -3. Continue promoting app-server-owned workspace and Git facts beyond the +2. Finish the filesystem family now that reads, watches, and internal mutations + exist: decide the public mutation shape, repository-root discovery boundary, + observable operation events, and sandbox/app-access story together instead of + exposing isolated write methods. +3. Promote more app settings, config writes, rules, and thread settings in one + settings-oriented family. The useful consumer outcome is a SwiftASB-owned + way to inspect current Codex behavior, apply explicit config edits, and show + reviewable before/after changes without consumers hand-writing TOML. +4. Design a house-style config system as an optional SwiftASB policy surface. + It should support repo-local defaults, language-specific auto-enhance for + automatically adding language skills or guidance to repos, idempotent config + writes, observable mutation events, and rollback where the app-server gives + enough information to do that honestly. +5. Promote marketplace, plugin, and skill-management routes as a coherent + extension-management family: inventory, detail, install or remove, upgrade, + sharing, skill config reads/writes, and marketplace configuration changes. + Treat this as a high-value "we do it for you" surface because consumers + should not need to understand every app-server extension route to keep a + user's Codex environment useful. +6. Rework thread settings and promote `thread/inject_items` together. Saved + prompts, automated setup messages, and agent-driven thread preparation need + an intentional model that distinguishes user-authored prompt material, + app-injected setup context, and automation-added items. +7. Add a WebKit-based auth helper flow for both Codex and MCP setup. This + should help a macOS consuming app complete browser/OAuth-style auth without + forcing users to bounce through unrelated terminals, while preserving a clear + boundary between Codex account auth and MCP server auth. +8. Continue promoting app-server-owned workspace and Git facts beyond the current cwd, origin metadata, runtime permission-profile provenance, and `CodexWorkspace.WorktreeSnapshot`: Git worktree root if upstream exposes it, branch/SHA observables, and any workspace listing/search/status actions that upstream already owns. Use sandboxed `command/exec` fallback only for typed Git fact intents that upstream does not expose yet; do not use unsandboxed `process/spawn` for permission-sensitive helpers. -4. Add a deliberate `codex mcp-server` support plan as a separate integration +9. Add a deliberate `codex mcp-server` support plan as a separate integration lane from `codex app-server`. The current MCP mode should be treated as an external-agent bridge with a smaller stdio tool surface, not as a replacement for the app-server lifecycle SwiftASB already wraps. Verify the live `initialize`, `tools/list`, `resources/list`, and `prompts/list` surface before deciding whether SwiftASB should expose client helpers, examples, or a dedicated package module for it. -5. Evaluate a Worktrunk-based worktree system only after the workspace and Git +10. Evaluate runtime/process routes as a command-organization problem before + promoting them. The public shape should connect to the existing + command-observable and feature-permission model, distinguish app-server + command helpers from raw process control, and avoid creating a second, + less-safe command surface beside `command/exec` and `thread/shellCommand`. +11. Evaluate a Worktrunk-based worktree system only after the workspace and Git fact boundary is clearer. The useful shape is a SwiftASB-supported way for clients to ask Codex-owned services for workspace/worktree identity, branch/status facts, and safe handoff points, without committing @@ -184,49 +215,58 @@ After those audit hardening items, the current broader priority order is: report user-granted directory access or pass a security-scoped bookmark, following Apple's sandbox model instead of treating local disk access as an implicit SwiftASB capability. -6. Plan command-execution-backed Git and GitHub actions for consuming apps that +12. Plan command-execution-backed Git and GitHub actions for consuming apps that want Codex-like repository operations through SwiftASB. The first useful shape should route explicit user-reviewed actions through installed `git` and optional `gh` binaries when available, keep command output and approval decisions observable, and reuse the app-access/perms model instead of silently expanding filesystem authority. -7. Explore a custom approval auto-reviewer after the answerable +13. Explore a custom approval auto-reviewer after the answerable server-request model is stable enough to distinguish advisory review from action approval. The first useful slice should classify approval requests and produce review recommendations; automatically answering requests should wait for an explicit policy model and tests that prove dangerous actions stay user-controlled. -8. Finish the next descriptor increment beyond the current list, history, and +14. Treat attestation as an internal auth/trust-adjacent request until a + consumer workflow proves it should be public. In the schema, + `requestAttestation` opts the client into `attestation/generate` requests, + whose response is only an opaque token; SwiftASB should avoid inventing + semantics beyond handing that token to the app-server path that requested it. +15. Keep realtime routes in the backlog until a concrete live-collaboration + consumer needs them. +16. Keep search as a SwiftASB-owned/local-app concern for now. The likely path + is Core Data for stored thread and item facts plus SearchKit-backed indexing + for transcript and artifact search, with upstream fuzzy file search promoted + only if its schema grows a stable cursor and result contract. +17. Finish the next descriptor increment beyond the current list, history, and recent-activity descriptors: broader public cursor semantics, any selection-centered reads that become necessary, and later search-hit hydration. -9. Finish the next app-wide settings/actions slice on `CodexAppServer` only - after the relevant app-server facts have earned stable public models. -10. Keep tuning `RecentTurns`, `RecentFiles`, and `RecentCommands` after v1 as +18. Keep tuning `RecentTurns`, `RecentFiles`, and `RecentCommands` after v1 as real UI usage teaches better calibration. The v1 review keeps the separate turn/file/command companions, current cache-policy names and defaults, selection/visibility protection, slimming behavior, and rehydration model as stable enough; remaining work is calibration and richer previews, not proving the model exists. -11. Keep future Codex CLI schema additions classified before public promotion: +19. Keep future Codex CLI schema additions classified before public promotion: `excludeTurns` remains public on resume/fork request models because it directly supports the existing paged history model; permission-profile families stay internal until SwiftASB owns a deliberate public permission - model; hooks, models, MCP status, and MCP resource reads remain app-wide - diagnostics/capability snapshots; thread goals, realtime, fuzzy file search - sessions, marketplace/account-management families, and guardian - denied-action approval remain post-v1 until their consumer workflows are - clearer. -12. Flesh out archive-aware retention and eviction beyond the current list-driven + model; hooks, models, MCP status, MCP resource reads, thread goals, and + guardian denied-action approval now have internal or public paths to build + from; realtime, upstream fuzzy file search sessions, marketplace mutations, + account-management families, thread settings, injected items, and config + writes still need promotion decisions. +20. Flesh out archive-aware retention and eviction beyond the current list-driven archive-state drift correction. -13. Add any sharper binary-discovery diagnostics we want alongside the +21. Add any sharper binary-discovery diagnostics we want alongside the current-reviewed compatibility window before a broader compatibility release. -14. Revisit whether a convenience `run(...)` API is earned only after the +22. Revisit whether a convenience `run(...)` API is earned only after the lower-level lifecycle has more production mileage. ## V1 Readiness Checklist -This checklist records the work that made `SwiftASB` ready for the `v1.6.0` +This checklist records the work that made `SwiftASB` ready for the `v1.7.0` tag. The goal was not to make every possible app-server feature public before v1. The goal was to make the supported lifecycle honest, durable, well documented, and intentionally shaped. @@ -246,8 +286,9 @@ documented, and intentionally shaped. post-v1 unless a real consumer workflow reclassifies one before the v1 API freeze. - [x] Keep guardian denied-action approval internal for v1. - Decision: post-v1. It needs a stable user-facing control-flow model for what - is being approved and how a Swift consumer should answer it. + Decision: post-v1 at the v1 boundary. It is now internally promoted through + the existing approval-needed model so SwiftASB can answer auto-review denial + approvals without inventing a parallel consumer flow. - [x] Keep marketplace upgrade, account-management variants, richer MCP progress, external-agent config import, structured patch rendering, and mixed recent activity out of v1. @@ -263,7 +304,9 @@ documented, and intentionally shaped. These are intentionally outside the v1 promise unless a concrete consumer workflow earns them in a later feature release. -- [ ] Guardian denied-action approval with a stable request and response model. +- [x] Guardian denied-action approval through the existing approval-needed + request and response model. Public naming and docs can still improve after + live auto-review coverage proves the final consumer wording. - [x] Hooks list surface after v1. `CodexAppServer.listHooks(...)` exposes per-cwd hook metadata, warnings, and load errors through a deliberate diagnostics/capability API so Swift clients can show what hooks are active @@ -295,13 +338,30 @@ workflow earns them in a later feature release. - [ ] Add the trusted Swift repo guidance sync category, starting with Apple/Swift repo guidance, Git preflight, idempotent writes, observable mutation events, and one-action rollback when possible. +- [ ] Add a house-style config policy surface that can optionally auto-apply + repo guidance, language skills, and other language-specific auto-enhance + defaults through explicit, idempotent, reviewable config writes. - [ ] Review and promote more app-server schema families before widening query descriptors, prioritizing workspace, filesystem, Git/repository, and app-server action surfaces that let sandboxed clients ask Codex for facts instead of reading local disk directly. +- [ ] Promote app settings, config writes, rules, and thread settings as a + settings/config family with reviewable before/after effects and observable + mutation events. +- [ ] Promote marketplace, plugin, and skill-management mutations beyond + configured marketplace upgrades, including install/remove, marketplace config + writes, sharing, and skill config writes once the permission model is clear. +- [ ] Promote `thread/inject_items` with a saved-prompt and automation-oriented + model that makes injected context explicit to consumers and users. +- [ ] Add a WebKit-based auth helper flow for Codex account auth and MCP + OAuth-style setup so consuming macOS apps can guide users through auth without + terminal-only handoffs. - [ ] Add `codex mcp-server` support as a separate external-agent bridge from `codex app-server`, starting with live surface verification and a clear boundary between MCP tools and SwiftASB's app-server lifecycle API. +- [ ] Evaluate attestation as an internal auth/trust-adjacent route first: + `requestAttestation` opts into `attestation/generate`, and the response is an + opaque token rather than a stable public domain model. - [ ] Plan a custom approval auto-reviewer that can classify approval requests and recommend responses without silently approving actions before SwiftASB has an explicit policy model. @@ -316,8 +376,17 @@ workflow earns them in a later feature release. `git` and optional `gh`, including capability diagnostics, user-reviewed command intents, observable output, and permission/access boundaries for repository mutations. -- [x] Add `CodexAppServer.Inventory` for automatic app-wide capability and +- [ ] Organize runtime/process routes around the existing command permission and + observable model before promoting raw process control. +- [x] Add `CodexExtensions.Inventory` for automatic app-wide capability and extension inventory. +- [ ] Remove deprecated `appServer.mcp`, `appServer.makeInventory(...)`, + `CodexAppServer.Inventory`, and old direct extension list/read/upgrade + forwarding paths in the next major version after consumers have migrated to + top-level `CodexExtensions`. +- [ ] Add automatic plugin and skill update checking, with an optional + policy-controlled auto-update mode that reports SwiftASB feature operation + events for every update attempt. - [ ] Promote broader app-wide settings/actions only when they have concrete user workflows and stable public models. - [ ] SwiftASB-owned query descriptors for thread lists, project grouping, @@ -326,6 +395,11 @@ workflow earns them in a later feature release. explanations, without exposing generated wire shapes. - [ ] Later upstream fuzzy file-search promotion after the app-server schema has a clear search, cursor, and result-stability contract. +- [ ] Prefer local SwiftASB/client search over broad upstream search promotion + for now: Core Data can own stored thread/item facts, and SearchKit can later + index transcript and artifact text for UI search. +- [ ] Keep realtime routes in backlog until a concrete live-collaboration + consumer workflow needs them. - [ ] Broader public history cursor semantics. - [ ] Transcript search. - [ ] Richer non-UI history query helpers beyond the current local windows. @@ -427,8 +501,8 @@ workflow earns them in a later feature release. ### Documentation And Examples -- [x] Update stale release references after the `v1.6.0` release. - Decision: README now names `v1.6.0` as the current released baseline and no +- [x] Update stale release references after the `v1.7.0` release. + Decision: README now names `v1.7.0` as the current released baseline and no longer describes the package as early development. - [x] Finish DocC symbol comments for the supported lifecycle, not just the conceptual articles. @@ -629,10 +703,10 @@ workflow earns them in a later feature release. the `release/v1.0.0` branch on 2026-05-02 and on the `release/v1.0.1-prep` branch on 2026-05-02. - [x] Decide whether another targeted `v0.9.x` patch release is needed before - `v1.6.0`, or whether the remaining work should go straight into the v1 + `v1.7.0`, or whether the remaining work should go straight into the v1 release branch. Decision: no additional `v0.9.x` patch is needed. The remaining work should go - straight into the `v1.6.0` release branch. + straight into the `v1.7.0` release branch. - [x] Prepare v1 release notes with explicit sections for public surface, intentionally internal surfaces, compatibility window, migration notes, validation performed, and known post-v1 work. @@ -686,7 +760,7 @@ workflow earns them in a later feature release. #### Migration Notes - Existing `v0.9.x` consumers should update the SwiftPM dependency to - `from: "1.6.0"` once the tag is published. + `from: "1.7.0"` once the tag is published. - The v1 API surface has removed stale pre-v1 compatibility shims and phantom fields that no longer exist in the reviewed `v0.128.0` schema. - Same-thread overlapping turns are rejected client-side with @@ -711,7 +785,7 @@ workflow earns them in a later feature release. - Keep an eye on future Swift Package Index builds after compatibility-window or DocC changes; the `v1.1.1` listing and documentation link are live, and - `v1.6.0` should be rechecked after the patch tag is indexed. + `v1.7.0` should be rechecked after the patch tag is indexed. - Add broader live server-request coverage for permissions and MCP elicitation if those become stronger public runtime guarantees. - Continue tuning recent companion cache calibration, richer file previews, @@ -727,12 +801,12 @@ that should stay visible until closed. Audit bundle: [`docs/security-audits/82ea49d_20260511T213956-0400/report.md`](docs/security-audits/82ea49d_20260511T213956-0400/report.md). -- [ ] Fix JSON-RPC numeric ID narrowing. +- [x] Fix JSON-RPC numeric ID narrowing. `CodexRPCEnvelope.parseRequestID(_:)` currently checks whole-number shape and then uses `NSNumber.intValue`. Replace that with a range-preserving conversion or explicit out-of-range rejection, then add boundary tests around 32-bit and platform `Int` limits. -- [ ] Fix fail-open network-policy amendment mapping. +- [x] Fix fail-open network-policy amendment mapping. `CodexProtocolNetworkPolicyAmendment.publicValue` currently maps unknown wire `action` strings to `.allow`. Preserve unknown values or fail closed so approval UI and app logic cannot misrepresent malformed or future actions as @@ -1321,7 +1395,7 @@ Completed - [x] Add version-compatibility policy notes for the local Codex binary. - [x] Refresh the compatibility window and promoted generated snapshot against the current `v0.124.0` schema dump once the added endpoint, notification, and field families have been classified. - [x] Curate the public API before v1 by splitting large source files along existing responsibility boundaries where still helpful, tightening public names/defaults, and finishing targeted source-level symbol documentation for the supported lifecycle. - Decision: completed for the `v1.6.0` boundary through the public API audit, + Decision: completed for the `v1.7.0` boundary through the public API audit, symbol inventory, source-comment pass, and focused public file organization. - [x] Add the first DocC documentation catalog before v1, including a package landing page, public-handle topic groups, and conceptual articles for the interactive lifecycle, history companions, and generated-wire boundary. - [x] Validate the DocC catalog through Xcode `docbuild` and document the maintainer command. diff --git a/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift b/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift index 9163c66..a03de42 100644 --- a/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift +++ b/Sources/SwiftASB/Protocol/CodexAppServerProtocol+Types.swift @@ -36,6 +36,8 @@ enum CodexAppServerProtocolEvent: Equatable, Sendable { case turnCompleted(CodexWireTurnCompletedNotification) case itemStarted(CodexWireItemStartedNotification) case itemCompleted(CodexWireItemCompletedNotification) + case itemGuardianApprovalReviewStarted(CodexWireItemGuardianApprovalReviewStartedNotification) + case itemGuardianApprovalReviewCompleted(CodexProtocolGuardianApprovalReviewCompletedNotification) case commandExecOutputDelta(CodexWireCommandExecOutputDeltaNotification) case commandExecutionOutputDelta(CodexWireCommandExecutionOutputDeltaNotification) case fileChangeOutputDelta(CodexWireFileChangeOutputDeltaNotification) @@ -61,6 +63,13 @@ struct CodexProtocolThreadArchiveResponse: Decodable, Equatable, Sendable {} struct CodexProtocolThreadSetNameResponse: Decodable, Equatable, Sendable {} +struct CodexProtocolThreadApproveGuardianDeniedActionResponse: Decodable, Equatable, Sendable {} + +struct CodexProtocolGuardianApprovalReviewCompletedNotification: Equatable, Sendable { + let event: CodexWireJSONValue + let notification: CodexWireItemGuardianApprovalReviewCompletedNotification +} + struct CodexProtocolModelProviderCapabilitiesReadParams: Encodable, Equatable, Sendable {} struct CodexProtocolModelProviderCapabilitiesReadResponse: Decodable, Equatable, Sendable { @@ -137,6 +146,36 @@ enum CodexProtocolConfigWriteStatus: String, Decodable, Equatable, Sendable { case okOverridden } +struct CodexProtocolFSWriteFileParams: Encodable, Equatable, Sendable { + let dataBase64: String + let path: String +} + +struct CodexProtocolFSCreateDirectoryParams: Encodable, Equatable, Sendable { + let path: String + let recursive: Bool? +} + +struct CodexProtocolFSRemoveParams: Encodable, Equatable, Sendable { + let force: Bool? + let path: String + let recursive: Bool? +} + +struct CodexProtocolFSCopyParams: Encodable, Equatable, Sendable { + let destinationPath: String + let recursive: Bool? + let sourcePath: String +} + +struct CodexProtocolFSWriteFileResponse: Decodable, Equatable, Sendable {} + +struct CodexProtocolFSCreateDirectoryResponse: Decodable, Equatable, Sendable {} + +struct CodexProtocolFSRemoveResponse: Decodable, Equatable, Sendable {} + +struct CodexProtocolFSCopyResponse: Decodable, Equatable, Sendable {} + struct CodexProtocolThreadMetadataUpdateParams: Encodable, Equatable, Sendable { let gitInfo: GitInfo? let threadID: String diff --git a/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift b/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift index 5b34778..cb8df11 100644 --- a/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift +++ b/Sources/SwiftASB/Protocol/CodexAppServerProtocol.swift @@ -22,6 +22,7 @@ struct CodexAppServerProtocol { case threadGoalSet = "thread/goal/set" case threadGoalClear = "thread/goal/clear" case threadShellCommand = "thread/shellCommand" + case threadApproveGuardianDeniedAction = "thread/approveGuardianDeniedAction" case turnStart = "turn/start" case turnSteer = "turn/steer" case turnInterrupt = "turn/interrupt" @@ -30,6 +31,10 @@ struct CodexAppServerProtocol { case fsReadFile = "fs/readFile" case fsWatch = "fs/watch" case fsUnwatch = "fs/unwatch" + case fsWriteFile = "fs/writeFile" + case fsCreateDirectory = "fs/createDirectory" + case fsRemove = "fs/remove" + case fsCopy = "fs/copy" case appList = "app/list" case collaborationModeList = "collaborationMode/list" case configRead = "config/read" @@ -105,6 +110,16 @@ struct CodexAppServerProtocol { ) } + func makeThreadApproveGuardianDeniedActionRequest( + id: CodexRPCRequestID, + params: CodexWireThreadApproveGuardianDeniedActionParams + ) throws -> Data { + try encodeRequest( + JSONRPCRequestEnvelope(id: id, method: .threadApproveGuardianDeniedAction, params: params), + method: .threadApproveGuardianDeniedAction + ) + } + func makeReviewStartRequest( id: CodexRPCRequestID, params: CodexWireReviewStartParams @@ -295,6 +310,46 @@ struct CodexAppServerProtocol { ) } + func makeFSWriteFileRequest( + id: CodexRPCRequestID, + params: CodexProtocolFSWriteFileParams + ) throws -> Data { + try encodeRequest( + JSONRPCRequestEnvelope(id: id, method: .fsWriteFile, params: params), + method: .fsWriteFile + ) + } + + func makeFSCreateDirectoryRequest( + id: CodexRPCRequestID, + params: CodexProtocolFSCreateDirectoryParams + ) throws -> Data { + try encodeRequest( + JSONRPCRequestEnvelope(id: id, method: .fsCreateDirectory, params: params), + method: .fsCreateDirectory + ) + } + + func makeFSRemoveRequest( + id: CodexRPCRequestID, + params: CodexProtocolFSRemoveParams + ) throws -> Data { + try encodeRequest( + JSONRPCRequestEnvelope(id: id, method: .fsRemove, params: params), + method: .fsRemove + ) + } + + func makeFSCopyRequest( + id: CodexRPCRequestID, + params: CodexProtocolFSCopyParams + ) throws -> Data { + try encodeRequest( + JSONRPCRequestEnvelope(id: id, method: .fsCopy, params: params), + method: .fsCopy + ) + } + func makeConfigReadRequest( id: CodexRPCRequestID, params: CodexWireConfigReadParams @@ -604,6 +659,18 @@ struct CodexAppServerProtocol { ) } + func decodeThreadApproveGuardianDeniedActionResponse( + _ responsePayload: Data, + expectedID: CodexRPCRequestID + ) throws -> CodexProtocolThreadApproveGuardianDeniedActionResponse { + try decodeResponse( + responsePayload, + expectedID: expectedID, + method: .threadApproveGuardianDeniedAction, + resultType: CodexProtocolThreadApproveGuardianDeniedActionResponse.self + ) + } + func decodeThreadMetadataUpdateResponse( _ responsePayload: Data, expectedID: CodexRPCRequestID @@ -784,6 +851,54 @@ struct CodexAppServerProtocol { ) } + func decodeFSWriteFileResponse( + _ responsePayload: Data, + expectedID: CodexRPCRequestID + ) throws -> CodexProtocolFSWriteFileResponse { + try decodeResponse( + responsePayload, + expectedID: expectedID, + method: .fsWriteFile, + resultType: CodexProtocolFSWriteFileResponse.self + ) + } + + func decodeFSCreateDirectoryResponse( + _ responsePayload: Data, + expectedID: CodexRPCRequestID + ) throws -> CodexProtocolFSCreateDirectoryResponse { + try decodeResponse( + responsePayload, + expectedID: expectedID, + method: .fsCreateDirectory, + resultType: CodexProtocolFSCreateDirectoryResponse.self + ) + } + + func decodeFSRemoveResponse( + _ responsePayload: Data, + expectedID: CodexRPCRequestID + ) throws -> CodexProtocolFSRemoveResponse { + try decodeResponse( + responsePayload, + expectedID: expectedID, + method: .fsRemove, + resultType: CodexProtocolFSRemoveResponse.self + ) + } + + func decodeFSCopyResponse( + _ responsePayload: Data, + expectedID: CodexRPCRequestID + ) throws -> CodexProtocolFSCopyResponse { + try decodeResponse( + responsePayload, + expectedID: expectedID, + method: .fsCopy, + resultType: CodexProtocolFSCopyResponse.self + ) + } + func decodeConfigReadResponse( _ responsePayload: Data, expectedID: CodexRPCRequestID @@ -1071,7 +1186,7 @@ struct CodexAppServerProtocol { resultType: [String: CodexWireJSONValue].self ) ) - case "mcpServer/status/updated": + case "mcpServer/startupStatus/updated": return .mcpServerStatusUpdated( try decodeNotification( payload, @@ -1279,6 +1394,29 @@ struct CodexAppServerProtocol { resultType: CodexWireItemCompletedNotification.self ) ) + case "item/autoApprovalReview/started": + return .itemGuardianApprovalReviewStarted( + try decodeNotification( + payload, + method: method, + resultType: CodexWireItemGuardianApprovalReviewStartedNotification.self + ) + ) + case "item/autoApprovalReview/completed": + return .itemGuardianApprovalReviewCompleted( + .init( + event: try decodeNotification( + payload, + method: method, + resultType: CodexWireJSONValue.self + ), + notification: try decodeNotification( + payload, + method: method, + resultType: CodexWireItemGuardianApprovalReviewCompletedNotification.self + ) + ) + ) case "item/commandExecution/outputDelta": return .commandExecutionOutputDelta( try decodeNotification( diff --git a/Sources/SwiftASB/Protocol/CodexRPCEnvelope.swift b/Sources/SwiftASB/Protocol/CodexRPCEnvelope.swift index 33fdc42..9dc8862 100644 --- a/Sources/SwiftASB/Protocol/CodexRPCEnvelope.swift +++ b/Sources/SwiftASB/Protocol/CodexRPCEnvelope.swift @@ -64,13 +64,26 @@ internal enum CodexRPCEnvelope { ) } - let doubleValue = number.doubleValue - let integerValue = number.intValue - guard doubleValue.rounded(.towardZero) == doubleValue else { + let decimalValue = number.decimalValue + var roundedValue = Decimal() + var sourceValue = decimalValue + NSDecimalRound(&roundedValue, &sourceValue, 0, .plain) + guard roundedValue == decimalValue else { throw CodexTransportError.invalidJSONRPCEnvelope( reason: "JSON-RPC numeric request IDs must be whole numbers." ) } + guard decimalValue >= Decimal(Int.min), decimalValue <= Decimal(Int.max) else { + throw CodexTransportError.invalidJSONRPCEnvelope( + reason: "JSON-RPC numeric request ID \(number) is outside the supported Swift Int range." + ) + } + + guard let integerValue = Int(exactly: NSDecimalNumber(decimal: decimalValue)) else { + throw CodexTransportError.invalidJSONRPCEnvelope( + reason: "JSON-RPC numeric request ID \(number) could not be represented exactly as a Swift Int." + ) + } return .int(integerValue) } diff --git a/Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift b/Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift index 9db5831..f8cfdb6 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+CodexExtensions.swift @@ -1,351 +1,466 @@ import Foundation -public extension CodexAppServer { - /// App-server-owned extension inventory for apps, skills, plugins, and collaboration modes. - struct CodexExtensions: Sendable { +/// App-server-owned extension, MCP, app, skill, plugin, and collaboration-mode surface. +public struct CodexExtensions: Sendable { + let appServer: CodexAppServer + + init(appServer: CodexAppServer) { + self.appServer = appServer + } + + /// Install intent for one Codex extension family. + public enum InstallRequest: Sendable, Equatable { + case mcp(MCP.ServerDefinition) + } + + /// Result returned after installing one Codex extension family item. + public enum InstallResult: Sendable, Equatable { + case mcp(MCP.InstallResult) + } + + /// Installs one extension-family item through SwiftASB's preferred unified install surface. + @discardableResult + public func install(_ request: InstallRequest) async throws -> InstallResult { + switch request { + case let .mcp(definition): + return .mcp(try await mcp.install(definition)) + } + } + + /// App and connector inventory family. + public struct Apps: Sendable { private let appServer: CodexAppServer init(appServer: CodexAppServer) { self.appServer = appServer } - /// Request used to list available apps and connectors. - public struct AppListRequest: Sendable, Equatable { - public var cursor: String? - public var forceRefetch: Bool? - public var limit: Int? - public var threadID: String? - - public init( - cursor: String? = nil, - limit: Int? = nil, - forceRefetch: Bool? = nil, - threadID: String? = nil - ) { - self.cursor = cursor - self.forceRefetch = forceRefetch - self.limit = limit - self.threadID = threadID - } + public func list(_ request: AppListRequest = .init()) async throws -> AppListPage { + try await appServer.listExtensionApps(request) } + } - /// One page of app or connector metadata. - public struct AppListPage: Sendable, Equatable { - public let apps: [AppInfo] - public let nextCursor: String? - } - - /// App or connector metadata returned by the app-server. - public struct AppInfo: Sendable, Equatable, Identifiable { - public let branding: AppBranding? - public let categories: [String]? - public let description: String? - public let developer: String? - public let distributionChannel: String? - public let id: String - public let installURL: String? - public let isAccessible: Bool? - public let isEnabled: Bool? - public let labels: [String: String]? - public let logoURL: String? - public let logoURLDark: String? - public let name: String - public let pluginDisplayNames: [String]? - public let screenshots: [AppScreenshot]? - public let version: String? - public let versionID: String? - public let versionNotes: String? - } - - public struct AppBranding: Sendable, Equatable { - public let category: String? - public let developer: String? - public let isDiscoverableApp: Bool - public let privacyPolicy: String? - public let termsOfService: String? - public let website: String? - } - - public struct AppScreenshot: Sendable, Equatable { - public let fileID: String? - public let url: String? - public let userPrompt: String - } - - /// Request used to list skills visible from one or more working directories. - public struct SkillListRequest: Sendable, Equatable { - public struct ExtraUserRootsForCurrentDirectory: Sendable, Equatable { - public var currentDirectoryPath: String - public var extraUserRoots: [String] - - public init(currentDirectoryPath: String, extraUserRoots: [String]) { - self.currentDirectoryPath = currentDirectoryPath - self.extraUserRoots = extraUserRoots - } - } + /// Skill inventory family. + public struct Skills: Sendable { + private let appServer: CodexAppServer - public var currentDirectoryPaths: [String]? - public var forceReload: Bool? - /// Deprecated by Codex CLI 0.130.0. The app-server no longer accepts - /// per-cwd extra skill roots on `skills/list`. - public var perCurrentDirectoryExtraUserRoots: [ExtraUserRootsForCurrentDirectory]? - - public init( - currentDirectoryPaths: [String]? = nil, - forceReload: Bool? = nil, - perCurrentDirectoryExtraUserRoots: [ExtraUserRootsForCurrentDirectory]? = nil - ) { - self.currentDirectoryPaths = currentDirectoryPaths - self.forceReload = forceReload - self.perCurrentDirectoryExtraUserRoots = perCurrentDirectoryExtraUserRoots - } + init(appServer: CodexAppServer) { + self.appServer = appServer } - public struct SkillListSnapshot: Sendable, Equatable { - public let entries: [SkillListEntry] + public func list(_ request: SkillListRequest = .init()) async throws -> SkillListSnapshot { + try await appServer.listExtensionSkills(request) } + } - public struct SkillListEntry: Sendable, Equatable, Identifiable { - public var id: String { currentDirectoryPath } + /// Plugin and marketplace inventory and maintenance family. + public struct Plugins: Sendable { + private let appServer: CodexAppServer - public let currentDirectoryPath: String - public let errors: [SkillError] - public let skills: [SkillMetadata] + init(appServer: CodexAppServer) { + self.appServer = appServer } - public struct SkillError: Sendable, Equatable { - public let message: String - public let path: String + public func list(_ request: PluginListRequest = .init()) async throws -> PluginListSnapshot { + try await appServer.listExtensionPlugins(request) } - public struct SkillMetadata: Sendable, Equatable, Identifiable { - public enum Scope: String, Sendable, Equatable { - case admin, repo, system, user - } - - public var id: String { path } + public func read(_ request: PluginReadRequest) async throws -> PluginDetail { + try await appServer.readExtensionPlugin(request) + } - public let description: String - public let displayName: String? - public let enabled: Bool - public let name: String - public let path: String - public let scope: Scope - public let shortDescription: String? + public func upgradeMarketplace( + _ request: MarketplaceUpgradeRequest + ) async throws -> MarketplaceUpgradeResult { + try await appServer.upgradeExtensionMarketplace(request) } + } - public struct PluginListRequest: Sendable, Equatable { - public var currentDirectoryPaths: [String]? + /// Collaboration-mode inventory family. + public struct CollaborationModes: Sendable { + private let appServer: CodexAppServer - public init(currentDirectoryPaths: [String]? = nil) { - self.currentDirectoryPaths = currentDirectoryPaths - } + init(appServer: CodexAppServer) { + self.appServer = appServer } - public struct PluginListSnapshot: Sendable, Equatable { - public let featuredPluginIDs: [String] - public let marketplaceLoadErrors: [MarketplaceLoadError] - public let marketplaces: [PluginMarketplace] + public func list() async throws -> CollaborationModeList { + try await appServer.listExtensionCollaborationModes() } + } - public struct MarketplaceLoadError: Sendable, Equatable { - public let marketplacePath: String - public let message: String - } + /// MCP server configuration, status, and resource family. + public var mcp: MCP { + MCP(appServer: appServer) + } + + /// App and connector inventory family. + public var apps: Apps { + Apps(appServer: appServer) + } - public struct PluginMarketplace: Sendable, Equatable, Identifiable { - public var id: String { path ?? name } + /// Skill inventory family. + public var skills: Skills { + Skills(appServer: appServer) + } + + /// Plugin and marketplace inventory and maintenance family. + public var plugins: Plugins { + Plugins(appServer: appServer) + } - public let displayName: String? - public let name: String - public let path: String? - public let plugins: [PluginSummary] + /// Collaboration-mode inventory family. + public var collaborationModes: CollaborationModes { + CollaborationModes(appServer: appServer) + } + + /// Request used to list available apps and connectors. + public struct AppListRequest: Sendable, Equatable { + public var cursor: String? + public var forceRefetch: Bool? + public var limit: Int? + public var threadID: String? + + public init( + cursor: String? = nil, + limit: Int? = nil, + forceRefetch: Bool? = nil, + threadID: String? = nil + ) { + self.cursor = cursor + self.forceRefetch = forceRefetch + self.limit = limit + self.threadID = threadID } + } - public struct PluginSummary: Sendable, Equatable, Identifiable { - public enum AuthPolicy: String, Sendable, Equatable { - case onInstall - case onUse - } + /// One page of app or connector metadata. + public struct AppListPage: Sendable, Equatable { + public let apps: [AppInfo] + public let nextCursor: String? + } - public enum InstallPolicy: String, Sendable, Equatable { - case available - case installedByDefault - case notAvailable - } + /// App or connector metadata returned by the app-server. + public struct AppInfo: Sendable, Equatable, Identifiable { + public let branding: AppBranding? + public let categories: [String]? + public let description: String? + public let developer: String? + public let distributionChannel: String? + public let id: String + public let installURL: String? + public let isAccessible: Bool? + public let isEnabled: Bool? + public let labels: [String: String]? + public let logoURL: String? + public let logoURLDark: String? + public let name: String + public let pluginDisplayNames: [String]? + public let screenshots: [AppScreenshot]? + public let version: String? + public let versionID: String? + public let versionNotes: String? + } - public enum SourceKind: String, Sendable, Equatable { - case git - case local - case remote - } + public struct AppBranding: Sendable, Equatable { + public let category: String? + public let developer: String? + public let isDiscoverableApp: Bool + public let privacyPolicy: String? + public let termsOfService: String? + public let website: String? + } - public let authPolicy: AuthPolicy - public let enabled: Bool - public let id: String - public let installed: Bool - public let installPolicy: InstallPolicy - public let interface: PluginInterface? - public let name: String - public let sourceKind: SourceKind - public let sourcePath: String? - public let sourceRefName: String? - public let sourceSHA: String? - public let sourceURL: String? - } - - public struct PluginInterface: Sendable, Equatable { - public let brandColor: String? - public let capabilities: [String] - public let category: String? - public let defaultPrompt: [String]? - public let developerName: String? - public let displayName: String? - public let longDescription: String? - public let shortDescription: String? - } - - public struct PluginReadRequest: Sendable, Equatable { - public var marketplacePath: String? - public var pluginName: String - public var remoteMarketplaceName: String? - - public init( - pluginName: String, - marketplacePath: String? = nil, - remoteMarketplaceName: String? = nil - ) { - self.marketplacePath = marketplacePath - self.pluginName = pluginName - self.remoteMarketplaceName = remoteMarketplaceName - } - } + public struct AppScreenshot: Sendable, Equatable { + public let fileID: String? + public let url: String? + public let userPrompt: String + } - public struct MarketplaceUpgradeRequest: Sendable, Equatable { - public var currentDirectoryPaths: [String]? - public var marketplaceName: String - public var timeoutMilliseconds: Int + /// Request used to list skills visible from one or more working directories. + public struct SkillListRequest: Sendable, Equatable { + public struct ExtraUserRootsForCurrentDirectory: Sendable, Equatable { + public var currentDirectoryPath: String + public var extraUserRoots: [String] - public init( - marketplaceName: String, - currentDirectoryPaths: [String]? = nil, - timeoutMilliseconds: Int = 120_000 - ) { - self.marketplaceName = marketplaceName - self.currentDirectoryPaths = currentDirectoryPaths - self.timeoutMilliseconds = max(1_000, timeoutMilliseconds) + public init(currentDirectoryPath: String, extraUserRoots: [String]) { + self.currentDirectoryPath = currentDirectoryPath + self.extraUserRoots = extraUserRoots } } - public struct MarketplaceUpgradeResult: Sendable, Equatable { - public let command: [String] - public let exitCode: Int - public let marketplaceName: String - public let operationID: String - public let status: SwiftASBFeatureOperationEvent.Status - public let stderr: String - public let stdout: String - } + public var currentDirectoryPaths: [String]? + public var forceReload: Bool? + /// Deprecated by Codex CLI 0.130.0. The app-server no longer accepts + /// per-cwd extra skill roots on `skills/list`. + public var perCurrentDirectoryExtraUserRoots: [ExtraUserRootsForCurrentDirectory]? - public struct PluginDetail: Sendable, Equatable { - public let apps: [AppSummary] - public let description: String? - public let hooks: [PluginHookSummary] - public let marketplaceName: String - public let marketplacePath: String? - public let mcpServers: [String] - public let skills: [SkillSummary] - public let summary: PluginSummary + public init( + currentDirectoryPaths: [String]? = nil, + forceReload: Bool? = nil, + perCurrentDirectoryExtraUserRoots: [ExtraUserRootsForCurrentDirectory]? = nil + ) { + self.currentDirectoryPaths = currentDirectoryPaths + self.forceReload = forceReload + self.perCurrentDirectoryExtraUserRoots = perCurrentDirectoryExtraUserRoots } + } - public struct PluginHookSummary: Sendable, Equatable, Identifiable { - public var id: String { key } + public struct SkillListSnapshot: Sendable, Equatable { + public let entries: [SkillListEntry] + } - public let eventName: HookMetadata.EventName - public let key: String - } + public struct SkillListEntry: Sendable, Equatable, Identifiable { + public var id: String { currentDirectoryPath } - public struct AppSummary: Sendable, Equatable, Identifiable { - public let description: String? - public let id: String - public let installURL: String? - public let name: String - public let needsAuth: Bool - } + public let currentDirectoryPath: String + public let errors: [SkillError] + public let skills: [SkillMetadata] + } - public struct SkillSummary: Sendable, Equatable, Identifiable { - public var id: String { path ?? name } + public struct SkillError: Sendable, Equatable { + public let message: String + public let path: String + } - public let description: String - public let displayName: String? - public let enabled: Bool - public let name: String - public let path: String? - public let shortDescription: String? + public struct SkillMetadata: Sendable, Equatable, Identifiable { + public enum Scope: String, Sendable, Equatable { + case admin, repo, system, user } - public struct CollaborationModeList: Sendable, Equatable { - public let modes: [CollaborationMode] - } + public var id: String { path } - public struct CollaborationMode: Sendable, Equatable, Identifiable { - public enum Kind: String, Sendable, Equatable { - case defaultMode = "default" - case plan - } + public let description: String + public let displayName: String? + public let enabled: Bool + public let name: String + public let path: String + public let scope: Scope + public let shortDescription: String? + } - public var id: String { name } + public struct PluginListRequest: Sendable, Equatable { + public var currentDirectoryPaths: [String]? - public let kind: Kind? - public let model: String? - public let name: String - public let reasoningEffort: ReasoningEffort? + public init(currentDirectoryPaths: [String]? = nil) { + self.currentDirectoryPaths = currentDirectoryPaths } + } - public func listApps(_ request: AppListRequest = .init()) async throws -> AppListPage { - try await appServer.listExtensionApps(request) + public struct PluginListSnapshot: Sendable, Equatable { + public let featuredPluginIDs: [String] + public let marketplaceLoadErrors: [MarketplaceLoadError] + public let marketplaces: [PluginMarketplace] + } + + public struct MarketplaceLoadError: Sendable, Equatable { + public let marketplacePath: String + public let message: String + } + + public struct PluginMarketplace: Sendable, Equatable, Identifiable { + public var id: String { path ?? name } + + public let displayName: String? + public let name: String + public let path: String? + public let plugins: [PluginSummary] + } + + public struct PluginSummary: Sendable, Equatable, Identifiable { + public enum AuthPolicy: String, Sendable, Equatable { + case onInstall + case onUse } - public func listSkills(_ request: SkillListRequest = .init()) async throws -> SkillListSnapshot { - try await appServer.listExtensionSkills(request) + public enum InstallPolicy: String, Sendable, Equatable { + case available + case installedByDefault + case notAvailable } - public func listPlugins(_ request: PluginListRequest = .init()) async throws -> PluginListSnapshot { - try await appServer.listExtensionPlugins(request) + public enum SourceKind: String, Sendable, Equatable { + case git + case local + case remote } - public func readPlugin(_ request: PluginReadRequest) async throws -> PluginDetail { - try await appServer.readExtensionPlugin(request) + public let authPolicy: AuthPolicy + public let enabled: Bool + public let id: String + public let installed: Bool + public let installPolicy: InstallPolicy + public let interface: PluginInterface? + public let name: String + public let sourceKind: SourceKind + public let sourcePath: String? + public let sourceRefName: String? + public let sourceSHA: String? + public let sourceURL: String? + } + + public struct PluginInterface: Sendable, Equatable { + public let brandColor: String? + public let capabilities: [String] + public let category: String? + public let defaultPrompt: [String]? + public let developerName: String? + public let displayName: String? + public let longDescription: String? + public let shortDescription: String? + } + + public struct PluginReadRequest: Sendable, Equatable { + public var marketplacePath: String? + public var pluginName: String + public var remoteMarketplaceName: String? + + public init( + pluginName: String, + marketplacePath: String? = nil, + remoteMarketplaceName: String? = nil + ) { + self.marketplacePath = marketplacePath + self.pluginName = pluginName + self.remoteMarketplaceName = remoteMarketplaceName } + } - /// Upgrades an already-configured plugin marketplace through Codex. - /// - /// SwiftASB preflights the marketplace through `plugin/list`, runs the - /// installed Codex CLI's `plugin marketplace upgrade` command through - /// app-server `command/exec`, and emits a feature-operation event. New - /// marketplace installs and marketplace removals remain separate, - /// stricter mutation categories. - public func upgradeMarketplace( - _ request: MarketplaceUpgradeRequest - ) async throws -> MarketplaceUpgradeResult { - try await appServer.upgradeExtensionMarketplace(request) + public struct MarketplaceUpgradeRequest: Sendable, Equatable { + public var currentDirectoryPaths: [String]? + public var marketplaceName: String + public var timeoutMilliseconds: Int + + public init( + marketplaceName: String, + currentDirectoryPaths: [String]? = nil, + timeoutMilliseconds: Int = 120_000 + ) { + self.marketplaceName = marketplaceName + self.currentDirectoryPaths = currentDirectoryPaths + self.timeoutMilliseconds = max(1_000, timeoutMilliseconds) } + } - public func listCollaborationModes() async throws -> CollaborationModeList { - try await appServer.listExtensionCollaborationModes() + public struct MarketplaceUpgradeResult: Sendable, Equatable { + public let command: [String] + public let exitCode: Int + public let marketplaceName: String + public let operationID: String + public let status: SwiftASBFeatureOperationEvent.Status + public let stderr: String + public let stdout: String + } + + public struct PluginDetail: Sendable, Equatable { + public let apps: [AppSummary] + public let description: String? + public let hooks: [PluginHookSummary] + public let marketplaceName: String + public let marketplacePath: String? + public let mcpServers: [String] + public let skills: [SkillSummary] + public let summary: PluginSummary + } + + public struct PluginHookSummary: Sendable, Equatable, Identifiable { + public var id: String { key } + + public let eventName: CodexAppServer.HookMetadata.EventName + public let key: String + } + + public struct AppSummary: Sendable, Equatable, Identifiable { + public let description: String? + public let id: String + public let installURL: String? + public let name: String + public let needsAuth: Bool + } + + public struct SkillSummary: Sendable, Equatable, Identifiable { + public var id: String { path ?? name } + + public let description: String + public let displayName: String? + public let enabled: Bool + public let name: String + public let path: String? + public let shortDescription: String? + } + + public struct CollaborationModeList: Sendable, Equatable { + public let modes: [CollaborationMode] + } + + public struct CollaborationMode: Sendable, Equatable, Identifiable { + public enum Kind: String, Sendable, Equatable { + case defaultMode = "default" + case plan } + + public var id: String { name } + + public let kind: Kind? + public let model: String? + public let name: String + public let reasoningEffort: CodexAppServer.ReasoningEffort? } + @available(*, deprecated, message: "Use appServer.extensions.apps.list(...) instead.") + public func listApps(_ request: AppListRequest = .init()) async throws -> AppListPage { + try await apps.list(request) + } + + @available(*, deprecated, message: "Use appServer.extensions.skills.list(...) instead.") + public func listSkills(_ request: SkillListRequest = .init()) async throws -> SkillListSnapshot { + try await skills.list(request) + } + + @available(*, deprecated, message: "Use appServer.extensions.plugins.list(...) instead.") + public func listPlugins(_ request: PluginListRequest = .init()) async throws -> PluginListSnapshot { + try await plugins.list(request) + } + + @available(*, deprecated, message: "Use appServer.extensions.plugins.read(...) instead.") + public func readPlugin(_ request: PluginReadRequest) async throws -> PluginDetail { + try await plugins.read(request) + } + + /// Upgrades an already-configured plugin marketplace through Codex. + /// + /// SwiftASB preflights the marketplace through `plugin/list`, runs the + /// installed Codex CLI's `plugin marketplace upgrade` command through + /// app-server `command/exec`, and emits a feature-operation event. New + /// marketplace installs and marketplace removals remain separate, + /// stricter mutation categories. + @available(*, deprecated, message: "Use appServer.extensions.plugins.upgradeMarketplace(...) instead.") + public func upgradeMarketplace( + _ request: MarketplaceUpgradeRequest + ) async throws -> MarketplaceUpgradeResult { + try await plugins.upgradeMarketplace(request) + } + + @available(*, deprecated, message: "Use appServer.extensions.collaborationModes.list() instead.") + public func listCollaborationModes() async throws -> CollaborationModeList { + try await collaborationModes.list() + } +} + +public extension CodexAppServer { /// App-server-owned extension inventory surface. - var extensions: CodexExtensions { - CodexExtensions(appServer: self) + var extensions: SwiftASB.CodexExtensions { + SwiftASB.CodexExtensions(appServer: self) } + + @available(*, deprecated, renamed: "CodexExtensions") + typealias CodexExtensions = SwiftASB.CodexExtensions } extension CodexAppServer { func upgradeExtensionMarketplace( - _ request: CodexExtensions.MarketplaceUpgradeRequest - ) async throws -> CodexExtensions.MarketplaceUpgradeResult { + _ request: SwiftASB.CodexExtensions.MarketplaceUpgradeRequest + ) async throws -> SwiftASB.CodexExtensions.MarketplaceUpgradeResult { try requireFeatureEnabled(.extensionMaintenance, for: "plugin marketplace upgrade") let marketplaceName = request.marketplaceName.trimmingCharacters(in: .whitespacesAndNewlines) @@ -441,19 +556,19 @@ extension CodexAppServer { } } -extension CodexAppServer.CodexExtensions.AppListPage { +extension CodexExtensions.AppListPage { init(wireValue: CodexWireAppsListResponse) { self.init( - apps: wireValue.data.map(CodexAppServer.CodexExtensions.AppInfo.init(wireValue:)), + apps: wireValue.data.map(CodexExtensions.AppInfo.init(wireValue:)), nextCursor: wireValue.nextCursor ) } } -extension CodexAppServer.CodexExtensions.AppInfo { +extension CodexExtensions.AppInfo { init(wireValue: CodexWireAppInfo) { self.init( - branding: wireValue.branding.map(CodexAppServer.CodexExtensions.AppBranding.init), + branding: wireValue.branding.map(CodexExtensions.AppBranding.init), categories: wireValue.appMetadata?.categories, description: wireValue.description, developer: wireValue.appMetadata?.developer, @@ -467,7 +582,7 @@ extension CodexAppServer.CodexExtensions.AppInfo { logoURLDark: wireValue.logoURLDark, name: wireValue.name, pluginDisplayNames: wireValue.pluginDisplayNames, - screenshots: wireValue.appMetadata?.screenshots?.map(CodexAppServer.CodexExtensions.AppScreenshot.init), + screenshots: wireValue.appMetadata?.screenshots?.map(CodexExtensions.AppScreenshot.init), version: wireValue.appMetadata?.version, versionID: wireValue.appMetadata?.versionID, versionNotes: wireValue.appMetadata?.versionNotes @@ -475,7 +590,7 @@ extension CodexAppServer.CodexExtensions.AppInfo { } } -extension CodexAppServer.CodexExtensions.AppBranding { +extension CodexExtensions.AppBranding { init(wireValue: CodexWireAppBranding) { self.init( category: wireValue.category, @@ -488,35 +603,35 @@ extension CodexAppServer.CodexExtensions.AppBranding { } } -extension CodexAppServer.CodexExtensions.AppScreenshot { +extension CodexExtensions.AppScreenshot { init(wireValue: CodexWireAppScreenshot) { self.init(fileID: wireValue.fileID, url: wireValue.url, userPrompt: wireValue.userPrompt) } } -extension CodexAppServer.CodexExtensions.SkillListSnapshot { +extension CodexExtensions.SkillListSnapshot { init(wireValue: CodexWireSkillsListResponse) { - self.init(entries: wireValue.data.map(CodexAppServer.CodexExtensions.SkillListEntry.init)) + self.init(entries: wireValue.data.map(CodexExtensions.SkillListEntry.init)) } } -extension CodexAppServer.CodexExtensions.SkillListEntry { +extension CodexExtensions.SkillListEntry { init(wireValue: CodexWireSkillsListEntry) { self.init( currentDirectoryPath: wireValue.cwd, - errors: wireValue.errors.map(CodexAppServer.CodexExtensions.SkillError.init), - skills: wireValue.skills.map(CodexAppServer.CodexExtensions.SkillMetadata.init) + errors: wireValue.errors.map(CodexExtensions.SkillError.init), + skills: wireValue.skills.map(CodexExtensions.SkillMetadata.init) ) } } -extension CodexAppServer.CodexExtensions.SkillError { +extension CodexExtensions.SkillError { init(wireValue: CodexWireSkillErrorInfo) { self.init(message: wireValue.message, path: wireValue.path) } } -extension CodexAppServer.CodexExtensions.SkillMetadata { +extension CodexExtensions.SkillMetadata { init(wireValue: CodexWireSkillMetadata) { self.init( description: wireValue.description, @@ -530,7 +645,7 @@ extension CodexAppServer.CodexExtensions.SkillMetadata { } } -extension CodexAppServer.CodexExtensions.SkillMetadata.Scope { +extension CodexExtensions.SkillMetadata.Scope { init(wireValue: CodexWireSkillScope) { switch wireValue { case .admin: @@ -545,36 +660,36 @@ extension CodexAppServer.CodexExtensions.SkillMetadata.Scope { } } -extension CodexAppServer.CodexExtensions.PluginListSnapshot { +extension CodexExtensions.PluginListSnapshot { init(wireValue: CodexWirePluginListResponse) { self.init( featuredPluginIDs: wireValue.featuredPluginIDS ?? [], marketplaceLoadErrors: (wireValue.marketplaceLoadErrors ?? []).map( - CodexAppServer.CodexExtensions.MarketplaceLoadError.init + CodexExtensions.MarketplaceLoadError.init ), - marketplaces: wireValue.marketplaces.map(CodexAppServer.CodexExtensions.PluginMarketplace.init) + marketplaces: wireValue.marketplaces.map(CodexExtensions.PluginMarketplace.init) ) } } -extension CodexAppServer.CodexExtensions.MarketplaceLoadError { +extension CodexExtensions.MarketplaceLoadError { init(wireValue: CodexWireMarketplaceLoadErrorInfo) { self.init(marketplacePath: wireValue.marketplacePath, message: wireValue.message) } } -extension CodexAppServer.CodexExtensions.PluginMarketplace { +extension CodexExtensions.PluginMarketplace { init(wireValue: CodexWirePluginMarketplaceEntry) { self.init( displayName: wireValue.interface?.displayName, name: wireValue.name, path: wireValue.path, - plugins: wireValue.plugins.map(CodexAppServer.CodexExtensions.PluginSummary.init) + plugins: wireValue.plugins.map(CodexExtensions.PluginSummary.init) ) } } -extension CodexAppServer.CodexExtensions.PluginSummary { +extension CodexExtensions.PluginSummary { init(wireValue: CodexWirePluginSummary) { self.init( authPolicy: .init(wireValue: wireValue.authPolicy), @@ -582,7 +697,7 @@ extension CodexAppServer.CodexExtensions.PluginSummary { id: wireValue.id, installed: wireValue.installed, installPolicy: .init(wireValue: wireValue.installPolicy), - interface: wireValue.interface.map(CodexAppServer.CodexExtensions.PluginInterface.init), + interface: wireValue.interface.map(CodexExtensions.PluginInterface.init), name: wireValue.name, sourceKind: .init(wireValue: wireValue.source.type), sourcePath: wireValue.source.path, @@ -593,7 +708,7 @@ extension CodexAppServer.CodexExtensions.PluginSummary { } } -extension CodexAppServer.CodexExtensions.PluginSummary.AuthPolicy { +extension CodexExtensions.PluginSummary.AuthPolicy { init(wireValue: CodexWirePluginAuthPolicy) { switch wireValue { case .onInstall: @@ -604,7 +719,7 @@ extension CodexAppServer.CodexExtensions.PluginSummary.AuthPolicy { } } -extension CodexAppServer.CodexExtensions.PluginSummary.InstallPolicy { +extension CodexExtensions.PluginSummary.InstallPolicy { init(wireValue: CodexWirePluginInstallPolicy) { switch wireValue { case .available: @@ -617,7 +732,7 @@ extension CodexAppServer.CodexExtensions.PluginSummary.InstallPolicy { } } -extension CodexAppServer.CodexExtensions.PluginSummary.SourceKind { +extension CodexExtensions.PluginSummary.SourceKind { init(wireValue: CodexWirePluginSourceType) { switch wireValue { case .git: @@ -630,7 +745,7 @@ extension CodexAppServer.CodexExtensions.PluginSummary.SourceKind { } } -extension CodexAppServer.CodexExtensions.PluginInterface { +extension CodexExtensions.PluginInterface { init(wireValue: CodexWirePluginInterface) { self.init( brandColor: wireValue.brandColor, @@ -645,22 +760,22 @@ extension CodexAppServer.CodexExtensions.PluginInterface { } } -extension CodexAppServer.CodexExtensions.PluginDetail { +extension CodexExtensions.PluginDetail { init(wireValue: CodexWirePluginDetail) { self.init( - apps: wireValue.apps.map(CodexAppServer.CodexExtensions.AppSummary.init), + apps: wireValue.apps.map(CodexExtensions.AppSummary.init), description: wireValue.description, - hooks: wireValue.hooks.map(CodexAppServer.CodexExtensions.PluginHookSummary.init), + hooks: wireValue.hooks.map(CodexExtensions.PluginHookSummary.init), marketplaceName: wireValue.marketplaceName, marketplacePath: wireValue.marketplacePath, mcpServers: wireValue.mcpServers, - skills: wireValue.skills.map(CodexAppServer.CodexExtensions.SkillSummary.init), + skills: wireValue.skills.map(CodexExtensions.SkillSummary.init), summary: .init(wireValue: wireValue.summary) ) } } -extension CodexAppServer.CodexExtensions.PluginHookSummary { +extension CodexExtensions.PluginHookSummary { init(wireValue: CodexWirePluginHookSummary) { self.init( eventName: .init(wireValue: wireValue.eventName), @@ -696,7 +811,7 @@ extension CodexAppServer.HookMetadata.EventName { } } -extension CodexAppServer.CodexExtensions.AppSummary { +extension CodexExtensions.AppSummary { init(wireValue: CodexWireAppSummary) { self.init( description: wireValue.description, @@ -708,7 +823,7 @@ extension CodexAppServer.CodexExtensions.AppSummary { } } -extension CodexAppServer.CodexExtensions.SkillSummary { +extension CodexExtensions.SkillSummary { init(wireValue: CodexWireSkillSummary) { self.init( description: wireValue.description, @@ -721,13 +836,13 @@ extension CodexAppServer.CodexExtensions.SkillSummary { } } -extension CodexAppServer.CodexExtensions.CollaborationModeList { +extension CodexExtensions.CollaborationModeList { init(wireValue: CodexWireCollaborationModeListResponse) { - self.init(modes: wireValue.data.map(CodexAppServer.CodexExtensions.CollaborationMode.init)) + self.init(modes: wireValue.data.map(CodexExtensions.CollaborationMode.init)) } } -extension CodexAppServer.CodexExtensions.CollaborationMode { +extension CodexExtensions.CollaborationMode { init(wireValue: CodexWireCollaborationModeMask) { self.init( kind: wireValue.mode.map(Kind.init), @@ -738,7 +853,7 @@ extension CodexAppServer.CodexExtensions.CollaborationMode { } } -extension CodexAppServer.CodexExtensions.CollaborationMode.Kind { +extension CodexExtensions.CollaborationMode.Kind { init(wireValue: CodexWireModeKind) { switch wireValue { case .modeKindDefault: diff --git a/Sources/SwiftASB/Public/CodexAppServer+Inventory.swift b/Sources/SwiftASB/Public/CodexAppServer+Inventory.swift index d765b71..3bd3bcd 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+Inventory.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+Inventory.swift @@ -32,14 +32,14 @@ extension CodexAppServer { } internal struct AppInventorySnapshot: Sendable, Equatable { - var appListPage: CodexExtensions.AppListPage? - var collaborationModes: CodexExtensions.CollaborationModeList? + var appListPage: SwiftASB.CodexExtensions.AppListPage? + var collaborationModes: SwiftASB.CodexExtensions.CollaborationModeList? var errorDescriptions: [String] = [] var hookListSnapshot: HookListSnapshot? var mcpServerStatusPage: McpServerStatusPage? var modelCapabilities: ModelCapabilities? - var pluginListSnapshot: CodexExtensions.PluginListSnapshot? - var skillListSnapshot: CodexExtensions.SkillListSnapshot? + var pluginListSnapshot: SwiftASB.CodexExtensions.PluginListSnapshot? + var skillListSnapshot: SwiftASB.CodexExtensions.SkillListSnapshot? var succeededCompletely: Bool { errorDescriptions.isEmpty @@ -135,7 +135,7 @@ private extension CodexAppServer.AppInventorySnapshot { } } -public extension CodexAppServer { +public extension CodexExtensions { @MainActor @Observable final class Inventory { @@ -163,35 +163,35 @@ public extension CodexAppServer { case loading } - public private(set) var appListPage: CodexExtensions.AppListPage? - public private(set) var collaborationModes: CodexExtensions.CollaborationModeList? - public private(set) var hookListSnapshot: HookListSnapshot? + public private(set) var appListPage: SwiftASB.CodexExtensions.AppListPage? + public private(set) var collaborationModes: SwiftASB.CodexExtensions.CollaborationModeList? + public private(set) var hookListSnapshot: CodexAppServer.HookListSnapshot? public private(set) var lastRefreshedAt: Date? public private(set) var latestErrorDescription: String? public private(set) var mcpServerNextCursor: String? public private(set) var mcpServers: [CodexAppServer.McpServerSummary] - public private(set) var modelCapabilities: ModelCapabilities? + public private(set) var modelCapabilities: CodexAppServer.ModelCapabilities? public private(set) var phase: Phase - public private(set) var pluginListSnapshot: CodexExtensions.PluginListSnapshot? - public private(set) var skillListSnapshot: CodexExtensions.SkillListSnapshot? + public private(set) var pluginListSnapshot: SwiftASB.CodexExtensions.PluginListSnapshot? + public private(set) var skillListSnapshot: SwiftASB.CodexExtensions.SkillListSnapshot? - public var apps: [CodexExtensions.AppInfo] { + public var apps: [SwiftASB.CodexExtensions.AppInfo] { appListPage?.apps ?? [] } - public var skillEntries: [CodexExtensions.SkillListEntry] { + public var skillEntries: [SwiftASB.CodexExtensions.SkillListEntry] { skillListSnapshot?.entries ?? [] } - public var skills: [CodexExtensions.SkillMetadata] { + public var skills: [SwiftASB.CodexExtensions.SkillMetadata] { skillEntries.flatMap(\.skills) } - public var pluginMarketplaces: [CodexExtensions.PluginMarketplace] { + public var pluginMarketplaces: [SwiftASB.CodexExtensions.PluginMarketplace] { pluginListSnapshot?.marketplaces ?? [] } - public var collaborationModeEntries: [CodexExtensions.CollaborationMode] { + public var collaborationModeEntries: [SwiftASB.CodexExtensions.CollaborationMode] { collaborationModes?.modes ?? [] } @@ -319,6 +319,19 @@ public extension CodexAppServer { func makeInventory( configuration: Inventory.Configuration = .init() ) async throws -> Inventory { - Inventory(appServer: self, configuration: configuration) + Inventory(appServer: appServer, configuration: configuration) + } +} + +public extension CodexAppServer { + @available(*, deprecated, renamed: "CodexExtensions.Inventory") + typealias Inventory = SwiftASB.CodexExtensions.Inventory + + @available(*, deprecated, message: "Use appServer.extensions.makeInventory(configuration:) instead.") + @MainActor + func makeInventory( + configuration: Inventory.Configuration = .init() + ) async throws -> Inventory { + try await extensions.makeInventory(configuration: configuration) } } diff --git a/Sources/SwiftASB/Public/CodexAppServer+ProtocolPayloads.swift b/Sources/SwiftASB/Public/CodexAppServer+ProtocolPayloads.swift index b1c7a51..8dce17f 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+ProtocolPayloads.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+ProtocolPayloads.swift @@ -37,7 +37,7 @@ enum CodexProtocolCommandExecutionApprovalDecision: Encodable { [ "applyNetworkPolicyAmendment": [ "network_policy_amendment": [ - "action": amendment.action.rawValue, + "action": amendment.action.wireValue, "host": amendment.host, ] ] @@ -139,6 +139,171 @@ extension CodexProtocolPermissionsApprovalRequest { } } +extension CodexProtocolGuardianApprovalReviewCompletedNotification { + var publicDeniedActionApprovalRequest: CodexApprovalRequest? { + guard notification.review.status == .denied else { + return nil + } + + return .guardianDeniedAction( + .init( + requestID: .string("guardian-denied-action:\(notification.reviewID)"), + threadID: notification.threadID, + turnID: notification.turnID, + reviewID: notification.reviewID, + targetItemID: notification.targetItemID, + startedAtMS: notification.startedAtMS, + completedAtMS: notification.completedAtMS, + decisionSource: notification.decisionSource.rawValue, + action: .init(wireValue: notification.action), + review: .init(wireValue: notification.review), + event: .init(wireValue: event) + ) + ) + } +} + +extension CodexGuardianApprovalReviewAction { + init(wireValue: CodexWireGuardianApprovalReviewAction) { + self.init( + type: .init(wireValue: wireValue.type), + command: wireValue.command, + currentDirectoryPath: wireValue.cwd, + source: wireValue.source.map(CommandSource.init(wireValue:)), + argv: wireValue.argv, + program: wireValue.program, + files: wireValue.files, + host: wireValue.host, + port: wireValue.port, + networkProtocol: wireValue.guardianApprovalReviewActionProtocol.map(NetworkProtocol.init(wireValue:)), + target: wireValue.target, + connectorID: wireValue.connectorID, + connectorName: wireValue.connectorName, + server: wireValue.server, + toolName: wireValue.toolName, + toolTitle: wireValue.toolTitle, + permissions: wireValue.permissions.map(CodexPermissionProfile.init(wireValue:)), + reason: wireValue.reason + ) + } +} + +extension CodexGuardianApprovalReviewAction.ActionType { + init(wireValue: CodexWireGuardianApprovalReviewActionType) { + switch wireValue { + case .applyPatch: + self = .applyPatch + case .command: + self = .command + case .execve: + self = .execve + case .mcpToolCall: + self = .mcpToolCall + case .networkAccess: + self = .networkAccess + case .requestPermissions: + self = .requestPermissions + } + } +} + +extension CodexGuardianApprovalReviewAction.CommandSource { + init(wireValue: CodexWireGuardianCommandSource) { + switch wireValue { + case .shell: + self = .shell + case .unifiedExec: + self = .unifiedExec + } + } +} + +extension CodexGuardianApprovalReviewAction.NetworkProtocol { + init(wireValue: CodexWireNetworkApprovalProtocol) { + switch wireValue { + case .http: + self = .http + case .https: + self = .https + case .socks5TCP: + self = .socks5TCP + case .socks5UDP: + self = .socks5UDP + } + } +} + +extension CodexGuardianApprovalReview { + init(wireValue: CodexWireGuardianApprovalReview) { + self.init( + rationale: wireValue.rationale, + riskLevel: wireValue.riskLevel.map(RiskLevel.init(wireValue:)), + status: .init(wireValue: wireValue.status), + userAuthorization: wireValue.userAuthorization.map(UserAuthorization.init(wireValue:)) + ) + } +} + +extension CodexGuardianApprovalReview.RiskLevel { + init(wireValue: CodexWireGuardianRiskLevel) { + switch wireValue { + case .critical: + self = .critical + case .high: + self = .high + case .low: + self = .low + case .medium: + self = .medium + } + } +} + +extension CodexGuardianApprovalReview.Status { + init(wireValue: CodexWireGuardianApprovalReviewStatus) { + switch wireValue { + case .aborted: + self = .aborted + case .approved: + self = .approved + case .denied: + self = .denied + case .inProgress: + self = .inProgress + case .timedOut: + self = .timedOut + } + } +} + +extension CodexGuardianApprovalReview.UserAuthorization { + init(wireValue: CodexWireGuardianUserAuthorization) { + switch wireValue { + case .high: + self = .high + case .low: + self = .low + case .medium: + self = .medium + case .unknown: + self = .unknown + } + } +} + +extension CodexPermissionProfile { + init(wireValue: CodexWireRequestPermissionProfile) { + self.init( + fileSystem: wireValue.fileSystem.map { + .init(read: $0.read, write: $0.write) + }, + network: wireValue.network.map { + .init(enabled: $0.enabled) + } + ) + } +} + extension CodexProtocolToolUserInputRequest { var publicValue: CodexElicitationRequest { .toolUserInput( @@ -226,7 +391,7 @@ extension CodexProtocolCommandAction { extension CodexProtocolNetworkPolicyAmendment { var publicValue: CodexNetworkPolicyAmendment { .init( - action: .init(rawValue: action) ?? .allow, + action: .init(wireValue: action), host: host ) } diff --git a/Sources/SwiftASB/Public/CodexAppServer+WireMapping.swift b/Sources/SwiftASB/Public/CodexAppServer+WireMapping.swift index c3cc8aa..cace6d2 100644 --- a/Sources/SwiftASB/Public/CodexAppServer+WireMapping.swift +++ b/Sources/SwiftASB/Public/CodexAppServer+WireMapping.swift @@ -994,6 +994,23 @@ extension CodexAppServer.ThreadActiveFlag { } } +extension CodexThread.Dashboard.AutoReviewStatus { + init(wireValue: CodexWireGuardianApprovalReviewStatus) { + switch wireValue { + case .aborted: + self = .aborted + case .approved: + self = .approved + case .denied: + self = .denied + case .inProgress: + self = .inProgress + case .timedOut: + self = .timedOut + } + } +} + extension CodexAppServer.TurnSession { init(wireValue: CodexWireTurnStartResponse) { self.init(turn: .init(wireValue: wireValue.turn)) diff --git a/Sources/SwiftASB/Public/CodexAppServer.swift b/Sources/SwiftASB/Public/CodexAppServer.swift index 6daa38f..58b1ded 100644 --- a/Sources/SwiftASB/Public/CodexAppServer.swift +++ b/Sources/SwiftASB/Public/CodexAppServer.swift @@ -7,8 +7,10 @@ public actor CodexAppServer { } private struct ThreadObservableActivityState: Sendable, Equatable { + var activeAutoReviewIDs: Set = [] var activeToolLikeItemIDs: Set = [] var activeMcpItemIDs: Set = [] + var autoReviewStatus: CodexThread.Dashboard.AutoReviewStatus = .idle var hasToolErrorResidue = false var hasMcpErrorResidue = false var hookRuns: [CodexThread.Dashboard.HookRun] = [] @@ -586,6 +588,10 @@ public actor CodexAppServer { globalMcpServerStatusPage } + internal func mcpServerStatusSnapshot(threadID: String) -> McpServerStatusPage { + threadMcpServerStatusPages[threadID] ?? .init(nextCursor: nil, servers: []) + } + /// Reads the app-server's current MCP server status snapshots. /// /// Omitting `request` sends an empty status-list request, leaving @@ -617,8 +623,7 @@ public actor CodexAppServer { internal func hydrateMcpServerSummaries(threadID: String) async -> [McpServerSummary] { do { - let page = try await readMcpServerStatusPage(.init(threadID: threadID)) - threadMcpServerStatusPages[threadID] = page + let page = try await refreshMcpServerStatusSnapshot(threadID: threadID) return mcpServerSummaries(forThreadStatusPage: page) } catch { return threadMcpServerStatusPages[threadID] @@ -626,6 +631,12 @@ public actor CodexAppServer { } } + internal func refreshMcpServerStatusSnapshot(threadID: String) async throws -> McpServerStatusPage { + let page = try await readMcpServerStatusPage(.init(threadID: threadID)) + threadMcpServerStatusPages[threadID] = page + return page + } + private func readMcpServerStatusPage( _ request: McpServerStatusListRequest ) async throws -> McpServerStatusPage { @@ -676,7 +687,9 @@ public actor CodexAppServer { } } - func installMCPServer(_ definition: CodexMCP.ServerDefinition) async throws -> CodexMCP.InstallResult { + func installMCPServer( + _ definition: SwiftASB.CodexExtensions.MCP.ServerDefinition + ) async throws -> SwiftASB.CodexExtensions.MCP.InstallResult { try requireInitialized(for: "config/batchWrite") try validateMCPServerName(definition.name) @@ -1418,8 +1431,8 @@ public actor CodexAppServer { } func listExtensionApps( - _ request: CodexExtensions.AppListRequest - ) async throws -> CodexExtensions.AppListPage { + _ request: SwiftASB.CodexExtensions.AppListRequest + ) async throws -> SwiftASB.CodexExtensions.AppListPage { try requireInitialized(for: "app/list") let requestID = CodexRPCRequestID.generated() @@ -1447,8 +1460,8 @@ public actor CodexAppServer { } func listExtensionSkills( - _ request: CodexExtensions.SkillListRequest - ) async throws -> CodexExtensions.SkillListSnapshot { + _ request: SwiftASB.CodexExtensions.SkillListRequest + ) async throws -> SwiftASB.CodexExtensions.SkillListSnapshot { try requireInitialized(for: "skills/list") if request.perCurrentDirectoryExtraUserRoots != nil { throw CodexAppServerError.invalidState( @@ -1479,8 +1492,8 @@ public actor CodexAppServer { } func listExtensionPlugins( - _ request: CodexExtensions.PluginListRequest - ) async throws -> CodexExtensions.PluginListSnapshot { + _ request: SwiftASB.CodexExtensions.PluginListRequest + ) async throws -> SwiftASB.CodexExtensions.PluginListSnapshot { try requireInitialized(for: "plugin/list") let requestID = CodexRPCRequestID.generated() @@ -1503,8 +1516,8 @@ public actor CodexAppServer { } func readExtensionPlugin( - _ request: CodexExtensions.PluginReadRequest - ) async throws -> CodexExtensions.PluginDetail { + _ request: SwiftASB.CodexExtensions.PluginReadRequest + ) async throws -> SwiftASB.CodexExtensions.PluginDetail { try requireInitialized(for: "plugin/read") let requestID = CodexRPCRequestID.generated() @@ -1530,7 +1543,7 @@ public actor CodexAppServer { } } - func listExtensionCollaborationModes() async throws -> CodexExtensions.CollaborationModeList { + func listExtensionCollaborationModes() async throws -> SwiftASB.CodexExtensions.CollaborationModeList { try requireInitialized(for: "collaborationMode/list") let requestID = CodexRPCRequestID.generated() @@ -1877,8 +1890,10 @@ public actor CodexAppServer { internal func threadObservableActivityState(threadID: String) -> CodexThread.Dashboard.ActivityState { let state = threadObservableActivityStates[threadID] ?? .init() return .init( + activeAutoReviewIDs: state.activeAutoReviewIDs, activeMcpItemIDs: state.activeMcpItemIDs, activeToolLikeItemIDs: state.activeToolLikeItemIDs, + autoReviewStatus: state.autoReviewStatus, hasMcpErrorResidue: state.hasMcpErrorResidue, hookRuns: state.hookRuns, hasToolErrorResidue: state.hasToolErrorResidue, @@ -2681,6 +2696,30 @@ public actor CodexAppServer { id: requestID, result: CodexProtocolFileChangeApprovalDecisionPayload(decision: decision.rawValue) ) + case let (.guardianDeniedAction(guardianRequest), .guardianDeniedAction(.approve)) + where outstandingRequest.kind == .guardianDeniedActionApproval: + let approvalRequestID = CodexRPCRequestID.generated() + payload = try protocolLayer.makeThreadApproveGuardianDeniedActionRequest( + id: approvalRequestID, + params: .init( + event: guardianRequest.event.wireValue, + threadID: guardianRequest.threadID + ) + ) + let responsePayload: Data + do { + responsePayload = try await transport.send(payload, id: approvalRequestID) + _ = try protocolLayer.decodeThreadApproveGuardianDeniedActionResponse( + responsePayload, + expectedID: approvalRequestID + ) + } catch { + throw CodexAppServerError.wrap(error, operation: "thread/approveGuardianDeniedAction") + } + outstandingInteractiveRequests.removeValue(forKey: requestID) + updateThreadObservableActivityForGuardianDeniedActionApproved(threadID: guardianRequest.threadID) + publishInteractiveRequestResolution(outstandingRequest, requestID: requestID) + return case (_, .permissions(let grantedPermissions)) where outstandingRequest.kind == .permissionsApproval: payload = try protocolLayer.makeServerResponse( id: requestID, @@ -2805,6 +2844,8 @@ public actor CodexAppServer { private func settleThreadObservableActivity(threadID: String) { var state = threadObservableActivityStates[threadID] ?? .init() + state.activeAutoReviewIDs.removeAll() + state.autoReviewStatus = .idle state.activeMcpItemIDs.removeAll() state.activeToolLikeItemIDs.removeAll() state.isCompactingThreadContext = false @@ -2880,6 +2921,13 @@ public actor CodexAppServer { switch event { case .appListUpdated, .skillsChanged: publishLibraryEvent(.appSnapshotsChanged) + case let .itemGuardianApprovalReviewStarted(notification): + updateThreadObservableActivityForAutoReviewStarted(notification) + case let .itemGuardianApprovalReviewCompleted(completion): + updateThreadObservableActivityForAutoReviewCompleted(completion.notification) + if let request = completion.publicDeniedActionApprovalRequest { + handleInteractiveApprovalRequest(request) + } case let .mcpServerStatusUpdated(notification): handleDiagnosticEvent(.init(wireValue: notification)) _ = try? await refreshGlobalMcpServerStatusSnapshot() @@ -3767,6 +3815,35 @@ public actor CodexAppServer { publishThreadObservableActivityState(threadID: threadID) } + private func updateThreadObservableActivityForAutoReviewStarted( + _ notification: CodexWireItemGuardianApprovalReviewStartedNotification + ) { + var state = threadObservableActivityStates[notification.threadID] ?? .init() + state.activeAutoReviewIDs.insert(notification.reviewID) + state.autoReviewStatus = .inProgress + threadObservableActivityStates[notification.threadID] = state + publishThreadObservableActivityState(threadID: notification.threadID) + } + + private func updateThreadObservableActivityForAutoReviewCompleted( + _ notification: CodexWireItemGuardianApprovalReviewCompletedNotification + ) { + var state = threadObservableActivityStates[notification.threadID] ?? .init() + state.activeAutoReviewIDs.remove(notification.reviewID) + state.autoReviewStatus = state.activeAutoReviewIDs.isEmpty + ? .init(wireValue: notification.review.status) + : .inProgress + threadObservableActivityStates[notification.threadID] = state + publishThreadObservableActivityState(threadID: notification.threadID) + } + + private func updateThreadObservableActivityForGuardianDeniedActionApproved(threadID: String) { + var state = threadObservableActivityStates[threadID] ?? .init() + state.autoReviewStatus = state.activeAutoReviewIDs.isEmpty ? .approved : .inProgress + threadObservableActivityStates[threadID] = state + publishThreadObservableActivityState(threadID: threadID) + } + private func updateThreadObservableActivityForHookRun( _ run: CodexWireHookRunSummary, turnID: String?, @@ -4202,6 +4279,13 @@ public actor CodexAppServer { return } + publishInteractiveRequestResolution(outstandingRequest, requestID: requestID) + } + + private func publishInteractiveRequestResolution( + _ outstandingRequest: OutstandingInteractiveRequest, + requestID: CodexRPCRequestID + ) { let resolution = CodexInteractiveRequestResolved( requestID: requestID, threadID: outstandingRequest.threadID, diff --git a/Sources/SwiftASB/Public/CodexInteractiveRequests.swift b/Sources/SwiftASB/Public/CodexInteractiveRequests.swift index f0042d6..40019bb 100644 --- a/Sources/SwiftASB/Public/CodexInteractiveRequests.swift +++ b/Sources/SwiftASB/Public/CodexInteractiveRequests.swift @@ -4,6 +4,7 @@ import Foundation public enum CodexInteractiveRequestKind: String, Sendable, Equatable { case commandExecutionApproval case fileChangeApproval + case guardianDeniedActionApproval case permissionsApproval case toolUserInput case mcpServerElicitation @@ -34,6 +35,7 @@ public struct CodexInteractiveRequestResolved: Sendable, Equatable { public enum CodexApprovalRequest: Sendable, Equatable { case commandExecution(CodexCommandExecutionApprovalRequest) case fileChange(CodexFileChangeApprovalRequest) + case guardianDeniedAction(CodexGuardianDeniedActionApprovalRequest) case permissions(CodexPermissionsApprovalRequest) public var threadID: String { @@ -42,6 +44,8 @@ public enum CodexApprovalRequest: Sendable, Equatable { request.threadID case let .fileChange(request): request.threadID + case let .guardianDeniedAction(request): + request.threadID case let .permissions(request): request.threadID } @@ -53,6 +57,8 @@ public enum CodexApprovalRequest: Sendable, Equatable { request.turnID case let .fileChange(request): request.turnID + case let .guardianDeniedAction(request): + request.turnID case let .permissions(request): request.turnID } @@ -64,6 +70,8 @@ public enum CodexApprovalRequest: Sendable, Equatable { .commandExecutionApproval case .fileChange: .fileChangeApproval + case .guardianDeniedAction: + .guardianDeniedActionApproval case .permissions: .permissionsApproval } @@ -75,6 +83,8 @@ public enum CodexApprovalRequest: Sendable, Equatable { request.requestID case let .fileChange(request): request.requestID + case let .guardianDeniedAction(request): + request.requestID case let .permissions(request): request.requestID } @@ -180,6 +190,154 @@ public struct CodexPermissionsApprovalRequest: Sendable, Equatable { } } +/// Guardian auto-review denial that can be explicitly approved by the caller. +public struct CodexGuardianDeniedActionApprovalRequest: Sendable, Equatable { + public let threadID: String + public let turnID: String + public let reviewID: String + public let targetItemID: String? + public let startedAtMS: Int? + public let completedAtMS: Int? + public let decisionSource: String + public let action: CodexGuardianApprovalReviewAction + public let review: CodexGuardianApprovalReview + public let event: CodexAppServer.JSONValue + + internal let requestID: CodexRPCRequestID + + internal init( + requestID: CodexRPCRequestID, + threadID: String, + turnID: String, + reviewID: String, + targetItemID: String?, + startedAtMS: Int?, + completedAtMS: Int?, + decisionSource: String, + action: CodexGuardianApprovalReviewAction, + review: CodexGuardianApprovalReview, + event: CodexAppServer.JSONValue + ) { + self.requestID = requestID + self.threadID = threadID + self.turnID = turnID + self.reviewID = reviewID + self.targetItemID = targetItemID + self.startedAtMS = startedAtMS + self.completedAtMS = completedAtMS + self.decisionSource = decisionSource + self.action = action + self.review = review + self.event = event + } +} + +/// Action reviewed by guardian auto-review. +public struct CodexGuardianApprovalReviewAction: Sendable, Equatable { + public enum ActionType: String, Sendable, Equatable { + case applyPatch, command, execve, mcpToolCall, networkAccess, requestPermissions + } + + public enum CommandSource: String, Sendable, Equatable { + case shell, unifiedExec + } + + public enum NetworkProtocol: String, Sendable, Equatable { + case http, https, socks5TCP, socks5UDP + } + + public let type: ActionType + public let command: String? + public let currentDirectoryPath: String? + public let source: CommandSource? + public let argv: [String]? + public let program: String? + public let files: [String]? + public let host: String? + public let port: Int? + public let networkProtocol: NetworkProtocol? + public let target: String? + public let connectorID: String? + public let connectorName: String? + public let server: String? + public let toolName: String? + public let toolTitle: String? + public let permissions: CodexPermissionProfile? + public let reason: String? + + internal init( + type: ActionType, + command: String?, + currentDirectoryPath: String?, + source: CommandSource?, + argv: [String]?, + program: String?, + files: [String]?, + host: String?, + port: Int?, + networkProtocol: NetworkProtocol?, + target: String?, + connectorID: String?, + connectorName: String?, + server: String?, + toolName: String?, + toolTitle: String?, + permissions: CodexPermissionProfile?, + reason: String? + ) { + self.type = type + self.command = command + self.currentDirectoryPath = currentDirectoryPath + self.source = source + self.argv = argv + self.program = program + self.files = files + self.host = host + self.port = port + self.networkProtocol = networkProtocol + self.target = target + self.connectorID = connectorID + self.connectorName = connectorName + self.server = server + self.toolName = toolName + self.toolTitle = toolTitle + self.permissions = permissions + self.reason = reason + } +} + +/// Guardian auto-review result attached to a reviewed action. +public struct CodexGuardianApprovalReview: Sendable, Equatable { + public enum RiskLevel: String, Sendable, Equatable { + case critical, high, low, medium + } + + public enum Status: String, Sendable, Equatable { + case aborted, approved, denied, inProgress, timedOut + } + + public enum UserAuthorization: String, Sendable, Equatable { + case high, low, medium, unknown + } + + public let rationale: String? + public let riskLevel: RiskLevel? + public let status: Status + public let userAuthorization: UserAuthorization? + + internal init( + rationale: String?, + riskLevel: RiskLevel?, + status: Status, + userAuthorization: UserAuthorization? + ) { + self.rationale = rationale + self.riskLevel = riskLevel + self.status = status + self.userAuthorization = userAuthorization + } +} + /// Structured command action attached to a command-execution approval request. public enum CodexCommandAction: Sendable, Equatable { case read(Read) @@ -233,9 +391,32 @@ public enum CodexCommandAction: Sendable, Equatable { /// Network-policy change proposed by Codex or returned by a caller. public struct CodexNetworkPolicyAmendment: Sendable, Equatable { /// Network-policy action requested for one host. - public enum Action: String, Sendable, Equatable { + public enum Action: Sendable, Equatable { case allow case deny + case unknown(String) + + public init(wireValue: String) { + switch wireValue { + case "allow": + self = .allow + case "deny": + self = .deny + default: + self = .unknown(wireValue) + } + } + + public var wireValue: String { + switch self { + case .allow: + "allow" + case .deny: + "deny" + case let .unknown(value): + value + } + } } public let action: Action @@ -463,6 +644,7 @@ public struct CodexMcpServerElicitationRequest: Sendable, Equatable { public enum CodexApprovalResponse: Sendable, Equatable { case commandExecution(CodexCommandExecutionApprovalResponse) case fileChange(CodexFileChangeApprovalResponse) + case guardianDeniedAction(CodexGuardianDeniedActionApprovalResponse) case permissions(CodexPermissionsApprovalResponse) } @@ -481,6 +663,11 @@ public enum CodexFileChangeApprovalResponse: String, Sendable, Equatable { case accept, acceptForSession, decline, cancel } +/// Guardian denied-action approval response sent by the caller. +public enum CodexGuardianDeniedActionApprovalResponse: Sendable, Equatable { + case approve +} + /// Permissions approval response sent by the caller. public struct CodexPermissionsApprovalResponse: Sendable, Equatable { /// Duration of a permissions grant. diff --git a/Sources/SwiftASB/Public/CodexMCP.swift b/Sources/SwiftASB/Public/CodexMCP.swift index 4cc38a6..ac305de 100644 --- a/Sources/SwiftASB/Public/CodexMCP.swift +++ b/Sources/SwiftASB/Public/CodexMCP.swift @@ -1,250 +1,256 @@ import Foundation -/// App-server-owned MCP configuration surface. -/// -/// `CodexMCP` exposes opinionated MCP server installation without exposing the -/// app-server's raw config editing API. -public struct CodexMCP: Sendable { - private let appServer: CodexAppServer - - init(appServer: CodexAppServer) { - self.appServer = appServer - } +@available(*, deprecated, renamed: "CodexExtensions.MCP") +public typealias CodexMCP = CodexExtensions.MCP - /// Transport-specific MCP server definition to install into Codex config. - public enum ServerDefinition: Sendable, Equatable { - case stdio(StdioServer) - case http(HTTPServer) - - /// Creates a stdio MCP server definition. - public static func stdio( - name: String, - command: String, - arguments: [String] = [], - currentDirectoryPath: String? = nil, - environment: [String: String] = [:], - inheritedEnvironmentVariables: [String] = [], - options: InstallOptions = .init() - ) -> Self { - .stdio( - .init( - name: name, - command: command, - arguments: arguments, - currentDirectoryPath: currentDirectoryPath, - environment: environment, - inheritedEnvironmentVariables: inheritedEnvironmentVariables, - options: options - ) - ) +public extension CodexExtensions { + /// App-server-owned MCP configuration surface. + /// + /// `CodexExtensions.MCP` exposes opinionated MCP server installation without + /// exposing the app-server's raw config editing API. + struct MCP: Sendable { + private let appServer: CodexAppServer + + init(appServer: CodexAppServer) { + self.appServer = appServer } - /// Creates a streamable HTTP MCP server definition. - public static func http( - name: String, - url: URL, - authorization: HTTPAuthorization? = nil, - headers: [String: String] = [:], - environmentHeaders: [String: String] = [:], - options: InstallOptions = .init() - ) -> Self { - .http( - .init( - name: name, - url: url, - authorization: authorization, - headers: headers, - environmentHeaders: environmentHeaders, - options: options + /// Transport-specific MCP server definition to install into Codex config. + public enum ServerDefinition: Sendable, Equatable { + case stdio(StdioServer) + case http(HTTPServer) + + /// Creates a stdio MCP server definition. + public static func stdio( + name: String, + command: String, + arguments: [String] = [], + currentDirectoryPath: String? = nil, + environment: [String: String] = [:], + inheritedEnvironmentVariables: [String] = [], + options: InstallOptions = .init() + ) -> Self { + .stdio( + .init( + name: name, + command: command, + arguments: arguments, + currentDirectoryPath: currentDirectoryPath, + environment: environment, + inheritedEnvironmentVariables: inheritedEnvironmentVariables, + options: options + ) ) - ) - } + } - public var name: String { - switch self { - case let .stdio(server): - server.name - case let .http(server): - server.name + /// Creates a streamable HTTP MCP server definition. + public static func http( + name: String, + url: URL, + authorization: HTTPAuthorization? = nil, + headers: [String: String] = [:], + environmentHeaders: [String: String] = [:], + options: InstallOptions = .init() + ) -> Self { + .http( + .init( + name: name, + url: url, + authorization: authorization, + headers: headers, + environmentHeaders: environmentHeaders, + options: options + ) + ) } - } - } - /// Stdio MCP server launch definition. - public struct StdioServer: Sendable, Equatable { - public var name: String - public var command: String - public var arguments: [String] - public var currentDirectoryPath: String? - public var environment: [String: String] - public var inheritedEnvironmentVariables: [String] - public var options: InstallOptions - - public init( - name: String, - command: String, - arguments: [String] = [], - currentDirectoryPath: String? = nil, - environment: [String: String] = [:], - inheritedEnvironmentVariables: [String] = [], - options: InstallOptions = .init() - ) { - self.name = name - self.command = command - self.arguments = arguments - self.currentDirectoryPath = currentDirectoryPath - self.environment = environment - self.inheritedEnvironmentVariables = inheritedEnvironmentVariables - self.options = options + public var name: String { + switch self { + case let .stdio(server): + server.name + case let .http(server): + server.name + } + } } - } - /// Streamable HTTP MCP server definition. - public struct HTTPServer: Sendable, Equatable { - public var name: String - public var url: URL - public var authorization: HTTPAuthorization? - public var headers: [String: String] - public var environmentHeaders: [String: String] - public var options: InstallOptions - - public init( - name: String, - url: URL, - authorization: HTTPAuthorization? = nil, - headers: [String: String] = [:], - environmentHeaders: [String: String] = [:], - options: InstallOptions = .init() - ) { - self.name = name - self.url = url - self.authorization = authorization - self.headers = headers - self.environmentHeaders = environmentHeaders - self.options = options + /// Stdio MCP server launch definition. + public struct StdioServer: Sendable, Equatable { + public var name: String + public var command: String + public var arguments: [String] + public var currentDirectoryPath: String? + public var environment: [String: String] + public var inheritedEnvironmentVariables: [String] + public var options: InstallOptions + + public init( + name: String, + command: String, + arguments: [String] = [], + currentDirectoryPath: String? = nil, + environment: [String: String] = [:], + inheritedEnvironmentVariables: [String] = [], + options: InstallOptions = .init() + ) { + self.name = name + self.command = command + self.arguments = arguments + self.currentDirectoryPath = currentDirectoryPath + self.environment = environment + self.inheritedEnvironmentVariables = inheritedEnvironmentVariables + self.options = options + } } - } - /// HTTP authorization source for an MCP server. - public enum HTTPAuthorization: Sendable, Equatable { - case bearerTokenEnvironmentVariable(String) - } + /// Streamable HTTP MCP server definition. + public struct HTTPServer: Sendable, Equatable { + public var name: String + public var url: URL + public var authorization: HTTPAuthorization? + public var headers: [String: String] + public var environmentHeaders: [String: String] + public var options: InstallOptions + + public init( + name: String, + url: URL, + authorization: HTTPAuthorization? = nil, + headers: [String: String] = [:], + environmentHeaders: [String: String] = [:], + options: InstallOptions = .init() + ) { + self.name = name + self.url = url + self.authorization = authorization + self.headers = headers + self.environmentHeaders = environmentHeaders + self.options = options + } + } - /// Shared install options for stdio and HTTP MCP servers. - public struct InstallOptions: Sendable, Equatable { - public var enabled: Bool - public var required: Bool? - public var startupTimeoutSeconds: Double? - public var toolTimeoutSeconds: Double? - public var toolPolicy: ToolPolicy - - public init( - enabled: Bool = true, - required: Bool? = nil, - startupTimeoutSeconds: Double? = nil, - toolTimeoutSeconds: Double? = nil, - toolPolicy: ToolPolicy = .automatic - ) { - self.enabled = enabled - self.required = required - self.startupTimeoutSeconds = startupTimeoutSeconds - self.toolTimeoutSeconds = toolTimeoutSeconds - self.toolPolicy = toolPolicy + /// HTTP authorization source for an MCP server. + public enum HTTPAuthorization: Sendable, Equatable { + case bearerTokenEnvironmentVariable(String) } - } - /// Tool exposure and approval policy for one MCP server. - public struct ToolPolicy: Sendable, Equatable { - public var enabledTools: [String]? - public var disabledTools: [String]? - public var defaultApprovalMode: ToolApprovalMode? - public var toolApprovalModes: [String: ToolApprovalMode] - - public init( - enabledTools: [String]? = nil, - disabledTools: [String]? = nil, - defaultApprovalMode: ToolApprovalMode? = nil, - toolApprovalModes: [String: ToolApprovalMode] = [:] - ) { - self.enabledTools = enabledTools - self.disabledTools = disabledTools - self.defaultApprovalMode = defaultApprovalMode - self.toolApprovalModes = toolApprovalModes + /// Shared install options for stdio and HTTP MCP servers. + public struct InstallOptions: Sendable, Equatable { + public var enabled: Bool + public var required: Bool? + public var startupTimeoutSeconds: Double? + public var toolTimeoutSeconds: Double? + public var toolPolicy: ToolPolicy + + public init( + enabled: Bool = true, + required: Bool? = nil, + startupTimeoutSeconds: Double? = nil, + toolTimeoutSeconds: Double? = nil, + toolPolicy: ToolPolicy = .automatic + ) { + self.enabled = enabled + self.required = required + self.startupTimeoutSeconds = startupTimeoutSeconds + self.toolTimeoutSeconds = toolTimeoutSeconds + self.toolPolicy = toolPolicy + } } - public static let automatic = Self() + /// Tool exposure and approval policy for one MCP server. + public struct ToolPolicy: Sendable, Equatable { + public var enabledTools: [String]? + public var disabledTools: [String]? + public var defaultApprovalMode: ToolApprovalMode? + public var toolApprovalModes: [String: ToolApprovalMode] + + public init( + enabledTools: [String]? = nil, + disabledTools: [String]? = nil, + defaultApprovalMode: ToolApprovalMode? = nil, + toolApprovalModes: [String: ToolApprovalMode] = [:] + ) { + self.enabledTools = enabledTools + self.disabledTools = disabledTools + self.defaultApprovalMode = defaultApprovalMode + self.toolApprovalModes = toolApprovalModes + } - public static func allowOnly(_ toolNames: [String]) -> Self { - .init(enabledTools: toolNames) - } + public static let automatic = Self() + + public static func allowOnly(_ toolNames: [String]) -> Self { + .init(enabledTools: toolNames) + } + + public static func deny(_ toolNames: [String]) -> Self { + .init(disabledTools: toolNames) + } - public static func deny(_ toolNames: [String]) -> Self { - .init(disabledTools: toolNames) + public static func defaultApproval(_ mode: ToolApprovalMode) -> Self { + .init(defaultApprovalMode: mode) + } } - public static func defaultApproval(_ mode: ToolApprovalMode) -> Self { - .init(defaultApprovalMode: mode) + /// Approval behavior for MCP tools. + public enum ToolApprovalMode: String, Sendable, Equatable { + case automatic = "auto" + case prompt + case approve } - } - /// Approval behavior for MCP tools. - public enum ToolApprovalMode: String, Sendable, Equatable { - case automatic = "auto" - case prompt - case approve - } + /// Result returned after installing an MCP server definition. + public struct InstallResult: Sendable, Equatable { + public enum WriteStatus: String, Sendable, Equatable { + case ok + case okOverridden + } - /// Result returned after installing an MCP server definition. - public struct InstallResult: Sendable, Equatable { - public enum WriteStatus: String, Sendable, Equatable { - case ok - case okOverridden + public let configFilePath: String + public let server: CodexAppServer.McpServerSummary? + public let status: WriteStatus + public let version: String } - public let configFilePath: String - public let server: CodexAppServer.McpServerSummary? - public let status: WriteStatus - public let version: String - } - - /// Installs an MCP server into user-level Codex configuration. - @discardableResult - public func install(_ definition: ServerDefinition) async throws -> InstallResult { - try await appServer.installMCPServer(definition) - } + /// Installs an MCP server into user-level Codex configuration. + @discardableResult + public func install(_ definition: ServerDefinition) async throws -> InstallResult { + try await appServer.installMCPServer(definition) + } - /// Returns SwiftASB's latest full global MCP server status snapshot. - public func statusSnapshot() async -> CodexAppServer.McpServerStatusPage { - await appServer.mcpServerStatusSnapshot() - } + /// Returns SwiftASB's latest full global MCP server status snapshot. + public func statusSnapshot() async -> CodexAppServer.McpServerStatusPage { + await appServer.mcpServerStatusSnapshot() + } - /// Reads one advertised MCP resource. - public func readResource( - _ request: CodexAppServer.McpResourceReadRequest - ) async throws -> CodexAppServer.McpResourceReadResult { - try await appServer.readMcpResource(request) - } + /// Reads one advertised MCP resource. + public func readResource( + _ request: CodexAppServer.McpResourceReadRequest + ) async throws -> CodexAppServer.McpResourceReadResult { + try await appServer.readMcpResource(request) + } - /// Reads one advertised MCP resource by server name and URI. - public func readResource( - server: String, - uri: String, - threadID: String? = nil - ) async throws -> CodexAppServer.McpResourceReadResult { - try await readResource( - .init(server: server, uri: uri, threadID: threadID) - ) + /// Reads one advertised MCP resource by server name and URI. + public func readResource( + server: String, + uri: String, + threadID: String? = nil + ) async throws -> CodexAppServer.McpResourceReadResult { + try await readResource( + .init(server: server, uri: uri, threadID: threadID) + ) + } } } public extension CodexAppServer { /// App-server-owned MCP configuration surface. - var mcp: CodexMCP { - CodexMCP(appServer: self) + @available(*, deprecated, message: "Use appServer.extensions.mcp or appServer.extensions.install(.mcp(...)) instead.") + var mcp: SwiftASB.CodexExtensions.MCP { + SwiftASB.CodexExtensions.MCP(appServer: self) } } -extension CodexMCP.ServerDefinition { +extension CodexExtensions.MCP.ServerDefinition { var configValue: CodexAppServer.JSONValue { switch self { case let .stdio(server): @@ -255,7 +261,7 @@ extension CodexMCP.ServerDefinition { } } -extension CodexMCP.StdioServer { +extension CodexExtensions.MCP.StdioServer { var configValue: CodexAppServer.JSONValue { var object: [String: CodexAppServer.JSONValue] = [ "command": .string(command), @@ -280,7 +286,7 @@ extension CodexMCP.StdioServer { } } -extension CodexMCP.HTTPServer { +extension CodexExtensions.MCP.HTTPServer { var configValue: CodexAppServer.JSONValue { var object: [String: CodexAppServer.JSONValue] = [ "enabled": .bool(options.enabled), @@ -306,7 +312,7 @@ extension CodexMCP.HTTPServer { } } -extension CodexMCP.InstallOptions { +extension CodexExtensions.MCP.InstallOptions { func addConfigFields(to object: inout [String: CodexAppServer.JSONValue]) { if let required { object["required"] = .bool(required) @@ -321,7 +327,7 @@ extension CodexMCP.InstallOptions { } } -extension CodexMCP.ToolPolicy { +extension CodexExtensions.MCP.ToolPolicy { func addConfigFields(to object: inout [String: CodexAppServer.JSONValue]) { if let enabledTools { object["enabled_tools"] = .array(enabledTools.map(CodexAppServer.JSONValue.string)) @@ -342,7 +348,7 @@ extension CodexMCP.ToolPolicy { } } -extension CodexMCP.InstallResult.WriteStatus { +extension CodexExtensions.MCP.InstallResult.WriteStatus { init(protocolValue: CodexProtocolConfigWriteStatus) { switch protocolValue { case .ok: diff --git a/Sources/SwiftASB/Public/CodexThread+Dashboard.swift b/Sources/SwiftASB/Public/CodexThread+Dashboard.swift index e06410f..59e16cd 100644 --- a/Sources/SwiftASB/Public/CodexThread+Dashboard.swift +++ b/Sources/SwiftASB/Public/CodexThread+Dashboard.swift @@ -78,9 +78,20 @@ extension CodexThread { case inProgress } + public enum AutoReviewStatus: String, Sendable, Equatable { + case aborted + case approved + case denied + case idle + case inProgress + case timedOut + } + internal struct ActivityState: Sendable, Equatable { + var activeAutoReviewIDs: Set = [] var activeMcpItemIDs: Set = [] var activeToolLikeItemIDs: Set = [] + var autoReviewStatus: AutoReviewStatus = .idle var hasMcpErrorResidue = false var hookRuns: [HookRun] = [] var hasToolErrorResidue = false @@ -92,6 +103,7 @@ extension CodexThread { public private(set) var isClosed: Bool public private(set) var isCompactingThreadContext: Bool public private(set) var goalTitle: String + public private(set) var autoReviewStatus: AutoReviewStatus public private(set) var latestDiagnostic: CodexDiagnosticEvent? public private(set) var latestTokenUsage: CodexThreadTokenUsageUpdated? public private(set) var mcpCallingStatus: ActivityStatus @@ -136,6 +148,7 @@ extension CodexThread { self.preview = initialInfo.preview self.status = initialInfo.status self.activityState = initialActivityState + self.autoReviewStatus = initialActivityState.autoReviewStatus self.hookRuns = initialActivityState.hookRuns self.isCompactingThreadContext = initialActivityState.isCompactingThreadContext self.mcpCallingStatus = Self.activityStatus( @@ -252,6 +265,7 @@ extension CodexThread { private func syncActivityPresentation() { hookRuns = activityState.hookRuns + autoReviewStatus = activityState.autoReviewStatus isCompactingThreadContext = activityState.isCompactingThreadContext toolCallingStatus = Self.activityStatus( activeIDs: activityState.activeToolLikeItemIDs, diff --git a/Sources/SwiftASB/Public/CodexThread.swift b/Sources/SwiftASB/Public/CodexThread.swift index 2cf3fa8..27ed9cb 100644 --- a/Sources/SwiftASB/Public/CodexThread.swift +++ b/Sources/SwiftASB/Public/CodexThread.swift @@ -182,6 +182,45 @@ public struct CodexThread: Sendable { } } + /// Thread-scoped MCP status and resource reads. + public struct MCP: Sendable { + private let appServer: CodexAppServer + private let threadID: String + + internal init(appServer: CodexAppServer, threadID: String) { + self.appServer = appServer + self.threadID = threadID + } + + /// Returns SwiftASB's latest MCP status snapshot for this thread. + public func statusSnapshot() async -> CodexAppServer.McpServerStatusPage { + await appServer.mcpServerStatusSnapshot(threadID: threadID) + } + + /// Refreshes and returns the app-server MCP status snapshot for this thread. + @discardableResult + public func refreshStatusSnapshot() async throws -> CodexAppServer.McpServerStatusPage { + try await appServer.refreshMcpServerStatusSnapshot(threadID: threadID) + } + + /// Reads one advertised MCP resource with this thread's context. + public func readResource( + _ request: CodexAppServer.McpResourceReadRequest + ) async throws -> CodexAppServer.McpResourceReadResult { + try await appServer.readMcpResource( + .init(server: request.server, uri: request.uri, threadID: threadID) + ) + } + + /// Reads one advertised MCP resource by server name and URI with this thread's context. + public func readResource( + server: String, + uri: String + ) async throws -> CodexAppServer.McpResourceReadResult { + try await readResource(.init(server: server, uri: uri)) + } + } + /// Request used to start a turn from this thread handle. public struct TurnStartRequest: Sendable, Equatable { public var approvalPolicy: CodexAppServer.ApprovalPolicy? @@ -319,6 +358,11 @@ public struct CodexThread: Sendable { /// app-server event feed fails unexpectedly. public let events: AsyncThrowingStream + /// Thread-scoped MCP status and resource surface. + public var mcp: MCP { + MCP(appServer: appServer, threadID: id) + } + private let appServer: CodexAppServer internal init( diff --git a/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md b/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md index e0474a8..6040b11 100644 --- a/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md +++ b/Sources/SwiftASB/SwiftASB.docc/AppWideCapabilities.md @@ -6,14 +6,14 @@ Discover model, MCP-server, MCP-resource, hook diagnostics, and model-capability Some app-server operations describe the connection rather than one conversation thread. SwiftASB exposes opinionated snapshots on ``CodexAppServer`` and observable companions so consumers can populate settings screens, model pickers, feature gates, MCP inspectors, hook diagnostics, and other app-wide views without orchestrating every app-server read. -Use ``CodexAppServer/makeInventory(configuration:)`` for routine app-wide UI that needs model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes. Inventory loads these snapshots on creation by default and refreshes when the app-server reports app-list, skill, or MCP-server status changes. +Use ``CodexExtensions/makeInventory(configuration:)`` for routine app-wide UI that needs model capabilities, global MCP summaries, hook diagnostics, apps, skills, plugins, and collaboration modes. Inventory loads these snapshots on creation by default and refreshes when the app-server reports app-list, skill, or MCP-server status changes. Use ``CodexAppServer/makeLibrary(configuration:)`` when these same model, MCP, and hook snapshots should live beside observable stored-thread lists. ``CodexAppServer/Library/refreshAppSnapshots()`` reads the current app-wide snapshots and publishes them as Library state; MCP status uses SwiftASB's owned cache, and hook diagnostics use Library thread `cwd` values unless configuration passes explicit hook current-directory paths. -Use ``CodexAppServer/listModels(_:)``, ``CodexAppServer/readModelCapabilities()``, ``CodexAppServer/listHooks(_:)``, and ``CodexAppServer/extensions`` as direct escape hatches when the caller intentionally owns pagination, one-off reads, or custom refresh timing. Use ``CodexMCP/statusSnapshot()`` to inspect SwiftASB's latest full MCP server catalog, including resources, resource templates, and tools. Use ``CodexMCP/readResource(server:uri:threadID:)`` to read one advertised MCP resource. ``CodexAppServer/CodexExtensions/upgradeMarketplace(_:)`` is the narrow maintenance mutation in this app-wide family: it upgrades an already-configured plugin marketplace through app-server `command/exec` and reports the operation through ``CodexAppServer/featureOperationEvents()``. +Use ``CodexAppServer/listModels(_:)``, ``CodexAppServer/readModelCapabilities()``, ``CodexAppServer/listHooks(_:)``, and ``CodexAppServer/extensions`` as direct escape hatches when the caller intentionally owns pagination, one-off reads, or custom refresh timing. Use ``CodexExtensions/MCP/statusSnapshot()`` to inspect SwiftASB's latest full MCP server catalog, including resources, resource templates, and tools. Use ``CodexExtensions/MCP/readResource(server:uri:threadID:)`` to read one advertised MCP resource. ``CodexExtensions/Plugins/upgradeMarketplace(_:)`` is the narrow maintenance mutation in this app-wide family: it upgrades an already-configured plugin marketplace through app-server `command/exec` and reports the operation through ``CodexAppServer/featureOperationEvents()``. ```swift -let inventory = try await appServer.makeInventory() +let inventory = try await appServer.extensions.makeInventory() let modelCapabilities = inventory.modelCapabilities let globalMCPServers = inventory.mcpServers @@ -32,9 +32,9 @@ let models = try await appServer.listModels( .init(limit: 50, includeHidden: false) ) -let statuses = await appServer.mcp.statusSnapshot() +let statuses = await appServer.extensions.mcp.statusSnapshot() -let resource = try await appServer.mcp.readResource( +let resource = try await appServer.extensions.mcp.readResource( server: "docs", uri: "docs://swiftasb/current" ) @@ -44,7 +44,7 @@ let hooks = try await appServer.listHooks( ) ``` -These direct requests are snapshots. If your UI needs refresh behavior, prefer ``CodexAppServer/Inventory`` so SwiftASB owns the refresh path and notification handling. Use ``CodexAppServer/Library`` instead when the same model, MCP, and hook snapshots should sit beside stored-thread lists. Inventory, Library, and thread dashboard MCP state intentionally use ``CodexAppServer/McpServerSummary`` instead of the full catalog so common SwiftUI surfaces can stay compact. +These direct requests are snapshots. If your UI needs refresh behavior, prefer ``CodexExtensions/Inventory`` so SwiftASB owns the refresh path and notification handling. Use ``CodexAppServer/Library`` instead when the same model, MCP, and hook snapshots should sit beside stored-thread lists. Inventory, Library, and thread dashboard MCP state intentionally use ``CodexAppServer/McpServerSummary`` instead of the full catalog so common SwiftUI surfaces can stay compact. ## Model Capabilities @@ -104,8 +104,8 @@ These types are public because a consumer can use them directly today. Other gen - ``CodexAppServer/mcpServerStatusSnapshot()`` - ``CodexAppServer/readMcpResource(_:)`` -- ``CodexMCP/statusSnapshot()`` -- ``CodexMCP/readResource(server:uri:threadID:)`` +- ``CodexExtensions/MCP/statusSnapshot()`` +- ``CodexExtensions/MCP/readResource(server:uri:threadID:)`` - ``CodexAppServer/McpServerStatusListRequest`` - ``CodexAppServer/McpServerStatusPage`` - ``CodexAppServer/McpServerStatus`` @@ -129,10 +129,10 @@ These types are public because a consumer can use them directly today. Other gen ### Extensions -- ``CodexAppServer/makeInventory(configuration:)`` -- ``CodexAppServer/Inventory`` +- ``CodexExtensions/makeInventory(configuration:)`` +- ``CodexExtensions/Inventory`` - ``CodexAppServer/extensions`` -- ``CodexAppServer/CodexExtensions`` -- ``CodexAppServer/CodexExtensions/upgradeMarketplace(_:)`` -- ``CodexAppServer/CodexExtensions/MarketplaceUpgradeRequest`` -- ``CodexAppServer/CodexExtensions/MarketplaceUpgradeResult`` +- ``CodexExtensions`` +- ``CodexExtensions/Plugins/upgradeMarketplace(_:)`` +- ``CodexExtensions/MarketplaceUpgradeRequest`` +- ``CodexExtensions/MarketplaceUpgradeResult`` diff --git a/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md b/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md index 45a618e..e6e013b 100644 --- a/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md +++ b/Sources/SwiftASB/SwiftASB.docc/CodexAppServer.md @@ -112,7 +112,7 @@ Set ``ThreadResumeRequest/excludeTurns`` or ``ThreadForkRequest/excludeTurns`` w - ``config`` - ``CodexConfig`` - ``mcp`` -- ``CodexMCP`` +- ``CodexExtensions/MCP`` - ``extensions`` - ``CodexExtensions`` - ``CodexExtensions/upgradeMarketplace(_:)`` diff --git a/Sources/SwiftASB/SwiftASB.docc/CodexExtensions.md b/Sources/SwiftASB/SwiftASB.docc/CodexExtensions.md index 6fd8a37..f3dcbdd 100644 --- a/Sources/SwiftASB/SwiftASB.docc/CodexExtensions.md +++ b/Sources/SwiftASB/SwiftASB.docc/CodexExtensions.md @@ -1,25 +1,31 @@ -# ``CodexAppServer/CodexExtensions`` +# ``CodexExtensions`` -Read app-server extension inventory. +Manage app-server extension inventory and extension-family helpers. ## Overview `CodexExtensions` is exposed through ``CodexAppServer/extensions``. Prefer -``CodexAppServer/makeInventory(configuration:)`` for routine app, skill, -plugin, and collaboration-mode UI so SwiftASB owns loading and notification -refresh. Use `CodexExtensions` directly when a caller intentionally owns +``makeInventory(configuration:)`` for routine MCP, app, skill, plugin, +collaboration-mode, model, and hook UI so SwiftASB owns loading and notification +refresh. Use the family surfaces directly when a caller intentionally owns pagination, custom refresh timing, or one selected plugin detail. ```swift -let apps = try await appServer.extensions.listApps() -let skills = try await appServer.extensions.listSkills( +let apps = try await appServer.extensions.apps.list() +let skills = try await appServer.extensions.skills.list( .init(currentDirectoryPaths: [thread.currentDirectoryPath]) ) ``` -The namespace is read-only. Plugin install, uninstall, marketplace mutation, and -skill config writes remain unpromoted until SwiftASB has a clearer permission -and user-review story for those operations. +Use the unified install surface for extension-family installs: + +```swift +try await appServer.extensions.install(.mcp(.stdio(name: "docs", command: "/usr/bin/env"))) +``` + +Plugin install, uninstall, marketplace mutation, and skill config writes remain +unpromoted until SwiftASB has a clearer permission and user-review story for +those operations. Plugin detail reads stay explicit because selecting one plugin to inspect is caller intent. Detail responses include app, skill, MCP server, and hook @@ -28,13 +34,21 @@ contributes without reading plugin files directly. ## Topics -### Reads +### Inventory + +- ``makeInventory(configuration:)`` +- ``Inventory`` + +### Families -- ``listApps(_:)`` -- ``listSkills(_:)`` -- ``listPlugins(_:)`` -- ``readPlugin(_:)`` -- ``listCollaborationModes()`` +- ``mcp`` +- ``apps`` +- ``skills`` +- ``plugins`` +- ``collaborationModes`` +- ``install(_:)`` +- ``InstallRequest`` +- ``InstallResult`` ### Apps diff --git a/Sources/SwiftASB/SwiftASB.docc/CodexInventory.md b/Sources/SwiftASB/SwiftASB.docc/CodexInventory.md index 9996b30..6d51bab 100644 --- a/Sources/SwiftASB/SwiftASB.docc/CodexInventory.md +++ b/Sources/SwiftASB/SwiftASB.docc/CodexInventory.md @@ -1,4 +1,4 @@ -# ``CodexAppServer/Inventory`` +# ``CodexExtensions/Inventory`` Observe app-wide Codex catalogs and diagnostics without issuing every list request yourself. @@ -11,7 +11,7 @@ app-server, then publishes compact Swift values for SwiftUI and other state-driven clients. ```swift -let inventory = try await appServer.makeInventory( +let inventory = try await appServer.extensions.makeInventory( configuration: .init( hookListCurrentDirectoryPaths: [workspaceURL.path], extensionCurrentDirectoryPaths: [workspaceURL.path] @@ -27,7 +27,7 @@ By default, Inventory loads once when it is created and refreshes again when the app-server reports app-list, skill, or MCP-server status changes. Use ``refresh()`` for an explicit reload. -Direct methods on ``CodexAppServer/CodexExtensions`` remain available for +Direct methods on ``CodexExtensions`` remain available for advanced callers that need one-off reads, custom pagination, or plugin-detail inspection. Routine app, skill, plugin, and collaboration-mode displays should prefer Inventory so SwiftASB owns refresh behavior. @@ -36,7 +36,7 @@ prefer Inventory so SwiftASB owns refresh behavior. ### Creating Inventory -- ``CodexAppServer/makeInventory(configuration:)`` +- ``CodexExtensions/makeInventory(configuration:)`` - ``Configuration`` - ``Phase`` diff --git a/Sources/SwiftASB/SwiftASB.docc/CodexMCP.md b/Sources/SwiftASB/SwiftASB.docc/CodexMCP.md index 0386631..994d1f0 100644 --- a/Sources/SwiftASB/SwiftASB.docc/CodexMCP.md +++ b/Sources/SwiftASB/SwiftASB.docc/CodexMCP.md @@ -1,21 +1,23 @@ -# ``CodexMCP`` +# ``CodexExtensions/MCP`` Install MCP servers and inspect MCP details through SwiftASB-owned helpers. ## Overview -`CodexMCP` is exposed through ``CodexAppServer/mcp``. Use it when a consumer -needs to install a stdio or streamable HTTP MCP server without editing -`config.toml` directly, inspect the full cached global MCP catalog, or read one -advertised MCP resource. +`CodexExtensions.MCP` is exposed through ``CodexAppServer/extensions`` as +``CodexExtensions/mcp``. Use it when a consumer needs to inspect the full cached +global MCP catalog or read one advertised MCP resource. For installs, prefer the +unified extension install surface. ```swift -try await appServer.mcp.install( - .stdio( - name: "docs", - command: "/usr/bin/env", - arguments: ["node", "/path/to/server.js"], - options: .init(toolPolicy: .automatic) +try await appServer.extensions.install( + .mcp( + .stdio( + name: "docs", + command: "/usr/bin/env", + arguments: ["node", "/path/to/server.js"], + options: .init(toolPolicy: .automatic) + ) ) ) ``` @@ -27,7 +29,7 @@ snapshot after the write succeeds. Server names must contain only ASCII letters, numbers, hyphens, or underscores because Codex's config-write method receives a dotted key path. -Use observable companions such as ``CodexAppServer/Inventory/mcpServers``, +Use observable companions such as ``CodexExtensions/Inventory/mcpServers``, ``CodexAppServer/Library/mcpServers``, and ``CodexThread/Dashboard/mcpServers`` for compact UI summaries. Those properties intentionally expose ``CodexAppServer/McpServerSummary`` values: name, scope, auth state, and @@ -36,6 +38,9 @@ the full cached catalog with resources, resource templates, and tool schemas. Use ``readResource(_:)`` or ``readResource(server:uri:threadID:)`` to read the contents for one advertised resource. +For thread-scoped MCP reads, use ``CodexThread/mcp``. It returns the cached MCP +status page for that thread and reads resources with the thread id filled in. + ## Topics ### Installing @@ -49,6 +54,11 @@ contents for one advertised resource. - ``readResource(_:)`` - ``readResource(server:uri:threadID:)`` +### Thread-Scoped Reads + +- ``CodexThread/MCP`` +- ``CodexThread/mcp`` + ### Server Definitions - ``ServerDefinition`` diff --git a/Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md b/Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md index de7d8f1..02eaa67 100644 --- a/Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md +++ b/Sources/SwiftASB/SwiftASB.docc/FeaturePermissionPolicy.md @@ -25,7 +25,7 @@ as selected-worktree Git status hydration stay quiet. Pass ``SwiftASBFeaturePolicy`` through ``CodexAppServer/Configuration`` to control app-server-owned convenience mutations. The default policy enables ``SwiftASBFeatureCategory/ID/extensionMaintenance``, which permits -``CodexAppServer/CodexExtensions/upgradeMarketplace(_:)`` for already-configured +``CodexExtensions/upgradeMarketplace(_:)`` for already-configured plugin marketplaces while leaving new installs, removals, sharing changes, and configuration writes out of scope. diff --git a/Sources/SwiftASB/SwiftASB.docc/GeneratedWireBoundary.md b/Sources/SwiftASB/SwiftASB.docc/GeneratedWireBoundary.md index 9aaae64..aa2adf5 100644 --- a/Sources/SwiftASB/SwiftASB.docc/GeneratedWireBoundary.md +++ b/Sources/SwiftASB/SwiftASB.docc/GeneratedWireBoundary.md @@ -24,10 +24,10 @@ Generated types are promoted to public wrappers only when there is a clear suppo Examples currently promoted through hand-owned public types include: - model catalog snapshots through ``CodexAppServer/listModels(_:)`` -- MCP server status snapshots through ``CodexMCP/statusSnapshot()`` -- MCP resource reads through ``CodexMCP/readResource(server:uri:threadID:)`` +- MCP server status snapshots through ``CodexExtensions/MCP/statusSnapshot()`` +- MCP resource reads through ``CodexExtensions/MCP/readResource(server:uri:threadID:)`` - hook diagnostics snapshots through ``CodexAppServer/listHooks(_:)`` -- app-wide observable inventory through ``CodexAppServer/makeInventory(configuration:)`` +- app-wide observable inventory through ``CodexExtensions/makeInventory(configuration:)`` - thread naming through ``CodexThread/setName(_:)`` - thread archive-state actions through ``CodexThread/archive()`` and ``CodexThread/unarchive()`` - thread metadata patches through ``CodexThread/updateMetadata(gitInfo:)`` diff --git a/Sources/SwiftASB/SwiftASB.docc/HandlingTurnProgressAndApprovals.md b/Sources/SwiftASB/SwiftASB.docc/HandlingTurnProgressAndApprovals.md index 7f14ac7..39f53df 100644 --- a/Sources/SwiftASB/SwiftASB.docc/HandlingTurnProgressAndApprovals.md +++ b/Sources/SwiftASB/SwiftASB.docc/HandlingTurnProgressAndApprovals.md @@ -57,6 +57,9 @@ func answerApproval( case .fileChange: try await turn.respond(to: request, with: .fileChange(.accept)) + case .guardianDeniedAction: + try await turn.respond(to: request, with: .guardianDeniedAction(.approve)) + case let .permissions(permissions): try await turn.respond( to: request, diff --git a/Sources/SwiftASB/SwiftASB.docc/SwiftASB.md b/Sources/SwiftASB/SwiftASB.docc/SwiftASB.md index 30ed84f..20574d5 100644 --- a/Sources/SwiftASB/SwiftASB.docc/SwiftASB.md +++ b/Sources/SwiftASB/SwiftASB.docc/SwiftASB.md @@ -9,12 +9,11 @@ It owns the subprocess transport, the JSON-RPC protocol boundary, typed request The public surface has three main handles: -- ``CodexAppServer`` owns the app-server process, initialization, app-wide observable inventory, capability snapshots, and stored-thread operations. +- ``CodexAppServer`` owns the app-server process, initialization, capability snapshots, and stored-thread operations. - ``CodexFS`` owns app-server-routed filesystem reads for sandboxed clients. - ``CodexWorkspace`` owns app-server-routed workspace permission selections and runtime permission facts. - ``CodexConfig`` owns app-server-routed configuration reads for sandboxed clients. -- ``CodexMCP`` owns opinionated MCP server installation through app-server configuration writes. -- ``CodexAppServer/CodexExtensions`` owns app, skill, plugin, and collaboration-mode inventory. +- ``CodexExtensions`` owns app, skill, plugin, MCP, collaboration-mode inventory, and the app-wide observable inventory. - ``SwiftASBFeaturePolicy`` owns SwiftASB convenience-feature categories, defaults, and host-access declarations. - ``SwiftASBFeatureOperationEvent`` reports SwiftASB-owned mutation operations in human-readable form. - ``CodexThread`` owns a single conversation thread, including new turns, thread-management actions, thread event streams, local history windows, and thread-scoped observable companions. @@ -40,12 +39,12 @@ Generated Codex wire types remain internal scaffolding. Public callers should us ### Primary Handles - ``CodexAppServer`` -- ``CodexAppServer/Inventory`` - ``CodexFS`` - ``CodexWorkspace`` - ``CodexConfig`` -- ``CodexMCP`` -- ``CodexAppServer/CodexExtensions`` +- ``CodexExtensions`` +- ``CodexExtensions/Inventory`` +- ``CodexExtensions/MCP`` - ``SwiftASBFeaturePolicy`` - ``SwiftASBFeatureCategory`` - ``SwiftASBFeatureOperationEvent`` diff --git a/Sources/SwiftASB/SwiftASB.docc/SwiftUIObservableCompanions.md b/Sources/SwiftASB/SwiftASB.docc/SwiftUIObservableCompanions.md index deb229b..bbcf7ba 100644 --- a/Sources/SwiftASB/SwiftASB.docc/SwiftUIObservableCompanions.md +++ b/Sources/SwiftASB/SwiftASB.docc/SwiftUIObservableCompanions.md @@ -6,7 +6,7 @@ Use dashboard, agenda, minimap, recent-file, and recent-command companions as cu SwiftASB's observable companions are ready-made `@Observable` state objects for SwiftUI surfaces. They are current-state mirrors over live streams and local history; they are not replayable protocol logs. -Use ``CodexAppServer/makeInventory(configuration:)`` for app-wide capability and extension inventory, ``CodexAppServer/makeLibrary(configuration:)`` for app-wide stored-thread lists, ``CodexThread/makeDashboard()`` for thread-level status, ``CodexThread/makeAgenda()`` for goal and plan state, ``CodexTurnHandle/minimap`` for one active turn, and the recent companions for completed turn, file, and command views. +Use ``CodexExtensions/makeInventory(configuration:)`` through ``CodexAppServer/extensions`` for app-wide capability and extension inventory, ``CodexAppServer/makeLibrary(configuration:)`` for app-wide stored-thread lists, ``CodexThread/makeDashboard()`` for thread-level status, ``CodexThread/makeAgenda()`` for goal and plan state, ``CodexTurnHandle/minimap`` for one active turn, and the recent companions for completed turn, file, and command views. ```swift import Observation @@ -18,7 +18,7 @@ final class ThreadInspectorModel { private let appServer: CodexAppServer private let thread: CodexThread - var inventory: CodexAppServer.Inventory? + var inventory: CodexExtensions.Inventory? var library: CodexAppServer.Library? var dashboard: CodexThread.Dashboard? var agenda: CodexThread.Agenda? @@ -34,7 +34,7 @@ final class ThreadInspectorModel { func start() async { do { - inventory = try await appServer.makeInventory() + inventory = try await appServer.extensions.makeInventory() library = try await appServer.makeLibrary( configuration: .init( sortedBy: .turnFinishedNewestFirst, @@ -114,7 +114,7 @@ Use ``CodexAppServer/Library/worktreeGroups`` when a sidebar needs repository/wo When `gitObservability` is enabled in ``SwiftASBFeaturePolicy``, selecting a library thread refreshes ``CodexAppServer/Library/selectedGitStatus`` for that worktree. The status snapshot combines Codex-reported branch, SHA, and origin metadata with sandboxed app-server `command/exec` facts for repository root, remotes, ahead/behind, and dirty/untracked counts. -Use ``CodexAppServer/Inventory`` when an app-wide UI needs model capabilities, MCP server summaries, hook diagnostics, apps, skills, plugins, and collaboration modes without also needing stored-thread lists. Use ``CodexAppServer/Library/refreshAppSnapshots()`` when model, MCP, and hook snapshots should sit beside the thread library. SwiftASB owns MCP status refresh and keeps summary lists current from startup and app-server status-change notifications. +Use ``CodexExtensions/Inventory`` when an app-wide UI needs model capabilities, MCP server summaries, hook diagnostics, apps, skills, plugins, and collaboration modes without also needing stored-thread lists. Use ``CodexAppServer/Library/refreshAppSnapshots()`` when model, MCP, and hook snapshots should sit beside the thread library. SwiftASB owns MCP status refresh and keeps summary lists current from startup and app-server status-change notifications. Use ``CodexThread/Agenda`` when a UI wants to show the thread's current task target, current accepted plan, and proposed plan text while Codex is still shaping it. SwiftASB reads the current goal, listens for goal changes, accepts authoritative plan snapshots, and treats experimental plan deltas as agenda state instead of making app code assemble them. @@ -136,8 +136,8 @@ Store the companion object itself in your view model. Do not copy its arrays int - ``CodexAppServer/makeLibrary(configuration:)`` - ``CodexAppServer/Library`` -- ``CodexAppServer/makeInventory(configuration:)`` -- ``CodexAppServer/Inventory`` +- ``CodexExtensions/makeInventory(configuration:)`` +- ``CodexExtensions/Inventory`` - ``CodexThread/makeDashboard()`` - ``CodexThread/Dashboard`` - ``CodexThread/makeAgenda()`` diff --git a/Sources/SwiftASB/SwiftASB.docc/ThreadHistoryAndObservables.md b/Sources/SwiftASB/SwiftASB.docc/ThreadHistoryAndObservables.md index f35b112..ca28e0b 100644 --- a/Sources/SwiftASB/SwiftASB.docc/ThreadHistoryAndObservables.md +++ b/Sources/SwiftASB/SwiftASB.docc/ThreadHistoryAndObservables.md @@ -64,6 +64,9 @@ Use the named cache-policy presets first: ``CodexThread/Dashboard`` summarizes thread-level current state such as active tool, MCP, hook, compaction activity, and plan or goal title text. ``CodexThread/Agenda`` owns the detailed goal and plan state for a thread, including the current goal, latest accepted plan, and proposed plan text assembled from live deltas. ``CodexTurnHandle/Minimap`` summarizes one active turn's command, file-edit, MCP, dynamic-tool, and collab-tool activity. +Use ``CodexThread/mcp`` when an inspector needs the thread-scoped MCP status +page or wants to read an MCP resource with the thread id filled in by SwiftASB. + Use these for "what is happening now" UI. Use history windows or closed turns for completed transcript data. These companions are not alternate event logs. `Dashboard` starts from the current thread snapshot and aggregate activity state, then mirrors later thread and activity updates. `Agenda` reads the current goal when it starts, then mirrors later goal and plan changes. `Minimap`, `RecentTurns`, `RecentFiles`, and `RecentCommands` listen to live feeds after they are created; command-output and file-output deltas that arrive before a recent companion exists are not replayed as delta events, though completed history can still be rehydrated from the local history store. @@ -93,6 +96,7 @@ These companions are not alternate event logs. `Dashboard` starts from the curre - ``CodexAppServer/Library`` - ``CodexThread/Dashboard`` +- ``CodexThread/MCP`` - ``CodexThread/Agenda`` - ``CodexThread/RecentTurns`` - ``CodexThread/RecentFiles`` diff --git a/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift b/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift index 02b5079..f5fa032 100644 --- a/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift +++ b/Tests/SwiftASBTests/Protocol/CodexAppServerProtocolTests.swift @@ -131,6 +131,39 @@ struct CodexAppServerProtocolTests { #expect(params["threadId"] as? String == "thread-123") } + @Test("encodes thread guardian denied-action approval requests") + func encodesThreadGuardianDeniedActionApprovalRequest() throws { + let payload = try protocolLayer.makeThreadApproveGuardianDeniedActionRequest( + id: .string("guardian-approval-1"), + params: .init( + event: .object([ + "assessmentId": .string("assessment-123"), + "status": .string("denied"), + ]), + threadID: "thread-123" + ) + ) + + let object = try #require(try JSONSerialization.jsonObject(with: payload) as? [String: Any]) + #expect(object["jsonrpc"] == nil) + #expect(object["method"] as? String == "thread/approveGuardianDeniedAction") + #expect(object["id"] as? String == "guardian-approval-1") + + let params = try #require(object["params"] as? [String: Any]) + #expect(params["threadId"] as? String == "thread-123") + let event = try #require(params["event"] as? [String: Any]) + #expect(event["assessmentId"] as? String == "assessment-123") + #expect(event["status"] as? String == "denied") + + let responsePayload = #"{"id":"guardian-approval-1","result":{}}"#.data(using: .utf8)! + #expect( + try protocolLayer.decodeThreadApproveGuardianDeniedActionResponse( + responsePayload, + expectedID: .string("guardian-approval-1") + ) == .init() + ) + } + @Test("encodes review/start with subject and placement") func encodesReviewStartWithSubjectAndPlacement() throws { let payload = try protocolLayer.makeReviewStartRequest( @@ -289,6 +322,93 @@ struct CodexAppServerProtocolTests { #expect((unwatchRequest["params"] as? [String: Any])?["watchId"] as? String == "watch-123") } + @Test("encodes internal fs mutation requests with app-server method names") + func encodesInternalFSMutationRequests() throws { + let writePayload = try protocolLayer.makeFSWriteFileRequest( + id: .string("write-file-1"), + params: .init( + dataBase64: Data("Hello".utf8).base64EncodedString(), + path: "/tmp/project/README.md" + ) + ) + let writeRequest = try #require(try JSONSerialization.jsonObject(with: writePayload) as? [String: Any]) + #expect(writeRequest["method"] as? String == "fs/writeFile") + let writeParams = try #require(writeRequest["params"] as? [String: Any]) + #expect(writeParams["dataBase64"] as? String == "SGVsbG8=") + #expect(writeParams["path"] as? String == "/tmp/project/README.md") + + let createDirectoryPayload = try protocolLayer.makeFSCreateDirectoryRequest( + id: .string("create-directory-1"), + params: .init(path: "/tmp/project/Sources/New", recursive: true) + ) + let createDirectoryRequest = try #require( + try JSONSerialization.jsonObject(with: createDirectoryPayload) as? [String: Any] + ) + #expect(createDirectoryRequest["method"] as? String == "fs/createDirectory") + let createDirectoryParams = try #require(createDirectoryRequest["params"] as? [String: Any]) + #expect(createDirectoryParams["path"] as? String == "/tmp/project/Sources/New") + #expect(createDirectoryParams["recursive"] as? Bool == true) + + let removePayload = try protocolLayer.makeFSRemoveRequest( + id: .string("remove-1"), + params: .init(force: false, path: "/tmp/project/obsolete.txt", recursive: false) + ) + let removeRequest = try #require(try JSONSerialization.jsonObject(with: removePayload) as? [String: Any]) + #expect(removeRequest["method"] as? String == "fs/remove") + let removeParams = try #require(removeRequest["params"] as? [String: Any]) + #expect(removeParams["force"] as? Bool == false) + #expect(removeParams["path"] as? String == "/tmp/project/obsolete.txt") + #expect(removeParams["recursive"] as? Bool == false) + + let copyPayload = try protocolLayer.makeFSCopyRequest( + id: .string("copy-1"), + params: .init( + destinationPath: "/tmp/project/copy.txt", + recursive: nil, + sourcePath: "/tmp/project/source.txt" + ) + ) + let copyRequest = try #require(try JSONSerialization.jsonObject(with: copyPayload) as? [String: Any]) + #expect(copyRequest["method"] as? String == "fs/copy") + let copyParams = try #require(copyRequest["params"] as? [String: Any]) + #expect(copyParams["destinationPath"] as? String == "/tmp/project/copy.txt") + #expect(copyParams["recursive"] == nil) + #expect(copyParams["sourcePath"] as? String == "/tmp/project/source.txt") + } + + @Test("decodes internal fs mutation responses") + func decodesInternalFSMutationResponses() throws { + let writePayload = #"{"id":"write-file-1","result":{}}"#.data(using: .utf8)! + let createDirectoryPayload = #"{"id":"create-directory-1","result":{}}"#.data(using: .utf8)! + let removePayload = #"{"id":"remove-1","result":{}}"#.data(using: .utf8)! + let copyPayload = #"{"id":"copy-1","result":{}}"#.data(using: .utf8)! + + #expect( + try protocolLayer.decodeFSWriteFileResponse( + writePayload, + expectedID: .string("write-file-1") + ) == .init() + ) + #expect( + try protocolLayer.decodeFSCreateDirectoryResponse( + createDirectoryPayload, + expectedID: .string("create-directory-1") + ) == .init() + ) + #expect( + try protocolLayer.decodeFSRemoveResponse( + removePayload, + expectedID: .string("remove-1") + ) == .init() + ) + #expect( + try protocolLayer.decodeFSCopyResponse( + copyPayload, + expectedID: .string("copy-1") + ) == .init() + ) + } + @Test("encodes loaded-thread list requests") func encodesLoadedThreadListRequest() throws { let payload = try protocolLayer.makeThreadLoadedListRequest( @@ -1216,7 +1336,7 @@ struct CodexAppServerProtocolTests { let mcpStatusEvent = try #require( try decodeEvent( - method: "mcpServer/status/updated", + method: "mcpServer/startupStatus/updated", payload: Data(#"{"error":null,"name":"calendar","status":"ready"}"#.utf8) ) ) @@ -1225,7 +1345,7 @@ struct CodexAppServerProtocolTests { #expect(notification.name == "calendar") #expect(notification.status == .ready) default: - Issue.record("Expected mcpServer/status/updated to decode into .mcpServerStatusUpdated.") + Issue.record("Expected mcpServer/startupStatus/updated to decode into .mcpServerStatusUpdated.") } let remoteStatusEvent = try #require( @@ -1377,6 +1497,61 @@ struct CodexAppServerProtocolTests { Issue.record("Expected item/completed to decode into .itemCompleted.") } + let autoReviewStartedPayload = Data( + #""" + {"action":{"command":"git status","cwd":"/tmp/project","source":"shell","type":"command"},"review":{"rationale":"Read-only repository inspection.","riskLevel":"low","status":"inProgress","userAuthorization":"medium"},"reviewId":"review-123","startedAtMs":1713350002000,"targetItemId":"item-command-1","threadId":"thread-123","turnId":"turn-123"} + """#.utf8 + ) + + let autoReviewStartedEvent = try #require( + try decodeEvent(method: "item/autoApprovalReview/started", payload: autoReviewStartedPayload) + ) + + switch autoReviewStartedEvent { + case let .itemGuardianApprovalReviewStarted(notification): + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + #expect(notification.targetItemID == "item-command-1") + #expect(notification.reviewID == "review-123") + #expect(notification.startedAtMS == 1_713_350_002_000) + #expect(notification.action.type == .command) + #expect(notification.action.command == "git status") + #expect(notification.action.source == .shell) + #expect(notification.review.status == .inProgress) + #expect(notification.review.riskLevel == .low) + default: + Issue.record("Expected item/autoApprovalReview/started to decode into .itemGuardianApprovalReviewStarted.") + } + + let autoReviewCompletedPayload = Data( + #""" + {"action":{"host":"api.example.com","port":443,"protocol":"https","target":"https://api.example.com","type":"networkAccess"},"completedAtMs":1713350003000,"decisionSource":"agent","review":{"rationale":"Network access is limited to the requested host.","riskLevel":"medium","status":"approved","userAuthorization":"high"},"reviewId":"review-124","startedAtMs":1713350002000,"targetItemId":null,"threadId":"thread-123","turnId":"turn-123"} + """#.utf8 + ) + + let autoReviewCompletedEvent = try #require( + try decodeEvent(method: "item/autoApprovalReview/completed", payload: autoReviewCompletedPayload) + ) + + switch autoReviewCompletedEvent { + case let .itemGuardianApprovalReviewCompleted(completion): + let notification = completion.notification + #expect(notification.threadID == "thread-123") + #expect(notification.turnID == "turn-123") + #expect(notification.targetItemID == nil) + #expect(notification.reviewID == "review-124") + #expect(notification.startedAtMS == 1_713_350_002_000) + #expect(notification.completedAtMS == 1_713_350_003_000) + #expect(notification.decisionSource == .agent) + #expect(notification.action.type == .networkAccess) + #expect(notification.action.host == "api.example.com") + #expect(notification.action.guardianApprovalReviewActionProtocol == .https) + #expect(notification.review.status == .approved) + #expect(notification.review.userAuthorization == .high) + default: + Issue.record("Expected item/autoApprovalReview/completed to decode into .itemGuardianApprovalReviewCompleted.") + } + let agentMessageDeltaPayload = Data( #"{"delta":"Hello there","itemId":"item-123","threadId":"thread-123","turnId":"turn-123"}"#.utf8 ) @@ -1544,7 +1719,7 @@ struct CodexAppServerProtocolTests { func decodesServerRequests() throws { let commandApprovalPayload = Data( #""" - {"command":"git status","commandActions":[{"command":"git status","type":"unknown"}],"cwd":"/tmp/project","itemId":"item-command-1","reason":"Needs approval to inspect repository state.","threadId":"thread-123","turnId":"turn-123"} + {"command":"git status","commandActions":[{"command":"git status","type":"unknown"}],"cwd":"/tmp/project","itemId":"item-command-1","proposedNetworkPolicyAmendments":[{"action":"audit","host":"example.com"}],"reason":"Needs approval to inspect repository state.","threadId":"thread-123","turnId":"turn-123"} """#.utf8 ) @@ -1565,6 +1740,9 @@ struct CodexAppServerProtocolTests { #expect(request.turnID == "turn-123") #expect(request.itemID == "item-command-1") #expect(request.command == "git status") + let amendment = try #require(request.proposedNetworkPolicyAmendments?.first) + #expect(amendment.publicValue.action == .unknown("audit")) + #expect(amendment.publicValue.host == "example.com") default: Issue.record("Expected command approval server request to decode into .commandExecutionApprovalRequested.") } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerCompanionSurfaceTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerCompanionSurfaceTests.swift index 4c0d802..4cd42e7 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerCompanionSurfaceTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerCompanionSurfaceTests.swift @@ -71,6 +71,7 @@ extension CodexAppServerTests { #expect(dashboard.isClosed == false) #expect(dashboard.goalTitle == "") #expect(dashboard.planTitle == "") + #expect(dashboard.autoReviewStatus == .idle) #expect(dashboard.latestTokenUsage == nil) await transport.emitHookStarted( @@ -96,6 +97,31 @@ extension CodexAppServerTests { #expect(dashboard.hookRuns[0].status == .running) #expect(dashboard.hookRuns[0].turnID == turnHandle.turn.id) + await transport.emitGuardianAutoReviewStarted( + threadID: thread.id, + turnID: turnHandle.turn.id, + reviewID: "review-guardian-1", + targetItemID: "item-command-1" + ) + + await waitForObservableState { + dashboard.autoReviewStatus == .inProgress + } + + await transport.emitGuardianAutoReviewCompleted( + threadID: thread.id, + turnID: turnHandle.turn.id, + reviewID: "review-guardian-1", + status: "denied", + targetItemID: "item-command-1" + ) + + await waitForObservableState { + dashboard.autoReviewStatus == .denied + } + + #expect(dashboard.autoReviewStatus == .denied) + await transport.emitItemCompleted( threadID: thread.id, turnID: turnHandle.turn.id, @@ -180,6 +206,7 @@ extension CodexAppServerTests { #expect(dashboard.latestTokenUsage?.total.totalTokens == 650) #expect(dashboard.toolCallingStatus == .errored) #expect(dashboard.mcpCallingStatus == .idle) + #expect(dashboard.autoReviewStatus == .idle) await client.stop() } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerFileSystemTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerFileSystemTests.swift index 64f5550..3424998 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerFileSystemTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerFileSystemTests.swift @@ -294,7 +294,7 @@ extension CodexAppServerTests { ) ) - let apps = try await client.extensions.listApps(.init(cursor: "apps-cursor", limit: 1, forceRefetch: true, threadID: "thread-123")) + let apps = try await client.extensions.apps.list(.init(cursor: "apps-cursor", limit: 1, forceRefetch: true, threadID: "thread-123")) #expect(apps.apps.map(\.name) == ["GitHub"]) #expect(apps.apps.first?.branding?.isDiscoverableApp == true) #expect(apps.apps.first?.branding?.category == "developer-tools") @@ -306,7 +306,7 @@ extension CodexAppServerTests { #expect(apps.apps.first?.versionID == "version-123") #expect(apps.nextCursor == "apps-next") - let skills = try await client.extensions.listSkills( + let skills = try await client.extensions.skills.list( .init( currentDirectoryPaths: ["/tmp/project"], forceReload: true @@ -318,7 +318,7 @@ extension CodexAppServerTests { #expect(skills.entries.first?.skills.first?.displayName == "Swift Package Workflow") #expect(skills.entries.first?.skills.first?.shortDescription == "SwiftPM workflow from interface") - let plugins = try await client.extensions.listPlugins(.init(currentDirectoryPaths: ["/tmp/project"])) + let plugins = try await client.extensions.plugins.list(.init(currentDirectoryPaths: ["/tmp/project"])) #expect(plugins.featuredPluginIDs == ["github"]) #expect(plugins.marketplaceLoadErrors.first?.marketplacePath == "/tmp/bad-marketplace.json") #expect(plugins.marketplaces.first?.displayName == "Curated") @@ -328,7 +328,7 @@ extension CodexAppServerTests { #expect(plugins.marketplaces.first?.plugins.last?.sourceKind == .local) #expect(plugins.marketplaces.first?.plugins.last?.sourcePath == "/tmp/plugins/local-plugin") - let plugin = try await client.extensions.readPlugin(.init(pluginName: "GitHub", remoteMarketplaceName: "openai-curated")) + let plugin = try await client.extensions.plugins.read(.init(pluginName: "GitHub", remoteMarketplaceName: "openai-curated")) #expect(plugin.marketplaceName == "openai-curated") #expect(plugin.marketplacePath == "/tmp/marketplaces/openai-curated.json") #expect(plugin.description == "GitHub plugin detail fixture.") @@ -342,7 +342,7 @@ extension CodexAppServerTests { #expect(plugin.summary.sourceSHA == "abc123") #expect(plugin.summary.sourceURL == "https://github.com/openai/github-plugin") - let modes = try await client.extensions.listCollaborationModes() + let modes = try await client.extensions.collaborationModes.list() #expect(modes.modes.first?.kind == .plan) #expect(modes.modes.first?.reasoningEffort == .medium) @@ -399,7 +399,7 @@ extension CodexAppServerTests { ) ) - let result = try await client.extensions.upgradeMarketplace( + let result = try await client.extensions.plugins.upgradeMarketplace( .init( marketplaceName: "openai-curated", currentDirectoryPaths: ["/tmp/project"], @@ -461,7 +461,7 @@ extension CodexAppServerTests { ) await #expect(throws: CodexAppServerError.self) { - try await client.extensions.upgradeMarketplace( + try await client.extensions.plugins.upgradeMarketplace( .init(marketplaceName: "openai-curated") ) } @@ -493,7 +493,7 @@ extension CodexAppServerTests { ) await #expect(throws: CodexAppServerError.self) { - try await client.extensions.upgradeMarketplace( + try await client.extensions.plugins.upgradeMarketplace( .init(marketplaceName: "openai-curated") ) } @@ -522,7 +522,7 @@ extension CodexAppServerTests { ) do { - _ = try await client.extensions.listSkills( + _ = try await client.extensions.skills.list( .init( currentDirectoryPaths: ["/tmp/project"], perCurrentDirectoryExtraUserRoots: [ diff --git a/Tests/SwiftASBTests/Public/CodexAppServerInventoryTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerInventoryTests.swift index 78fa01e..8bbd931 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerInventoryTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerInventoryTests.swift @@ -14,7 +14,7 @@ extension CodexAppServerTests { .init(clientInfo: .init(name: "SwiftASBTests", title: "SwiftASB Tests", version: "0.1.0")) ) - let inventory = try await client.makeInventory( + let inventory = try await client.extensions.makeInventory( configuration: .init( hookListCurrentDirectoryPaths: ["/tmp/project"], extensionCurrentDirectoryPaths: ["/tmp/project"], @@ -76,7 +76,7 @@ extension CodexAppServerTests { .init(clientInfo: .init(name: "SwiftASBTests", title: "SwiftASB Tests", version: "0.1.0")) ) - let inventory = try await client.makeInventory( + let inventory = try await client.extensions.makeInventory( configuration: .init(loadsOnCreation: false) ) @@ -105,7 +105,7 @@ extension CodexAppServerTests { .init(clientInfo: .init(name: "SwiftASBTests", title: "SwiftASB Tests", version: "0.1.0")) ) - let inventory = try await client.makeInventory( + let inventory = try await client.extensions.makeInventory( configuration: .init(loadsOnCreation: false) ) await inventory.refresh() @@ -150,7 +150,7 @@ extension CodexAppServerTests { .init(clientInfo: .init(name: "SwiftASBTests", title: "SwiftASB Tests", version: "0.1.0")) ) - let inventory = try await client.makeInventory( + let inventory = try await client.extensions.makeInventory( configuration: .init(loadsOnCreation: false) ) diff --git a/Tests/SwiftASBTests/Public/CodexAppServerLiveApprovalProbeTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerLiveApprovalProbeTests.swift index 617fe34..90db35c 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerLiveApprovalProbeTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerLiveApprovalProbeTests.swift @@ -70,6 +70,9 @@ extension CodexAppServerLiveIntegrationTests { case let .fileChange(fileRequest): #expect(fileRequest.threadID == approvalThread.id) #expect(fileRequest.turnID == approvalTurn.turn.id) + case let .guardianDeniedAction(guardianRequest): + #expect(guardianRequest.threadID == approvalThread.id) + #expect(guardianRequest.turnID == approvalTurn.turn.id) case let .permissions(permissionsRequest): #expect(permissionsRequest.threadID == approvalThread.id) #expect(permissionsRequest.turnID == approvalTurn.turn.id) diff --git a/Tests/SwiftASBTests/Public/CodexAppServerLiveIntegrationTestSupport.swift b/Tests/SwiftASBTests/Public/CodexAppServerLiveIntegrationTestSupport.swift index 9bf81a5..7a49f53 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerLiveIntegrationTestSupport.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerLiveIntegrationTestSupport.swift @@ -2186,6 +2186,8 @@ func acceptanceResponse(for request: CodexApprovalRequest) -> CodexApprovalRespo return .commandExecution(.accept) case .fileChange: return .fileChange(.accept) + case .guardianDeniedAction: + return .guardianDeniedAction(.approve) case let .permissions(permissionsRequest): return .permissions( .init( diff --git a/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift b/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift index 3d1bb35..f8cbc87 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerTestSupport.swift @@ -361,6 +361,8 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { ) case "thread/archive": return responsePayload(id: id, result: [:]) + case "thread/approveGuardianDeniedAction": + return responsePayload(id: id, result: [:]) case "thread/unarchive": return responsePayload( id: id, @@ -1372,6 +1374,72 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { ) } + func emitGuardianAutoReviewStarted( + threadID: String, + turnID: String, + reviewID: String, + targetItemID: String? + ) { + let payload = payloadObject([ + "action": [ + "command": "git status", + "cwd": "/tmp/project", + "source": "shell", + "type": "command", + ], + "review": [ + "rationale": "Read-only repository inspection.", + "riskLevel": "low", + "status": "inProgress", + "userAuthorization": "medium", + ], + "reviewId": reviewID, + "startedAtMs": 1_713_350_002_000, + "targetItemId": (targetItemID as Any?) ?? NSNull(), + "threadId": threadID, + "turnId": turnID, + ]) + + serverEventContinuation?.yield( + .notification(method: "item/autoApprovalReview/started", payload: payload) + ) + } + + func emitGuardianAutoReviewCompleted( + threadID: String, + turnID: String, + reviewID: String, + status: String, + targetItemID: String? + ) { + let payload = payloadObject([ + "action": [ + "host": "api.example.com", + "port": 443, + "protocol": "https", + "target": "https://api.example.com", + "type": "networkAccess", + ], + "completedAtMs": 1_713_350_003_000, + "decisionSource": "agent", + "review": [ + "rationale": "Network access is limited to the requested host.", + "riskLevel": "medium", + "status": status, + "userAuthorization": "high", + ], + "reviewId": reviewID, + "startedAtMs": 1_713_350_002_000, + "targetItemId": (targetItemID as Any?) ?? NSNull(), + "threadId": threadID, + "turnId": turnID, + ]) + + serverEventContinuation?.yield( + .notification(method: "item/autoApprovalReview/completed", payload: payload) + ) + } + func emitToolUserInputRequest( requestID: CodexRPCRequestID, threadID: String, @@ -1897,7 +1965,7 @@ actor FakeCodexAppServerTransport: CodexAppServerTransporting { ]) serverEventContinuation?.yield( - .notification(method: "mcpServer/status/updated", payload: payload) + .notification(method: "mcpServer/startupStatus/updated", payload: payload) ) } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerThreadManagementTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerThreadManagementTests.swift index 84f0fb0..61ec26f 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerThreadManagementTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerThreadManagementTests.swift @@ -31,12 +31,38 @@ extension CodexAppServerTests { #expect(dashboard.mcpServers.map(\.name) == ["calendar", "thread_notes"]) #expect(dashboard.mcpServers.map(\.scope) == [.global, .thread]) + let cachedThreadMcpPage = await thread.mcp.statusSnapshot() + #expect(cachedThreadMcpPage.servers.map(\.name) == ["calendar", "thread_notes"]) + + let statusRequestCountBeforeRefresh = await transport.requestPayloads(for: "mcpServerStatus/list").count + let refreshedThreadMcpPage = try await thread.mcp.refreshStatusSnapshot() + let statusRequestCountAfterRefresh = await transport.requestPayloads(for: "mcpServerStatus/list").count + #expect(statusRequestCountAfterRefresh > statusRequestCountBeforeRefresh) + #expect(refreshedThreadMcpPage.servers.map(\.name) == ["calendar", "thread_notes"]) + + let resource = try await thread.mcp.readResource( + server: "calendar", + uri: "calendar://events/today" + ) + #expect(resource.contents.first?.uri == "calendar://events/today") + let requests = await transport.requestPayloads(for: "mcpServerStatus/list") let lastPayload = try #require(requests.last) let lastRequest = try #require(try JSONSerialization.jsonObject(with: lastPayload) as? [String: Any]) let lastParams = try #require(lastRequest["params"] as? [String: Any]) #expect(lastParams["threadId"] as? String == thread.id) + let resourceRequests = await transport.requestPayloads(for: "mcpServer/resource/read") + let resourcePayload = try #require(resourceRequests.last) + let resourceRequest = try #require(try JSONSerialization.jsonObject(with: resourcePayload) as? [String: Any]) + let resourceParams = try #require(resourceRequest["params"] as? [String: Any]) + #expect(resourceParams["server"] as? String == "calendar") + #expect(resourceParams["uri"] as? String == "calendar://events/today") + #expect(resourceParams["threadId"] as? String == thread.id) + + let cachedThreadMcpPageAfterRefresh = await thread.mcp.statusSnapshot() + #expect(cachedThreadMcpPageAfterRefresh.servers.map(\.name) == ["calendar", "thread_notes"]) + await client.stop() } diff --git a/Tests/SwiftASBTests/Public/CodexAppServerTurnInteractionTests.swift b/Tests/SwiftASBTests/Public/CodexAppServerTurnInteractionTests.swift index b1da282..0e6cec8 100644 --- a/Tests/SwiftASBTests/Public/CodexAppServerTurnInteractionTests.swift +++ b/Tests/SwiftASBTests/Public/CodexAppServerTurnInteractionTests.swift @@ -308,6 +308,94 @@ extension CodexAppServerTests { await client.stop() } + @Test("surfaces denied guardian auto-review as an approval request") + func surfacesDeniedGuardianAutoReviewAsApprovalRequest() async throws { + let transport = FakeCodexAppServerTransport() + let client = CodexAppServer(transport: transport) + + try await client.start() + _ = try await client.initialize( + .init( + clientInfo: .init( + name: "SwiftASBTests", + title: "SwiftASB Tests", + version: "0.1.0" + ) + ) + ) + + let thread = try await client.startThread( + .init( + currentDirectoryPath: "/tmp/project", + model: "gpt-5.4", + modelProvider: "openai" + ) + ) + + let turnHandle = try await thread.startTextTurn("Fetch the protected endpoint.") + var iterator = turnHandle.events.makeAsyncIterator() + await transport.emitGuardianAutoReviewCompleted( + threadID: thread.id, + turnID: turnHandle.turn.id, + reviewID: "review-guardian-1", + status: "denied", + targetItemID: nil + ) + + let firstEvent = try await iterator.next() + guard case let .approvalRequested(approvalRequest)? = firstEvent else { + Issue.record("Expected denied guardian auto-review to surface as .approvalRequested.") + await client.stop() + return + } + + guard case let .guardianDeniedAction(guardianRequest) = approvalRequest else { + Issue.record("Expected a guardian denied-action approval request.") + await client.stop() + return + } + + #expect(guardianRequest.threadID == thread.id) + #expect(guardianRequest.turnID == turnHandle.turn.id) + #expect(guardianRequest.reviewID == "review-guardian-1") + #expect(guardianRequest.review.status == .denied) + #expect(guardianRequest.review.riskLevel == .medium) + #expect(guardianRequest.action.type == .networkAccess) + #expect(guardianRequest.action.host == "api.example.com") + #expect(guardianRequest.action.networkProtocol == .https) + + try await turnHandle.respond( + to: approvalRequest, + with: .guardianDeniedAction(.approve) + ) + + let secondEvent = try await iterator.next() + guard case let .serverRequestResolved(resolution)? = secondEvent else { + Issue.record("Expected approving a guardian denied action to resolve the approval request.") + await client.stop() + return + } + + #expect(resolution.threadID == thread.id) + #expect(resolution.turnID == turnHandle.turn.id) + #expect(resolution.kind == .guardianDeniedActionApproval) + + let requestPayloads = await transport.requestPayloads(for: "thread/approveGuardianDeniedAction") + #expect(requestPayloads.count == 1) + let requestObject = try #require( + try JSONSerialization.jsonObject(with: requestPayloads[0]) as? [String: Any] + ) + #expect(requestObject["method"] as? String == "thread/approveGuardianDeniedAction") + let params = try #require(requestObject["params"] as? [String: Any]) + #expect(params["threadId"] as? String == thread.id) + let event = try #require(params["event"] as? [String: Any]) + #expect(event["reviewId"] as? String == "review-guardian-1") + let review = try #require(event["review"] as? [String: Any]) + #expect(review["status"] as? String == "denied") + + await client.stop() + } + @Test("rejects interactive approval responses sent through the wrong surface") func rejectsApprovalResponsesSentThroughWrongSurface() async throws { let transport = FakeCodexAppServerTransport() diff --git a/Tests/SwiftASBTests/Public/CodexMCPTests.swift b/Tests/SwiftASBTests/Public/CodexMCPTests.swift index 5c52734..87c4e5f 100644 --- a/Tests/SwiftASBTests/Public/CodexMCPTests.swift +++ b/Tests/SwiftASBTests/Public/CodexMCPTests.swift @@ -19,27 +19,34 @@ extension CodexAppServerTests { ) ) - let result = try await client.mcp.install( - .stdio( - name: "docs", - command: "/usr/bin/env", - arguments: ["node", "/tmp/docs-server.js"], - currentDirectoryPath: "/tmp/docs", - environment: ["DOCS_MODE": "test"], - inheritedEnvironmentVariables: ["OPENAI_API_KEY"], - options: .init( - enabled: true, - required: false, - startupTimeoutSeconds: 5, - toolTimeoutSeconds: 30, - toolPolicy: .init( - enabledTools: ["search"], - defaultApprovalMode: .prompt, - toolApprovalModes: ["write": .approve] + let installResult = try await client.extensions.install( + .mcp( + .stdio( + name: "docs", + command: "/usr/bin/env", + arguments: ["node", "/tmp/docs-server.js"], + currentDirectoryPath: "/tmp/docs", + environment: ["DOCS_MODE": "test"], + inheritedEnvironmentVariables: ["OPENAI_API_KEY"], + options: .init( + enabled: true, + required: false, + startupTimeoutSeconds: 5, + toolTimeoutSeconds: 30, + toolPolicy: .init( + enabledTools: ["search"], + defaultApprovalMode: .prompt, + toolApprovalModes: ["write": .approve] + ) ) ) ) ) + let result: CodexExtensions.MCP.InstallResult + switch installResult { + case let .mcp(mcpResult): + result = mcpResult + } #expect(result.configFilePath == "/Users/example/.codex/config.toml") #expect(result.status == .ok) @@ -90,16 +97,18 @@ extension CodexAppServerTests { ) ) - try await client.mcp.install( - .http( - name: "search", - url: try #require(URL(string: "https://example.com/mcp")), - authorization: .bearerTokenEnvironmentVariable("SEARCH_MCP_TOKEN"), - headers: ["X-Static": "yes"], - environmentHeaders: ["Authorization": "SEARCH_MCP_AUTH_HEADER"], - options: .init( - enabled: false, - toolPolicy: .deny(["delete"]) + try await client.extensions.install( + .mcp( + .http( + name: "search", + url: try #require(URL(string: "https://example.com/mcp")), + authorization: .bearerTokenEnvironmentVariable("SEARCH_MCP_TOKEN"), + headers: ["X-Static": "yes"], + environmentHeaders: ["Authorization": "SEARCH_MCP_AUTH_HEADER"], + options: .init( + enabled: false, + toolPolicy: .deny(["delete"]) + ) ) ) ) @@ -138,8 +147,8 @@ extension CodexAppServerTests { ) await #expect(throws: CodexAppServerError.self) { - try await client.mcp.install( - .stdio(name: "bad.name", command: "/usr/bin/env") + try await client.extensions.install( + .mcp(.stdio(name: "bad.name", command: "/usr/bin/env")) ) } @@ -165,12 +174,12 @@ extension CodexAppServerTests { ) ) - let snapshot = await client.mcp.statusSnapshot() + let snapshot = await client.extensions.mcp.statusSnapshot() #expect(snapshot.servers.map(\.name) == ["calendar"]) #expect(snapshot.servers.first?.resources.map(\.uri) == ["calendar://events/today"]) #expect(snapshot.servers.first?.tools.keys.sorted() == ["list_events"]) - let resource = try await client.mcp.readResource( + let resource = try await client.extensions.mcp.readResource( server: "calendar", uri: "calendar://events/today" ) diff --git a/Tests/SwiftASBTests/Transport/CodexRPCEnvelopeTests.swift b/Tests/SwiftASBTests/Transport/CodexRPCEnvelopeTests.swift index a6048ac..7e53ea2 100644 --- a/Tests/SwiftASBTests/Transport/CodexRPCEnvelopeTests.swift +++ b/Tests/SwiftASBTests/Transport/CodexRPCEnvelopeTests.swift @@ -70,6 +70,15 @@ struct CodexRPCEnvelopeTests { ) } + @Test("preserves numeric request IDs at the Swift Int boundary") + func preservesNumericRequestIDAtIntBoundary() throws { + let payload = Data(#"{"id":\#(Int.max),"result":{"ok":true}}"#.utf8) + + let classified = try CodexRPCEnvelope.classifyInboundMessage(payload) + + #expect(classified == .response(id: .int(Int.max), payload: payload)) + } + @Test("rejects envelopes that have neither method nor ID") func rejectsMeaninglessEnvelope() throws { let payload = #"{"params":{"ok":true}}"#.data(using: .utf8)! @@ -97,6 +106,15 @@ struct CodexRPCEnvelopeTests { } } + @Test("rejects numeric request IDs beyond the Swift Int range") + func rejectsOutOfRangeRequestID() throws { + let payload = #"{"id":9223372036854775808,"result":{"ok":true}}"#.data(using: .utf8)! + + #expect(throws: CodexTransportError.self) { + try CodexRPCEnvelope.classifyInboundMessage(payload) + } + } + @Test("rejects object request IDs") func rejectsObjectRequestID() throws { let payload = #"{"id":{"nested":1},"result":{"ok":true}}"#.data(using: .utf8)! diff --git a/docs/maintainers/v1-public-api-audit.md b/docs/maintainers/v1-public-api-audit.md index b2e020b..3d4faa4 100644 --- a/docs/maintainers/v1-public-api-audit.md +++ b/docs/maintainers/v1-public-api-audit.md @@ -2,7 +2,7 @@ This document is the working checklist for the `SwiftASB` v1 public API curation pass. The goal is to freeze a compact, Swift-native surface for the -supported app-server lifecycle before `v1.6.0`, not to expose every generated +supported app-server lifecycle before `v1.7.0`, not to expose every generated wire family. ## Current Public Source Inventory @@ -432,7 +432,7 @@ Use these decisions for every public symbol: - [x] Add symbol comments for every stable v1 public type and method that is not self-explanatory from its declaration. - Decision: complete for the `v1.6.0` release boundary. Default-bearing public + Decision: complete for the `v1.7.0` release boundary. Default-bearing public initializers and methods now document whether omission delegates to Codex, chooses a SwiftASB local-history/UI default, or applies an explicit safety default such as `.turn` or `.unchanged`. The source-level pass also covers the @@ -514,7 +514,7 @@ Use these decisions for every public symbol: Decision: covered by the startup, progress/approval, diagnostics/history, and SwiftUI observable companion walkthroughs in `Sources/SwiftASB/SwiftASB.docc/`. - [x] Update stale README release references before the next release. - Decision: README now names `v1.6.0` as the current released baseline. + Decision: README now names `v1.7.0` as the current released baseline. - [x] Confirm README, DocC, and this audit use the same v1 release boundary. Decision: README, DocC, and this audit now describe the same narrow v1 promise: app-server lifecycle, app-wide capability reads, stored-thread