From 028feb45b20c38ae209c4cf3e39f1712d18fd6df Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:36:20 +0200 Subject: [PATCH 01/40] docs: design embedded Hermes agent selection --- ...-embedded-hermes-agent-selection-design.md | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-01-embedded-hermes-agent-selection-design.md diff --git a/docs/superpowers/specs/2026-08-01-embedded-hermes-agent-selection-design.md b/docs/superpowers/specs/2026-08-01-embedded-hermes-agent-selection-design.md new file mode 100644 index 000000000..fe99e6e0f --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-embedded-hermes-agent-selection-design.md @@ -0,0 +1,273 @@ +# Embedded Hermes Agent Selection + +Date: 2026-08-01 +Status: Conversational design approved; written specification awaiting review + +## Objective + +Turn the existing Hermes surface in MTPLX into a working embedded Hermes +client. A user selects a Hermes profile, then selects or creates a session in +that profile, and uses the agent directly inside the MTPLX GUI. The embedded +session uses the model currently served by MTPLX without changing the selected +profile's persistent provider configuration or disrupting Telegram, the root +Hermes gateway, Hermes Desktop, or other Hermes clients. + +Sessions created or resumed from MTPLX remain ordinary Hermes sessions in the +selected profile. They must remain visible and resumable later from Hermes +Desktop, the Hermes TUI, and MTPLX. + +## Existing Integration + +MTPLX already contains most of the presentation and client structure: + +- `HermesIntegration` discovers the default Hermes home and named profiles. +- `HermesAgentStore` models profile selection, session lists, transcripts, + tool events, and JSON-RPC calls. +- `HermesOverlay` contains the intended Profiles, Agents, transcript, and + composer layout. +- `HermesGatewayClient` implements the JSON-RPC/WebSocket client. +- `HermesSidecar` owns a child process and its connection information. + +The embedded surface is currently disabled because +`HermesIntegration.nativeDashboardSupported` is false and `startDashboard()` +unconditionally reports an incompatible Hermes build. That assumption is now +stale: the installed Hermes exposes `hermes serve`, a headless JSON-RPC and +WebSocket backend, and supports a profile-scoped `--isolated` process. + +The current messaging diagnostic is separate from this feature. It inspects +only the root `.env`, which is not sufficient for a multiplex gateway whose +Telegram credentials live in a routed profile. Embedded agent selection must +not depend on that root-only diagnostic and must not mutate gateway routing. + +## User Experience + +The existing Hermes overlay becomes the embedded agent surface. + +### Profile selection + +The Profiles section lists the default Hermes home and every valid named +profile under `~/.hermes/profiles`. All profiles are selectable. Each row shows +one of these routing states: + +- **MTPLX**: the persistent profile already targets the active MTPLX endpoint. +- **External**: the persistent profile uses another provider or endpoint; the + embedded sidecar will use a process-local MTPLX route. +- **Unavailable**: the profile cannot be read or cannot be started safely. + +Selecting a profile starts or reuses an MTPLX-owned isolated Hermes sidecar for +that profile and loads its sessions. Profile and session selection are saved in +the existing `lastHermesProfile`, `lastHermesSessionID`, and +`lastHermesSessionTitle` settings. + +### Session selection + +The Agents section lists saved sessions for the selected profile with title, +last activity, and state: + +- **Ready**: resumable in MTPLX. +- **Running in MTPLX**: owned by the current embedded sidecar. +- **Externally active**: active in another Hermes surface and not writable from + MTPLX at the same time. + +The user may resume a ready session or create a new one. An externally active +session remains visible but read-only; MTPLX offers to create a new session +instead of attempting concurrent writes. + +### Embedded conversation + +The right pane displays the selected profile, session title, and MTPLX routing +state in its header. It renders the persisted transcript, live assistant +streaming, reasoning, tool progress, approval or clarification requests, and +errors. The composer submits prompts over Hermes' native JSON-RPC protocol. + +The GUI does not embed Hermes Desktop and does not reimplement the agent loop. +Hermes remains responsible for sessions, tool execution, streaming events, and +persistence. + +## Runtime Architecture + +MTPLX starts a dedicated process for the selected profile: + +```text +hermes -p serve --isolated --host 127.0.0.1 --port 0 +``` + +The default profile uses the equivalent default-home invocation without an +invalid profile name. The process binds only to loopback and selects an +OS-assigned port. + +The sidecar receives process-local overrides for: + +- the active MTPLX OpenAI-compatible base URL; +- the local MTPLX API key; +- the model identifier currently served by MTPLX; +- inference provider and API mode; +- reasoning, approval, workspace, and tool settings owned by MTPLX; +- an MTPLX launch identifier and the MTPLX parent PID. + +These overrides are applied only to the child process. MTPLX does not rewrite +the selected profile's `config.yaml`, `.env`, gateway routes, channel +credentials, or provider settings. The selected profile still supplies its +identity, sessions, skills, memories, rules, and other user-owned configuration. + +The sidecar publishes its actual port and ephemeral authentication material +through Hermes' supported startup contract. MTPLX waits for that information, +connects `HermesGatewayClient`, and treats the agent as ready only after the +`gateway.ready` event. + +## Data Flow + +1. Opening the Hermes overlay refreshes the Hermes installation state and + discovers profiles. +2. Selecting a profile builds the process-local MTPLX routing environment and + starts `hermes serve --isolated`. +3. The WebSocket client connects and waits for `gateway.ready`. +4. MTPLX calls `session.list` and displays the selected profile's saved + sessions. +5. `session.create` creates an ordinary session in that profile; + `session.resume` resumes an ordinary saved session. +6. `prompt.submit` starts a turn. Hermes emits transcript, reasoning, tool, + approval, clarification, completion, and error events. +7. Hermes persists the session in the original profile store. MTPLX stores only + the last selected profile/session reference in its own settings. +8. Switching profiles or closing the surface disconnects the client and stops + the MTPLX-owned sidecar without changing the profile files. + +## Coexistence and Concurrency + +The root Hermes gateway and all non-MTPLX Hermes processes remain independent. +Telegram continues to use its existing multiplex routing and profile +credentials. If Telegram and the embedded GUI both call the same MTPLX model +server, the server's scheduler may queue one generation behind the other, but +both request paths remain valid. + +Different sessions in the same profile may be used concurrently. MTPLX must +not submit to the same concrete session while another Hermes process owns or +actively writes it. Before enabling the composer, MTPLX checks Hermes' active +session state and resumes through Hermes' ownership-aware API. An external-active +result or ownership conflict makes the session read-only. If the installed +Hermes build cannot provide a trustworthy ownership result for an existing +session, MTPLX fails closed for that session and offers a new one. It never +guesses that an ambiguous session is safe to write. + +MTPLX never stops, restarts, or repairs the root Hermes gateway as part of +embedded profile/session selection. + +## Lifecycle and Recovery + +Each sidecar has an MTPLX-specific launch identifier and records the MTPLX +parent PID. Normal profile changes, overlay closure, app termination, and an +explicit stop action disconnect the client and terminate the owned sidecar. + +On startup, MTPLX may remove only orphaned sidecars that carry a valid MTPLX +ownership marker and whose recorded parent no longer exists. It must not match +or terminate generic `hermes serve`, Hermes Desktop, TUI, gateway, or profile +processes. + +Because provider routing is process-local, crash recovery does not restore +profile files. Terminating or losing the sidecar discards the temporary route +automatically. Persisted Hermes session data remains in the original profile. + +## Error Handling + +- Missing or incompatible Hermes keeps the existing setup/recheck state. +- A sidecar launch failure shows the concrete bounded stderr summary in the + Hermes pane and does not silently open Terminal. +- Failure to obtain the port or ephemeral authentication material stops the + child and reports a startup error. +- The WebSocket client uses a bounded connection timeout. A disconnect leaves + the displayed transcript intact and offers reconnect to the same profile and + session. +- MTPLX connection failures preserve the Hermes session and allow retry after + the model server recovers. +- Busy or externally owned sessions are not resumed writable. +- Profile parsing errors affect only that profile and do not hide healthy + profiles. +- Secrets are excluded from UI text and logs. + +## Security Boundaries + +- The sidecar binds to `127.0.0.1` only. +- Hermes' ephemeral WebSocket authentication remains enabled. +- API keys and routing overrides exist only in the child environment. +- The app never copies messaging credentials between profiles for this feature. +- The app never changes Telegram routes or gateway service configuration. +- Process cleanup requires an exact MTPLX ownership marker and dead parent PID. +- User-owned profile sections and files remain unchanged. + +## Test Strategy + +Implementation follows red-green-refactor TDD. + +### Unit and command tests + +- Discover default and named profiles and classify MTPLX/external/unavailable + routing states. +- Build the exact default-profile and named-profile isolated serve commands. +- Build process-local routing overrides without writing profile files. +- Persist and restore the selected profile/session reference. +- Restrict orphan cleanup to exact MTPLX-owned processes. + +### JSON-RPC integration tests + +A fake Hermes backend exercises: + +- startup information and `gateway.ready`; +- `session.list`, `session.create`, and `session.resume`; +- transcript restoration and `prompt.submit`; +- message streaming, reasoning, tool, approval, clarification, completion, and + error events; +- reconnect behavior and profile switching; +- busy/external session handling. + +### Regression tests + +- The chosen profile's `config.yaml` and `.env` remain byte-identical before, + during, and after an embedded session. +- Starting and stopping an embedded sidecar does not change root gateway state. +- A session created from MTPLX appears in the normal Hermes session list for + the original profile. +- Parallel requests from Telegram and a different embedded session remain + valid; the MTPLX server may serialize generation without changing routing. +- App shutdown and orphan recovery terminate only MTPLX-owned sidecars. + +### Build and live acceptance + +- Run the focused Swift tests and the complete MTPLXApp test suite. +- Build through `apps/MTPLXApp/script/build_and_run.sh` using the configured + Xcode toolchain. +- With a real profile, create a session in MTPLX, stream a response, close the + embedded client, and verify that the same session is visible and resumable in + Hermes. +- While the embedded client is connected, send a Telegram message routed to a + different session and verify that it still completes through MTPLX. + +## Acceptance Criteria + +The feature is complete when: + +1. Every readable Hermes profile is selectable in MTPLX. +2. Every saved session for the selected profile is visible with an honest + activity state. +3. New and resumed sessions work inside the MTPLX Hermes pane with native + streaming and tool events. +4. Embedded sessions use the currently active MTPLX model regardless of the + profile's persistent provider. +5. Original profile configuration and gateway routing remain unchanged. +6. Telegram and other Hermes clients continue to operate concurrently. +7. The same session cannot be written concurrently from MTPLX and another + Hermes process. +8. Sessions created in MTPLX remain visible and resumable in Hermes later. +9. Sidecar shutdown, reconnect, app termination, and orphan recovery are + bounded and ownership-safe. +10. Automated tests and the macOS app build pass with no new warnings + attributable to this feature. + +## Out of Scope + +- Reconfiguring Telegram, Discord, or other messaging platforms. +- Changing Hermes multiplex profile routes. +- Replacing Hermes' session store or agent loop. +- Synchronizing simultaneous writes to the same session across surfaces. +- Editing a profile's permanent provider/model selection from MTPLX. +- Embedding the Hermes Desktop application or its web frontend. From cc31e07ad24646113f8243ff8757424f5c224f91 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:48:58 +0200 Subject: [PATCH 02/40] docs: plan embedded Hermes agent selection --- ...6-08-01-embedded-hermes-agent-selection.md | 769 ++++++++++++++++++ ...-embedded-hermes-agent-selection-design.md | 2 +- 2 files changed, 770 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-08-01-embedded-hermes-agent-selection.md diff --git a/docs/superpowers/plans/2026-08-01-embedded-hermes-agent-selection.md b/docs/superpowers/plans/2026-08-01-embedded-hermes-agent-selection.md new file mode 100644 index 000000000..9cef73c42 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-embedded-hermes-agent-selection.md @@ -0,0 +1,769 @@ +# Embedded Hermes Agent Selection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the existing MTPLX Hermes pane a native client for any local Hermes profile and its saved sessions while routing only the embedded process through the currently running MTPLX model. + +**Architecture:** MTPLX launches one loopback-only `hermes serve --isolated` child for the selected profile, injects the MTPLX endpoint/model only into that child environment, and talks to it through Hermes' authenticated JSON-RPC WebSocket API. Focused runtime, transport, and ownership types keep process lifecycle and fail-closed session permissions out of the SwiftUI view; existing Hermes profile files, the root messaging gateway, and Telegram routing are never modified. + +**Tech Stack:** Swift 6, SwiftUI, Combine, Foundation `Process`, `URLSessionWebSocketTask`, XCTest, Hermes 0.19.1 JSON-RPC/WebSocket gateway, macOS 14. + +## Global Constraints + +- Bind every embedded Hermes sidecar to `127.0.0.1` and request port `0`. +- Start named profiles as `hermes -p serve --isolated`; start the default profile without `-p`. +- Put MTPLX base URL, API key, model, provider, reasoning, approval, workspace, tool, launch ID, and parent PID overrides only in the child process environment or command arguments. +- Never call `HermesIntegration.sync(configuration:)` from the embedded path. +- Never rewrite a selected profile's `config.yaml`, `.env`, gateway routes, channel credentials, provider settings, skills, memories, or rules. +- Never stop, start, repair, or reconfigure the root Hermes gateway during profile/session selection. +- Preserve ordinary Hermes session persistence so sessions created in MTPLX remain visible in Hermes Desktop and TUI. +- Treat external or ambiguous ownership as read-only and re-check ownership immediately before every `prompt.submit`. +- Kill only a sidecar whose PID, exact launch ID, exact `--isolated` command shape, and dead recorded parent all match an MTPLX ownership record. +- Keep authentication tokens, API keys, and messaging secrets out of UI text and logs. +- Follow red-green-refactor TDD and commit each independently testable task. + +--- + +## File Structure + +- Create `apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift`: embedded routing classification, launch specification, readiness parsing, ownership registry inspection, ownership records, and process-sidecar lifecycle. +- Create `apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift`: JSON-RPC request/response transport, event decoding, authenticated WebSocket connection, and bounded `gateway.ready` wait. +- Modify `apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift`: conform the existing integration to the embedded-runtime interface and replace the stale `startDashboard` failure with the isolated sidecar launcher while leaving legacy Desktop/Terminal handoff intact. +- Modify `apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift`: profile route state, sidecar switching, session ownership, read-only protection, native session RPCs, pending user requests, streaming, reconnect, and shutdown. +- Modify `apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift`: remove the terminal-only gate, render profile/session state, make pending requests actionable, and stop the embedded sidecar when the surface closes. +- Create `apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift`: command, environment, classification, startup parsing, byte-preservation, and ownership-safe cleanup tests. +- Create `apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift`: real local fake-WebSocket tests for JSON-RPC, authentication URL, `gateway.ready`, disconnect, timeout, and event decoding. +- Create `apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift`: fake runtime/transport tests for profile switching, list/create/resume/send, permissions, streaming, prompts, persistence references, reconnect, and shutdown. +- Create `apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift`: loopback RFC 6455 fixture shared only by gateway-client tests. + +--- + +### Task 1: Embedded Profile Routing and Launch Specification + +**Files:** +- Create: `apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift` +- Modify: `apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift:7-18,209-250,252-397` +- Test: `apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift` + +**Interfaces:** +- Consumes: `HermesProfile`, `MTPLXAppConfiguration`, `OpenCodeIntegration.modelID(for:)`, `OpenCodeIntegration.baseURLString(host:port:)`, and `HermesIntegration.launchEnvironment(configuration:)`. +- Produces: `HermesProfileRoutingState`, `HermesServeLaunchSpec`, `HermesEmbeddedRuntime`, `HermesIntegration.routingState(for:configuration:)`, and `HermesIntegration.serveLaunchSpec(profile:configuration:token:launchID:parentPID:)`. + +- [ ] **Step 1: Write failing routing and command tests** + +```swift +func testNamedProfileLaunchUsesIsolatedServeAndProcessLocalMTPLXRoute() throws { + let profile = HermesProfile(name: "bernd", path: tempProfile.path, isDefault: false) + let spec = try integration.serveLaunchSpec( + profile: profile, + configuration: configuration, + token: "test-session-token", + launchID: "0123456789abcdef", + parentPID: 4242 + ) + + XCTAssertEqual(spec.arguments, [ + "-p", "bernd", "serve", "--isolated", "--host", "127.0.0.1", + "--port", "0", "--ssh-owner-nonce", "0123456789abcdef", + ]) + XCTAssertEqual(spec.environment["HERMES_INFERENCE_PROVIDER"], "custom") + XCTAssertEqual(spec.environment["CUSTOM_BASE_URL"], "http://127.0.0.1:18080/v1") + XCTAssertEqual(spec.environment["HERMES_INFERENCE_MODEL"], "current-model") + XCTAssertEqual(spec.environment["HERMES_DASHBOARD_SESSION_TOKEN"], "test-session-token") + XCTAssertEqual(spec.environment["MTPLX_HERMES_PARENT_PID"], "4242") + XCTAssertNil(spec.environment["HERMES_HOME"]) +} + +func testDefaultProfileLaunchOmitsProfileFlag() throws { + let spec = try integration.serveLaunchSpec( + profile: HermesProfile(name: "default", path: hermesHome.path, isDefault: true), + configuration: configuration, + token: "test-session-token", + launchID: "fedcba9876543210", + parentPID: 4242 + ) + XCTAssertEqual(Array(spec.arguments.prefix(5)), ["serve", "--isolated", "--host", "127.0.0.1", "--port"]) + XCTAssertFalse(spec.arguments.contains("-p")) +} + +func testProfileRoutingClassifiesMTPLXExternalAndUnavailableIndependently() throws { + XCTAssertEqual(integration.routingState(for: mtplxProfile, configuration: configuration), .mtplx) + XCTAssertEqual(integration.routingState(for: externalProfile, configuration: configuration), .external) + guard case .unavailable = integration.routingState(for: unreadableProfile, configuration: configuration) else { + return XCTFail("Unreadable profile must remain visible as unavailable") + } +} +``` + +- [ ] **Step 2: Run the new tests and confirm the missing interfaces fail** + +Run: `cd apps/MTPLXApp && swift test --filter HermesEmbeddedRuntimeTests` + +Expected: compile failures naming `HermesProfileRoutingState`, `HermesServeLaunchSpec`, `routingState`, and `serveLaunchSpec`. + +- [ ] **Step 3: Implement the routing and launch types** + +```swift +public enum HermesProfileRoutingState: Equatable, Sendable { + case mtplx + case external + case unavailable(String) +} + +public struct HermesServeLaunchSpec: Equatable, Sendable { + public let executableURL: URL + public let arguments: [String] + public let environment: [String: String] + public let token: String + public let launchID: String + public let parentPID: Int32 +} + +public protocol HermesEmbeddedRuntime: Sendable { + func routingState( + for profile: HermesProfile, + configuration: MTPLXAppConfiguration + ) -> HermesProfileRoutingState + func startEmbeddedSidecar( + profile: HermesProfile, + configuration: MTPLXAppConfiguration + ) async throws -> any HermesSidecarControlling + func sessionOwnership( + profile: HermesProfile, + sessionID: String, + ownedSidecarPID: Int32? + ) -> HermesSessionOwnership + @discardableResult func reapOrphanedEmbeddedSidecars() -> [Int32] +} +``` + +In `serveLaunchSpec`, validate the launch ID with `^[0-9a-f]{16}$`, start from `launchEnvironment(configuration:)`, remove inherited `HERMES_HOME`, set `CUSTOM_BASE_URL`, `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `HERMES_MODEL`, `HERMES_INFERENCE_MODEL`, `HERMES_INFERENCE_PROVIDER=custom`, `HERMES_DASHBOARD_SESSION_TOKEN`, `HERMES_SESSION_PLATFORM=mtplx-app`, `MTPLX_HERMES_LAUNCH_ID`, and `MTPLX_HERMES_PARENT_PID`, then build the exact default/named argument arrays asserted above. + +Classify a profile as `.mtplx` only when its readable effective `model.provider`, `model.base_url`/`CUSTOM_BASE_URL`, and model reference equal the active configuration; classify other readable profiles as `.external`; preserve discovery but return `.unavailable(redactedReason)` for an unreadable or structurally invalid profile. + +- [ ] **Step 4: Re-run focused and existing Hermes environment tests** + +Run: `cd apps/MTPLXApp && swift test --filter HermesEmbeddedRuntimeTests && swift test --filter MTPLXAppCoreTests.testHermesIntegrationSyncsMTPLXProfileAndLaunchEnvironment` + +Expected: both commands pass, proving the new child-only path coexists with the legacy explicit `mtplx` profile sync. + +- [ ] **Step 5: Commit the routing slice** + +```bash +git add apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift \ + apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift \ + apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift +git commit -m "feat(hermes): define embedded profile routing" +``` + +### Task 2: Isolated Sidecar Startup, Authentication, and Ownership-Safe Cleanup + +**Files:** +- Modify: `apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift` +- Modify: `apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift:146-195,735-744,1487-1539,1661-1735` +- Test: `apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift` + +**Interfaces:** +- Consumes: `HermesServeLaunchSpec` from Task 1 and `SubprocessTailBuffer` from `Services/SubprocessSupport.swift`. +- Produces: `HermesSidecarControlling`, `HermesBackendReadyParser`, `HermesSidecarOwnershipRecord`, `HermesIntegration.startEmbeddedSidecar(profile:configuration:)`, and `HermesIntegration.reapOrphanedEmbeddedSidecars()`. + +- [ ] **Step 1: Write failing startup and cleanup tests** + +```swift +func testReadyParserAcceptsHeadlessSentinelOnly() { + XCTAssertEqual(HermesBackendReadyParser.port(from: "HERMES_BACKEND_READY port=45123"), 45123) + XCTAssertNil(HermesBackendReadyParser.port(from: "Hermes backend listening on 0.0.0.0:45123")) + XCTAssertNil(HermesBackendReadyParser.port(from: "HERMES_BACKEND_READY port=0")) +} + +func testSidecarUsesCallerTokenAndRemovesOwnershipRecordOnStop() async throws { + let sidecar = try await integration.startEmbeddedSidecar( + profile: defaultProfile, + configuration: configuration + ) + XCTAssertEqual(sidecar.webSocketURL.host, "127.0.0.1") + XCTAssertEqual(sidecar.webSocketURL.path, "/api/ws") + XCTAssertNotNil(URLComponents(url: sidecar.webSocketURL, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "token" })?.value) + XCTAssertTrue(fileManager.fileExists(atPath: sidecar.ownershipRecordURL.path)) + sidecar.stop() + XCTAssertFalse(fileManager.fileExists(atPath: sidecar.ownershipRecordURL.path)) +} + +func testOrphanCleanupRequiresExactMarkerCommandAndDeadParent() { + let killed = HermesOrphanSidecarScanner.orphanPIDs( + records: records, + processes: processSnapshot, + livePIDs: [9001] + ) + XCTAssertEqual(killed, [7101]) + XCTAssertFalse(killed.contains(7102)) // generic hermes serve + XCTAssertFalse(killed.contains(7103)) // marker mismatch + XCTAssertFalse(killed.contains(7104)) // parent still alive +} +``` + +Use a temporary executable fixture that writes `HERMES_BACKEND_READY port=` to stdout, records its received environment with secret values replaced by `present`, and stays alive until SIGTERM. The test must compare profile `config.yaml` and `.env` bytes before and after start/stop. + +- [ ] **Step 2: Run the lifecycle tests and verify red** + +Run: `cd apps/MTPLXApp && swift test --filter HermesEmbeddedRuntimeTests` + +Expected: compile failures for the parser, sidecar protocol, ownership record, and orphan scanner. + +- [ ] **Step 3: Implement bounded sidecar launch and exact ownership** + +```swift +public protocol HermesSidecarControlling: AnyObject, Sendable { + var processIdentifier: Int32 { get } + var isRunning: Bool { get } + var webSocketURL: URL { get } + var ownershipRecordURL: URL { get } + func stop() +} + +public struct HermesSidecarOwnershipRecord: Codable, Equatable, Sendable { + public let launchID: String + public let pid: Int32 + public let parentPID: Int32 + public let profileName: String + public let createdAt: Date +} + +enum HermesBackendReadyParser { + static func port(from line: String) -> Int? { + guard line.hasPrefix("HERMES_BACKEND_READY port="), + let port = Int(line.dropFirst("HERMES_BACKEND_READY port=".count)), + (1...65535).contains(port) else { return nil } + return port + } +} +``` + +Launch `Process` with pipes attached before `run()`, retain a 4 KiB redacted stderr tail, wait at most 15 seconds for the sentinel, and terminate the child on exit-before-ready, timeout, malformed port, or ownership-record write failure. Generate a 32-byte URL-safe session token in MTPLX, pass it through `HERMES_DASHBOARD_SESSION_TOKEN`, and construct `ws://127.0.0.1:/api/ws?token=` without logging it. + +Store records under the injected `sidecarRuntimeDirectory` (default `~/.mtplx/hermes-sidecars`). On cleanup, require the exact PID plus the exact `serve --isolated --ssh-owner-nonce ` argv sequence and a dead `parentPID`; send TERM, wait two seconds, then KILL only that verified PID. Delete stale records whose process is already dead. Do not call `hermes serve --stop`. + +- [ ] **Step 4: Run lifecycle and legacy cleanup regressions** + +Run: `cd apps/MTPLXApp && swift test --filter HermesEmbeddedRuntimeTests && swift test --filter MTPLXAppCoreTests.testHermesTerminalCleanupOnlyMatchesAppLaunchedChat` + +Expected: all tests pass; generic Hermes Desktop/TUI/serve commands remain unmatched. + +- [ ] **Step 5: Commit the sidecar slice** + +```bash +git add apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift \ + apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift \ + apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift +git commit -m "feat(hermes): launch owned isolated sidecars" +``` + +### Task 3: Authenticated JSON-RPC WebSocket Client with Readiness Gate + +**Files:** +- Create: `apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift` +- Modify: `apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift:90-231` +- Create: `apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift` +- Create: `apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift` + +**Interfaces:** +- Consumes: authenticated `HermesSidecarControlling.webSocketURL` from Task 2 and the existing `JSONValue` type. +- Produces: `HermesGatewayEvent`, `HermesGatewayClientProtocol`, `URLSessionHermesGatewayClient`, and `HermesGatewayClientFactory`. + +- [ ] **Step 1: Write failing local-WebSocket tests** + +```swift +@MainActor +func testConnectWaitsForGatewayReadyBeforeReturning() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: []) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: "fixture-token")) + let connect = Task { try await client.connectAndWaitUntilReady(timeoutSeconds: 1) } + try await Task.sleep(for: .milliseconds(50)) + XCTAssertFalse(connect.isCancelled) + backend.sendEvent(type: "gateway.ready", sessionID: nil, payload: [:]) + try await connect.value +} + +@MainActor +func testRPCResponseAndEventAreDecoded() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + backend.respond(to: "session.list", result: .object(["sessions": .array([])])) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: "fixture-token")) + var received: [HermesGatewayEvent] = [] + client.onEvent = { received.append($0) } + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + let value = try await client.call(method: "session.list", params: ["limit": .number(200)]) + XCTAssertEqual(value.objectValue?["sessions"]?.arrayValue, []) + backend.sendEvent(type: "message.delta", sessionID: "live-1", payload: ["text": .string("Hi")]) + await eventually { received.contains(where: { $0.type == "message.delta" }) } +} +``` + +Add timeout, RPC-error, disconnect-with-pending-request, malformed-frame, and token-query tests. `FakeHermesGateway` binds only to `127.0.0.1`, performs the RFC 6455 handshake using `Insecure.SHA1`, decodes masked client text frames, and emits unmasked server text frames. + +- [ ] **Step 2: Run the gateway tests and verify red** + +Run: `cd apps/MTPLXApp && swift test --filter HermesGatewayClientTests` + +Expected: compile failure because the extracted protocol/client and fixture do not exist. + +- [ ] **Step 3: Extract and implement the transport** + +```swift +struct HermesGatewayEvent: Equatable, Sendable { + let type: String + let sessionID: String? + let payload: [String: JSONValue] +} + +@MainActor +protocol HermesGatewayClientProtocol: AnyObject { + var onEvent: ((HermesGatewayEvent) -> Void)? { get set } + var onDisconnect: ((String) -> Void)? { get set } + func connectAndWaitUntilReady(timeoutSeconds: Double) async throws + func call(method: String, params: [String: JSONValue]) async throws -> JSONValue + func close() +} + +typealias HermesGatewayClientFactory = @MainActor (URL) -> any HermesGatewayClientProtocol +``` + +Move the current JSON-RPC encoding, response continuation table, and event parsing into `URLSessionHermesGatewayClient`. Install the readiness continuation before resuming the socket, resolve it only on `gateway.ready`, fail it on disconnect, and race it against a 10-second timeout. Redact query strings from every surfaced connection error. + +- [ ] **Step 4: Run the client tests** + +Run: `cd apps/MTPLXApp && swift test --filter HermesGatewayClientTests` + +Expected: all gateway tests pass without external network access. + +- [ ] **Step 5: Commit the transport slice** + +```bash +git add apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift \ + apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift \ + apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift \ + apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift +git commit -m "feat(hermes): add authenticated gateway client" +``` + +### Task 4: Store Lifecycle, Profile Switching, and Native Session RPCs + +**Files:** +- Modify: `apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift:233-563,708-760` +- Create: `apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift` + +**Interfaces:** +- Consumes: `HermesEmbeddedRuntime`, `HermesSidecarControlling`, and `HermesGatewayClientFactory` from Tasks 1-3. +- Produces: the injectable store initializer, honest readiness state, profile route map, and working `session.list`, `session.create`, `session.resume`, `session.interrupt`, and `prompt.submit` orchestration. + +- [ ] **Step 1: Write failing lifecycle and RPC orchestration tests** + +```swift +@MainActor +func testLoadSessionsWaitsForReadyAndReusesMatchingSidecar() async { + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient() + let store = HermesAgentStore( + integration: integration, + embeddedRuntime: runtime, + clientFactory: { _ in client } + ) + let load = Task { await store.loadSessions(profile: bernd, configuration: configuration) } + XCTAssertEqual(store.connectionState, .starting) + XCTAssertEqual(client.calls, []) + client.finishReady() + await load.value + XCTAssertEqual(client.calls.first?.method, "session.list") + XCTAssertEqual(store.connectionState, .connected) + await store.loadSessions(profile: bernd, configuration: configuration) + XCTAssertEqual(runtime.startCount, 1) +} + +@MainActor +func testProfileSwitchClosesClientAndStopsOnlyOwnedSidecar() async { + let firstLoad = Task { await store.loadSessions(profile: bernd, configuration: configuration) } + firstClient.finishReady() + await firstLoad.value + let secondLoad = Task { await store.loadSessions(profile: researcher, configuration: configuration) } + secondClient.finishReady() + await secondLoad.value + XCTAssertTrue(firstClient.didClose) + XCTAssertEqual(firstSidecar.stopCount, 1) + XCTAssertEqual(secondSidecar.stopCount, 0) +} +``` + +Add tests that create a new native session, resume a saved session with transcript messages, submit a prompt, interrupt a turn, restore `lastHermesProfile/sessionID/title`, reconnect after disconnect without clearing the visible transcript, and call orphan cleanup once during `prepare`. + +- [ ] **Step 2: Run the store tests and verify red** + +Run: `cd apps/MTPLXApp && swift test --filter HermesAgentStoreTests` + +Expected: initializer and readiness-related compile failures. + +- [ ] **Step 3: Inject runtime/client dependencies and gate connected state** + +```swift +@MainActor +init( + integration: HermesIntegration, + embeddedRuntime: any HermesEmbeddedRuntime, + clientFactory: @escaping HermesGatewayClientFactory +) { + self.integration = integration + self.embeddedRuntime = embeddedRuntime + self.clientFactory = clientFactory +} +``` + +Keep `public convenience init(integration:)` as the live path using the same `HermesIntegration` for `embeddedRuntime` and `URLSessionHermesGatewayClient.init(url:)` for the factory. Store the sidecar as `any HermesSidecarControlling`. In `ensureGateway`, tear down the previous generation, start the new isolated sidecar, call `connectAndWaitUntilReady(timeoutSeconds: 10)`, then set `gatewayReady=true` and `.connected`; never report connected before the event. + +In `prepare`, call `reapOrphanedEmbeddedSidecars()`, discover all profiles, populate `[profile.id: routingState]`, and restore the remembered profile. Keep per-profile failures local. Preserve transcript/session references across a transport disconnect and expose `reconnect(configuration:)` that reopens the selected profile and resumes the selected saved session. + +- [ ] **Step 4: Run store, transport, and persistence tests** + +Run: `cd apps/MTPLXApp && swift test --filter HermesAgentStoreTests && swift test --filter HermesGatewayClientTests && swift test --filter MTPLXAppCoreTests.testAppConfigurationPersistsHermesResumeState` + +Expected: all pass. + +- [ ] **Step 5: Commit the store lifecycle slice** + +```bash +git add apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift \ + apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +git commit -m "feat(hermes): connect profiles and native sessions" +``` + +### Task 5: Fail-Closed Cross-Process Session Ownership + +**Files:** +- Modify: `apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift` +- Modify: `apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift:65-88,334-462,550-563` +- Test: `apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift` +- Test: `apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift` + +**Interfaces:** +- Consumes: Hermes' per-profile `runtime/active_sessions.json`, the selected profile path, sidecar PID, and session IDs returned by `session.list`/`session.resume`. +- Produces: `HermesSessionOwnership`, `HermesSessionActivityState`, `HermesSavedSession.activity`, `HermesAgentStore.activeSessionWritable`, and `HermesAgentStore.readOnlyReason`. + +- [ ] **Step 1: Write failing ownership and send-gate tests** + +```swift +func testOwnershipRegistryDistinguishesCurrentSidecarFromExternalProcess() throws { + try writeRegistry(entries: [ + ["session_id": "ours", "surface": "mtplx-app", "pid": 7001], + ["session_id": "telegram", "surface": "telegram", "pid": 7002], + ]) + XCTAssertEqual(runtime.sessionOwnership(profile: bernd, sessionID: "ours", ownedSidecarPID: 7001), .ownedByMTPLX) + XCTAssertEqual(runtime.sessionOwnership(profile: bernd, sessionID: "telegram", ownedSidecarPID: 7001), .external(surface: "telegram")) + XCTAssertEqual(runtime.sessionOwnership(profile: bernd, sessionID: "idle", ownedSidecarPID: 7001), .ready) +} + +@MainActor +func testExternalAndUnknownSessionsRemainReadableButCannotSubmit() async { + _ = try? await store.resume(externalSession, profile: bernd, configuration: configuration) + XCTAssertFalse(store.activeSessionWritable) + await store.send("must not leave MTPLX") + XCTAssertFalse(client.calls.contains(where: { $0.method == "prompt.submit" })) + XCTAssertNotNil(store.readOnlyReason) +} + +@MainActor +func testSendRechecksOwnershipImmediatelyBeforeSubmit() async { + await resumeReadySession() + runtime.nextOwnership = .external(surface: "telegram") + await store.send("race check") + XCTAssertFalse(client.calls.contains(where: { $0.method == "prompt.submit" })) + XCTAssertEqual(store.activeSessionActivity, .externallyActive(surface: "telegram")) +} +``` + +Add corrupt registry, unreadable registry, dead-PID pruning, fresh-session writable, and different-session concurrency tests. Treat no registry file as `.ready`; treat malformed/unreadable content as `.unknown(redactedReason)`; ignore dead entries after `kill(pid, 0)`/start-time validation. + +- [ ] **Step 2: Run ownership tests and verify red** + +Run: `cd apps/MTPLXApp && swift test --filter HermesEmbeddedRuntimeTests && swift test --filter HermesAgentStoreTests` + +Expected: failures for missing ownership/activity state and send guard. + +- [ ] **Step 3: Implement activity mapping and fail-closed checks** + +```swift +public enum HermesSessionOwnership: Equatable, Sendable { + case ready + case ownedByMTPLX + case external(surface: String) + case unknown(String) +} + +public enum HermesSessionActivityState: Equatable, Sendable { + case ready + case runningInMTPLX + case externallyActive(surface: String) + case ownershipUnknown(String) +} +``` + +Add `public let activity: HermesSessionActivityState` to `HermesSavedSession` and give its initializer the source-compatible parameter `activity: HermesSessionActivityState = .ready`. + +Parse only `entries` containing a nonempty `session_id`, positive PID, and live process identity. A matching session entry owned by the current sidecar PID is `.ownedByMTPLX`; a live different PID is `.external`; multiple conflicting entries are `.unknown`. Refresh all list-row states after `session.list`, before `session.resume`, and immediately before `prompt.submit`. A resumed external/unknown session may load its native transcript but must keep `activeSessionWritable=false`; `send` must return without appending a user message or issuing RPC. New sessions are writable unless a later pre-submit check finds a conflict. + +- [ ] **Step 4: Run ownership and session regression tests** + +Run: `cd apps/MTPLXApp && swift test --filter HermesEmbeddedRuntimeTests && swift test --filter HermesAgentStoreTests` + +Expected: all pass, including simultaneous use of different session IDs. + +- [ ] **Step 5: Commit the ownership slice** + +```bash +git add apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift \ + apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift \ + apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift \ + apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +git commit -m "feat(hermes): guard cross-process session ownership" +``` + +### Task 6: Streaming Events and Actionable Hermes Requests + +**Files:** +- Modify: `apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift:19-63,565-706` +- Test: `apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift` + +**Interfaces:** +- Consumes: `HermesGatewayEvent` and the active runtime session ID. +- Produces: `HermesPendingRequest`, `HermesPendingRequestKind`, `HermesAgentStore.pendingRequest`, `respondToPendingRequest(value:)`, and `denyPendingApproval()`. + +- [ ] **Step 1: Write failing event and request-response tests** + +```swift +@MainActor +func testStreamingReasoningToolsAndCompletionUpdateTranscript() async { + client.emit(.init(type: "message.start", sessionID: "live-1", payload: [:])) + client.emit(.init(type: "message.delta", sessionID: "live-1", payload: ["text": .string("Hel")])) + client.emit(.init(type: "tool.start", sessionID: "live-1", payload: ["name": .string("terminal")])) + client.emit(.init(type: "message.complete", sessionID: "live-1", payload: [ + "text": .string("Hello"), "reasoning": .string("checked state"), + ])) + XCTAssertEqual(store.messages.last?.text, "Hello") + XCTAssertTrue(store.toolTraces.contains(where: { $0.name == "Thought" })) + XCTAssertFalse(store.isStreaming) +} + +@MainActor +func testAskFirstSurfacesApprovalAndRespondsWithSelectedChoice() async { + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: [ + "command": .string("git status"), + "choices": .array([.string("once"), .string("deny")]), + ])) + XCTAssertEqual(store.pendingRequest?.kind, .approval) + await store.respondToPendingRequest(value: "once") + XCTAssertEqual(client.calls.last?.method, "approval.respond") + XCTAssertEqual(client.calls.last?.params["choice"], .string("once")) +} +``` + +Add tests for configured auto-approve, clarify answer, sudo password, secret value, request expiry matched by `request_id`, error events, disconnect during streaming, and events from non-active sessions being ignored. + +- [ ] **Step 2: Run event tests and verify red** + +Run: `cd apps/MTPLXApp && swift test --filter HermesAgentStoreTests` + +Expected: pending-request model and response method failures. + +- [ ] **Step 3: Implement request state and response RPC mapping** + +```swift +public enum HermesPendingRequestKind: Equatable, Sendable { + case approval + case clarification + case sudo + case secret +} + +public struct HermesPendingRequest: Identifiable, Equatable, Sendable { + public let id: String + public let kind: HermesPendingRequestKind + public let prompt: String + public let choices: [String] +} +``` + +Map request events and response fields exactly: + +| Event | RPC | Value field | +|---|---|---| +| `approval.request` | `approval.respond` | `choice` plus `session_id`; deny uses `deny` | +| `clarify.request` | `clarify.respond` | `answer` plus `request_id` | +| `sudo.request` | `sudo.respond` | `password` plus `request_id` | +| `secret.request` | `secret.respond` | `value` plus `request_id` | + +When `configuration.hermesAutoApprove` is true, answer approvals with `choice=once` and never set `all=true`; when false, keep the composer paused and show the request. Clear only the pending request whose ID matches the corresponding `*.expire` event. Never log sudo/secret values. + +- [ ] **Step 4: Run the complete store test file** + +Run: `cd apps/MTPLXApp && swift test --filter HermesAgentStoreTests` + +Expected: all streaming, request, ownership, and lifecycle tests pass. + +- [ ] **Step 5: Commit the event slice** + +```bash +git add apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift \ + apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +git commit -m "feat(hermes): handle native agent events and prompts" +``` + +### Task 7: Enable the Embedded Hermes GUI + +**Files:** +- Modify: `apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift:5-975` + +**Interfaces:** +- Consumes: `profileRoutingStates`, `HermesSavedSession.activity`, `activeSessionWritable`, `readOnlyReason`, `pendingRequest`, reconnect, create/resume/send/interrupt, and stop APIs from Tasks 4-6. +- Produces: selectable profile/session UI, routing/activity badges, embedded transcript/composer, read-only new-session offer, request cards, reconnect action, and close cleanup. + +- [ ] **Step 1: Build once to capture the existing disabled-surface baseline** + +Run: `cd apps/MTPLXApp && swift build --product MTPLXApp` + +Expected: pass before the view edit; the source still contains three `nativeDashboardSupported` gates. + +- [ ] **Step 2: Replace the terminal gates with the embedded state** + +Remove the sidebar branch at line 140, composer branch at line 604, and `prepare()` early return at line 770. Render: + +```swift +Text(routeLabel(for: hermes.profileRoutingStates[profile.id] ?? .external)) + .font(.system(size: 9, weight: .heavy, design: .monospaced)) + .foregroundStyle(routeColor(for: profile)) + +Text(activityLabel(session.activity)) + .font(.system(size: 9, weight: .bold, design: .monospaced)) + +TextEditor(text: $composerText) + .disabled(!hermes.activeSessionWritable || !hermes.gatewayReady || hermes.pendingRequest != nil) +``` + +Show `MTPLX`, `External`, or `Unavailable` on profile rows. Show `Ready`, `Running in MTPLX`, `Externally active`, or `Ownership unknown` on session rows. Disable unavailable profile selection. Keep external profiles selectable because their child-only override is the feature. + +- [ ] **Step 3: Add read-only, reconnect, and pending-request actions** + +For external/unknown ownership, retain the transcript and replace the composer with the concrete reason plus a `New Agent` button calling `startNew()`. For `.failed`, retain transcript and render a `Reconnect` button calling `hermes.reconnect(configuration:)`. Render approval choices as buttons; render clarification as a text field; render sudo/secret values with `SecureField`; submit through `respondToPendingRequest(value:)`. + +- [ ] **Step 4: Stop only the embedded sidecar on close and start only the model daemon** + +Change `ensureDaemonReady()` to: + +```swift +private func ensureDaemonReady() async -> Bool { + guard backend.daemonState.kind != .running else { return true } + await backend.startDaemon(target: nil) + guard backend.daemonState.kind == .running else { + localError = "MTPLX is not ready yet." + return false + } + return true +} +``` + +Inject `HermesAgentStore` into `HermesOverlay` and wrap collapse: + +```swift +private func collapseAndStop() { + Task { + await hermes.stop() + onCollapse() + } +} +``` + +Use `ChatCloseButton(action: collapseAndStop)` and `.onDisappear { Task { await hermes.stop() } }`. This preserves the root gateway and MTPLX daemon while ending only the owned embedded sidecar. + +- [ ] **Step 5: Build and scan out stale gates** + +Run: `cd apps/MTPLXApp && swift build --product MTPLXApp` + +Run: `rg -n "nativeDashboardSupported|startDaemon\(target: \.hermes\)" apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift` + +Expected: build passes; the scan returns no matches. + +- [ ] **Step 6: Commit the GUI slice** + +```bash +git add apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift +git commit -m "feat(app): embed Hermes profile sessions" +``` + +### Task 8: Regression, Bundle Build, and Live Coexistence Acceptance + +**Files:** +- Modify only if a failing regression identifies a feature-owned defect in files from Tasks 1-7. + +**Interfaces:** +- Consumes: complete feature from Tasks 1-7. +- Produces: verified unit/integration suite, release app bundle, profile byte-preservation evidence, native Hermes session evidence, and Telegram coexistence evidence. + +- [ ] **Step 1: Run focused Hermes tests** + +Run: `cd apps/MTPLXApp && swift test --filter HermesEmbeddedRuntimeTests && swift test --filter HermesGatewayClientTests && swift test --filter HermesAgentStoreTests && swift test --filter MTPLXAppCoreTests.testHermes` + +Expected: all focused tests pass. + +- [ ] **Step 2: Run the complete Swift suite** + +Run: `cd apps/MTPLXApp && swift test` + +Expected: zero failures and no new warnings attributable to the Hermes feature. + +- [ ] **Step 3: Build the distributable app with the configured Xcode toolchain** + +Run: + +```bash +DEVELOPER_DIR=/Volumes/nugly/Applications/Xcode.app \ + apps/MTPLXApp/script/build_and_run.sh --no-launch +``` + +Expected: exit 0 and `apps/MTPLXApp/dist/MTPLXApp.app/Contents/MacOS/MTPLXApp` exists. + +- [ ] **Step 4: Record non-secret live baselines** + +For the selected external-provider profile, record SHA-256 checksums of `config.yaml` and `.env` without printing their contents. Record `env -u HERMES_HOME hermes gateway status`, the existing root gateway PID, and the profile/session IDs used for MTPLX and Telegram. Use different concrete session IDs. + +- [ ] **Step 5: Exercise a real embedded native session** + +Launch the built app deliberately, start the current MTPLX model, open Hermes, select the external-provider profile, create a new agent, send a harmless prompt, observe streaming completion/tool state, close the pane, reopen it, and resume the same saved session. Verify the profile checksums remain identical throughout. + +- [ ] **Step 6: Verify Hermes-native persistence** + +Use Hermes Desktop or the profile-scoped Hermes TUI/session list to confirm the MTPLX-created session ID and transcript are visible and resumable after the MTPLX sidecar stops. Do not inspect or copy secrets. + +- [ ] **Step 7: Verify Telegram coexistence on a different session** + +While the embedded pane is connected, send one Telegram message to an agent routed through the existing root multiplex gateway. Confirm the root gateway PID remains unchanged, the Telegram turn completes through MTPLX on its different session ID, and the embedded turn still completes. A serialized model generation is acceptable; route/config changes are not. + +- [ ] **Step 8: Verify ownership and cleanup behavior live** + +Open one saved session in another Hermes surface, confirm MTPLX marks it read-only and offers a new agent, then close the external owner and confirm the next refresh marks it ready. Quit MTPLX during an embedded sidecar run, relaunch, and confirm only the exact orphan ownership record is reaped while generic Hermes Desktop/TUI/root-gateway processes remain alive. + +- [ ] **Step 9: Re-run checks after any acceptance fix and commit** + +Run: `cd apps/MTPLXApp && swift test` + +Run: + +```bash +DEVELOPER_DIR=/Volumes/nugly/Applications/Xcode.app \ + apps/MTPLXApp/script/build_and_run.sh --no-launch +``` + +Expected: both pass after the last change. + +```bash +git add apps/MTPLXApp/Sources apps/MTPLXApp/Tests +git commit -m "test(hermes): verify embedded coexistence" +``` + +Skip the final commit when Tasks 1-7 already contain every needed change and the working tree has no feature-owned edits. diff --git a/docs/superpowers/specs/2026-08-01-embedded-hermes-agent-selection-design.md b/docs/superpowers/specs/2026-08-01-embedded-hermes-agent-selection-design.md index fe99e6e0f..6e16fdb1f 100644 --- a/docs/superpowers/specs/2026-08-01-embedded-hermes-agent-selection-design.md +++ b/docs/superpowers/specs/2026-08-01-embedded-hermes-agent-selection-design.md @@ -1,7 +1,7 @@ # Embedded Hermes Agent Selection Date: 2026-08-01 -Status: Conversational design approved; written specification awaiting review +Status: Approved ## Objective From 8623c6838cf6e2f160f674864b5f4205bf5b715f Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:06:09 +0200 Subject: [PATCH 03/40] feat(hermes): define embedded profile routing --- .../Services/HermesEmbeddedRuntime.swift | 62 ++++++++ .../Services/HermesIntegration.swift | 146 ++++++++++++++++++ .../HermesEmbeddedRuntimeTests.swift | 127 +++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift create mode 100644 apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift new file mode 100644 index 000000000..da6568c15 --- /dev/null +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift @@ -0,0 +1,62 @@ +import Foundation + +public enum HermesProfileRoutingState: Equatable, Sendable { + case mtplx + case external + case unavailable(String) +} + +public struct HermesServeLaunchSpec: Equatable, Sendable { + public let executableURL: URL + public let arguments: [String] + public let environment: [String: String] + public let token: String + public let launchID: String + public let parentPID: Int32 + + public init( + executableURL: URL, + arguments: [String], + environment: [String: String], + token: String, + launchID: String, + parentPID: Int32 + ) { + self.executableURL = executableURL + self.arguments = arguments + self.environment = environment + self.token = token + self.launchID = launchID + self.parentPID = parentPID + } +} + +/// The lifecycle operations provided by the embedded-runtime implementation. +/// Task 1 deliberately declares this contract without launching a process. +public protocol HermesSidecarControlling: Sendable { + var processIdentifier: Int32 { get } + func stop() +} + +public enum HermesSessionOwnership: Equatable, Sendable { + case appOwned + case external + case unavailable(String) +} + +public protocol HermesEmbeddedRuntime: Sendable { + func routingState( + for profile: HermesProfile, + configuration: MTPLXAppConfiguration + ) -> HermesProfileRoutingState + func startEmbeddedSidecar( + profile: HermesProfile, + configuration: MTPLXAppConfiguration + ) async throws -> any HermesSidecarControlling + func sessionOwnership( + profile: HermesProfile, + sessionID: String, + ownedSidecarPID: Int32? + ) -> HermesSessionOwnership + @discardableResult func reapOrphanedEmbeddedSidecars() -> [Int32] +} diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift index 1e46e7817..a0230afd1 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift @@ -396,6 +396,83 @@ public struct HermesIntegration: Sendable { return env } + /// Builds the environment and argument vector for a dashboard server that + /// belongs only to this app process. It does not launch the process. + public func serveLaunchSpec( + profile: HermesProfile, + configuration: MTPLXAppConfiguration, + token: String, + launchID: String, + parentPID: Int32 + ) throws -> HermesServeLaunchSpec { + guard Self.isValidEmbeddedLaunchID(launchID) else { + throw HermesIntegrationError.launchFailed("invalid embedded launch identifier") + } + guard let executableURL = resolveExecutable() else { + throw HermesIntegrationError.executableNotFound + } + + let modelID = OpenCodeIntegration.modelID(for: configuration.model) + let baseURL = OpenCodeIntegration.baseURLString( + host: configuration.host, + port: configuration.port + ) + let apiKey = configuration.apiKey?.isEmpty == false + ? configuration.apiKey! + : Self.localAPIKey + var processEnvironment = launchEnvironment(configuration: configuration) + processEnvironment.removeValue(forKey: "HERMES_HOME") + processEnvironment["CUSTOM_BASE_URL"] = baseURL + processEnvironment["OPENAI_BASE_URL"] = baseURL + processEnvironment["OPENAI_API_KEY"] = apiKey + processEnvironment["HERMES_MODEL"] = modelID + processEnvironment["HERMES_INFERENCE_MODEL"] = modelID + processEnvironment["HERMES_INFERENCE_PROVIDER"] = "custom" + processEnvironment["HERMES_DASHBOARD_SESSION_TOKEN"] = token + processEnvironment["HERMES_SESSION_PLATFORM"] = "mtplx-app" + processEnvironment["MTPLX_HERMES_LAUNCH_ID"] = launchID + processEnvironment["MTPLX_HERMES_PARENT_PID"] = String(parentPID) + + var arguments: [String] = [] + if !profile.isDefault { + arguments += ["-p", profile.name] + } + arguments += [ + "serve", "--isolated", "--host", "127.0.0.1", + "--port", "0", "--ssh-owner-nonce", launchID, + ] + return HermesServeLaunchSpec( + executableURL: executableURL, + arguments: arguments, + environment: processEnvironment, + token: token, + launchID: launchID, + parentPID: parentPID + ) + } + + /// Routes profiles without writing to them. An unavailable profile is + /// intentionally kept distinct from a readable, user-managed profile. + public func routingState( + for profile: HermesProfile, + configuration: MTPLXAppConfiguration + ) -> HermesProfileRoutingState { + guard let effective = Self.effectiveProfileConfiguration(at: URL(fileURLWithPath: profile.path)) else { + return .unavailable("Profile configuration is unavailable.") + } + let expectedBaseURL = OpenCodeIntegration.baseURLString( + host: configuration.host, + port: configuration.port + ) + let expectedModelID = OpenCodeIntegration.modelID(for: configuration.model) + if effective.provider == "custom" + && effective.baseURL == expectedBaseURL + && effective.modelReference == expectedModelID { + return .mtplx + } + return .external + } + @discardableResult public func sync(configuration: MTPLXAppConfiguration) throws -> HermesConfigResult { let modelID = OpenCodeIntegration.modelID(for: configuration.model) @@ -857,6 +934,75 @@ public struct HermesIntegration: Sendable { return candidate } + private struct HermesEffectiveProfileConfiguration { + let provider: String + let baseURL: String + let modelReference: String + } + + /// Reads only the routing fields from a profile. This deliberately does + /// not try to repair malformed user configuration: callers need a stable + /// unavailable state instead of silently treating a broken profile as an + /// external one. + private static func effectiveProfileConfiguration( + at profileURL: URL + ) -> HermesEffectiveProfileConfiguration? { + let configURL = profileURL.appendingPathComponent("config.yaml") + guard let configText = try? String(contentsOf: configURL, encoding: .utf8) else { + return nil + } + let document = parseTopLevelBlocks(configText) + guard let modelBlock = document.blocks.first(where: { $0.keyName == "model" }) else { + return nil + } + var values: [String: String] = [:] + for child in directChildBlocks(of: modelBlock) { + guard + values[child.key] == nil, + let value = yamlScalarValue(in: child.lines.first ?? "") + else { + return nil + } + values[child.key] = value + } + guard + let provider = values["provider"], !provider.isEmpty, + let modelReference = values["default"], !modelReference.isEmpty + else { + return nil + } + + let envURL = profileURL.appendingPathComponent(".env") + let envText = try? String(contentsOf: envURL, encoding: .utf8) + let baseURL = values["base_url"] + ?? envText.flatMap { dotenvValue("CUSTOM_BASE_URL", in: $0) } + guard let baseURL, !baseURL.isEmpty else { return nil } + return HermesEffectiveProfileConfiguration( + provider: provider, + baseURL: baseURL, + modelReference: modelReference + ) + } + + private static func yamlScalarValue(in line: String) -> String? { + guard let colon = line.firstIndex(of: ":") else { return nil } + var value = String(line[line.index(after: colon)...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return nil } + if value.hasPrefix("\"") || value.hasPrefix("'") { + guard value.count >= 2, value.first == value.last else { return nil } + value = String(value.dropFirst().dropLast()) + } else if let commentStart = value.firstIndex(of: "#") { + value = String(value[.. Bool { + value.range(of: "^[0-9a-f]{16}$", options: .regularExpression) != nil + } + private static func configYAML( modelID: String, baseURL: String, diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift new file mode 100644 index 000000000..88555e1d2 --- /dev/null +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift @@ -0,0 +1,127 @@ +import XCTest +@testable import MTPLXAppCore + +final class HermesEmbeddedRuntimeTests: XCTestCase { + private var root: URL! + private var hermesHome: URL! + private var integration: HermesIntegration! + private var configuration: MTPLXAppConfiguration! + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("HermesEmbeddedRuntimeTests-\(UUID().uuidString)", isDirectory: true) + hermesHome = root.appendingPathComponent(".hermes", isDirectory: true) + try FileManager.default.createDirectory(at: hermesHome, withIntermediateDirectories: true) + integration = HermesIntegration( + hermesHome: hermesHome, + executablePath: "/usr/bin/true", + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin", + "HERMES_HOME": "/inherited/hermes-home", + ] + ) + configuration = MTPLXAppConfiguration( + model: "/models/current-model", + host: "127.0.0.1", + port: 18080, + apiKey: "test-api-key" + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + func testNamedProfileLaunchUsesIsolatedServeAndProcessLocalMTPLXRoute() throws { + let tempProfile = try makeProfile(named: "bernd", config: mtplxConfig()) + let profile = HermesProfile(name: "bernd", path: tempProfile.path, isDefault: false) + + let spec = try integration.serveLaunchSpec( + profile: profile, + configuration: configuration, + token: "test-session-token", + launchID: "0123456789abcdef", + parentPID: 4242 + ) + + XCTAssertEqual(spec.arguments, [ + "-p", "bernd", "serve", "--isolated", "--host", "127.0.0.1", + "--port", "0", "--ssh-owner-nonce", "0123456789abcdef", + ]) + XCTAssertEqual(spec.environment["HERMES_INFERENCE_PROVIDER"], "custom") + XCTAssertEqual(spec.environment["CUSTOM_BASE_URL"], "http://127.0.0.1:18080/v1") + XCTAssertEqual(spec.environment["HERMES_INFERENCE_MODEL"], "current-model") + XCTAssertEqual(spec.environment["HERMES_DASHBOARD_SESSION_TOKEN"], "test-session-token") + XCTAssertEqual(spec.environment["MTPLX_HERMES_PARENT_PID"], "4242") + XCTAssertNil(spec.environment["HERMES_HOME"]) + } + + func testDefaultProfileLaunchOmitsProfileFlag() throws { + let spec = try integration.serveLaunchSpec( + profile: HermesProfile(name: "default", path: hermesHome.path, isDefault: true), + configuration: configuration, + token: "test-session-token", + launchID: "fedcba9876543210", + parentPID: 4242 + ) + + XCTAssertEqual(Array(spec.arguments.prefix(5)), ["serve", "--isolated", "--host", "127.0.0.1", "--port"]) + XCTAssertFalse(spec.arguments.contains("-p")) + } + + func testServeLaunchSpecRejectsNonHexOrWrongLengthLaunchID() { + XCTAssertThrowsError( + try integration.serveLaunchSpec( + profile: HermesProfile(name: "default", path: hermesHome.path, isDefault: true), + configuration: configuration, + token: "test-session-token", + launchID: "not-a-safe-launch-id", + parentPID: 4242 + ) + ) + } + + func testProfileRoutingClassifiesMTPLXExternalAndUnavailableIndependently() throws { + let mtplxProfile = HermesProfile( + name: "mtplx", + path: try makeProfile(named: "mtplx", config: mtplxConfig()).path, + isDefault: false + ) + let externalProfile = HermesProfile( + name: "external", + path: try makeProfile( + named: "external", + config: "model:\n default: external-model\n provider: anthropic\n base_url: https://api.example.test/v1\n" + ).path, + isDefault: false + ) + let unreadableProfile = HermesProfile( + name: "unavailable", + path: try makeProfile(named: "unavailable", config: "not: [valid").path, + isDefault: false + ) + + XCTAssertEqual(integration.routingState(for: mtplxProfile, configuration: configuration), .mtplx) + XCTAssertEqual(integration.routingState(for: externalProfile, configuration: configuration), .external) + guard case .unavailable = integration.routingState(for: unreadableProfile, configuration: configuration) else { + return XCTFail("Unreadable profile must remain visible as unavailable") + } + } + + private func makeProfile(named name: String, config: String) throws -> URL { + let profile = hermesHome.appendingPathComponent("profiles/\(name)", isDirectory: true) + try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) + try config.write(to: profile.appendingPathComponent("config.yaml"), atomically: true, encoding: .utf8) + return profile + } + + private func mtplxConfig() -> String { + """ + model: + default: current-model + provider: custom + base_url: http://127.0.0.1:18080/v1 + """ + } +} From 30c7e31ae0c3d173b1aef9a5dad58bd72abc1de6 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:13:14 +0200 Subject: [PATCH 04/40] fix(hermes): isolate embedded routing --- .../Services/HermesIntegration.swift | 31 +++++++- .../HermesEmbeddedRuntimeTests.swift | 75 ++++++++++++++++++- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift index a0230afd1..3c5d1cee3 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift @@ -422,6 +422,9 @@ public struct HermesIntegration: Sendable { : Self.localAPIKey var processEnvironment = launchEnvironment(configuration: configuration) processEnvironment.removeValue(forKey: "HERMES_HOME") + for key in Self.messagingBridgeKeys { + processEnvironment.removeValue(forKey: key) + } processEnvironment["CUSTOM_BASE_URL"] = baseURL processEnvironment["OPENAI_BASE_URL"] = baseURL processEnvironment["OPENAI_API_KEY"] = apiKey @@ -952,7 +955,8 @@ public struct HermesIntegration: Sendable { return nil } let document = parseTopLevelBlocks(configText) - guard let modelBlock = document.blocks.first(where: { $0.keyName == "model" }) else { + let modelBlocks = document.blocks.filter { $0.keyName == "model" } + guard modelBlocks.count == 1, let modelBlock = modelBlocks.first else { return nil } var values: [String: String] = [:] @@ -974,8 +978,10 @@ public struct HermesIntegration: Sendable { let envURL = profileURL.appendingPathComponent(".env") let envText = try? String(contentsOf: envURL, encoding: .utf8) + let customBaseURLs = envText.map { dotenvValues("CUSTOM_BASE_URL", in: $0) } ?? [] + guard customBaseURLs.count <= 1 else { return nil } let baseURL = values["base_url"] - ?? envText.flatMap { dotenvValue("CUSTOM_BASE_URL", in: $0) } + ?? customBaseURLs.first guard let baseURL, !baseURL.isEmpty else { return nil } return HermesEffectiveProfileConfiguration( provider: provider, @@ -999,6 +1005,27 @@ public struct HermesIntegration: Sendable { return value } + private static func dotenvValues(_ key: String, in text: String) -> [String] { + text.split(whereSeparator: \.isNewline).compactMap { rawLine in + var line = String(rawLine).trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty, !line.hasPrefix("#") else { return nil } + if line.hasPrefix("export ") { + line = String(line.dropFirst("export ".count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + guard let equals = line.firstIndex(of: "="), String(line[.. Bool { value.range(of: "^[0-9a-f]{16}$", options: .regularExpression) != nil } diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift index 88555e1d2..55d8ab2a7 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift @@ -70,6 +70,50 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { XCTAssertFalse(spec.arguments.contains("-p")) } + func testEmbeddedLaunchDoesNotInheritRootMessagingCredentials() throws { + try """ + TELEGRAM_BOT_TOKEN=root-telegram-token + TELEGRAM_ALLOWED_USERS=123456 + DISCORD_BOT_TOKEN=root-discord-token + SLACK_BOT_TOKEN=root-slack-token + SIGNAL_ACCOUNT=root-signal-account + """.write( + to: hermesHome.appendingPathComponent(".env"), + atomically: true, + encoding: .utf8 + ) + + let credentialedIntegration = HermesIntegration( + hermesHome: hermesHome, + executablePath: "/usr/bin/true", + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin", + "HERMES_HOME": "/inherited/hermes-home", + "TELEGRAM_BOT_TOKEN": "root-telegram-token", + "TELEGRAM_ALLOWED_USERS": "123456", + "DISCORD_BOT_TOKEN": "root-discord-token", + "SLACK_BOT_TOKEN": "root-slack-token", + "SIGNAL_ACCOUNT": "root-signal-account", + ] + ) + + let spec = try credentialedIntegration.serveLaunchSpec( + profile: HermesProfile(name: "default", path: hermesHome.path, isDefault: true), + configuration: configuration, + token: "test-session-token", + launchID: "fedcba9876543210", + parentPID: 4242 + ) + + for key in [ + "TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS", "DISCORD_BOT_TOKEN", + "SLACK_BOT_TOKEN", "SIGNAL_ACCOUNT", + ] { + XCTAssertNil(spec.environment[key], "Embedded child must not receive \(key)") + } + } + func testServeLaunchSpecRejectsNonHexOrWrongLengthLaunchID() { XCTAssertThrowsError( try integration.serveLaunchSpec( @@ -109,10 +153,39 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { } } - private func makeProfile(named name: String, config: String) throws -> URL { + func testProfileRoutingRejectsDuplicateModelBlocksAndRoutingKeys() throws { + let duplicateModel = HermesProfile( + name: "duplicate-model", + path: try makeProfile( + named: "duplicate-model", + config: mtplxConfig() + "\n" + mtplxConfig() + ).path, + isDefault: false + ) + let duplicateBaseURL = HermesProfile( + name: "duplicate-base-url", + path: try makeProfile( + named: "duplicate-base-url", + config: "model:\n default: current-model\n provider: custom\n", + env: "CUSTOM_BASE_URL=http://127.0.0.1:18080/v1\nCUSTOM_BASE_URL=http://127.0.0.1:18080/v1\n" + ).path, + isDefault: false + ) + + for profile in [duplicateModel, duplicateBaseURL] { + guard case .unavailable = integration.routingState(for: profile, configuration: configuration) else { + return XCTFail("Duplicate routing configuration must fail closed") + } + } + } + + private func makeProfile(named name: String, config: String, env: String? = nil) throws -> URL { let profile = hermesHome.appendingPathComponent("profiles/\(name)", isDirectory: true) try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) try config.write(to: profile.appendingPathComponent("config.yaml"), atomically: true, encoding: .utf8) + if let env { + try env.write(to: profile.appendingPathComponent(".env"), atomically: true, encoding: .utf8) + } return profile } From 043f9cfafcc1d4875ca83df86be399dd8e7eac97 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:24:41 +0200 Subject: [PATCH 05/40] feat(hermes): launch owned isolated sidecars --- .../Services/HermesEmbeddedRuntime.swift | 125 ++++++- .../Services/HermesIntegration.swift | 351 +++++++++++++++++- .../HermesEmbeddedRuntimeTests.swift | 109 +++++- 3 files changed, 572 insertions(+), 13 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift index da6568c15..86c000197 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift @@ -31,13 +31,132 @@ public struct HermesServeLaunchSpec: Equatable, Sendable { } } -/// The lifecycle operations provided by the embedded-runtime implementation. -/// Task 1 deliberately declares this contract without launching a process. -public protocol HermesSidecarControlling: Sendable { +public protocol HermesSidecarControlling: AnyObject, Sendable { var processIdentifier: Int32 { get } + var isRunning: Bool { get } + var webSocketURL: URL { get } + var ownershipRecordURL: URL { get } func stop() } +public struct HermesSidecarOwnershipRecord: Codable, Equatable, Sendable { + public let launchID: String + public let pid: Int32 + public let parentPID: Int32 + public let profileName: String + public let createdAt: Date + + public init(launchID: String, pid: Int32, parentPID: Int32, profileName: String, createdAt: Date) { + self.launchID = launchID + self.pid = pid + self.parentPID = parentPID + self.profileName = profileName + self.createdAt = createdAt + } +} + +public struct HermesSidecarProcessSnapshot: Equatable, Sendable { + public let pid: Int32 + public let arguments: [String] + + public init(pid: Int32, arguments: [String]) { + self.pid = pid + self.arguments = arguments + } +} + +enum HermesBackendReadyParser { + static func port(from line: String) -> Int? { + let prefix = "HERMES_BACKEND_READY port=" + guard line.hasPrefix(prefix), + let port = Int(line.dropFirst(prefix.count)), + (1...65_535).contains(port) + else { return nil } + return port + } +} + +/// Pure command matching used by orphan recovery. It intentionally does not +/// attempt to infer ownership from a generic Hermes command: a persisted MTPLX +/// record, exact PID, dead recorded parent, and the two exact argument pairs +/// are all required before the caller sends a signal. +enum HermesOrphanSidecarScanner { + static func orphanPIDs( + records: [HermesSidecarOwnershipRecord], + processes: [HermesSidecarProcessSnapshot], + livePIDs: Set + ) -> [Int32] { + let processesByPID = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) }) + return records.compactMap { record in + guard record.pid > 1, + record.parentPID > 1, + !livePIDs.contains(record.parentPID), + let process = processesByPID[record.pid], + isExactOwnedSidecar(process, launchID: record.launchID) + else { return nil } + return record.pid + } + .sorted() + } + + static func isExactOwnedSidecar( + _ process: HermesSidecarProcessSnapshot, + launchID: String + ) -> Bool { + let arguments = process.arguments + guard let serveIndex = arguments.firstIndex(of: "serve"), + arguments.indices.contains(serveIndex + 1), + arguments[serveIndex + 1] == "--isolated", + arguments.filter({ $0 == "serve" }).count == 1, + arguments.filter({ $0 == "--isolated" }).count == 1, + let markerIndex = arguments.firstIndex(of: "--ssh-owner-nonce"), + arguments.indices.contains(markerIndex + 1), + arguments[markerIndex + 1] == launchID, + arguments.filter({ $0 == "--ssh-owner-nonce" }).count == 1 + else { return false } + return true + } +} + +/// Thread-safe readiness state shared by pipe reader callbacks and the +/// bounded launcher worker. Sentinel parsing is deliberately the only source +/// of a port; prose emitted by Hermes can never become a connection target. +final class HermesSidecarReadiness: @unchecked Sendable { + private let lock = NSLock() + private var remainder = "" + private var readyPort: Int? + private var malformedSentinel = false + + func consume(_ data: Data) { + let text = String(decoding: data, as: UTF8.self) + lock.lock() + remainder.append(text) + let lines = remainder.split(separator: "\n", omittingEmptySubsequences: false) + if remainder.hasSuffix("\n") { + remainder = "" + } else { + remainder = lines.last.map(String.init) ?? "" + } + let completeLines = remainder.isEmpty ? lines : lines.dropLast() + for line in completeLines { + let string = String(line).trimmingCharacters(in: .newlines) + guard string.hasPrefix("HERMES_BACKEND_READY port=") else { continue } + if let port = HermesBackendReadyParser.port(from: string) { + readyPort = port + } else { + malformedSentinel = true + } + } + lock.unlock() + } + + func result() -> (port: Int?, malformed: Bool) { + lock.lock() + defer { lock.unlock() } + return (readyPort, malformedSentinel) + } +} + public enum HermesSessionOwnership: Equatable, Sendable { case appOwned case external diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift index 3c5d1cee3..da8dcbc82 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift @@ -166,22 +166,37 @@ public enum HermesIntegrationError: Error, Equatable, LocalizedError { } } -public final class HermesSidecar: @unchecked Sendable { +public final class HermesSidecar: HermesSidecarControlling, @unchecked Sendable { public let process: Process public let port: Int - public let token: String public let dashboardURL: URL public let webSocketURL: URL + public let ownershipRecordURL: URL + private let outputPipes: [Pipe] - init(process: Process, port: Int, token: String) { + init( + process: Process, + port: Int, + token: String, + ownershipRecordURL: URL, + outputPipes: [Pipe] + ) { self.process = process self.port = port - self.token = token self.dashboardURL = URL(string: "http://127.0.0.1:\(port)/")! self.webSocketURL = URL(string: "ws://127.0.0.1:\(port)/api/ws?token=\(token)")! + self.ownershipRecordURL = ownershipRecordURL + self.outputPipes = outputPipes } + public var processIdentifier: Int32 { process.processIdentifier } + public var isRunning: Bool { process.isRunning } + public func stop() { + defer { + outputPipes.forEach { $0.fileHandleForReading.readabilityHandler = nil } + try? FileManager.default.removeItem(at: ownershipRecordURL) + } guard process.isRunning else { return } process.terminate() let deadline = Date().addingTimeInterval(2) @@ -226,6 +241,9 @@ public struct HermesIntegration: Sendable { /// (`{"profile": ""}`, validated by their renderer's /// PROFILE_NAME_RE; the desktop persists its own selection thereafter). public let activeProfileURL: URL + /// Runtime-only MTPLX ownership records for isolated embedded children. + /// This is deliberately outside every Hermes profile directory. + public let sidecarRuntimeDirectory: URL /// Test seam: bypass bootstrap-layout + LaunchServices discovery. public let desktopApplicationOverride: URL? @@ -239,6 +257,8 @@ public struct HermesIntegration: Sendable { activeProfileURL: URL = URL(fileURLWithPath: NSHomeDirectory()) .appendingPathComponent("Library/Application Support/Hermes") .appendingPathComponent("active-profile.json"), + sidecarRuntimeDirectory: URL = URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent(".mtplx/hermes-sidecars", isDirectory: true), desktopApplicationOverride: URL? = nil ) { self.hermesHome = hermesHome @@ -246,6 +266,7 @@ public struct HermesIntegration: Sendable { self.environment = environment self.terminalCommandURL = terminalCommandURL self.activeProfileURL = activeProfileURL + self.sidecarRuntimeDirectory = sidecarRuntimeDirectory self.desktopApplicationOverride = desktopApplicationOverride } @@ -816,11 +837,193 @@ public struct HermesIntegration: Sendable { profile: HermesProfile, configuration: MTPLXAppConfiguration ) async throws -> HermesSidecar { - _ = profile - _ = configuration - throw HermesIntegrationError.incompatible( - "This Hermes build exposes CLI chat and ACP, not the dashboard WebSocket surface." - ) + guard let sidecar = try await startEmbeddedSidecar( + profile: profile, + configuration: configuration + ) as? HermesSidecar else { + throw HermesIntegrationError.launchFailed("embedded sidecar controller was unavailable") + } + return sidecar + } + + /// Launches one process-local, loopback-only Hermes backend. Nothing in + /// this path calls `sync(configuration:)`: selected profiles remain byte + /// for byte user-owned while their child receives the MTPLX route only in + /// its environment. + public func startEmbeddedSidecar( + profile: HermesProfile, + configuration: MTPLXAppConfiguration + ) async throws -> any HermesSidecarControlling { + try await Task.detached(priority: .userInitiated) { [self] in + let token = Self.randomURLSafeToken(byteCount: 32) + let launchID = Self.randomLaunchID() + let spec = try serveLaunchSpec( + profile: profile, + configuration: configuration, + token: token, + launchID: launchID, + parentPID: getpid() + ) + + let process = Process() + process.executableURL = spec.executableURL + process.arguments = spec.arguments + process.environment = spec.environment + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + let readiness = HermesSidecarReadiness() + let stderrTail = SubprocessTailBuffer(capacity: 4_096) + let redact = Self.stderrRedactor(for: spec) + stdout.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + if chunk.isEmpty { + handle.readabilityHandler = nil + } else { + readiness.consume(chunk) + } + } + stderr.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + if chunk.isEmpty { + handle.readabilityHandler = nil + } else { + stderrTail.append(Data(redact(String(decoding: chunk, as: UTF8.self)).utf8)) + } + } + + var launched = false + defer { + if launched { + stdout.fileHandleForReading.readabilityHandler = nil + stderr.fileHandleForReading.readabilityHandler = nil + if process.isRunning { + Self.stopChild(process) + } + } + } + + do { + try process.run() + launched = true + } catch { + throw HermesIntegrationError.launchFailed("could not launch isolated Hermes sidecar") + } + + let deadline = Date().addingTimeInterval(15) + var port: Int? + while Date() < deadline { + let result = readiness.result() + if result.malformed { + throw HermesIntegrationError.launchFailed( + Self.startupDiagnostic("Hermes emitted an invalid readiness sentinel.", stderrTail: stderrTail) + ) + } + if let readyPort = result.port { + port = readyPort + break + } + if !process.isRunning { + // A just-exited child can race its final stdout callback. + try await Task.sleep(for: .milliseconds(50)) + let final = readiness.result() + if final.malformed { + throw HermesIntegrationError.launchFailed( + Self.startupDiagnostic("Hermes emitted an invalid readiness sentinel.", stderrTail: stderrTail) + ) + } + if let readyPort = final.port { + port = readyPort + break + } + throw HermesIntegrationError.launchFailed( + Self.startupDiagnostic("Hermes exited before it became ready.", stderrTail: stderrTail) + ) + } + try await Task.sleep(for: .milliseconds(20)) + } + guard let port else { + throw HermesIntegrationError.launchFailed( + Self.startupDiagnostic("Hermes did not become ready within 15 seconds.", stderrTail: stderrTail) + ) + } + + let recordURL = sidecarRuntimeDirectory + .appendingPathComponent("\(spec.launchID).json", isDirectory: false) + let record = HermesSidecarOwnershipRecord( + launchID: spec.launchID, + pid: process.processIdentifier, + parentPID: spec.parentPID, + profileName: profile.name, + createdAt: Date() + ) + do { + try Self.writeOwnershipRecord(record, to: recordURL) + } catch { + throw HermesIntegrationError.launchFailed( + Self.startupDiagnostic("Could not record isolated Hermes ownership.", stderrTail: stderrTail) + ) + } + + launched = false + return HermesSidecar( + process: process, + port: port, + token: token, + ownershipRecordURL: recordURL, + outputPipes: [stdout, stderr] + ) + }.value + } + + /// Removes only dead records and sidecars that can be proven to be ours at + /// the instant of signalling. A generic Hermes Desktop/TUI/gateway never + /// has a record plus this exact marker, so it remains read-only here. + @discardableResult + public func reapOrphanedEmbeddedSidecars() -> [Int32] { + let records = Self.readOwnershipRecords(in: sidecarRuntimeDirectory) + var liveRecords: [(URL, HermesSidecarOwnershipRecord, HermesSidecarProcessSnapshot)] = [] + for (url, record) in records { + guard let snapshot = Self.processSnapshot(pid: record.pid) else { + try? FileManager.default.removeItem(at: url) + continue + } + liveRecords.append((url, record, snapshot)) + } + + let candidatePIDs = Set(HermesOrphanSidecarScanner.orphanPIDs( + records: liveRecords.map(\.1), + processes: liveRecords.map(\.2), + livePIDs: Set(liveRecords.map(\.1.parentPID).filter(Self.isPIDAlive)) + )) + var reaped: [Int32] = [] + for (url, record, _) in liveRecords where candidatePIDs.contains(record.pid) { + // Re-read immediately before TERM. PID reuse or a changed command + // must turn cleanup into a no-op rather than an accidental kill. + guard !Self.isPIDAlive(record.parentPID), + let beforeTERM = Self.processSnapshot(pid: record.pid), + HermesOrphanSidecarScanner.isExactOwnedSidecar(beforeTERM, launchID: record.launchID) + else { continue } + _ = kill(record.pid, SIGTERM) + let deadline = Date().addingTimeInterval(2) + while Self.isPIDAlive(record.pid) && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + if Self.isPIDAlive(record.pid) { + guard !Self.isPIDAlive(record.parentPID), + let beforeKILL = Self.processSnapshot(pid: record.pid), + HermesOrphanSidecarScanner.isExactOwnedSidecar(beforeKILL, launchID: record.launchID) + else { continue } + _ = kill(record.pid, SIGKILL) + } + if !Self.isPIDAlive(record.pid) { + try? FileManager.default.removeItem(at: url) + reaped.append(record.pid) + } + } + return reaped.sorted() } public func createProfile(named rawName: String) async throws -> HermesProfile { @@ -1671,6 +1874,134 @@ public struct HermesIntegration: Sendable { throw HermesIntegrationError.dashboardTokenTimeout } + private static func randomURLSafeToken(byteCount: Int) -> String { + let data = Data((0.. String { + Data((0..<8).map { _ in UInt8.random(in: .min ... .max) }) + .map { String(format: "%02x", $0) } + .joined() + } + + private static func stderrRedactor(for spec: HermesServeLaunchSpec) -> @Sendable (String) -> String { + let values = Set(spec.environment.values + [spec.token]) + .filter { $0.count >= 4 } + .sorted { $0.count > $1.count } + return { text in + values.reduce(text) { partial, value in + partial.replacingOccurrences(of: value, with: "[redacted]") + } + } + } + + private static func startupDiagnostic(_ summary: String, stderrTail: SubprocessTailBuffer) -> String { + let tail = stderrTail.snapshot() + .trimmingCharacters(in: .whitespacesAndNewlines) + return tail.isEmpty ? summary : "\(summary) \(tail)" + } + + private static func stopChild(_ process: Process) { + guard process.isRunning else { return } + process.terminate() + let deadline = Date().addingTimeInterval(2) + while process.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + } + + private static func writeOwnershipRecord( + _ record: HermesSidecarOwnershipRecord, + to recordURL: URL + ) throws { + let directory = recordURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + let data = try JSONEncoder().encode(record) + try data.write(to: recordURL, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: recordURL.path) + } + + private static func readOwnershipRecords( + in directory: URL + ) -> [(URL, HermesSidecarOwnershipRecord)] { + guard let files = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return [] } + let decoder = JSONDecoder() + return files.compactMap { url in + guard url.pathExtension == "json", + let data = try? Data(contentsOf: url), + let record = try? decoder.decode(HermesSidecarOwnershipRecord.self, from: data), + isValidEmbeddedLaunchID(record.launchID), + record.pid > 1, + record.parentPID > 1 + else { return nil } + return (url, record) + } + } + + private static func isPIDAlive(_ pid: Int32) -> Bool { + guard pid > 1 else { return false } + if kill(pid, 0) == 0 { return true } + return errno == EPERM + } + + private static func processSnapshot(pid: Int32) -> HermesSidecarProcessSnapshot? { + guard pid > 1 else { return nil } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-p", String(pid), "-o", "pid=,command="] + let output = Pipe() + process.standardOutput = output + process.standardError = Pipe() + let watchdog = SubprocessWatchdog(process) + do { + try process.run() + } catch { + return nil + } + let drain = SubprocessPipeDrain(output, capacity: 16_384) + guard watchdog.wait(for: process, timeout: 2, terminateGrace: 0.2, killGrace: 0.2) else { + drain.join(timeout: 0.2) + return nil + } + drain.join(timeout: 0.2) + guard process.terminationStatus == 0, + let row = drain.snapshot().split(separator: "\n").first + else { return nil } + let text = String(row).trimmingCharacters(in: .whitespacesAndNewlines) + guard let separator = text.firstIndex(where: { $0.isWhitespace }), + Int32(text[.. 1 else { return nil } + return HermesSidecarProcessSnapshot(pid: pid, arguments: Array(parts.dropFirst())) + } + + public func sessionOwnership( + profile: HermesProfile, + sessionID: String, + ownedSidecarPID: Int32? + ) -> HermesSessionOwnership { + _ = profile + _ = sessionID + _ = ownedSidecarPID + // Task 5 replaces this conservative placeholder with Hermes' native + // active-session registry. Until then, no session is assumed writable. + return .unavailable("Session ownership is unavailable.") + } + private func runAndCapture( executableURL: URL, arguments: [String], @@ -1907,3 +2238,5 @@ public struct HermesIntegration: Sendable { return Int(UInt16(bigEndian: addr.sin_port)) } } + +extension HermesIntegration: HermesEmbeddedRuntime {} diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift index 55d8ab2a7..f6eb362ae 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift @@ -4,6 +4,7 @@ import XCTest final class HermesEmbeddedRuntimeTests: XCTestCase { private var root: URL! private var hermesHome: URL! + private var sidecarRuntimeDirectory: URL! private var integration: HermesIntegration! private var configuration: MTPLXAppConfiguration! @@ -11,6 +12,7 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { root = FileManager.default.temporaryDirectory .appendingPathComponent("HermesEmbeddedRuntimeTests-\(UUID().uuidString)", isDirectory: true) hermesHome = root.appendingPathComponent(".hermes", isDirectory: true) + sidecarRuntimeDirectory = root.appendingPathComponent("sidecars", isDirectory: true) try FileManager.default.createDirectory(at: hermesHome, withIntermediateDirectories: true) integration = HermesIntegration( hermesHome: hermesHome, @@ -19,7 +21,8 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { "HOME": root.path, "PATH": "/usr/bin:/bin", "HERMES_HOME": "/inherited/hermes-home", - ] + ], + sidecarRuntimeDirectory: sidecarRuntimeDirectory ) configuration = MTPLXAppConfiguration( model: "/models/current-model", @@ -179,6 +182,91 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { } } + func testReadyParserAcceptsHeadlessSentinelOnly() { + XCTAssertEqual(HermesBackendReadyParser.port(from: "HERMES_BACKEND_READY port=45123"), 45123) + XCTAssertNil(HermesBackendReadyParser.port(from: "Hermes backend listening on 0.0.0.0:45123")) + XCTAssertNil(HermesBackendReadyParser.port(from: "HERMES_BACKEND_READY port=0")) + XCTAssertNil(HermesBackendReadyParser.port(from: "HERMES_BACKEND_READY port=45123 extra")) + } + + func testSidecarUsesCallerTokenAndRemovesOwnershipRecordOnStopWithoutChangingProfileFiles() async throws { + let profileURL = try makeProfile( + named: "fixture", + config: mtplxConfig(), + env: "EXTERNAL_PROVIDER_TOKEN=keep-this-byte-identical\n" + ) + let configURL = profileURL.appendingPathComponent("config.yaml") + let envURL = profileURL.appendingPathComponent(".env") + let configBefore = try Data(contentsOf: configURL) + let envBefore = try Data(contentsOf: envURL) + let environmentCaptureURL = root.appendingPathComponent("fixture-environment.txt") + let fixture = try makeSidecarFixture(environmentCaptureURL: environmentCaptureURL) + let fixtureIntegration = HermesIntegration( + hermesHome: hermesHome, + executablePath: fixture.path, + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin", + ], + sidecarRuntimeDirectory: sidecarRuntimeDirectory + ) + let profile = HermesProfile(name: "fixture", path: profileURL.path, isDefault: false) + + let sidecar = try await fixtureIntegration.startEmbeddedSidecar( + profile: profile, + configuration: configuration + ) + + XCTAssertEqual(sidecar.webSocketURL.host, "127.0.0.1") + XCTAssertEqual(sidecar.webSocketURL.path, "/api/ws") + let token = URLComponents(url: sidecar.webSocketURL, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "token" })? + .value + XCTAssertNotNil(token) + XCTAssertEqual(token?.count, 43) + XCTAssertTrue(FileManager.default.fileExists(atPath: sidecar.ownershipRecordURL.path)) + XCTAssertEqual(try Data(contentsOf: configURL), configBefore) + XCTAssertEqual(try Data(contentsOf: envURL), envBefore) + XCTAssertEqual( + try String(contentsOf: environmentCaptureURL, encoding: .utf8), + "HERMES_DASHBOARD_SESSION_TOKEN=present\nOPENAI_API_KEY=present\n" + ) + + sidecar.stop() + + XCTAssertFalse(FileManager.default.fileExists(atPath: sidecar.ownershipRecordURL.path)) + XCTAssertFalse(sidecar.isRunning) + XCTAssertEqual(try Data(contentsOf: configURL), configBefore) + XCTAssertEqual(try Data(contentsOf: envURL), envBefore) + } + + func testOrphanCleanupRequiresExactMarkerCommandAndDeadParent() { + let records = [ + HermesSidecarOwnershipRecord(launchID: "1111111111111111", pid: 7101, parentPID: 8001, profileName: "one", createdAt: .now), + HermesSidecarOwnershipRecord(launchID: "2222222222222222", pid: 7102, parentPID: 8002, profileName: "two", createdAt: .now), + HermesSidecarOwnershipRecord(launchID: "3333333333333333", pid: 7103, parentPID: 8003, profileName: "three", createdAt: .now), + HermesSidecarOwnershipRecord(launchID: "4444444444444444", pid: 7104, parentPID: 9001, profileName: "four", createdAt: .now), + ] + let processes = [ + HermesSidecarProcessSnapshot(pid: 7101, arguments: ["-p", "one", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "1111111111111111"]), + HermesSidecarProcessSnapshot(pid: 7102, arguments: ["serve", "--host", "127.0.0.1", "--port", "0"]), + HermesSidecarProcessSnapshot(pid: 7103, arguments: ["serve", "--isolated", "--ssh-owner-nonce", "wrong-marker"]), + HermesSidecarProcessSnapshot(pid: 7104, arguments: ["serve", "--isolated", "--ssh-owner-nonce", "4444444444444444"]), + ] + + let killed = HermesOrphanSidecarScanner.orphanPIDs( + records: records, + processes: processes, + livePIDs: [9001] + ) + + XCTAssertEqual(killed, [7101]) + XCTAssertFalse(killed.contains(7102)) + XCTAssertFalse(killed.contains(7103)) + XCTAssertFalse(killed.contains(7104)) + } + private func makeProfile(named name: String, config: String, env: String? = nil) throws -> URL { let profile = hermesHome.appendingPathComponent("profiles/\(name)", isDirectory: true) try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) @@ -197,4 +285,23 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { base_url: http://127.0.0.1:18080/v1 """ } + + private func makeSidecarFixture(environmentCaptureURL: URL) throws -> URL { + let fixture = root.appendingPathComponent("hermes-fixture.sh") + let script = """ + #!/bin/sh + if [ -n \"$HERMES_DASHBOARD_SESSION_TOKEN\" ]; then + printf 'HERMES_DASHBOARD_SESSION_TOKEN=present\\n' > \"\(environmentCaptureURL.path)\" + fi + if [ -n \"$OPENAI_API_KEY\" ]; then + printf 'OPENAI_API_KEY=present\\n' >> \"\(environmentCaptureURL.path)\" + fi + printf 'HERMES_BACKEND_READY port=45123\\n' + trap 'exit 0' TERM INT + while :; do sleep 1; done + """ + try script.write(to: fixture, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.path) + return fixture + } } From 428bcd9941187eec10be38d1b27198fc9e5074ef Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:39:19 +0200 Subject: [PATCH 06/40] fix(hermes): harden sidecar ownership checks --- .../Services/HermesEmbeddedRuntime.swift | 151 ++++++++++++++-- .../Services/HermesIntegration.swift | 161 +++++++++++------- .../HermesEmbeddedRuntimeTests.swift | 93 +++++++++- 3 files changed, 318 insertions(+), 87 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift index 86c000197..0d493dd9c 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift @@ -45,22 +45,74 @@ public struct HermesSidecarOwnershipRecord: Codable, Equatable, Sendable { public let parentPID: Int32 public let profileName: String public let createdAt: Date + public let executablePath: String + public let argv0: String + public let arguments: [String] - public init(launchID: String, pid: Int32, parentPID: Int32, profileName: String, createdAt: Date) { + public init( + launchID: String, + pid: Int32, + parentPID: Int32, + profileName: String, + createdAt: Date, + executablePath: String = "", + argv0: String? = nil, + arguments: [String] = [] + ) { self.launchID = launchID self.pid = pid self.parentPID = parentPID self.profileName = profileName self.createdAt = createdAt + self.executablePath = executablePath + self.argv0 = argv0 ?? executablePath + self.arguments = arguments + } + + private enum CodingKeys: String, CodingKey { + case launchID, pid, parentPID, profileName, createdAt, executablePath, argv0, arguments + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + launchID = try container.decode(String.self, forKey: .launchID) + pid = try container.decode(Int32.self, forKey: .pid) + parentPID = try container.decode(Int32.self, forKey: .parentPID) + profileName = try container.decode(String.self, forKey: .profileName) + createdAt = try container.decode(Date.self, forKey: .createdAt) + executablePath = try container.decodeIfPresent(String.self, forKey: .executablePath) ?? "" + argv0 = try container.decodeIfPresent(String.self, forKey: .argv0) ?? executablePath + arguments = try container.decodeIfPresent([String].self, forKey: .arguments) ?? [] + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(launchID, forKey: .launchID) + try container.encode(pid, forKey: .pid) + try container.encode(parentPID, forKey: .parentPID) + try container.encode(profileName, forKey: .profileName) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(executablePath, forKey: .executablePath) + try container.encode(argv0, forKey: .argv0) + try container.encode(arguments, forKey: .arguments) } } public struct HermesSidecarProcessSnapshot: Equatable, Sendable { public let pid: Int32 + public let executablePath: String + public let argv0: String public let arguments: [String] - public init(pid: Int32, arguments: [String]) { + public init( + pid: Int32, + executablePath: String = "", + argv0: String? = nil, + arguments: [String] + ) { self.pid = pid + self.executablePath = executablePath + self.argv0 = argv0 ?? executablePath self.arguments = arguments } } @@ -78,8 +130,8 @@ enum HermesBackendReadyParser { /// Pure command matching used by orphan recovery. It intentionally does not /// attempt to infer ownership from a generic Hermes command: a persisted MTPLX -/// record, exact PID, dead recorded parent, and the two exact argument pairs -/// are all required before the caller sends a signal. +/// record, exact PID, dead recorded parent, canonical executable, and complete +/// argument vector are all required before the caller sends a signal. enum HermesOrphanSidecarScanner { static func orphanPIDs( records: [HermesSidecarOwnershipRecord], @@ -92,7 +144,7 @@ enum HermesOrphanSidecarScanner { record.parentPID > 1, !livePIDs.contains(record.parentPID), let process = processesByPID[record.pid], - isExactOwnedSidecar(process, launchID: record.launchID) + isExactOwnedSidecar(process, record: record) else { return nil } return record.pid } @@ -101,20 +153,85 @@ enum HermesOrphanSidecarScanner { static func isExactOwnedSidecar( _ process: HermesSidecarProcessSnapshot, - launchID: String + record: HermesSidecarOwnershipRecord ) -> Bool { - let arguments = process.arguments - guard let serveIndex = arguments.firstIndex(of: "serve"), - arguments.indices.contains(serveIndex + 1), - arguments[serveIndex + 1] == "--isolated", - arguments.filter({ $0 == "serve" }).count == 1, - arguments.filter({ $0 == "--isolated" }).count == 1, - let markerIndex = arguments.firstIndex(of: "--ssh-owner-nonce"), - arguments.indices.contains(markerIndex + 1), - arguments[markerIndex + 1] == launchID, - arguments.filter({ $0 == "--ssh-owner-nonce" }).count == 1 + guard !record.executablePath.isEmpty, + process.executablePath == record.executablePath, + !record.argv0.isEmpty, + process.argv0 == record.argv0, + process.arguments == record.arguments else { return false } - return true + let profilePrefix = record.profileName == "default" ? [] : ["-p", record.profileName] + return record.arguments == profilePrefix + [ + "serve", "--isolated", "--host", "127.0.0.1", + "--port", "0", "--ssh-owner-nonce", record.launchID, + ] + } +} + +/// Redacts secrets while bytes arrive from stderr. It never emits a suffix +/// that could still be the beginning of a secret, so a token split across pipe +/// callbacks cannot enter the diagnostic tail as separate visible fragments. +final class HermesStreamingSecretRedactor: @unchecked Sendable { + private let lock = NSLock() + private let secrets: [Data] + private var pending = Data() + private let replacement = Data("[redacted]".utf8) + + init(secrets: [String]) { + self.secrets = Array(Set(secrets.filter { !$0.isEmpty })).map { Data($0.utf8) } + } + + func redact(_ chunk: Data) -> Data { + lock.lock() + defer { lock.unlock() } + pending.append(chunk) + var output = Data() + while true { + if let match = earliestSecretMatch(in: pending) { + output.append(contentsOf: pending[.. 0 { + output.append(contentsOf: pending.prefix(emittedCount)) + pending.removeFirst(emittedCount) + } + return output + } + } + + private func earliestSecretMatch(in data: Data) -> Range? { + secrets.reduce(nil) { current, secret in + guard let candidate = data.range(of: secret) else { return current } + guard let current else { return candidate } + if candidate.lowerBound < current.lowerBound { return candidate } + if candidate.lowerBound == current.lowerBound, + data.distance(from: candidate.lowerBound, to: candidate.upperBound) + > data.distance(from: current.lowerBound, to: current.upperBound) { + return candidate + } + return current + } + } + + private func longestSecretPrefixSuffix(in data: Data) -> Int { + guard !data.isEmpty else { return 0 } + var best = 0 + for secret in secrets where secret.count > 1 { + let maximum = min(secret.count - 1, data.count) + guard maximum > best else { continue } + for length in stride(from: maximum, through: best + 1, by: -1) { + if Array(data.suffix(length)) == Array(secret.prefix(length)) { + best = length + break + } + } + } + return best } } diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift index da8dcbc82..67cae6bcf 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift @@ -193,19 +193,24 @@ public final class HermesSidecar: HermesSidecarControlling, @unchecked Sendable public var isRunning: Bool { process.isRunning } public func stop() { - defer { - outputPipes.forEach { $0.fileHandleForReading.readabilityHandler = nil } - try? FileManager.default.removeItem(at: ownershipRecordURL) - } - guard process.isRunning else { return } - process.terminate() - let deadline = Date().addingTimeInterval(2) - while process.isRunning && Date() < deadline { - Thread.sleep(forTimeInterval: 0.05) - } if process.isRunning { - kill(process.processIdentifier, SIGKILL) + process.terminate() + let termDeadline = Date().addingTimeInterval(2) + while process.isRunning && Date() < termDeadline { + Thread.sleep(forTimeInterval: 0.05) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + let killDeadline = Date().addingTimeInterval(0.5) + while process.isRunning && Date() < killDeadline { + Thread.sleep(forTimeInterval: 0.05) + } + } } + // Do not lose the recovery record while the child might still exist. + guard !process.isRunning else { return } + outputPipes.forEach { $0.fileHandleForReading.readabilityHandler = nil } + try? FileManager.default.removeItem(at: ownershipRecordURL) } } @@ -876,7 +881,9 @@ public struct HermesIntegration: Sendable { let readiness = HermesSidecarReadiness() let stderrTail = SubprocessTailBuffer(capacity: 4_096) - let redact = Self.stderrRedactor(for: spec) + let stderrRedactor = HermesStreamingSecretRedactor( + secrets: Self.stderrSecrets(for: spec) + ) stdout.fileHandleForReading.readabilityHandler = { handle in let chunk = handle.availableData if chunk.isEmpty { @@ -890,7 +897,7 @@ public struct HermesIntegration: Sendable { if chunk.isEmpty { handle.readabilityHandler = nil } else { - stderrTail.append(Data(redact(String(decoding: chunk, as: UTF8.self)).utf8)) + stderrTail.append(stderrRedactor.redact(chunk)) } } @@ -957,7 +964,10 @@ public struct HermesIntegration: Sendable { pid: process.processIdentifier, parentPID: spec.parentPID, profileName: profile.name, - createdAt: Date() + createdAt: Date(), + executablePath: Self.canonicalExecutablePath(spec.executableURL), + argv0: spec.executableURL.path, + arguments: spec.arguments ) do { try Self.writeOwnershipRecord(record, to: recordURL) @@ -986,39 +996,50 @@ public struct HermesIntegration: Sendable { let records = Self.readOwnershipRecords(in: sidecarRuntimeDirectory) var liveRecords: [(URL, HermesSidecarOwnershipRecord, HermesSidecarProcessSnapshot)] = [] for (url, record) in records { - guard let snapshot = Self.processSnapshot(pid: record.pid) else { + switch Self.pidLiveness(record.pid) { + case .dead: try? FileManager.default.removeItem(at: url) + case .alive: + // Inspection failure is not evidence of death or ownership. + guard let snapshot = Self.processSnapshot(pid: record.pid) else { continue } + liveRecords.append((url, record, snapshot)) + case .unknown: continue } - liveRecords.append((url, record, snapshot)) } let candidatePIDs = Set(HermesOrphanSidecarScanner.orphanPIDs( records: liveRecords.map(\.1), processes: liveRecords.map(\.2), - livePIDs: Set(liveRecords.map(\.1.parentPID).filter(Self.isPIDAlive)) + livePIDs: Set(liveRecords.map(\.1.parentPID).filter { Self.pidLiveness($0) == .alive }) )) var reaped: [Int32] = [] for (url, record, _) in liveRecords where candidatePIDs.contains(record.pid) { // Re-read immediately before TERM. PID reuse or a changed command // must turn cleanup into a no-op rather than an accidental kill. - guard !Self.isPIDAlive(record.parentPID), + guard Self.pidLiveness(record.parentPID) == .dead, + Self.pidLiveness(record.pid) == .alive, let beforeTERM = Self.processSnapshot(pid: record.pid), - HermesOrphanSidecarScanner.isExactOwnedSidecar(beforeTERM, launchID: record.launchID) + HermesOrphanSidecarScanner.isExactOwnedSidecar(beforeTERM, record: record) else { continue } _ = kill(record.pid, SIGTERM) let deadline = Date().addingTimeInterval(2) - while Self.isPIDAlive(record.pid) && Date() < deadline { + while Self.pidLiveness(record.pid) == .alive && Date() < deadline { Thread.sleep(forTimeInterval: 0.05) } - if Self.isPIDAlive(record.pid) { - guard !Self.isPIDAlive(record.parentPID), + if Self.pidLiveness(record.pid) == .alive { + guard Self.pidLiveness(record.parentPID) == .dead, + Self.pidLiveness(record.pid) == .alive, let beforeKILL = Self.processSnapshot(pid: record.pid), - HermesOrphanSidecarScanner.isExactOwnedSidecar(beforeKILL, launchID: record.launchID) + HermesOrphanSidecarScanner.isExactOwnedSidecar(beforeKILL, record: record) else { continue } _ = kill(record.pid, SIGKILL) } - if !Self.isPIDAlive(record.pid) { + let killDeadline = Date().addingTimeInterval(0.5) + while Self.pidLiveness(record.pid) == .alive && Date() < killDeadline { + Thread.sleep(forTimeInterval: 0.05) + } + if Self.pidLiveness(record.pid) == .dead { try? FileManager.default.removeItem(at: url) reaped.append(record.pid) } @@ -1888,15 +1909,9 @@ public struct HermesIntegration: Sendable { .joined() } - private static func stderrRedactor(for spec: HermesServeLaunchSpec) -> @Sendable (String) -> String { - let values = Set(spec.environment.values + [spec.token]) + private static func stderrSecrets(for spec: HermesServeLaunchSpec) -> [String] { + Array(Set(spec.environment.values + [spec.token])) .filter { $0.count >= 4 } - .sorted { $0.count > $1.count } - return { text in - values.reduce(text) { partial, value in - partial.replacingOccurrences(of: value, with: "[redacted]") - } - } } private static func startupDiagnostic(_ summary: String, stderrTail: SubprocessTailBuffer) -> String { @@ -1950,43 +1965,65 @@ public struct HermesIntegration: Sendable { } } - private static func isPIDAlive(_ pid: Int32) -> Bool { - guard pid > 1 else { return false } - if kill(pid, 0) == 0 { return true } - return errno == EPERM + private enum PIDLiveness: Equatable { + case alive + case dead + case unknown + } + + private static func pidLiveness(_ pid: Int32) -> PIDLiveness { + guard pid > 1 else { return .dead } + if kill(pid, 0) == 0 { return .alive } + switch errno { + case ESRCH: return .dead + case EPERM: return .alive + default: return .unknown + } } private static func processSnapshot(pid: Int32) -> HermesSidecarProcessSnapshot? { guard pid > 1 else { return nil } - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/ps") - process.arguments = ["-p", String(pid), "-o", "pid=,command="] - let output = Pipe() - process.standardOutput = output - process.standardError = Pipe() - let watchdog = SubprocessWatchdog(process) - do { - try process.run() - } catch { - return nil - } - let drain = SubprocessPipeDrain(output, capacity: 16_384) - guard watchdog.wait(for: process, timeout: 2, terminateGrace: 0.2, killGrace: 0.2) else { - drain.join(timeout: 0.2) + var mib = [CTL_KERN, KERN_PROCARGS2, pid] + var byteCount = 0 + guard sysctl(&mib, u_int(mib.count), nil, &byteCount, nil, 0) == 0, + byteCount > MemoryLayout.size + else { return nil } + var bytes = [UInt8](repeating: 0, count: byteCount) + guard sysctl(&mib, u_int(mib.count), &bytes, &byteCount, nil, 0) == 0 else { return nil } - drain.join(timeout: 0.2) - guard process.terminationStatus == 0, - let row = drain.snapshot().split(separator: "\n").first - else { return nil } - let text = String(row).trimmingCharacters(in: .whitespacesAndNewlines) - guard let separator = text.firstIndex(where: { $0.isWhitespace }), - Int32(text[.. MemoryLayout.size else { return nil } + let argc = data.withUnsafeBytes { $0.loadUnaligned(as: Int32.self) } + guard argc > 0 else { return nil } + var offset = MemoryLayout.size + guard let executable = Self.nulTerminatedString(in: data, offset: &offset) else { return nil } + while offset < data.count, data[offset] == 0 { offset += 1 } + var arguments: [String] = [] + for _ in 0.. String? { + guard offset < data.count, + let terminator = data[offset...].firstIndex(of: 0) else { return nil } - let command = text[separator...].trimmingCharacters(in: .whitespacesAndNewlines) - let parts = command.split(whereSeparator: { $0.isWhitespace }).map(String.init) - guard parts.count > 1 else { return nil } - return HermesSidecarProcessSnapshot(pid: pid, arguments: Array(parts.dropFirst())) + let value = String(decoding: data[offset.. String { + url.standardizedFileURL.resolvingSymlinksInPath().path } public func sessionOwnership( diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift index f6eb362ae..5839b4459 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift @@ -241,18 +241,47 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { XCTAssertEqual(try Data(contentsOf: envURL), envBefore) } + func testStartupFailureRedactsTokenSplitAcrossStderrWrites() async throws { + let tokenCaptureURL = root.appendingPathComponent("split-token.txt") + let fixture = try makeSplitSecretFailureFixture(tokenCaptureURL: tokenCaptureURL) + let failingIntegration = HermesIntegration( + hermesHome: hermesHome, + executablePath: fixture.path, + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin", + "MTPLX_FIXTURE_TOKEN_FILE": tokenCaptureURL.path, + ], + sidecarRuntimeDirectory: sidecarRuntimeDirectory + ) + + do { + _ = try await failingIntegration.startEmbeddedSidecar( + profile: HermesProfile(name: "default", path: hermesHome.path, isDefault: true), + configuration: configuration + ) + XCTFail("The fixture exits before readiness and must fail startup") + } catch { + let diagnostic = error.localizedDescription + let token = try String(contentsOf: tokenCaptureURL, encoding: .utf8) + XCTAssertFalse(diagnostic.contains(token)) + XCTAssertFalse(diagnostic.contains(String(token.prefix(20)))) + XCTAssertFalse(diagnostic.contains(String(token.suffix(20)))) + } + } + func testOrphanCleanupRequiresExactMarkerCommandAndDeadParent() { let records = [ - HermesSidecarOwnershipRecord(launchID: "1111111111111111", pid: 7101, parentPID: 8001, profileName: "one", createdAt: .now), - HermesSidecarOwnershipRecord(launchID: "2222222222222222", pid: 7102, parentPID: 8002, profileName: "two", createdAt: .now), - HermesSidecarOwnershipRecord(launchID: "3333333333333333", pid: 7103, parentPID: 8003, profileName: "three", createdAt: .now), - HermesSidecarOwnershipRecord(launchID: "4444444444444444", pid: 7104, parentPID: 9001, profileName: "four", createdAt: .now), + HermesSidecarOwnershipRecord(launchID: "1111111111111111", pid: 7101, parentPID: 8001, profileName: "one", createdAt: .now, executablePath: "/usr/local/bin/hermes", arguments: ["-p", "one", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "1111111111111111"]), + HermesSidecarOwnershipRecord(launchID: "2222222222222222", pid: 7102, parentPID: 8002, profileName: "two", createdAt: .now, executablePath: "/usr/local/bin/hermes", arguments: ["-p", "two", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "2222222222222222"]), + HermesSidecarOwnershipRecord(launchID: "3333333333333333", pid: 7103, parentPID: 8003, profileName: "three", createdAt: .now, executablePath: "/usr/local/bin/hermes", arguments: ["-p", "three", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "3333333333333333"]), + HermesSidecarOwnershipRecord(launchID: "4444444444444444", pid: 7104, parentPID: 9001, profileName: "four", createdAt: .now, executablePath: "/usr/local/bin/hermes", arguments: ["-p", "four", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "4444444444444444"]), ] let processes = [ - HermesSidecarProcessSnapshot(pid: 7101, arguments: ["-p", "one", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "1111111111111111"]), - HermesSidecarProcessSnapshot(pid: 7102, arguments: ["serve", "--host", "127.0.0.1", "--port", "0"]), - HermesSidecarProcessSnapshot(pid: 7103, arguments: ["serve", "--isolated", "--ssh-owner-nonce", "wrong-marker"]), - HermesSidecarProcessSnapshot(pid: 7104, arguments: ["serve", "--isolated", "--ssh-owner-nonce", "4444444444444444"]), + HermesSidecarProcessSnapshot(pid: 7101, executablePath: "/usr/local/bin/hermes", arguments: ["-p", "one", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "1111111111111111"]), + HermesSidecarProcessSnapshot(pid: 7102, executablePath: "/usr/local/bin/hermes", arguments: ["serve", "--host", "127.0.0.1", "--port", "0"]), + HermesSidecarProcessSnapshot(pid: 7103, executablePath: "/usr/local/bin/hermes", arguments: ["-p", "three", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "wrong-marker"]), + HermesSidecarProcessSnapshot(pid: 7104, executablePath: "/usr/local/bin/hermes", arguments: ["-p", "four", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "4444444444444444"]), ] let killed = HermesOrphanSidecarScanner.orphanPIDs( @@ -267,6 +296,39 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { XCTAssertFalse(killed.contains(7104)) } + func testOrphanCleanupRejectsExecutableOrFullArgumentMismatch() { + let arguments = ["-p", "one", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "1111111111111111"] + let record = HermesSidecarOwnershipRecord( + launchID: "1111111111111111", + pid: 7101, + parentPID: 8001, + profileName: "one", + createdAt: .now, + executablePath: "/usr/local/bin/hermes", + arguments: arguments + ) + let wrongExecutable = HermesSidecarProcessSnapshot( + pid: 7101, + executablePath: "/usr/local/bin/not-hermes", + arguments: arguments + ) + let extraArgument = HermesSidecarProcessSnapshot( + pid: 7101, + executablePath: "/usr/local/bin/hermes", + arguments: arguments + ["--unexpected"] + ) + let wrongArgv0 = HermesSidecarProcessSnapshot( + pid: 7101, + executablePath: "/usr/local/bin/hermes", + argv0: "/tmp/not-the-recorded-hermes", + arguments: arguments + ) + + XCTAssertEqual(HermesOrphanSidecarScanner.orphanPIDs(records: [record], processes: [wrongExecutable], livePIDs: []), []) + XCTAssertEqual(HermesOrphanSidecarScanner.orphanPIDs(records: [record], processes: [extraArgument], livePIDs: []), []) + XCTAssertEqual(HermesOrphanSidecarScanner.orphanPIDs(records: [record], processes: [wrongArgv0], livePIDs: []), []) + } + private func makeProfile(named name: String, config: String, env: String? = nil) throws -> URL { let profile = hermesHome.appendingPathComponent("profiles/\(name)", isDirectory: true) try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) @@ -304,4 +366,19 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.path) return fixture } + + private func makeSplitSecretFailureFixture(tokenCaptureURL: URL) throws -> URL { + let fixture = root.appendingPathComponent("hermes-split-secret-fixture.sh") + let script = """ + #!/bin/sh + token="$HERMES_DASHBOARD_SESSION_TOKEN" + printf '%s' "$token" > "$MTPLX_FIXTURE_TOKEN_FILE" + printf '%s' "${token%????????????????????}" >&2 + printf '%s\\n' "${token#???????????????????????}" >&2 + exit 1 + """ + try script.write(to: fixture, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.path) + return fixture + } } From e04059f5b957094710957b900914be9365e88cc4 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:49:20 +0200 Subject: [PATCH 07/40] fix(hermes): preserve wrapped sidecar identity --- .../Services/HermesEmbeddedRuntime.swift | 26 ++- .../Services/HermesIntegration.swift | 18 +- .../HermesEmbeddedRuntimeTests.swift | 190 ++++++++++++++++-- 3 files changed, 213 insertions(+), 21 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift index 0d493dd9c..8d93f122f 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift @@ -138,12 +138,16 @@ enum HermesOrphanSidecarScanner { processes: [HermesSidecarProcessSnapshot], livePIDs: Set ) -> [Int32] { - let processesByPID = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) }) + let processesByPID = Dictionary(grouping: processes, by: \.pid) + let recordsByPID = Dictionary(grouping: records, by: \.pid) return records.compactMap { record in guard record.pid > 1, record.parentPID > 1, !livePIDs.contains(record.parentPID), - let process = processesByPID[record.pid], + recordsByPID[record.pid]?.count == 1, + let snapshots = processesByPID[record.pid], + snapshots.count == 1, + let process = snapshots.first, isExactOwnedSidecar(process, record: record) else { return nil } return record.pid @@ -161,10 +165,22 @@ enum HermesOrphanSidecarScanner { process.argv0 == record.argv0, process.arguments == record.arguments else { return false } - let profilePrefix = record.profileName == "default" ? [] : ["-p", record.profileName] - return record.arguments == profilePrefix + [ + return hasCanonicalArguments( + record.arguments, + profileName: record.profileName, + launchID: record.launchID + ) + } + + static func hasCanonicalArguments( + _ arguments: [String], + profileName: String, + launchID: String + ) -> Bool { + let profilePrefix = profileName == "default" ? [] : ["-p", profileName] + return arguments == profilePrefix + [ "serve", "--isolated", "--host", "127.0.0.1", - "--port", "0", "--ssh-owner-nonce", record.launchID, + "--port", "0", "--ssh-owner-nonce", launchID, ] } } diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift index 67cae6bcf..eb24a310a 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift @@ -959,14 +959,26 @@ public struct HermesIntegration: Sendable { let recordURL = sidecarRuntimeDirectory .appendingPathComponent("\(spec.launchID).json", isDirectory: false) + guard let identity = Self.processSnapshot(pid: process.processIdentifier), + identity.arguments == spec.arguments, + HermesOrphanSidecarScanner.hasCanonicalArguments( + spec.arguments, + profileName: profile.name, + launchID: spec.launchID + ) + else { + throw HermesIntegrationError.launchFailed( + Self.startupDiagnostic("Hermes process identity could not be verified.", stderrTail: stderrTail) + ) + } let record = HermesSidecarOwnershipRecord( launchID: spec.launchID, pid: process.processIdentifier, parentPID: spec.parentPID, profileName: profile.name, createdAt: Date(), - executablePath: Self.canonicalExecutablePath(spec.executableURL), - argv0: spec.executableURL.path, + executablePath: identity.executablePath, + argv0: identity.argv0, arguments: spec.arguments ) do { @@ -1911,7 +1923,7 @@ public struct HermesIntegration: Sendable { private static func stderrSecrets(for spec: HermesServeLaunchSpec) -> [String] { Array(Set(spec.environment.values + [spec.token])) - .filter { $0.count >= 4 } + .filter { !$0.isEmpty } } private static func startupDiagnostic(_ summary: String, stderrTail: SubprocessTailBuffer) -> String { diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift index 5839b4459..74ecf283c 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift @@ -207,6 +207,7 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { environment: [ "HOME": root.path, "PATH": "/usr/bin:/bin", + "MTPLX_FIXTURE_ENV_FILE": environmentCaptureURL.path, ], sidecarRuntimeDirectory: sidecarRuntimeDirectory ) @@ -241,6 +242,35 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { XCTAssertEqual(try Data(contentsOf: envURL), envBefore) } + func testExecWrapperRecordsVerifiedPostLaunchIdentity() async throws { + let environmentCaptureURL = root.appendingPathComponent("wrapper-environment.txt") + let fixture = try makeExecWrapperFixture(environmentCaptureURL: environmentCaptureURL) + let wrapperIntegration = HermesIntegration( + hermesHome: hermesHome, + executablePath: fixture.wrapper.path, + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin", + "MTPLX_FIXTURE_ENV_FILE": environmentCaptureURL.path, + ], + sidecarRuntimeDirectory: sidecarRuntimeDirectory + ) + + let sidecar = try await wrapperIntegration.startEmbeddedSidecar( + profile: HermesProfile(name: "default", path: hermesHome.path, isDefault: true), + configuration: configuration + ) + defer { sidecar.stop() } + let record = try JSONDecoder().decode( + HermesSidecarOwnershipRecord.self, + from: Data(contentsOf: sidecar.ownershipRecordURL) + ) + + XCTAssertEqual(record.executablePath, fixture.binary.standardizedFileURL.resolvingSymlinksInPath().path) + XCTAssertEqual(record.argv0, fixture.binary.path) + XCTAssertEqual(Array(record.arguments.suffix(6)), ["--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", record.launchID]) + } + func testStartupFailureRedactsTokenSplitAcrossStderrWrites() async throws { let tokenCaptureURL = root.appendingPathComponent("split-token.txt") let fixture = try makeSplitSecretFailureFixture(tokenCaptureURL: tokenCaptureURL) @@ -270,6 +300,40 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { } } + func testStartupFailureRedactsShortSecretSplitAcrossStderrWrites() async throws { + let secretCaptureURL = root.appendingPathComponent("short-secret.txt") + let fixture = try makeShortSplitSecretFailureFixture(secretCaptureURL: secretCaptureURL) + let shortSecretConfiguration = MTPLXAppConfiguration( + model: "/models/current-model", + host: "127.0.0.1", + port: 18080, + apiKey: "qq" + ) + let failingIntegration = HermesIntegration( + hermesHome: hermesHome, + executablePath: fixture.path, + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin", + "MTPLX_FIXTURE_SECRET_FILE": secretCaptureURL.path, + ], + sidecarRuntimeDirectory: sidecarRuntimeDirectory + ) + + do { + _ = try await failingIntegration.startEmbeddedSidecar( + profile: HermesProfile(name: "default", path: hermesHome.path, isDefault: true), + configuration: shortSecretConfiguration + ) + XCTFail("The fixture exits before readiness and must fail startup") + } catch { + let diagnostic = error.localizedDescription + XCTAssertEqual(try String(contentsOf: secretCaptureURL, encoding: .utf8), "qq") + XCTAssertFalse(diagnostic.contains("qq")) + XCTAssertFalse(diagnostic.contains("q")) + } + } + func testOrphanCleanupRequiresExactMarkerCommandAndDeadParent() { let records = [ HermesSidecarOwnershipRecord(launchID: "1111111111111111", pid: 7101, parentPID: 8001, profileName: "one", createdAt: .now, executablePath: "/usr/local/bin/hermes", arguments: ["-p", "one", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "1111111111111111"]), @@ -329,6 +393,37 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { XCTAssertEqual(HermesOrphanSidecarScanner.orphanPIDs(records: [record], processes: [wrongArgv0], livePIDs: []), []) } + func testOrphanCleanupFailsClosedForDuplicatePIDRecords() { + let first = HermesSidecarOwnershipRecord( + launchID: "1111111111111111", + pid: 7101, + parentPID: 8001, + profileName: "one", + createdAt: .now, + executablePath: "/usr/local/bin/hermes", + arguments: ["-p", "one", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "1111111111111111"] + ) + let conflicting = HermesSidecarOwnershipRecord( + launchID: "2222222222222222", + pid: 7101, + parentPID: 8002, + profileName: "two", + createdAt: .now, + executablePath: "/usr/local/bin/hermes", + arguments: ["-p", "two", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", "2222222222222222"] + ) + let process = HermesSidecarProcessSnapshot( + pid: 7101, + executablePath: "/usr/local/bin/hermes", + arguments: first.arguments + ) + + XCTAssertEqual( + HermesOrphanSidecarScanner.orphanPIDs(records: [first, conflicting], processes: [process, process], livePIDs: []), + [] + ) + } + private func makeProfile(named name: String, config: String, env: String? = nil) throws -> URL { let profile = hermesHome.appendingPathComponent("profiles/\(name)", isDirectory: true) try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true) @@ -349,22 +444,75 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { } private func makeSidecarFixture(environmentCaptureURL: URL) throws -> URL { - let fixture = root.appendingPathComponent("hermes-fixture.sh") + _ = environmentCaptureURL + return try makeSidecarBinary() + } + + private func makeExecWrapperFixture(environmentCaptureURL: URL) throws -> (wrapper: URL, binary: URL) { + let binary = try makeSidecarBinary() + let wrapper = root.appendingPathComponent("hermes-exec-wrapper.sh") let script = """ #!/bin/sh - if [ -n \"$HERMES_DASHBOARD_SESSION_TOKEN\" ]; then - printf 'HERMES_DASHBOARD_SESSION_TOKEN=present\\n' > \"\(environmentCaptureURL.path)\" - fi - if [ -n \"$OPENAI_API_KEY\" ]; then - printf 'OPENAI_API_KEY=present\\n' >> \"\(environmentCaptureURL.path)\" - fi - printf 'HERMES_BACKEND_READY port=45123\\n' - trap 'exit 0' TERM INT - while :; do sleep 1; done + exec "\(binary.path)" "$@" """ - try script.write(to: fixture, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.path) - return fixture + try script.write(to: wrapper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: wrapper.path) + _ = environmentCaptureURL + return (wrapper, binary) + } + + private func makeSidecarBinary() throws -> URL { + let source = root.appendingPathComponent("hermes-sidecar-fixture.c") + let binary = root.appendingPathComponent("hermes-sidecar-fixture") + let code = """ + #include + #include + #include + #include + + static volatile sig_atomic_t running = 1; + static void stop(int signal) { (void)signal; running = 0; } + + int main(void) { + const char *capture = getenv("MTPLX_FIXTURE_ENV_FILE"); + if (capture != NULL) { + FILE *file = fopen(capture, "w"); + if (file != NULL) { + if (getenv("HERMES_DASHBOARD_SESSION_TOKEN") != NULL) { + fputs("HERMES_DASHBOARD_SESSION_TOKEN=present\\n", file); + } + if (getenv("OPENAI_API_KEY") != NULL) { + fputs("OPENAI_API_KEY=present\\n", file); + } + fclose(file); + } + } + fputs("HERMES_BACKEND_READY port=45123\\n", stdout); + fflush(stdout); + signal(SIGTERM, stop); + signal(SIGINT, stop); + while (running) { pause(); } + return 0; + } + """ + try code.write(to: source, atomically: true, encoding: .utf8) + let compiler = Process() + compiler.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + compiler.arguments = ["clang", source.path, "-o", binary.path] + let output = Pipe() + compiler.standardOutput = output + compiler.standardError = output + let watchdog = SubprocessWatchdog(compiler) + try compiler.run() + let drain = SubprocessPipeDrain(output, capacity: 65_536) + guard watchdog.wait(for: compiler, timeout: 20), compiler.terminationStatus == 0 else { + drain.join(timeout: 1) + throw NSError(domain: "HermesEmbeddedRuntimeTests", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Could not compile sidecar fixture: \(drain.snapshot())", + ]) + } + drain.join(timeout: 1) + return binary } private func makeSplitSecretFailureFixture(tokenCaptureURL: URL) throws -> URL { @@ -381,4 +529,20 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.path) return fixture } + + private func makeShortSplitSecretFailureFixture(secretCaptureURL: URL) throws -> URL { + let fixture = root.appendingPathComponent("hermes-short-secret-fixture.sh") + let script = """ + #!/bin/sh + secret="$OPENAI_API_KEY" + printf '%s' "$secret" > "$MTPLX_FIXTURE_SECRET_FILE" + printf '%s' "${secret%?}" >&2 + printf '%s\\n' "${secret#?}" >&2 + exit 1 + """ + try script.write(to: fixture, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.path) + _ = secretCaptureURL + return fixture + } } From c7217a656b786c97bc7e727e663dbfcd9b912d50 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:06:13 +0200 Subject: [PATCH 08/40] feat(hermes): add authenticated gateway client --- .../Services/HermesGatewayClient.swift | 251 +++++++++++++++++ .../Stores/HermesAgentStore.swift | 149 +--------- .../HermesGatewayClientTests.swift | 172 ++++++++++++ .../Support/FakeHermesGateway.swift | 265 ++++++++++++++++++ 4 files changed, 691 insertions(+), 146 deletions(-) create mode 100644 apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift create mode 100644 apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift create mode 100644 apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift new file mode 100644 index 000000000..3b15702d2 --- /dev/null +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift @@ -0,0 +1,251 @@ +import Foundation + +public struct HermesGatewayEvent: Equatable, Sendable { + public let type: String + public let sessionID: String? + public let payload: [String: JSONValue] + + public init(type: String, sessionID: String?, payload: [String: JSONValue]) { + self.type = type + self.sessionID = sessionID + self.payload = payload + } +} + +public enum HermesGatewayClientError: Error, LocalizedError, Equatable { + case disconnected + case malformedResponse + case readinessTimedOut + case rpcError + case sendFailed + + public var errorDescription: String? { + switch self { + case .disconnected: "Hermes gateway disconnected." + case .malformedResponse: "Hermes returned a malformed response." + case .readinessTimedOut: "Hermes gateway did not become ready in time." + case .rpcError: "Hermes RPC request failed." + case .sendFailed: "Hermes request could not be sent." + } + } +} + +@MainActor +public protocol HermesGatewayClientProtocol: AnyObject { + var onEvent: ((HermesGatewayEvent) -> Void)? { get set } + var onDisconnect: ((String) -> Void)? { get set } + func connectAndWaitUntilReady(timeoutSeconds: Double) async throws + func call(method: String, params: [String: JSONValue]) async throws -> JSONValue + func close() +} + +public typealias HermesGatewayClientFactory = @MainActor (URL) -> any HermesGatewayClientProtocol + +/// JSON-RPC transport for the ephemeral, authenticated loopback sidecar. +@MainActor +public final class URLSessionHermesGatewayClient: HermesGatewayClientProtocol { + private let task: URLSessionWebSocketTask + private var nextID = 1 + private var pending: [Int: CheckedContinuation] = [:] + private var readinessWaiters: [UUID: CheckedContinuation] = [:] + private var readinessTimeouts: [UUID: Task] = [:] + private var started = false + private var ready = false + private var terminated = false + + public var onEvent: ((HermesGatewayEvent) -> Void)? + public var onDisconnect: ((String) -> Void)? + + public init(url: URL, session: URLSession = .shared) { + task = session.webSocketTask(with: url) + } + + /// Compatibility for the pre-readiness store lifecycle. New callers wait. + public func connect() { + startIfNeeded() + } + + public func connectAndWaitUntilReady(timeoutSeconds: Double) async throws { + guard !terminated else { throw HermesGatewayClientError.disconnected } + if ready { return } + let timeout = min(max(timeoutSeconds, 0.01), 10) + let waiterID = UUID() + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard !self.terminated else { + continuation.resume(throwing: HermesGatewayClientError.disconnected) + return + } + if self.ready { + continuation.resume() + return + } + // Install before task.resume(): a fast ready event must not race us. + self.readinessWaiters[waiterID] = continuation + self.readinessTimeouts[waiterID] = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + self?.timeoutReadinessWaiter(waiterID) + } + self.startIfNeeded() + } + } + + public func call(method: String, params: [String: JSONValue] = [:]) async throws -> JSONValue { + guard started, !terminated else { throw HermesGatewayClientError.disconnected } + let id = nextID + nextID += 1 + let request: [String: JSONValue] = [ + "jsonrpc": .string("2.0"), + "id": .number(Double(id)), + "method": .string(method), + "params": .object(params), + ] + let data = try JSONEncoder().encode(request) + guard let text = String(data: data, encoding: .utf8) else { + throw HermesGatewayClientError.malformedResponse + } + return try await withCheckedThrowingContinuation { continuation in + guard !self.terminated else { + continuation.resume(throwing: HermesGatewayClientError.disconnected) + return + } + self.pending[id] = continuation + self.task.send(.string(text)) { [weak self] error in + guard error != nil else { return } + Task { @MainActor [weak self] in + self?.failPendingRequest(id, error: .sendFailed) + } + } + } + } + + public func close() { + terminate(error: .disconnected, notify: false) + task.cancel(with: .goingAway, reason: nil) + } + + private func startIfNeeded() { + guard !started, !terminated else { return } + started = true + task.resume() + receiveNext() + } + + private func receiveNext() { + guard !terminated else { return } + task.receive { [weak self] result in + Task { @MainActor [weak self] in + guard let self, !self.terminated else { return } + switch result { + case .success(let message): + self.handle(message) + self.receiveNext() + case .failure: + self.terminate(error: .disconnected, notify: true) + } + } + } + } + + private func handle(_ message: URLSessionWebSocketTask.Message) { + let data: Data + switch message { + case .string(let text): data = Data(text.utf8) + case .data(let received): data = received + @unknown default: + terminate(error: .malformedResponse, notify: true) + return + } + guard let root = try? JSONDecoder().decode([String: JSONValue].self, from: data) else { + terminate(error: .malformedResponse, notify: true) + return + } + + if root["id"] != nil { + guard let id = rpcID(root["id"]), root["jsonrpc"]?.stringValue == "2.0" else { + terminate(error: .malformedResponse, notify: true) + return + } + if root["error"] != nil { + guard object(from: root["error"]) != nil else { + terminate(error: .malformedResponse, notify: true) + return + } + failPendingRequest(id, error: .rpcError) + return + } + guard let result = root["result"] else { + terminate(error: .malformedResponse, notify: true) + return + } + pending.removeValue(forKey: id)?.resume(returning: result) + return + } + + guard root["method"]?.stringValue == "event", + let params = object(from: root["params"]), + let type = params["type"]?.stringValue + else { + terminate(error: .malformedResponse, notify: true) + return + } + let event = HermesGatewayEvent( + type: type, + sessionID: params["session_id"]?.stringValue, + payload: object(from: params["payload"]) ?? [:] + ) + onEvent?(event) + if type == "gateway.ready" { finishReadiness() } + } + + private func rpcID(_ value: JSONValue?) -> Int? { + guard case .number(let number) = value, + number.isFinite, + number.rounded() == number, + number >= 0, + number <= Double(Int.max) + else { return nil } + return Int(number) + } + + private func object(from value: JSONValue?) -> [String: JSONValue]? { + guard case .object(let object) = value else { return nil } + return object + } + + private func finishReadiness() { + guard !ready else { return } + ready = true + let waiters = readinessWaiters + readinessWaiters.removeAll() + let timeouts = readinessTimeouts + readinessTimeouts.removeAll() + timeouts.values.forEach { $0.cancel() } + waiters.values.forEach { $0.resume() } + } + + private func timeoutReadinessWaiter(_ id: UUID) { + readinessTimeouts.removeValue(forKey: id) + readinessWaiters.removeValue(forKey: id)?.resume(throwing: HermesGatewayClientError.readinessTimedOut) + } + + private func failPendingRequest(_ id: Int, error: HermesGatewayClientError) { + pending.removeValue(forKey: id)?.resume(throwing: error) + } + + private func terminate(error: HermesGatewayClientError, notify: Bool) { + guard !terminated else { return } + terminated = true + let rpcWaiters = pending + pending.removeAll() + let readyWaiters = readinessWaiters + readinessWaiters.removeAll() + let timeouts = readinessTimeouts + readinessTimeouts.removeAll() + timeouts.values.forEach { $0.cancel() } + rpcWaiters.values.forEach { $0.resume(throwing: error) } + readyWaiters.values.forEach { $0.resume(throwing: error) } + if notify { + onDisconnect?(error.errorDescription ?? "Hermes gateway disconnected.") + } + } +} diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index a824473eb..857c11c6d 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -87,149 +87,6 @@ public struct HermesSavedSession: Identifiable, Equatable, Sendable { } } -private struct HermesGatewayEvent: Sendable { - let type: String - let sessionID: String? - let payload: [String: JSONValue] -} - -public enum HermesGatewayClientError: Error, LocalizedError { - case disconnected - case malformedResponse - case rpcError(String) - case sendFailed(String) - - public var errorDescription: String? { - switch self { - case .disconnected: - return "Hermes gateway disconnected." - case .malformedResponse: - return "Hermes returned a malformed response." - case .rpcError(let message): - return message - case .sendFailed(let message): - return "Hermes request could not be sent: \(message)" - } - } -} - -@MainActor -private final class HermesGatewayClient { - private let task: URLSessionWebSocketTask - private var nextID: Int = 1 - private var pending: [Int: CheckedContinuation] = [:] - var onEvent: ((HermesGatewayEvent) -> Void)? - var onDisconnect: ((String) -> Void)? - - init(url: URL) { - task = URLSession.shared.webSocketTask(with: url) - } - - func connect() { - task.resume() - receiveNext() - } - - func close() { - for (_, continuation) in pending { - continuation.resume(throwing: HermesGatewayClientError.disconnected) - } - pending.removeAll() - task.cancel(with: .goingAway, reason: nil) - } - - func call(method: String, params: [String: JSONValue] = [:]) async throws -> JSONValue { - let id = nextID - nextID += 1 - let request: [String: JSONValue] = [ - "jsonrpc": .string("2.0"), - "id": .number(Double(id)), - "method": .string(method), - "params": .object(params), - ] - let data = try JSONEncoder().encode(request) - guard let text = String(data: data, encoding: .utf8) else { - throw HermesGatewayClientError.malformedResponse - } - return try await withCheckedThrowingContinuation { continuation in - pending[id] = continuation - task.send(.string(text)) { [weak self] error in - guard let error else { return } - Task { @MainActor in - guard let self else { return } - self.pending.removeValue(forKey: id)? - .resume(throwing: HermesGatewayClientError.sendFailed(error.localizedDescription)) - } - } - } - } - - private func receiveNext() { - task.receive { [weak self] result in - Task { @MainActor in - guard let self else { return } - switch result { - case .success(let message): - self.handle(message) - self.receiveNext() - case .failure(let error): - for (_, continuation) in self.pending { - continuation.resume(throwing: HermesGatewayClientError.disconnected) - } - self.pending.removeAll() - self.onDisconnect?(error.localizedDescription) - } - } - } - } - - private func handle(_ message: URLSessionWebSocketTask.Message) { - let text: String? - switch message { - case .string(let raw): - text = raw - case .data(let data): - text = String(data: data, encoding: .utf8) - @unknown default: - text = nil - } - guard let text, - let data = text.data(using: .utf8), - let root = try? JSONDecoder().decode([String: JSONValue].self, from: data) - else { - return - } - - if let id = root["id"]?.intValue { - let continuation = pending.removeValue(forKey: id) - if let error = root["error"]?.objectValue { - continuation?.resume( - throwing: HermesGatewayClientError.rpcError( - error["message"]?.stringValue ?? "Hermes RPC failed." - ) - ) - } else { - continuation?.resume(returning: root["result"] ?? .null) - } - return - } - - guard root["method"]?.stringValue == "event", - let params = root["params"]?.objectValue, - let type = params["type"]?.stringValue - else { - return - } - onEvent?( - HermesGatewayEvent( - type: type, - sessionID: params["session_id"]?.stringValue, - payload: params["payload"]?.objectValue ?? [:] - ) - ) - } -} - @MainActor public final class HermesAgentStore: ObservableObject { @Published public private(set) var connectionState: HermesConnectionState = .idle @@ -252,7 +109,7 @@ public final class HermesAgentStore: ObservableObject { private var sidecar: HermesSidecar? private var sidecarProfileName: String? private var sidecarConfigurationSignature: String? - private var client: HermesGatewayClient? + private var client: URLSessionHermesGatewayClient? private var shuttingDown = false private var gatewayGeneration = 0 @@ -412,7 +269,7 @@ public final class HermesAgentStore: ObservableObject { let sessionID = configuration.lastHermesSessionID, let profile = profiles.first(where: { $0.name == profileName }) else { - throw HermesGatewayClientError.rpcError("No previous Hermes agent is saved.") + throw HermesGatewayClientError.rpcError } let session = HermesSavedSession( id: sessionID, @@ -520,7 +377,7 @@ public final class HermesAgentStore: ObservableObject { profile: profile, configuration: configuration ) - let nextClient = HermesGatewayClient(url: nextSidecar.webSocketURL) + let nextClient = URLSessionHermesGatewayClient(url: nextSidecar.webSocketURL) nextClient.onEvent = { [weak self] event in self?.handle(event) } diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift new file mode 100644 index 000000000..16ebd7d23 --- /dev/null +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift @@ -0,0 +1,172 @@ +import Foundation +import XCTest +@testable import MTPLXAppCore + +@MainActor +final class HermesGatewayClientTests: XCTestCase { + func testConnectWaitsForGatewayReadyBeforeReturning() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: []) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + let connect = Task { try await client.connectAndWaitUntilReady(timeoutSeconds: 1) } + + await eventually { backend.acceptedAuthentication() } + XCTAssertFalse(connect.isCancelled) + backend.sendEvent(type: "gateway.ready", sessionID: nil, payload: [:]) + try await connect.value + XCTAssertTrue(backend.acceptedAuthentication()) + client.close() + } + + func testRPCResponseAndEventAreDecoded() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + backend.respond(to: "session.list", result: .object(["sessions": .array([])])) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + var received: [HermesGatewayEvent] = [] + client.onEvent = { received.append($0) } + + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + let value = try await client.call(method: "session.list", params: ["limit": .number(200)]) + guard case .object(let response) = value, + case .array(let sessions)? = response["sessions"] + else { return XCTFail("Expected session array") } + XCTAssertEqual(sessions, []) + backend.sendEvent(type: "message.delta", sessionID: "live-1", payload: ["text": .string("Hi")]) + await eventually { received.contains(where: { $0.type == "message.delta" }) } + client.close() + } + + func testReadinessTimesOutWhenReadyEventNeverArrives() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: []) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + + await XCTAssertThrowsErrorAsync(try await client.connectAndWaitUntilReady(timeoutSeconds: 0.05)) { error in + guard case HermesGatewayClientError.readinessTimedOut = error else { + return XCTFail("Expected readiness timeout") + } + } + client.close() + } + + func testNonReadyEventDoesNotSatisfyReadinessGate() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: []) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + let connect = Task { try await client.connectAndWaitUntilReady(timeoutSeconds: 0.05) } + + await eventually { backend.acceptedAuthentication() } + backend.sendEvent(type: "message.delta", sessionID: "live-1", payload: [:]) + await XCTAssertThrowsErrorAsync(try await connect.value) { error in + guard case HermesGatewayClientError.readinessTimedOut = error else { + return XCTFail("Expected readiness timeout") + } + } + client.close() + } + + func testRPCErrorFailsRequestWithoutLeakingRemoteDescription() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + backend.respondWithRPCError(to: "session.list") + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + + await XCTAssertThrowsErrorAsync(try await client.call(method: "session.list", params: [:])) { error in + guard case HermesGatewayClientError.rpcError = error else { + return XCTFail("Expected RPC error") + } + } + client.close() + } + + func testDisconnectFailsPendingRequest() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + let request = Task { try await client.call(method: "session.list", params: [:]) } + + await eventually { backend.hasReceivedRequest(named: "session.list") } + backend.disconnect() + await XCTAssertThrowsErrorAsync(try await request.value) { error in + guard case HermesGatewayClientError.disconnected = error else { + return XCTFail("Expected disconnect") + } + } + client.close() + } + + func testDisconnectFailsReadinessWaiter() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: []) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + let connect = Task { try await client.connectAndWaitUntilReady(timeoutSeconds: 1) } + + await eventually { backend.acceptedAuthentication() } + backend.disconnect() + await XCTAssertThrowsErrorAsync(try await connect.value) { error in + guard case HermesGatewayClientError.disconnected = error else { + return XCTFail("Expected disconnect") + } + } + client.close() + } + + func testCloseFailsPendingRequest() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + let request = Task { try await client.call(method: "session.list", params: [:]) } + + await eventually { backend.hasReceivedRequest(named: "session.list") } + client.close() + await XCTAssertThrowsErrorAsync(try await request.value) { error in + guard case HermesGatewayClientError.disconnected = error else { + return XCTFail("Expected close failure") + } + } + } + + func testMalformedFrameFailsPendingRequest() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + let request = Task { try await client.call(method: "session.list", params: [:]) } + + await eventually { backend.hasReceivedRequest(named: "session.list") } + backend.sendMalformedFrame() + await XCTAssertThrowsErrorAsync(try await request.value) { error in + guard case HermesGatewayClientError.malformedResponse = error else { + return XCTFail("Expected malformed response") + } + } + client.close() + } + + func testAuthenticatedQueryIsSentWithoutSurfacingItInErrors() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + XCTAssertTrue(backend.acceptedAuthentication()) + client.close() + } + + private func eventually( + timeout: Duration = .seconds(1), + _ condition: @escaping @MainActor () -> Bool + ) async { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while !condition(), clock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + XCTAssertTrue(condition()) + } + + private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + _ handler: (Error) -> Void + ) async { + do { + _ = try await expression() + XCTFail("Expected an error") + } catch { + handler(error) + } + } +} diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift new file mode 100644 index 000000000..48f2ea7e7 --- /dev/null +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift @@ -0,0 +1,265 @@ +import CryptoKit +import Foundation +import Network +@testable import MTPLXAppCore + +/// A deliberately small loopback-only RFC 6455 fixture. It supports exactly +/// the text frames the gateway client uses; it is not a general WebSocket +/// implementation and never listens beyond 127.0.0.1. +final class FakeHermesGateway: @unchecked Sendable { + enum EventOnConnect { + case gatewayReady + } + + private let queue = DispatchQueue(label: "FakeHermesGateway") + private let listener: NWListener + private var port: NWEndpoint.Port? + private var connection: NWConnection? + private var handshakeBuffer = Data() + private var frameBuffer = Data() + private var handshakeComplete = false + private var responses: [String: JSONValue] = [:] + private var rpcErrors: Set = [] + private var receivedMethods: [String] = [] + private var authenticated = false + private let eventsOnConnect: [EventOnConnect] + + init(eventsOnConnect: [EventOnConnect]) throws { + self.eventsOnConnect = eventsOnConnect + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any) + listener = try NWListener(using: parameters) + let startup = FixtureStartupSignal() + listener.stateUpdateHandler = { state in + switch state { + case .ready: + startup.finish(failed: false) + case .failed: + startup.finish(failed: true) + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + guard let gateway = self else { return } + gateway.queue.async { gateway.accept(connection) } + } + listener.start(queue: queue) + guard startup.wait(timeout: .now() + 1), + !startup.failed, + let port = listener.port + else { + throw FixtureError.startFailed + } + self.port = port + } + + deinit { + listener.cancel() + connection?.cancel() + } + + func webSocketURL(token: String) -> URL { + return URL(string: "ws://127.0.0.1:\(port!.rawValue)/api/ws?token=\(token)")! + } + + func respond(to method: String, result: JSONValue) { + queue.sync { responses[method] = result } + } + + func respondWithRPCError(to method: String) { + _ = queue.sync { rpcErrors.insert(method) } + } + + func sendEvent(type: String, sessionID: String?, payload: [String: JSONValue]) { + queue.async { + var params: [String: JSONValue] = [ + "type": .string(type), + "payload": .object(payload), + ] + if let sessionID { + params["session_id"] = .string(sessionID) + } + self.sendJSON([ + "jsonrpc": .string("2.0"), + "method": .string("event"), + "params": .object(params), + ]) + } + } + + func sendMalformedFrame() { + queue.async { self.sendText("not-json") } + } + + func disconnect() { + queue.async { + self.connection?.cancel() + self.connection = nil + } + } + + func hasReceivedRequest(named method: String) -> Bool { + queue.sync { receivedMethods.contains(method) } + } + + func acceptedAuthentication() -> Bool { + queue.sync { authenticated } + } + + private func accept(_ connection: NWConnection) { + self.connection?.cancel() + self.connection = connection + handshakeBuffer.removeAll(keepingCapacity: true) + frameBuffer.removeAll(keepingCapacity: true) + handshakeComplete = false + connection.start(queue: queue) + receiveNext() + } + + private func receiveNext() { + connection?.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, isComplete, error in + guard let self else { return } + self.queue.async { + guard error == nil, !isComplete else { return } + if let data { + self.process(data) + } + self.receiveNext() + } + } + } + + private func process(_ data: Data) { + if !handshakeComplete { + handshakeBuffer.append(data) + guard let range = handshakeBuffer.range(of: Data("\r\n\r\n".utf8)) else { return } + let requestData = handshakeBuffer[.. Bool { + guard let request = String(data: requestData, encoding: .utf8) else { return false } + let lines = request.split(separator: "\r\n", omittingEmptySubsequences: false) + let requestTarget = lines.first?.split(separator: " ").dropFirst().first + guard let first = lines.first, + first.hasPrefix("GET /api/ws?token="), + requestTarget?.hasSuffix("?token=") == false, + let key = lines.first(where: { $0.lowercased().hasPrefix("sec-websocket-key:") }) + else { return false } + authenticated = true + let clientKey = key.split(separator: ":", maxSplits: 1)[1].trimmingCharacters(in: .whitespaces) + let accept = Data(Insecure.SHA1.hash(data: Data("\(clientKey)258EAFA5-E914-47DA-95CA-C5AB0DC85B11".utf8))).base64EncodedString() + let response = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: \(accept)\r\n\r\n" + connection?.send(content: Data(response.utf8), completion: .contentProcessed { _ in }) + return true + } + + private func nextClientTextFrame() -> String? { + guard frameBuffer.count >= 2 else { return nil } + let bytes = [UInt8](frameBuffer) + guard bytes[0] & 0x0F == 0x1 else { return nil } + guard bytes[1] & 0x80 != 0 else { return nil } + var offset = 2 + var length = Int(bytes[1] & 0x7F) + if length == 126 { + guard bytes.count >= offset + 2 else { return nil } + length = Int(bytes[offset]) << 8 | Int(bytes[offset + 1]) + offset += 2 + } else if length == 127 { + guard bytes.count >= offset + 8 else { return nil } + length = bytes[offset..<(offset + 8)].reduce(0) { ($0 << 8) | Int($1) } + offset += 8 + } + guard bytes.count >= offset + 4 + length else { return nil } + let mask = Array(bytes[offset..<(offset + 4)]) + offset += 4 + let payload = bytes[offset..<(offset + length)].enumerated().map { $0.element ^ mask[$0.offset % 4] } + frameBuffer.removeFirst(offset + length) + return String(bytes: payload, encoding: .utf8) + } + + private func handleClientText(_ text: String) { + guard let data = text.data(using: .utf8), + let request = try? JSONDecoder().decode([String: JSONValue].self, from: data), + let method = request["method"]?.stringValue, + let id = request["id"] + else { return } + receivedMethods.append(method) + if rpcErrors.contains(method) { + sendJSON([ + "jsonrpc": .string("2.0"), + "id": id, + "error": .object(["message": .string("fixture failure")]), + ]) + } else if let result = responses[method] { + sendJSON([ + "jsonrpc": .string("2.0"), + "id": id, + "result": result, + ]) + } + } + + private func sendJSON(_ object: [String: JSONValue]) { + guard let data = try? JSONEncoder().encode(object), + let text = String(data: data, encoding: .utf8) + else { return } + sendText(text) + } + + private func sendText(_ text: String) { + let payload = [UInt8](text.utf8) + var frame: [UInt8] = [0x81] + if payload.count < 126 { + frame.append(UInt8(payload.count)) + } else { + frame += [126, UInt8((payload.count >> 8) & 0xFF), UInt8(payload.count & 0xFF)] + } + frame += payload + connection?.send(content: Data(frame), completion: .contentProcessed { _ in }) + } + + private enum FixtureError: Error { + case startFailed + } +} + +private final class FixtureStartupSignal: @unchecked Sendable { + private let semaphore = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var didFail = false + + var failed: Bool { + lock.lock() + defer { lock.unlock() } + return didFail + } + + func finish(failed: Bool) { + lock.lock() + didFail = failed + lock.unlock() + semaphore.signal() + } + + func wait(timeout: DispatchTime) -> Bool { + semaphore.wait(timeout: timeout) == .success + } +} From a977485ffad6a7f75e0e572168fdb53f108d5315 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:14:13 +0200 Subject: [PATCH 09/40] fix(hermes): fail closed on unknown rpc ids --- .../Services/HermesGatewayClient.swift | 28 ++++- .../HermesGatewayClientTests.swift | 106 ++++++++++++++++++ .../Support/FakeHermesGateway.swift | 20 ++++ 3 files changed, 152 insertions(+), 2 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift index 3b15702d2..acb151764 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift @@ -44,9 +44,12 @@ public typealias HermesGatewayClientFactory = @MainActor (URL) -> any HermesGate /// JSON-RPC transport for the ephemeral, authenticated loopback sidecar. @MainActor public final class URLSessionHermesGatewayClient: HermesGatewayClientProtocol { + private static let retiredIDLimit = 256 private let task: URLSessionWebSocketTask private var nextID = 1 private var pending: [Int: CheckedContinuation] = [:] + private var retiredIDs: Set = [] + private var retiredIDOrder: [Int] = [] private var readinessWaiters: [UUID: CheckedContinuation] = [:] private var readinessTimeouts: [UUID: Task] = [:] private var started = false @@ -170,6 +173,11 @@ public final class URLSessionHermesGatewayClient: HermesGatewayClientProtocol { terminate(error: .malformedResponse, notify: true) return } + guard !retiredIDs.contains(id) else { return } + guard pending[id] != nil else { + terminate(error: .malformedResponse, notify: true) + return + } failPendingRequest(id, error: .rpcError) return } @@ -177,7 +185,13 @@ public final class URLSessionHermesGatewayClient: HermesGatewayClientProtocol { terminate(error: .malformedResponse, notify: true) return } - pending.removeValue(forKey: id)?.resume(returning: result) + guard !retiredIDs.contains(id) else { return } + guard let continuation = pending.removeValue(forKey: id) else { + terminate(error: .malformedResponse, notify: true) + return + } + retireRPCID(id) + continuation.resume(returning: result) return } @@ -229,7 +243,17 @@ public final class URLSessionHermesGatewayClient: HermesGatewayClientProtocol { } private func failPendingRequest(_ id: Int, error: HermesGatewayClientError) { - pending.removeValue(forKey: id)?.resume(throwing: error) + guard let continuation = pending.removeValue(forKey: id) else { return } + retireRPCID(id) + continuation.resume(throwing: error) + } + + private func retireRPCID(_ id: Int) { + guard retiredIDs.insert(id).inserted else { return } + retiredIDOrder.append(id) + if retiredIDOrder.count > Self.retiredIDLimit { + retiredIDs.remove(retiredIDOrder.removeFirst()) + } } private func terminate(error: HermesGatewayClientError, notify: Bool) { diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift index 16ebd7d23..970b69312 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift @@ -138,6 +138,112 @@ final class HermesGatewayClientTests: XCTestCase { client.close() } + func testUnknownResponseIDTerminatesPendingRequest() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + defer { client.close() } + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + let completed = expectation(description: "pending request fails") + var observed: Error? + Task { + do { + _ = try await client.call(method: "wrong-id", params: [:]) + } catch { + observed = error + completed.fulfill() + } + } + + await eventually { backend.hasReceivedRequest(named: "wrong-id") } + backend.sendResult(id: 99) + await fulfillment(of: [completed], timeout: 0.5) + XCTAssertEqual(observed as? HermesGatewayClientError, .malformedResponse) + } + + func testLateDuplicateOfRetiredIDDoesNotTerminateNewPendingRequest() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + defer { client.close() } + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + + let first = Task { try await client.call(method: "first", params: [:]) } + await eventually { backend.hasReceivedRequest(named: "first") } + backend.sendResult(id: 1, result: .string("first")) + let firstValue = try await first.value + XCTAssertEqual(firstValue, .string("first")) + + let second = Task { try await client.call(method: "second", params: [:]) } + await eventually { backend.hasReceivedRequest(named: "second") } + backend.sendResult(id: 1, result: .string("late duplicate")) + try await Task.sleep(for: .milliseconds(25)) + backend.sendResult(id: 2, result: .string("second")) + let secondValue = try await second.value + XCTAssertEqual(secondValue, .string("second")) + } + + func testLateDuplicateOfFailedRetiredIDDoesNotTerminateNewPendingRequest() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + defer { client.close() } + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + + let first = Task { try await client.call(method: "first-error", params: [:]) } + await eventually { backend.hasReceivedRequest(named: "first-error") } + backend.sendRPCError(id: 1) + do { + _ = try await first.value + XCTFail("Expected RPC error") + } catch { + XCTAssertEqual(error as? HermesGatewayClientError, .rpcError) + } + + let second = Task { try await client.call(method: "second-after-error", params: [:]) } + await eventually { backend.hasReceivedRequest(named: "second-after-error") } + backend.sendRPCError(id: 1) + try await Task.sleep(for: .milliseconds(25)) + backend.sendResult(id: 2, result: .string("second")) + let secondValue = try await second.value + XCTAssertEqual(secondValue, .string("second")) + } + + func testUnknownResponseIDTerminatesAllConcurrentPendingRequests() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + defer { client.close() } + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + let completed = expectation(description: "both pending requests fail") + completed.expectedFulfillmentCount = 2 + var observed: [HermesGatewayClientError] = [] + Task { + do { + _ = try await client.call(method: "concurrent-a", params: [:]) + } catch let error as HermesGatewayClientError { + observed.append(error) + completed.fulfill() + } catch { + XCTFail("Unexpected error type") + } + } + Task { + do { + _ = try await client.call(method: "concurrent-b", params: [:]) + } catch let error as HermesGatewayClientError { + observed.append(error) + completed.fulfill() + } catch { + XCTFail("Unexpected error type") + } + } + + await eventually { + backend.hasReceivedRequest(named: "concurrent-a") && + backend.hasReceivedRequest(named: "concurrent-b") + } + backend.sendResult(id: 99) + await fulfillment(of: [completed], timeout: 0.5) + XCTAssertEqual(observed, [.malformedResponse, .malformedResponse]) + } + func testAuthenticatedQueryIsSentWithoutSurfacingItInErrors() async throws { let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift index 48f2ea7e7..489b03968 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift @@ -92,6 +92,26 @@ final class FakeHermesGateway: @unchecked Sendable { queue.async { self.sendText("not-json") } } + func sendResult(id: Int, result: JSONValue = .null) { + queue.async { + self.sendJSON([ + "jsonrpc": .string("2.0"), + "id": .number(Double(id)), + "result": result, + ]) + } + } + + func sendRPCError(id: Int) { + queue.async { + self.sendJSON([ + "jsonrpc": .string("2.0"), + "id": .number(Double(id)), + "error": .object(["message": .string("fixture failure")]), + ]) + } + } + func disconnect() { queue.async { self.connection?.cancel() From 1a66a172b3234e70a0bcc534adc920dc8144ffc5 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:22:09 +0200 Subject: [PATCH 10/40] fix(hermes): bound gateway rpc ids --- .../Services/HermesGatewayClient.swift | 20 ++++-- .../HermesGatewayClientTests.swift | 67 +++++++++++++++++++ .../Support/FakeHermesGateway.swift | 26 ++++++- 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift index acb151764..285185fc5 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift @@ -18,6 +18,7 @@ public enum HermesGatewayClientError: Error, LocalizedError, Equatable { case readinessTimedOut case rpcError case sendFailed + case requestIDExhausted public var errorDescription: String? { switch self { @@ -26,6 +27,7 @@ public enum HermesGatewayClientError: Error, LocalizedError, Equatable { case .readinessTimedOut: "Hermes gateway did not become ready in time." case .rpcError: "Hermes RPC request failed." case .sendFailed: "Hermes request could not be sent." + case .requestIDExhausted: "Hermes request ID space is exhausted." } } } @@ -45,6 +47,7 @@ public typealias HermesGatewayClientFactory = @MainActor (URL) -> any HermesGate @MainActor public final class URLSessionHermesGatewayClient: HermesGatewayClientProtocol { private static let retiredIDLimit = 256 + private static let maxSafeJSONRPCID = 9_007_199_254_740_991 private let task: URLSessionWebSocketTask private var nextID = 1 private var pending: [Int: CheckedContinuation] = [:] @@ -63,6 +66,11 @@ public final class URLSessionHermesGatewayClient: HermesGatewayClientProtocol { task = session.webSocketTask(with: url) } + init(url: URL, startingRequestID: Int, session: URLSession = .shared) { + task = session.webSocketTask(with: url) + nextID = startingRequestID + } + /// Compatibility for the pre-readiness store lifecycle. New callers wait. public func connect() { startIfNeeded() @@ -94,6 +102,10 @@ public final class URLSessionHermesGatewayClient: HermesGatewayClientProtocol { public func call(method: String, params: [String: JSONValue] = [:]) async throws -> JSONValue { guard started, !terminated else { throw HermesGatewayClientError.disconnected } + guard (0...Self.maxSafeJSONRPCID).contains(nextID) else { + terminate(error: .requestIDExhausted, notify: true) + throw HermesGatewayClientError.requestIDExhausted + } let id = nextID nextID += 1 let request: [String: JSONValue] = [ @@ -123,7 +135,6 @@ public final class URLSessionHermesGatewayClient: HermesGatewayClientProtocol { public func close() { terminate(error: .disconnected, notify: false) - task.cancel(with: .goingAway, reason: nil) } private func startIfNeeded() { @@ -214,11 +225,11 @@ public final class URLSessionHermesGatewayClient: HermesGatewayClientProtocol { private func rpcID(_ value: JSONValue?) -> Int? { guard case .number(let number) = value, number.isFinite, - number.rounded() == number, number >= 0, - number <= Double(Int.max) + number <= Double(Self.maxSafeJSONRPCID), + let id = Int(exactly: number) else { return nil } - return Int(number) + return id } private func object(from value: JSONValue?) -> [String: JSONValue]? { @@ -259,6 +270,7 @@ public final class URLSessionHermesGatewayClient: HermesGatewayClientProtocol { private func terminate(error: HermesGatewayClientError, notify: Bool) { guard !terminated else { return } terminated = true + task.cancel(with: .goingAway, reason: nil) let rpcWaiters = pending pending.removeAll() let readyWaiters = readinessWaiters diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift index 970b69312..b5786a416 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift @@ -135,6 +135,7 @@ final class HermesGatewayClientTests: XCTestCase { return XCTFail("Expected malformed response") } } + await eventually { backend.peerWasDisconnected() } client.close() } @@ -158,6 +159,7 @@ final class HermesGatewayClientTests: XCTestCase { backend.sendResult(id: 99) await fulfillment(of: [completed], timeout: 0.5) XCTAssertEqual(observed as? HermesGatewayClientError, .malformedResponse) + await eventually { backend.peerWasDisconnected() } } func testLateDuplicateOfRetiredIDDoesNotTerminateNewPendingRequest() async throws { @@ -244,6 +246,49 @@ final class HermesGatewayClientTests: XCTestCase { XCTAssertEqual(observed, [.malformedResponse, .malformedResponse]) } + func testUnsupportedResponseIDsFailClosedWithoutTrapping() async throws { + for responseID: JSONValue in [ + .number(-1), + .number(1.5), + .number(9_007_199_254_740_992), + .number(9_223_372_036_854_775_808), + ] { + try await assertMalformedResponseID(responseID) + } + } + + func testRequestIDExhaustionFailsClosedWithoutReusingID() async throws { + let maxSafeID = 9_007_199_254_740_991 + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + let client = URLSessionHermesGatewayClient( + url: backend.webSocketURL(token: UUID().uuidString), + startingRequestID: maxSafeID + ) + defer { client.close() } + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + + let finalRequest = Task { try await client.call(method: "last-safe-id", params: [:]) } + await eventually { backend.hasReceivedRequest(named: "last-safe-id") } + backend.sendResult(id: maxSafeID, result: .string("final")) + let finalValue = try await finalRequest.value + XCTAssertEqual(finalValue, .string("final")) + + let completed = expectation(description: "request ID exhaustion") + var observed: Error? + Task { + do { + _ = try await client.call(method: "after-id-exhaustion", params: [:]) + } catch { + observed = error + completed.fulfill() + } + } + await fulfillment(of: [completed], timeout: 0.5) + XCTAssertEqual(observed as? HermesGatewayClientError, .requestIDExhausted) + XCTAssertFalse(backend.hasReceivedRequest(named: "after-id-exhaustion")) + await eventually { backend.peerWasDisconnected() } + } + func testAuthenticatedQueryIsSentWithoutSurfacingItInErrors() async throws { let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) @@ -275,4 +320,26 @@ final class HermesGatewayClientTests: XCTestCase { handler(error) } } + + private func assertMalformedResponseID(_ responseID: JSONValue) async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + defer { client.close() } + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + let completed = expectation(description: "invalid ID fails pending request") + var observed: Error? + Task { + do { + _ = try await client.call(method: "invalid-id", params: [:]) + } catch { + observed = error + completed.fulfill() + } + } + await eventually { backend.hasReceivedRequest(named: "invalid-id") } + backend.sendResult(id: responseID) + await fulfillment(of: [completed], timeout: 0.5) + XCTAssertEqual(observed as? HermesGatewayClientError, .malformedResponse) + await eventually { backend.peerWasDisconnected() } + } } diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift index 489b03968..861829f19 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/Support/FakeHermesGateway.swift @@ -22,6 +22,7 @@ final class FakeHermesGateway: @unchecked Sendable { private var rpcErrors: Set = [] private var receivedMethods: [String] = [] private var authenticated = false + private var peerDisconnected = false private let eventsOnConnect: [EventOnConnect] init(eventsOnConnect: [EventOnConnect]) throws { @@ -93,10 +94,14 @@ final class FakeHermesGateway: @unchecked Sendable { } func sendResult(id: Int, result: JSONValue = .null) { + sendResult(id: .number(Double(id)), result: result) + } + + func sendResult(id: JSONValue, result: JSONValue = .null) { queue.async { self.sendJSON([ "jsonrpc": .string("2.0"), - "id": .number(Double(id)), + "id": id, "result": result, ]) } @@ -127,12 +132,26 @@ final class FakeHermesGateway: @unchecked Sendable { queue.sync { authenticated } } + func peerWasDisconnected() -> Bool { + queue.sync { peerDisconnected } + } + private func accept(_ connection: NWConnection) { self.connection?.cancel() self.connection = connection handshakeBuffer.removeAll(keepingCapacity: true) frameBuffer.removeAll(keepingCapacity: true) handshakeComplete = false + peerDisconnected = false + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .cancelled, .failed: + guard let gateway = self else { return } + gateway.queue.async { gateway.peerDisconnected = true } + default: + break + } + } connection.start(queue: queue) receiveNext() } @@ -141,7 +160,10 @@ final class FakeHermesGateway: @unchecked Sendable { connection?.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, isComplete, error in guard let self else { return } self.queue.async { - guard error == nil, !isComplete else { return } + guard error == nil, !isComplete else { + self.peerDisconnected = true + return + } if let data { self.process(data) } From e7037d2eabc9a317856ec9e287296cbd91b10b13 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:28:26 +0200 Subject: [PATCH 11/40] fix(hermes): close socket on client deinit --- .../Services/HermesGatewayClient.swift | 4 ++++ .../HermesGatewayClientTests.swift | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift index 285185fc5..1deb8ccaf 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesGatewayClient.swift @@ -71,6 +71,10 @@ public final class URLSessionHermesGatewayClient: HermesGatewayClientProtocol { nextID = startingRequestID } + deinit { + task.cancel(with: .goingAway, reason: nil) + } + /// Compatibility for the pre-readiness store lifecycle. New callers wait. public func connect() { startIfNeeded() diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift index b5786a416..3bbc35599 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesGatewayClientTests.swift @@ -289,6 +289,20 @@ final class HermesGatewayClientTests: XCTestCase { await eventually { backend.peerWasDisconnected() } } + func testDeinitClosesStartedSocketAfterClientDeallocation() async throws { + let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) + weak var weakClient: URLSessionHermesGatewayClient? + + do { + let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) + weakClient = client + try await client.connectAndWaitUntilReady(timeoutSeconds: 1) + } + + await eventually { weakClient == nil } + await eventually { backend.peerWasDisconnected() } + } + func testAuthenticatedQueryIsSentWithoutSurfacingItInErrors() async throws { let backend = try FakeHermesGateway(eventsOnConnect: [.gatewayReady]) let client = URLSessionHermesGatewayClient(url: backend.webSocketURL(token: UUID().uuidString)) From 9844709df9d6d8f216ccdd91718875f57e02945b Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:40:54 +0200 Subject: [PATCH 12/40] feat(hermes): connect profiles and native sessions --- .../Stores/HermesAgentStore.swift | 249 +++++++++--- .../HermesAgentStoreTests.swift | 367 ++++++++++++++++++ 2 files changed, 571 insertions(+), 45 deletions(-) create mode 100644 apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index 857c11c6d..1859bb7a6 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -92,6 +92,7 @@ public final class HermesAgentStore: ObservableObject { @Published public private(set) var connectionState: HermesConnectionState = .idle @Published public private(set) var installStatus: HermesInstallStatus? @Published public private(set) var profiles: [HermesProfile] = [] + @Published public private(set) var profileRoutingStates: [String: HermesProfileRoutingState] = [:] @Published public private(set) var selectedProfile: HermesProfile? @Published public private(set) var sessions: [HermesSavedSession] = [] @Published public private(set) var messages: [HermesTranscriptMessage] = [] @@ -106,15 +107,32 @@ public final class HermesAgentStore: ObservableObject { @Published public private(set) var terminalAgentRunning: Bool = false private let integration: HermesIntegration - private var sidecar: HermesSidecar? + private let embeddedRuntime: any HermesEmbeddedRuntime + private let clientFactory: HermesGatewayClientFactory + private var sidecar: (any HermesSidecarControlling)? private var sidecarProfileName: String? private var sidecarConfigurationSignature: String? - private var client: URLSessionHermesGatewayClient? + private var client: (any HermesGatewayClientProtocol)? private var shuttingDown = false private var gatewayGeneration = 0 + private var didReapOrphanedSidecars = false - public init(integration: HermesIntegration = HermesIntegration()) { + public init( + integration: HermesIntegration, + embeddedRuntime: any HermesEmbeddedRuntime, + clientFactory: @escaping HermesGatewayClientFactory + ) { self.integration = integration + self.embeddedRuntime = embeddedRuntime + self.clientFactory = clientFactory + } + + public convenience init(integration: HermesIntegration = HermesIntegration()) { + self.init( + integration: integration, + embeddedRuntime: integration, + clientFactory: { URLSessionHermesGatewayClient(url: $0) } + ) } public var activeReference: HermesSessionReference? { @@ -131,10 +149,19 @@ public final class HermesAgentStore: ObservableObject { public func prepare(configuration: MTPLXAppConfiguration) async { connectionState = .checkingInstall gatewayRepairMessage = nil + if !didReapOrphanedSidecars { + _ = embeddedRuntime.reapOrphanedEmbeddedSidecars() + didReapOrphanedSidecars = true + } let status = await integration.installStatus() installStatus = status terminalAgentRunning = integration.hasLaunchedTerminalAgent() profiles = integration.discoverProfiles() + profileRoutingStates = Dictionary( + uniqueKeysWithValues: profiles.map { profile in + (profile.id, embeddedRuntime.routingState(for: profile, configuration: configuration)) + } + ) if let remembered = configuration.lastHermesProfile, let profile = profiles.first(where: { $0.name == remembered }) { selectedProfile = profile @@ -195,10 +222,13 @@ public final class HermesAgentStore: ObservableObject { selectedProfile = profile do { try await ensureGateway(profile: profile, configuration: configuration) + guard isCurrentProfile(profile) else { return } let result = try await rpc("session.list", params: ["limit": .number(200)]) + guard isCurrentProfile(profile), gatewayReady else { return } sessions = Self.parseSessions(result) connectionState = .connected } catch { + guard isCurrentProfile(profile), !shuttingDown else { return } sessions = [] connectionState = .failed(Self.message(for: error)) } @@ -211,15 +241,25 @@ public final class HermesAgentStore: ObservableObject { ) async throws -> HermesSessionReference { selectedProfile = profile try await ensureGateway(profile: profile, configuration: configuration) + guard isCurrentProfile(profile), gatewayReady else { + throw HermesGatewayClientError.disconnected + } let result = try await rpc("session.create", params: ["cols": .number(100)]) + guard isCurrentProfile(profile), gatewayReady else { + throw CancellationError() + } guard let sessionID = result.objectValue?["session_id"]?.stringValue else { throw HermesGatewayClientError.malformedResponse } + let sessionKey = (try? await liveSessionKey(for: sessionID)) ?? sessionID + guard isCurrentProfile(profile), gatewayReady else { + throw CancellationError() + } activeSessionID = sessionID activeSessionTitle = "New Hermes Agent" messages = [] toolTraces = [] - activeSessionKey = (try? await liveSessionKey(for: sessionID)) ?? sessionID + activeSessionKey = sessionKey connectionState = .connected return HermesSessionReference( profileName: profile.name, @@ -236,6 +276,9 @@ public final class HermesAgentStore: ObservableObject { ) async throws -> HermesSessionReference { selectedProfile = profile try await ensureGateway(profile: profile, configuration: configuration) + guard isCurrentProfile(profile), gatewayReady else { + throw HermesGatewayClientError.disconnected + } let result = try await rpc( "session.resume", params: [ @@ -243,17 +286,15 @@ public final class HermesAgentStore: ObservableObject { "cols": .number(100), ] ) - guard let object = result.objectValue, - let sessionID = object["session_id"]?.stringValue - else { - throw HermesGatewayClientError.malformedResponse - } - activeSessionID = sessionID - activeSessionKey = object["resumed"]?.stringValue ?? session.id - activeSessionTitle = session.title.isEmpty ? session.preview : session.title - messages = Self.parseMessages(object["messages"]) - toolTraces = [] - connectionState = .connected + guard isCurrentProfile(profile), gatewayReady else { + throw CancellationError() + } + try applyResumedSession( + result, + savedSessionID: session.id, + title: session.title.isEmpty ? session.preview : session.title, + preserveVisibleTranscript: false + ) return HermesSessionReference( profileName: profile.name, sessionID: activeSessionKey ?? session.id, @@ -267,7 +308,10 @@ public final class HermesAgentStore: ObservableObject { ) async throws -> HermesSessionReference { guard let profileName = configuration.lastHermesProfile, let sessionID = configuration.lastHermesSessionID, - let profile = profiles.first(where: { $0.name == profileName }) + let profile = ( + profiles.first(where: { $0.name == profileName }) + ?? selectedProfile.flatMap { $0.name == profileName ? $0 : nil } + ) else { throw HermesGatewayClientError.rpcError } @@ -284,7 +328,7 @@ public final class HermesAgentStore: ObservableObject { public func send(_ rawText: String) async { let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty, let sessionID = activeSessionID, !isStreaming else { return } + guard !text.isEmpty, let sessionID = activeSessionID, gatewayReady, !isStreaming else { return } messages.append(HermesTranscriptMessage(role: .user, text: text)) isStreaming = true do { @@ -307,7 +351,7 @@ public final class HermesAgentStore: ObservableObject { } public func interrupt() async { - guard let sessionID = activeSessionID else { return } + guard let sessionID = activeSessionID, gatewayReady else { return } do { _ = try await rpc("session.interrupt", params: ["session_id": .string(sessionID)]) } catch { @@ -322,6 +366,11 @@ public final class HermesAgentStore: ObservableObject { do { let profile = try await integration.createProfile(named: name) profiles = integration.discoverProfiles() + profileRoutingStates = Dictionary( + uniqueKeysWithValues: profiles.map { profile in + (profile.id, embeddedRuntime.routingState(for: profile, configuration: configuration)) + } + ) selectedProfile = profiles.first(where: { $0.name == profile.name }) ?? profile await loadSessions(profile: selectedProfile ?? profile, configuration: configuration) } catch { @@ -331,61 +380,114 @@ public final class HermesAgentStore: ObservableObject { public func stop() async { shuttingDown = true - gatewayGeneration += 1 - client?.close() - client = nil - sidecar?.stop() - sidecar = nil - sidecarProfileName = nil - sidecarConfigurationSignature = nil - gatewayReady = false - isStreaming = false + tearDownGateway(clearSession: true) activeSessionID = nil + activeSessionKey = nil + activeSessionTitle = nil + sessions = [] + messages = [] + toolTraces = [] terminalAgentRunning = integration.hasLaunchedTerminalAgent() connectionState = .idle } + /// Re-establishes the embedded sidecar for the selected profile and + /// reopens the selected persisted session without discarding the locally + /// visible transcript while transport recovery is in progress. + public func reconnect(configuration: MTPLXAppConfiguration) async throws { + guard let profile = selectedProfile else { throw HermesGatewayClientError.disconnected } + let savedSessionID = activeSessionKey ?? activeSessionID ?? configuration.lastHermesSessionID + let title = activeSessionTitle ?? configuration.lastHermesSessionTitle + try await ensureGateway(profile: profile, configuration: configuration, preserveSession: true) + guard let savedSessionID else { return } + let result = try await rpc( + "session.resume", + params: ["session_id": .string(savedSessionID), "cols": .number(100)] + ) + guard isCurrentProfile(profile), gatewayReady else { + throw CancellationError() + } + try applyResumedSession( + result, + savedSessionID: savedSessionID, + title: title, + preserveVisibleTranscript: true + ) + } + public func refreshTerminalAgentState() { terminalAgentRunning = integration.hasLaunchedTerminalAgent() } private func ensureGateway( profile: HermesProfile, - configuration: MTPLXAppConfiguration + configuration: MTPLXAppConfiguration, + preserveSession: Bool = false ) async throws { let signature = Self.configurationSignature(configuration) - if selectedProfile?.name == profile.name, - sidecarProfileName == profile.name, + if sidecarProfileName == profile.name, sidecarConfigurationSignature == signature, - sidecar?.process.isRunning == true, - client != nil { + sidecar?.isRunning == true, + client != nil, + gatewayReady { return } + let reuseSidecar = sidecarProfileName == profile.name + && sidecarConfigurationSignature == signature + && sidecar?.isRunning == true shuttingDown = true gatewayGeneration += 1 + let generation = gatewayGeneration client?.close() client = nil - sidecar?.stop() - sidecar = nil - sidecarProfileName = nil - sidecarConfigurationSignature = nil + if !reuseSidecar { + sidecar?.stop() + sidecar = nil + sidecarProfileName = nil + sidecarConfigurationSignature = nil + if !preserveSession { + sessions = [] + activeSessionID = nil + activeSessionKey = nil + activeSessionTitle = nil + messages = [] + toolTraces = [] + } + } gatewayReady = false - let generation = gatewayGeneration shuttingDown = false connectionState = .starting - let nextSidecar = try await integration.startDashboard( - profile: profile, - configuration: configuration - ) - let nextClient = URLSessionHermesGatewayClient(url: nextSidecar.webSocketURL) + let nextSidecar: any HermesSidecarControlling + if let sidecar, reuseSidecar { + nextSidecar = sidecar + } else { + nextSidecar = try await embeddedRuntime.startEmbeddedSidecar( + profile: profile, + configuration: configuration + ) + } + guard generation == gatewayGeneration, !shuttingDown else { + if !reuseSidecar { nextSidecar.stop() } + throw CancellationError() + } + let nextClient = clientFactory(nextSidecar.webSocketURL) nextClient.onEvent = { [weak self] event in - self?.handle(event) + guard let self, self.gatewayGeneration == generation, !self.shuttingDown else { return } + self.handle(event) } nextClient.onDisconnect = { [weak self] message in guard let self, self.gatewayGeneration == generation, !self.shuttingDown else { return } + self.gatewayReady = false + self.client?.close() + self.client = nil + self.sidecar?.stop() + self.sidecar = nil + self.sidecarProfileName = nil + self.sidecarConfigurationSignature = nil + self.isStreaming = false self.connectionState = .failed(message) } sidecar = nextSidecar @@ -393,17 +495,74 @@ public final class HermesAgentStore: ObservableObject { sidecarConfigurationSignature = signature client = nextClient selectedProfile = profile - nextClient.connect() + do { + try await nextClient.connectAndWaitUntilReady(timeoutSeconds: 10) + } catch { + guard generation == gatewayGeneration, !shuttingDown else { throw CancellationError() } + nextClient.close() + if sidecar === nextSidecar { sidecar = nil } + nextSidecar.stop() + sidecarProfileName = nil + sidecarConfigurationSignature = nil + gatewayReady = false + throw error + } + guard generation == gatewayGeneration, !shuttingDown else { + nextClient.close() + if !reuseSidecar { nextSidecar.stop() } + throw CancellationError() + } + gatewayReady = true connectionState = .connected } private func rpc(_ method: String, params: [String: JSONValue] = [:]) async throws -> JSONValue { - guard let client else { + guard gatewayReady, let client else { throw HermesGatewayClientError.disconnected } return try await client.call(method: method, params: params) } + private func isCurrentProfile(_ profile: HermesProfile) -> Bool { + selectedProfile?.id == profile.id + } + + private func tearDownGateway(clearSession: Bool) { + gatewayGeneration += 1 + client?.close() + client = nil + sidecar?.stop() + sidecar = nil + sidecarProfileName = nil + sidecarConfigurationSignature = nil + gatewayReady = false + isStreaming = false + if clearSession { + activeSessionID = nil + activeSessionKey = nil + activeSessionTitle = nil + } + } + + private func applyResumedSession( + _ result: JSONValue, + savedSessionID: String, + title: String?, + preserveVisibleTranscript: Bool + ) throws { + guard let object = result.objectValue, + let sessionID = object["session_id"]?.stringValue + else { throw HermesGatewayClientError.malformedResponse } + activeSessionID = sessionID + activeSessionKey = object["resumed"]?.stringValue ?? savedSessionID + activeSessionTitle = title + if !preserveVisibleTranscript || messages.isEmpty { + messages = Self.parseMessages(object["messages"]) + } + toolTraces = [] + connectionState = .connected + } + private func liveSessionKey(for sessionID: String) async throws -> String? { let result = try await rpc( "session.active_list", diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift new file mode 100644 index 000000000..2e3f06d89 --- /dev/null +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -0,0 +1,367 @@ +import Foundation +import XCTest +@testable import MTPLXAppCore + +final class HermesAgentStoreTests: XCTestCase { + private var root: URL! + private var integration: HermesIntegration! + private var configuration: MTPLXAppConfiguration! + private var bernd: HermesProfile! + private var researcher: HermesProfile! + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("HermesAgentStoreTests-\(UUID().uuidString)", isDirectory: true) + let hermesHome = root.appendingPathComponent(".hermes", isDirectory: true) + let profiles = hermesHome.appendingPathComponent("profiles", isDirectory: true) + try FileManager.default.createDirectory(at: profiles, withIntermediateDirectories: true) + for name in ["bernd", "researcher"] { + try FileManager.default.createDirectory( + at: profiles.appendingPathComponent(name, isDirectory: true), + withIntermediateDirectories: true + ) + } + integration = HermesIntegration( + hermesHome: hermesHome, + executablePath: "/usr/bin/true", + environment: ["HOME": root.path, "PATH": "/usr/bin:/bin"], + sidecarRuntimeDirectory: root.appendingPathComponent("sidecars", isDirectory: true) + ) + configuration = MTPLXAppConfiguration( + model: "current-model", + host: "127.0.0.1", + port: 18080, + apiKey: "test-key" + ) + bernd = HermesProfile(name: "bernd", path: profiles.appendingPathComponent("bernd").path, isDefault: false) + researcher = HermesProfile(name: "researcher", path: profiles.appendingPathComponent("researcher").path, isDefault: false) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + @MainActor + func testLoadSessionsWaitsForReadyAndReusesMatchingSidecar() async { + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient() + client.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + + let load = Task { await store.loadSessions(profile: bernd, configuration: configuration) } + await waitUntil { store.connectionState == .starting } + XCTAssertEqual(client.calls, []) + client.finishReady() + await load.value + + XCTAssertEqual(client.calls.first?.method, "session.list") + XCTAssertTrue(store.gatewayReady) + XCTAssertEqual(store.connectionState, .connected) + await store.loadSessions(profile: bernd, configuration: configuration) + XCTAssertEqual(runtime.startCount, 1) + } + + @MainActor + func testProfileSwitchClosesClientAndStopsOnlyCurrentOwnedSidecar() async { + let runtime = FakeHermesEmbeddedRuntime() + let firstClient = FakeHermesGatewayClient(readyImmediately: true) + let secondClient = FakeHermesGatewayClient(readyImmediately: true) + firstClient.resultByMethod["session.list"] = .object(["sessions": .array([])]) + secondClient.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [firstClient, secondClient]) + + await store.loadSessions(profile: bernd, configuration: configuration) + await store.loadSessions(profile: researcher, configuration: configuration) + + XCTAssertTrue(firstClient.didClose) + XCTAssertEqual(runtime.sidecars[0].stopCount, 1) + XCTAssertEqual(runtime.sidecars[1].stopCount, 0) + XCTAssertEqual(store.selectedProfile?.name, "researcher") + } + + @MainActor + func testNativeSessionRPCsRestoreTranscriptAndRememberedSession() async throws { + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("new-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.resultByMethod["session.resume"] = .object([ + "session_id": .string("live-7"), + "resumed": .string("saved-7"), + "messages": .array([ + .object(["role": .string("user"), "text": .string("Earlier question")]), + .object(["role": .string("assistant"), "content": .string("Earlier answer")]), + ]), + ]) + let store = makeStore(runtime: runtime, clients: [client]) + + let created = try await store.startNewAgent(profile: bernd, configuration: configuration) + XCTAssertEqual(created.sessionID, "new-1") + let resumed = try await store.resume( + HermesSavedSession(id: "saved-7", title: "Saved", preview: "", startedAt: 0, messageCount: 2, source: ""), + profile: bernd, + configuration: configuration + ) + + XCTAssertEqual(resumed, HermesSessionReference(profileName: "bernd", sessionID: "saved-7", title: "Saved")) + XCTAssertEqual(store.messages.map(\.text), ["Earlier question", "Earlier answer"]) + XCTAssertEqual(store.activeReference, resumed) + + let remembered = MTPLXAppConfiguration( + model: configuration.model, + host: configuration.host, + port: configuration.port, + apiKey: configuration.apiKey, + lastHermesProfile: "bernd", + lastHermesSessionID: "saved-7", + lastHermesSessionTitle: "Saved" + ) + _ = try await store.resumeLast(configuration: remembered) + XCTAssertEqual(store.activeReference?.sessionID, "saved-7") + } + + @MainActor + func testSendAndInterruptUseNativeSessionRPCs() async throws { + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + await store.send("Run diagnostics") + await store.interrupt() + + XCTAssertEqual( + client.calls.filter { $0.method == "prompt.submit" }.first?.params, + ["session_id": .string("live-1"), "text": .string("Run diagnostics")] + ) + XCTAssertEqual( + client.calls.filter { $0.method == "session.interrupt" }.first?.params, + ["session_id": .string("live-1")] + ) + } + + @MainActor + func testReconnectPreservesVisibleTranscriptAndResumesSelectedSession() async throws { + let runtime = FakeHermesEmbeddedRuntime() + let firstClient = FakeHermesGatewayClient(readyImmediately: true) + let secondClient = FakeHermesGatewayClient(readyImmediately: true) + let resume = JSONValue.object([ + "session_id": .string("live-9"), + "resumed": .string("saved-9"), + "messages": .array([ + .object(["role": .string("assistant"), "text": .string("Persisted answer")]), + ]), + ]) + firstClient.resultByMethod["session.resume"] = resume + secondClient.resultByMethod["session.resume"] = resume + let store = makeStore(runtime: runtime, clients: [firstClient, secondClient]) + let saved = HermesSavedSession(id: "saved-9", title: "Saved", preview: "", startedAt: 0, messageCount: 1, source: "") + _ = try await store.resume(saved, profile: bernd, configuration: configuration) + firstClient.disconnect("transport lost") + XCTAssertEqual(store.messages.map(\.text), ["Persisted answer"]) + + try await store.reconnect(configuration: configuration) + + XCTAssertEqual(store.connectionState, .connected) + XCTAssertEqual(store.activeReference?.sessionID, "saved-9") + XCTAssertEqual(store.messages.map(\.text), ["Persisted answer"]) + XCTAssertEqual(secondClient.calls.filter { $0.method == "session.resume" }.count, 1) + } + + @MainActor + func testLateResumeCannotOverwriteStateAfterProfileSwitch() async { + let runtime = FakeHermesEmbeddedRuntime() + let firstClient = FakeHermesGatewayClient(readyImmediately: true) + let secondClient = FakeHermesGatewayClient(readyImmediately: true) + firstClient.suspendedMethods = ["session.resume"] + firstClient.resultByMethod["session.resume"] = .object([ + "session_id": .string("obsolete-live"), + "resumed": .string("obsolete-saved"), + "messages": .array([.object(["role": .string("assistant"), "text": .string("stale")])]), + ]) + secondClient.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [firstClient, secondClient]) + let oldSession = HermesSavedSession( + id: "obsolete-saved", title: "Old", preview: "", startedAt: 0, messageCount: 1, source: "" + ) + + let resume = Task { try await store.resume(oldSession, profile: bernd, configuration: configuration) } + await waitUntil { firstClient.calls.contains(where: { $0.method == "session.resume" }) } + await store.loadSessions(profile: researcher, configuration: configuration) + firstClient.finishCall(method: "session.resume") + do { + _ = try await resume.value + XCTFail("Stale resume must not complete after a profile switch") + } catch is CancellationError { + // Expected: the newer profile owns the store state. + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(store.selectedProfile?.name, "researcher") + XCTAssertNil(store.activeSessionID) + XCTAssertEqual(store.messages, []) + } + + @MainActor + func testPrepareReapsOnceAndPublishesProfileRouting() async { + let runtime = FakeHermesEmbeddedRuntime() + runtime.routingByProfile = ["default": .mtplx, "bernd": .external, "researcher": .unavailable("invalid")] + let store = makeStore(runtime: runtime, clients: []) + var remembered = configuration! + remembered.lastHermesProfile = "bernd" + + await store.prepare(configuration: remembered) + await store.prepare(configuration: remembered) + + XCTAssertEqual(runtime.reapCount, 1) + XCTAssertEqual(store.selectedProfile?.name, "bernd") + XCTAssertEqual(store.profileRoutingStates["default"], .mtplx) + XCTAssertEqual(store.profileRoutingStates["bernd"], .external) + XCTAssertEqual(store.profileRoutingStates["researcher"], .unavailable("invalid")) + } + + @MainActor + private func makeStore( + runtime: FakeHermesEmbeddedRuntime, + clients: [FakeHermesGatewayClient] + ) -> HermesAgentStore { + var remaining = clients + return HermesAgentStore( + integration: integration, + embeddedRuntime: runtime, + clientFactory: { _ in + guard !remaining.isEmpty else { fatalError("Missing fake client") } + return remaining.removeFirst() + } + ) + } + + @MainActor + private func waitUntil( + timeout: TimeInterval = 1, + condition: @escaping @MainActor () -> Bool + ) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + await Task.yield() + } + XCTAssertTrue(condition()) + } +} + +private final class FakeHermesEmbeddedRuntime: HermesEmbeddedRuntime, @unchecked Sendable { + var routingByProfile: [String: HermesProfileRoutingState] = [:] + var startCount = 0 + var reapCount = 0 + private(set) var sidecars: [FakeHermesSidecar] = [] + + func routingState(for profile: HermesProfile, configuration: MTPLXAppConfiguration) -> HermesProfileRoutingState { + routingByProfile[profile.name] ?? .external + } + + func startEmbeddedSidecar( + profile: HermesProfile, + configuration: MTPLXAppConfiguration + ) async throws -> any HermesSidecarControlling { + startCount += 1 + let sidecar = FakeHermesSidecar(index: startCount) + sidecars.append(sidecar) + return sidecar + } + + func sessionOwnership( + profile: HermesProfile, + sessionID: String, + ownedSidecarPID: Int32? + ) -> HermesSessionOwnership { + .appOwned + } + + @discardableResult + func reapOrphanedEmbeddedSidecars() -> [Int32] { + reapCount += 1 + return [] + } +} + +private final class FakeHermesSidecar: HermesSidecarControlling, @unchecked Sendable { + let processIdentifier: Int32 + var isRunning = true + let webSocketURL: URL + let ownershipRecordURL: URL + private(set) var stopCount = 0 + + init(index: Int) { + processIdentifier = Int32(index + 100) + webSocketURL = URL(string: "ws://127.0.0.1:18080/api/ws?token=fake")! + ownershipRecordURL = URL(fileURLWithPath: "/tmp/fake-hermes-sidecar-\(index)") + } + + func stop() { + stopCount += 1 + isRunning = false + } +} + +@MainActor +private final class FakeHermesGatewayClient: HermesGatewayClientProtocol { + struct Call: Equatable { + let method: String + let params: [String: JSONValue] + } + + var onEvent: ((HermesGatewayEvent) -> Void)? + var onDisconnect: ((String) -> Void)? + var resultByMethod: [String: JSONValue] = [:] + private(set) var calls: [Call] = [] + private(set) var didClose = false + private var ready = false + private var readinessWaiters: [CheckedContinuation] = [] + var suspendedMethods: Set = [] + private var callWaiters: [String: [CheckedContinuation]] = [:] + + init(readyImmediately: Bool = false) { + ready = readyImmediately + } + + func connectAndWaitUntilReady(timeoutSeconds: Double) async throws { + if ready { return } + try await withCheckedThrowingContinuation { readinessWaiters.append($0) } + } + + func call(method: String, params: [String: JSONValue]) async throws -> JSONValue { + calls.append(Call(method: method, params: params)) + if suspendedMethods.contains(method) { + return try await withCheckedThrowingContinuation { continuation in + callWaiters[method, default: []].append(continuation) + } + } + return resultByMethod[method] ?? .object([:]) + } + + func close() { + didClose = true + readinessWaiters.forEach { $0.resume(throwing: HermesGatewayClientError.disconnected) } + readinessWaiters.removeAll() + } + + func finishReady() { + ready = true + onEvent?(HermesGatewayEvent(type: "gateway.ready", sessionID: nil, payload: [:])) + readinessWaiters.forEach { $0.resume() } + readinessWaiters.removeAll() + } + + func disconnect(_ message: String) { + onDisconnect?(message) + } + + func finishCall(method: String) { + let value = resultByMethod[method] ?? .object([:]) + let waiters = callWaiters.removeValue(forKey: method) ?? [] + waiters.forEach { $0.resume(returning: value) } + } +} From e9f199c98aba9944a79100e0d648a1f98eb7f388 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:59:11 +0200 Subject: [PATCH 13/40] fix(hermes): guard stale gateway operations --- .../Stores/HermesAgentStore.swift | 208 +++++++++++----- .../HermesAgentStoreTests.swift | 223 ++++++++++++++++++ 2 files changed, 369 insertions(+), 62 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index 1859bb7a6..72b0470b0 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -117,6 +117,12 @@ public final class HermesAgentStore: ObservableObject { private var gatewayGeneration = 0 private var didReapOrphanedSidecars = false + private struct GatewayOperation { + let generation: Int + let profileID: String + let clientIdentity: ObjectIdentifier + } + public init( integration: HermesIntegration, embeddedRuntime: any HermesEmbeddedRuntime, @@ -220,15 +226,16 @@ public final class HermesAgentStore: ObservableObject { configuration: MTPLXAppConfiguration ) async { selectedProfile = profile + var operation: GatewayOperation? do { - try await ensureGateway(profile: profile, configuration: configuration) - guard isCurrentProfile(profile) else { return } - let result = try await rpc("session.list", params: ["limit": .number(200)]) - guard isCurrentProfile(profile), gatewayReady else { return } + operation = try await ensureGateway(profile: profile, configuration: configuration) + guard let operation, isCurrent(operation) else { return } + let result = try await rpc(operation, method: "session.list", params: ["limit": .number(200)]) + guard isCurrent(operation) else { return } sessions = Self.parseSessions(result) connectionState = .connected } catch { - guard isCurrentProfile(profile), !shuttingDown else { return } + guard let operation, isCurrent(operation), !shuttingDown else { return } sessions = [] connectionState = .failed(Self.message(for: error)) } @@ -240,19 +247,19 @@ public final class HermesAgentStore: ObservableObject { configuration: MTPLXAppConfiguration ) async throws -> HermesSessionReference { selectedProfile = profile - try await ensureGateway(profile: profile, configuration: configuration) - guard isCurrentProfile(profile), gatewayReady else { + let operation = try await ensureGateway(profile: profile, configuration: configuration) + guard isCurrent(operation) else { throw HermesGatewayClientError.disconnected } - let result = try await rpc("session.create", params: ["cols": .number(100)]) - guard isCurrentProfile(profile), gatewayReady else { + let result = try await rpc(operation, method: "session.create", params: ["cols": .number(100)]) + guard isCurrent(operation) else { throw CancellationError() } guard let sessionID = result.objectValue?["session_id"]?.stringValue else { throw HermesGatewayClientError.malformedResponse } - let sessionKey = (try? await liveSessionKey(for: sessionID)) ?? sessionID - guard isCurrentProfile(profile), gatewayReady else { + let sessionKey = (try? await liveSessionKey(for: sessionID, operation: operation)) ?? sessionID + guard isCurrent(operation) else { throw CancellationError() } activeSessionID = sessionID @@ -275,18 +282,19 @@ public final class HermesAgentStore: ObservableObject { configuration: MTPLXAppConfiguration ) async throws -> HermesSessionReference { selectedProfile = profile - try await ensureGateway(profile: profile, configuration: configuration) - guard isCurrentProfile(profile), gatewayReady else { + let operation = try await ensureGateway(profile: profile, configuration: configuration) + guard isCurrent(operation) else { throw HermesGatewayClientError.disconnected } let result = try await rpc( - "session.resume", + operation, + method: "session.resume", params: [ "session_id": .string(session.id), "cols": .number(100), ] ) - guard isCurrentProfile(profile), gatewayReady else { + guard isCurrent(operation) else { throw CancellationError() } try applyResumedSession( @@ -328,18 +336,25 @@ public final class HermesAgentStore: ObservableObject { public func send(_ rawText: String) async { let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty, let sessionID = activeSessionID, gatewayReady, !isStreaming else { return } + guard !text.isEmpty, + let sessionID = activeSessionID, + let profile = selectedProfile, + let operation = currentOperation(for: profile), + !isStreaming + else { return } messages.append(HermesTranscriptMessage(role: .user, text: text)) isStreaming = true do { _ = try await rpc( - "prompt.submit", + operation, + method: "prompt.submit", params: [ "session_id": .string(sessionID), "text": .string(text), ] ) } catch { + guard isCurrent(operation, sessionID: sessionID) else { return } isStreaming = false messages.append( HermesTranscriptMessage( @@ -351,14 +366,19 @@ public final class HermesAgentStore: ObservableObject { } public func interrupt() async { - guard let sessionID = activeSessionID, gatewayReady else { return } + guard let sessionID = activeSessionID, + let profile = selectedProfile, + let operation = currentOperation(for: profile) + else { return } do { - _ = try await rpc("session.interrupt", params: ["session_id": .string(sessionID)]) + _ = try await rpc(operation, method: "session.interrupt", params: ["session_id": .string(sessionID)]) } catch { + guard isCurrent(operation, sessionID: sessionID) else { return } messages.append( HermesTranscriptMessage(role: .system, text: Self.message(for: error)) ) } + guard isCurrent(operation, sessionID: sessionID) else { return } isStreaming = false } @@ -398,13 +418,15 @@ public final class HermesAgentStore: ObservableObject { guard let profile = selectedProfile else { throw HermesGatewayClientError.disconnected } let savedSessionID = activeSessionKey ?? activeSessionID ?? configuration.lastHermesSessionID let title = activeSessionTitle ?? configuration.lastHermesSessionTitle - try await ensureGateway(profile: profile, configuration: configuration, preserveSession: true) + let operation = try await ensureGateway(profile: profile, configuration: configuration, preserveSession: true) + guard isCurrent(operation) else { throw CancellationError() } guard let savedSessionID else { return } let result = try await rpc( - "session.resume", + operation, + method: "session.resume", params: ["session_id": .string(savedSessionID), "cols": .number(100)] ) - guard isCurrentProfile(profile), gatewayReady else { + guard isCurrent(operation) else { throw CancellationError() } try applyResumedSession( @@ -423,14 +445,17 @@ public final class HermesAgentStore: ObservableObject { profile: HermesProfile, configuration: MTPLXAppConfiguration, preserveSession: Bool = false - ) async throws { + ) async throws -> GatewayOperation { let signature = Self.configurationSignature(configuration) if sidecarProfileName == profile.name, sidecarConfigurationSignature == signature, sidecar?.isRunning == true, client != nil, gatewayReady { - return + guard let operation = currentOperation(for: profile) else { + throw HermesGatewayClientError.disconnected + } + return operation } let reuseSidecar = sidecarProfileName == profile.name && sidecarConfigurationSignature == signature @@ -452,19 +477,26 @@ public final class HermesAgentStore: ObservableObject { activeSessionTitle = nil messages = [] toolTraces = [] + isStreaming = false } } gatewayReady = false shuttingDown = false connectionState = .starting let nextSidecar: any HermesSidecarControlling - if let sidecar, reuseSidecar { - nextSidecar = sidecar - } else { - nextSidecar = try await embeddedRuntime.startEmbeddedSidecar( - profile: profile, - configuration: configuration - ) + do { + if let sidecar, reuseSidecar { + nextSidecar = sidecar + } else { + nextSidecar = try await embeddedRuntime.startEmbeddedSidecar( + profile: profile, + configuration: configuration + ) + } + } catch { + guard generation == gatewayGeneration, !shuttingDown else { throw CancellationError() } + connectionState = .failed(Self.message(for: error)) + throw error } guard generation == gatewayGeneration, !shuttingDown else { if !reuseSidecar { nextSidecar.stop() } @@ -480,15 +512,13 @@ public final class HermesAgentStore: ObservableObject { self.gatewayGeneration == generation, !self.shuttingDown else { return } - self.gatewayReady = false - self.client?.close() - self.client = nil - self.sidecar?.stop() - self.sidecar = nil - self.sidecarProfileName = nil - self.sidecarConfigurationSignature = nil - self.isStreaming = false - self.connectionState = .failed(message) + if self.releaseGatewayIfOwned( + generation: generation, + client: nextClient, + sidecar: nextSidecar + ) { + self.connectionState = .failed(message) + } } sidecar = nextSidecar sidecarProfileName = profile.name @@ -499,32 +529,77 @@ public final class HermesAgentStore: ObservableObject { try await nextClient.connectAndWaitUntilReady(timeoutSeconds: 10) } catch { guard generation == gatewayGeneration, !shuttingDown else { throw CancellationError() } - nextClient.close() - if sidecar === nextSidecar { sidecar = nil } - nextSidecar.stop() - sidecarProfileName = nil - sidecarConfigurationSignature = nil - gatewayReady = false + if releaseGatewayIfOwned(generation: generation, client: nextClient, sidecar: nextSidecar) { + connectionState = .failed(Self.message(for: error)) + } throw error } guard generation == gatewayGeneration, !shuttingDown else { - nextClient.close() - if !reuseSidecar { nextSidecar.stop() } + _ = releaseGatewayIfOwned(generation: generation, client: nextClient, sidecar: nextSidecar) throw CancellationError() } gatewayReady = true connectionState = .connected + return GatewayOperation( + generation: generation, + profileID: profile.id, + clientIdentity: ObjectIdentifier(nextClient) + ) } - private func rpc(_ method: String, params: [String: JSONValue] = [:]) async throws -> JSONValue { - guard gatewayReady, let client else { + private func rpc( + _ operation: GatewayOperation, + method: String, + params: [String: JSONValue] = [:] + ) async throws -> JSONValue { + guard isCurrent(operation), let client else { throw HermesGatewayClientError.disconnected } return try await client.call(method: method, params: params) } - private func isCurrentProfile(_ profile: HermesProfile) -> Bool { - selectedProfile?.id == profile.id + private func currentOperation(for profile: HermesProfile) -> GatewayOperation? { + guard gatewayReady, + selectedProfile?.id == profile.id, + let client + else { return nil } + return GatewayOperation( + generation: gatewayGeneration, + profileID: profile.id, + clientIdentity: ObjectIdentifier(client) + ) + } + + private func isCurrent(_ operation: GatewayOperation, sessionID: String? = nil) -> Bool { + guard gatewayReady, + gatewayGeneration == operation.generation, + selectedProfile?.id == operation.profileID, + let client, + ObjectIdentifier(client) == operation.clientIdentity + else { return false } + return sessionID.map { $0 == activeSessionID } ?? true + } + + private func releaseGatewayIfOwned( + generation: Int, + client expectedClient: any HermesGatewayClientProtocol, + sidecar expectedSidecar: any HermesSidecarControlling + ) -> Bool { + guard gatewayGeneration == generation, + let client, + ObjectIdentifier(client) == ObjectIdentifier(expectedClient), + let sidecar, + ObjectIdentifier(sidecar) == ObjectIdentifier(expectedSidecar) + else { return false } + self.client = nil + self.sidecar = nil + sidecarProfileName = nil + sidecarConfigurationSignature = nil + gatewayReady = false + isStreaming = false + expectedClient.close() + expectedSidecar.stop() + return true } private func tearDownGateway(clearSession: Bool) { @@ -563,9 +638,10 @@ public final class HermesAgentStore: ObservableObject { connectionState = .connected } - private func liveSessionKey(for sessionID: String) async throws -> String? { + private func liveSessionKey(for sessionID: String, operation: GatewayOperation) async throws -> String? { let result = try await rpc( - "session.active_list", + operation, + method: "session.active_list", params: ["current_session_id": .string(sessionID)] ) guard let rows = result.objectValue?["sessions"]?.arrayValue else { return nil } @@ -580,7 +656,6 @@ public final class HermesAgentStore: ObservableObject { private func handle(_ event: HermesGatewayEvent) { if event.type == "gateway.ready" { - gatewayReady = true return } if let activeSessionID, @@ -636,10 +711,14 @@ public final class HermesAgentStore: ObservableObject { detail: "Auto-approved by MTPLX Hermes mode." ) ) - if let sessionID = activeSessionID { - Task { - _ = try? await rpc( - "approval.respond", + if let sessionID = activeSessionID, + let profile = selectedProfile, + let operation = currentOperation(for: profile) { + Task { [weak self] in + guard let self, self.isCurrent(operation, sessionID: sessionID) else { return } + _ = try? await self.rpc( + operation, + method: "approval.respond", params: [ "session_id": .string(sessionID), "choice": .string("allow"), @@ -703,9 +782,14 @@ public final class HermesAgentStore: ObservableObject { ) } isStreaming = false - if let activeSessionID { - Task { - activeSessionKey = (try? await liveSessionKey(for: activeSessionID)) ?? activeSessionKey + if let activeSessionID, + let profile = selectedProfile, + let operation = currentOperation(for: profile) { + Task { [weak self] in + guard let self else { return } + let key = try? await self.liveSessionKey(for: activeSessionID, operation: operation) + guard self.isCurrent(operation, sessionID: activeSessionID) else { return } + self.activeSessionKey = key ?? self.activeSessionKey } } } diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 2e3f06d89..7d554eed8 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -205,6 +205,171 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertEqual(store.messages, []) } + @MainActor + func testLateSameProfileLoadCannotOverwriteNewerConfigurationGeneration() async { + let runtime = FakeHermesEmbeddedRuntime() + let firstClient = FakeHermesGatewayClient(readyImmediately: true) + let secondClient = FakeHermesGatewayClient(readyImmediately: true) + firstClient.suspendedMethods = ["session.list"] + firstClient.resultByMethod["session.list"] = sessionsResult(id: "old", title: "Old") + secondClient.resultByMethod["session.list"] = sessionsResult(id: "new", title: "New") + let store = makeStore(runtime: runtime, clients: [firstClient, secondClient]) + + let oldLoad = Task { await store.loadSessions(profile: bernd, configuration: configuration) } + await waitUntil { firstClient.calls.contains(where: { $0.method == "session.list" }) } + await store.loadSessions(profile: bernd, configuration: alternateConfiguration()) + firstClient.finishCall(method: "session.list") + await oldLoad.value + + XCTAssertEqual(store.sessions.map(\.id), ["new"]) + XCTAssertEqual(store.connectionState, .connected) + } + + @MainActor + func testLateSameProfileCreateCannotOverwriteNewerConfigurationGeneration() async { + let runtime = FakeHermesEmbeddedRuntime() + let firstClient = FakeHermesGatewayClient(readyImmediately: true) + let secondClient = FakeHermesGatewayClient(readyImmediately: true) + firstClient.suspendedMethods = ["session.create"] + firstClient.resultByMethod["session.create"] = .object(["session_id": .string("obsolete")]) + secondClient.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [firstClient, secondClient]) + + let create = Task { try await store.startNewAgent(profile: bernd, configuration: configuration) } + await waitUntil { firstClient.calls.contains(where: { $0.method == "session.create" }) } + await store.loadSessions(profile: bernd, configuration: alternateConfiguration()) + firstClient.finishCall(method: "session.create") + + await assertCancellation(create) + XCTAssertNil(store.activeSessionID) + XCTAssertEqual(store.connectionState, .connected) + } + + @MainActor + func testLateSameProfileResumeCannotOverwriteNewerConfigurationGeneration() async { + let runtime = FakeHermesEmbeddedRuntime() + let firstClient = FakeHermesGatewayClient(readyImmediately: true) + let secondClient = FakeHermesGatewayClient(readyImmediately: true) + firstClient.suspendedMethods = ["session.resume"] + firstClient.resultByMethod["session.resume"] = resumeResult(liveID: "obsolete-live", savedID: "obsolete-saved") + secondClient.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [firstClient, secondClient]) + let saved = HermesSavedSession(id: "obsolete-saved", title: "Old", preview: "", startedAt: 0, messageCount: 1, source: "") + + let resume = Task { try await store.resume(saved, profile: bernd, configuration: configuration) } + await waitUntil { firstClient.calls.contains(where: { $0.method == "session.resume" }) } + await store.loadSessions(profile: bernd, configuration: alternateConfiguration()) + firstClient.finishCall(method: "session.resume") + + await assertCancellation(resume) + XCTAssertNil(store.activeSessionID) + XCTAssertEqual(store.messages, []) + } + + @MainActor + func testConcurrentReconnectKeepsOnlyNewestSameProfileGeneration() async throws { + let runtime = FakeHermesEmbeddedRuntime() + let initial = FakeHermesGatewayClient(readyImmediately: true) + let slowReconnect = FakeHermesGatewayClient() + let newestReconnect = FakeHermesGatewayClient(readyImmediately: true) + initial.resultByMethod["session.resume"] = resumeResult(liveID: "initial-live", savedID: "saved") + newestReconnect.resultByMethod["session.resume"] = resumeResult(liveID: "new-live", savedID: "saved") + let store = makeStore(runtime: runtime, clients: [initial, slowReconnect, newestReconnect]) + let saved = HermesSavedSession(id: "saved", title: "Saved", preview: "", startedAt: 0, messageCount: 1, source: "") + _ = try await store.resume(saved, profile: bernd, configuration: configuration) + initial.disconnect("transport lost") + + let olderReconnect = Task { try await store.reconnect(configuration: configuration) } + await waitUntil { runtime.startCount == 2 } + let newerReconnect = Task { try await store.reconnect(configuration: configuration) } + try await newerReconnect.value + await assertCancellation(olderReconnect) + + XCTAssertEqual(store.activeSessionID, "new-live") + XCTAssertEqual(store.activeReference?.sessionID, "saved") + XCTAssertTrue(slowReconnect.didClose) + XCTAssertEqual(runtime.startCount, 2) + } + + @MainActor + func testGatewayReadyEventDoesNotExposeStoreBeforeConnectReturns() async { + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient() + client.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + + let load = Task { await store.loadSessions(profile: bernd, configuration: configuration) } + await waitUntil { client.hasEventHandler } + client.emitReadyEventWithoutCompletingConnect() + await Task.yield() + + XCTAssertFalse(store.gatewayReady) + XCTAssertEqual(store.connectionState, .starting) + XCTAssertEqual(client.calls, []) + + client.completeReadyConnect() + await load.value + XCTAssertTrue(store.gatewayReady) + } + + @MainActor + func testDisconnectBeforeReadyStopsOwnedSidecarExactlyOnce() async { + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient() + let store = makeStore(runtime: runtime, clients: [client]) + + let load = Task { await store.loadSessions(profile: bernd, configuration: configuration) } + await waitUntil { client.hasDisconnectHandler } + client.disconnect("transport lost") + await load.value + + XCTAssertEqual(runtime.sidecars[0].stopCount, 1) + } + + @MainActor + func testLatePromptFailureCannotAppendErrorToNewProfileTranscript() async throws { + let runtime = FakeHermesEmbeddedRuntime() + let firstClient = FakeHermesGatewayClient(readyImmediately: true) + let secondClient = FakeHermesGatewayClient(readyImmediately: true) + firstClient.resultByMethod["session.create"] = .object(["session_id": .string("old-live")]) + firstClient.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + firstClient.suspendedMethods = ["prompt.submit"] + secondClient.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [firstClient, secondClient]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + let send = Task { await store.send("old prompt") } + await waitUntil { firstClient.calls.contains(where: { $0.method == "prompt.submit" }) } + await store.loadSessions(profile: researcher, configuration: configuration) + firstClient.failCall(method: "prompt.submit") + await send.value + + XCTAssertEqual(store.messages, []) + XCTAssertFalse(store.isStreaming) + } + + @MainActor + func testLateInterruptFailureCannotAppendErrorToNewProfileTranscript() async throws { + let runtime = FakeHermesEmbeddedRuntime() + let firstClient = FakeHermesGatewayClient(readyImmediately: true) + let secondClient = FakeHermesGatewayClient(readyImmediately: true) + firstClient.resultByMethod["session.create"] = .object(["session_id": .string("old-live")]) + firstClient.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + firstClient.suspendedMethods = ["session.interrupt"] + secondClient.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [firstClient, secondClient]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + let interrupt = Task { await store.interrupt() } + await waitUntil { firstClient.calls.contains(where: { $0.method == "session.interrupt" }) } + await store.loadSessions(profile: researcher, configuration: configuration) + firstClient.failCall(method: "session.interrupt") + await interrupt.value + + XCTAssertEqual(store.messages, []) + XCTAssertFalse(store.isStreaming) + } + @MainActor func testPrepareReapsOnceAndPublishesProfileRouting() async { let runtime = FakeHermesEmbeddedRuntime() @@ -250,6 +415,46 @@ final class HermesAgentStoreTests: XCTestCase { } XCTAssertTrue(condition()) } + + @MainActor + private func alternateConfiguration() -> MTPLXAppConfiguration { + var copy = configuration! + copy.port += 1 + return copy + } + + @MainActor + private func sessionsResult(id: String, title: String) -> JSONValue { + .object(["sessions": .array([.object([ + "id": .string(id), + "title": .string(title), + "preview": .string(""), + "started_at": .number(0), + "message_count": .number(0), + "source": .string(""), + ])])]) + } + + @MainActor + private func resumeResult(liveID: String, savedID: String) -> JSONValue { + .object([ + "session_id": .string(liveID), + "resumed": .string(savedID), + "messages": .array([]), + ]) + } + + @MainActor + private func assertCancellation(_ task: Task) async { + do { + _ = try await task.value + XCTFail("Expected stale task cancellation") + } catch is CancellationError { + // Expected: a newer generation owns store state. + } catch { + XCTFail("Unexpected error: \(error)") + } + } } private final class FakeHermesEmbeddedRuntime: HermesEmbeddedRuntime, @unchecked Sendable { @@ -323,6 +528,9 @@ private final class FakeHermesGatewayClient: HermesGatewayClientProtocol { var suspendedMethods: Set = [] private var callWaiters: [String: [CheckedContinuation]] = [:] + var hasEventHandler: Bool { onEvent != nil } + var hasDisconnectHandler: Bool { onDisconnect != nil } + init(readyImmediately: Bool = false) { ready = readyImmediately } @@ -355,6 +563,16 @@ private final class FakeHermesGatewayClient: HermesGatewayClientProtocol { readinessWaiters.removeAll() } + func emitReadyEventWithoutCompletingConnect() { + ready = true + onEvent?(HermesGatewayEvent(type: "gateway.ready", sessionID: nil, payload: [:])) + } + + func completeReadyConnect() { + readinessWaiters.forEach { $0.resume() } + readinessWaiters.removeAll() + } + func disconnect(_ message: String) { onDisconnect?(message) } @@ -364,4 +582,9 @@ private final class FakeHermesGatewayClient: HermesGatewayClientProtocol { let waiters = callWaiters.removeValue(forKey: method) ?? [] waiters.forEach { $0.resume(returning: value) } } + + func failCall(method: String) { + let waiters = callWaiters.removeValue(forKey: method) ?? [] + waiters.forEach { $0.resume(throwing: HermesGatewayClientError.disconnected) } + } } From 30491db4dc7730d6404e7d5b0c957abde90b8ca4 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:04:55 +0200 Subject: [PATCH 14/40] fix(hermes): release disconnected gateway resources --- .../Stores/HermesAgentStore.swift | 4 +- .../HermesAgentStoreTests.swift | 41 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index 72b0470b0..85700343b 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -507,8 +507,10 @@ public final class HermesAgentStore: ObservableObject { guard let self, self.gatewayGeneration == generation, !self.shuttingDown else { return } self.handle(event) } - nextClient.onDisconnect = { [weak self] message in + nextClient.onDisconnect = { [weak self, weak nextClient, weak nextSidecar] message in guard let self, + let nextClient, + let nextSidecar, self.gatewayGeneration == generation, !self.shuttingDown else { return } diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 7d554eed8..1f116eb1c 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -79,6 +79,43 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertEqual(store.selectedProfile?.name, "researcher") } + @MainActor + func testReleasedDisconnectCallbacksDoNotRetainOldClientsOrSidecars() async { + let runtime = FakeHermesEmbeddedRuntime() + var firstClient: FakeHermesGatewayClient? = FakeHermesGatewayClient(readyImmediately: true) + var secondClient: FakeHermesGatewayClient? = FakeHermesGatewayClient(readyImmediately: true) + let thirdClient = FakeHermesGatewayClient(readyImmediately: true) + firstClient!.resultByMethod["session.list"] = .object(["sessions": .array([])]) + secondClient!.resultByMethod["session.list"] = .object(["sessions": .array([])]) + thirdClient.resultByMethod["session.list"] = .object(["sessions": .array([])]) + weak var releasedFirstClient = firstClient + weak var releasedSecondClient = secondClient + let store = makeStore(runtime: runtime, clients: [firstClient!, secondClient!, thirdClient]) + + await store.loadSessions(profile: bernd, configuration: configuration) + weak var releasedFirstSidecar = runtime.sidecars[0] + await store.loadSessions(profile: researcher, configuration: configuration) + + firstClient!.disconnect("late first disconnect") + XCTAssertEqual(store.selectedProfile?.name, "researcher") + XCTAssertEqual(store.connectionState, .connected) + + weak var releasedSecondSidecar = runtime.sidecars[1] + await store.loadSessions(profile: bernd, configuration: alternateConfiguration()) + secondClient!.disconnect("late second disconnect") + XCTAssertEqual(store.selectedProfile?.name, "bernd") + XCTAssertEqual(store.connectionState, .connected) + + runtime.discardStoppedSidecars() + firstClient = nil + secondClient = nil + + XCTAssertNil(releasedFirstClient) + XCTAssertNil(releasedSecondClient) + XCTAssertNil(releasedFirstSidecar) + XCTAssertNil(releasedSecondSidecar) + } + @MainActor func testNativeSessionRPCsRestoreTranscriptAndRememberedSession() async throws { let runtime = FakeHermesEmbeddedRuntime() @@ -490,6 +527,10 @@ private final class FakeHermesEmbeddedRuntime: HermesEmbeddedRuntime, @unchecked reapCount += 1 return [] } + + func discardStoppedSidecars() { + sidecars.removeAll { !$0.isRunning } + } } private final class FakeHermesSidecar: HermesSidecarControlling, @unchecked Sendable { From ba83d3e512ea835f55711bea90e9c5d3dad36ad3 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:09:36 +0200 Subject: [PATCH 15/40] test(hermes): silence lifetime reference warnings --- .../Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 1f116eb1c..c3cf9fcad 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -88,19 +88,19 @@ final class HermesAgentStoreTests: XCTestCase { firstClient!.resultByMethod["session.list"] = .object(["sessions": .array([])]) secondClient!.resultByMethod["session.list"] = .object(["sessions": .array([])]) thirdClient.resultByMethod["session.list"] = .object(["sessions": .array([])]) - weak var releasedFirstClient = firstClient - weak var releasedSecondClient = secondClient + weak let releasedFirstClient = firstClient + weak let releasedSecondClient = secondClient let store = makeStore(runtime: runtime, clients: [firstClient!, secondClient!, thirdClient]) await store.loadSessions(profile: bernd, configuration: configuration) - weak var releasedFirstSidecar = runtime.sidecars[0] + weak let releasedFirstSidecar = runtime.sidecars[0] await store.loadSessions(profile: researcher, configuration: configuration) firstClient!.disconnect("late first disconnect") XCTAssertEqual(store.selectedProfile?.name, "researcher") XCTAssertEqual(store.connectionState, .connected) - weak var releasedSecondSidecar = runtime.sidecars[1] + weak let releasedSecondSidecar = runtime.sidecars[1] await store.loadSessions(profile: bernd, configuration: alternateConfiguration()) secondClient!.disconnect("late second disconnect") XCTAssertEqual(store.selectedProfile?.name, "bernd") From b68f5e0580887cae3018dbb4c25c4227878b9e83 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:30:20 +0200 Subject: [PATCH 16/40] feat(hermes): guard cross-process session ownership --- .../Services/HermesEmbeddedRuntime.swift | 153 +++++++++++++++++- .../Services/HermesIntegration.swift | 20 ++- .../Stores/HermesAgentStore.swift | 90 ++++++++++- .../HermesAgentStoreTests.swift | 70 +++++++- .../HermesEmbeddedRuntimeTests.swift | 82 ++++++++++ 5 files changed, 402 insertions(+), 13 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift index 8d93f122f..c0a4eb883 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift @@ -291,9 +291,156 @@ final class HermesSidecarReadiness: @unchecked Sendable { } public enum HermesSessionOwnership: Equatable, Sendable { - case appOwned - case external - case unavailable(String) + case ready + case ownedByMTPLX + case external(surface: String) + case unknown(String) +} + +public enum HermesSessionActivityState: Equatable, Sendable { + case ready + case runningInMTPLX + case externallyActive(surface: String) + case ownershipUnknown(String) + + init(ownership: HermesSessionOwnership) { + switch ownership { + case .ready: self = .ready + case .ownedByMTPLX: self = .runningInMTPLX + case .external(let surface): self = .externallyActive(surface: surface) + case .unknown(let reason): self = .ownershipUnknown(reason) + } + } +} + +/// Process observations used only to decide whether an active-session entry is +/// still trustworthy. The inspector never signals a process and never writes +/// the Hermes registry. +public enum HermesSessionProcessIdentity: Equatable, Sendable { + case live(startedAt: TimeInterval) + case dead + case unknown +} + +/// Read-only, fail-closed parser for Hermes' per-profile active-session +/// registry. Its process probe is injected so registry semantics can be +/// verified without probing real processes in tests. +public struct HermesActiveSessionRegistryInspector: @unchecked Sendable { + public typealias ProcessIdentity = @Sendable (Int32) -> HermesSessionProcessIdentity + + private let processIdentity: ProcessIdentity + private let readData: @Sendable (URL) throws -> Data + private let fileExists: @Sendable (URL) -> Bool + + public init( + processIdentity: @escaping ProcessIdentity = HermesActiveSessionRegistryInspector.liveProcessIdentity, + readData: @escaping @Sendable (URL) throws -> Data = { try Data(contentsOf: $0) }, + fileExists: @escaping @Sendable (URL) -> Bool = { FileManager.default.fileExists(atPath: $0.path) } + ) { + self.processIdentity = processIdentity + self.readData = readData + self.fileExists = fileExists + } + + public func ownership( + registryURL: URL, + sessionID: String, + ownedSidecarPID: Int32? + ) -> HermesSessionOwnership { + guard !sessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return .unknown(Self.inspectionUnavailableReason) + } + guard fileExists(registryURL) else { return .ready } + + let entries: [Entry] + do { + entries = try JSONDecoder().decode(Registry.self, from: readData(registryURL)).entries + } catch { + return .unknown(Self.inspectionUnavailableReason) + } + + let matching = entries.filter { $0.sessionID == sessionID } + guard !matching.isEmpty else { return .ready } + + var liveEntries: [Entry] = [] + for entry in matching { + switch processIdentity(entry.pid) { + case .dead: + continue + case .unknown: + return .unknown(Self.inspectionUnavailableReason) + case .live(let processStart): + // A live PID without the recorded start identity is a reused + // PID, not an active Hermes writer for this session. + guard abs(processStart - entry.startedAt) < 1 else { continue } + liveEntries.append(entry) + } + } + + guard liveEntries.count <= 1 else { + return .unknown(Self.inspectionUnavailableReason) + } + guard let entry = liveEntries.first else { return .ready } + if entry.pid == ownedSidecarPID { + return .ownedByMTPLX + } + return .external(surface: Self.sanitizedSurface(entry.surface)) + } + + private static let inspectionUnavailableReason = "Session activity could not be inspected." + + private static func sanitizedSurface(_ surface: String) -> String { + let trimmed = surface.trimmingCharacters(in: .whitespacesAndNewlines) + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: " -_")) + guard !trimmed.isEmpty, + trimmed.unicodeScalars.allSatisfy(allowed.contains), + trimmed.count <= 40 + else { return "another Hermes surface" } + return trimmed + } + + private struct Registry: Decodable { + let entries: [Entry] + } + + private struct Entry: Decodable { + let sessionID: String + let surface: String + let pid: Int32 + let startedAt: TimeInterval + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case surface + case pid + case startedAt = "start_time" + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + sessionID = try values.decode(String.self, forKey: .sessionID) + surface = try values.decode(String.self, forKey: .surface) + pid = try values.decode(Int32.self, forKey: .pid) + startedAt = try values.decode(TimeInterval.self, forKey: .startedAt) + guard !sessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + pid > 1, + startedAt.isFinite, + startedAt > 0 + else { throw DecodingError.dataCorruptedError(forKey: .sessionID, in: values, debugDescription: "Invalid active session entry.") } + } + } + + public static func liveProcessIdentity(_ pid: Int32) -> HermesSessionProcessIdentity { + guard pid > 1 else { return .dead } + if kill(pid, 0) != 0 { + return errno == ESRCH ? .dead : .unknown + } + var info = proc_bsdinfo() + let byteCount = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size)) + guard byteCount == MemoryLayout.size else { return .unknown } + let startedAt = TimeInterval(info.pbi_start_tvsec) + TimeInterval(info.pbi_start_tvusec) / 1_000_000 + return startedAt > 0 ? .live(startedAt: startedAt) : .unknown + } } public protocol HermesEmbeddedRuntime: Sendable { diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift index eb24a310a..1f389a7c3 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift @@ -251,6 +251,9 @@ public struct HermesIntegration: Sendable { public let sidecarRuntimeDirectory: URL /// Test seam: bypass bootstrap-layout + LaunchServices discovery. public let desktopApplicationOverride: URL? + /// Read-only inspector for Hermes' profile-local active-session registry. + /// Injected solely to make process-identity uncertainty testable. + public let activeSessionRegistryInspector: HermesActiveSessionRegistryInspector public init( hermesHome: URL = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".hermes", isDirectory: true), @@ -264,7 +267,8 @@ public struct HermesIntegration: Sendable { .appendingPathComponent("active-profile.json"), sidecarRuntimeDirectory: URL = URL(fileURLWithPath: NSHomeDirectory()) .appendingPathComponent(".mtplx/hermes-sidecars", isDirectory: true), - desktopApplicationOverride: URL? = nil + desktopApplicationOverride: URL? = nil, + activeSessionRegistryInspector: HermesActiveSessionRegistryInspector = .init() ) { self.hermesHome = hermesHome self.executablePath = executablePath @@ -273,6 +277,7 @@ public struct HermesIntegration: Sendable { self.activeProfileURL = activeProfileURL self.sidecarRuntimeDirectory = sidecarRuntimeDirectory self.desktopApplicationOverride = desktopApplicationOverride + self.activeSessionRegistryInspector = activeSessionRegistryInspector } public func discoverProfiles() -> [HermesProfile] { @@ -2043,12 +2048,13 @@ public struct HermesIntegration: Sendable { sessionID: String, ownedSidecarPID: Int32? ) -> HermesSessionOwnership { - _ = profile - _ = sessionID - _ = ownedSidecarPID - // Task 5 replaces this conservative placeholder with Hermes' native - // active-session registry. Until then, no session is assumed writable. - return .unavailable("Session ownership is unavailable.") + activeSessionRegistryInspector.ownership( + registryURL: URL(fileURLWithPath: profile.path, isDirectory: true) + .appendingPathComponent("runtime", isDirectory: true) + .appendingPathComponent("active_sessions.json", isDirectory: false), + sessionID: sessionID, + ownedSidecarPID: ownedSidecarPID + ) } private func runAndCapture( diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index 85700343b..d3a5c91ab 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -69,6 +69,7 @@ public struct HermesSavedSession: Identifiable, Equatable, Sendable { public let startedAt: Double public let messageCount: Int public let source: String + public let activity: HermesSessionActivityState public init( id: String, @@ -76,7 +77,8 @@ public struct HermesSavedSession: Identifiable, Equatable, Sendable { preview: String, startedAt: Double, messageCount: Int, - source: String + source: String, + activity: HermesSessionActivityState = .ready ) { self.id = id self.title = title @@ -84,6 +86,7 @@ public struct HermesSavedSession: Identifiable, Equatable, Sendable { self.startedAt = startedAt self.messageCount = messageCount self.source = source + self.activity = activity } } @@ -100,6 +103,9 @@ public final class HermesAgentStore: ObservableObject { @Published public private(set) var activeSessionID: String? @Published public private(set) var activeSessionKey: String? @Published public private(set) var activeSessionTitle: String? + @Published public private(set) var activeSessionActivity: HermesSessionActivityState = .ready + @Published public private(set) var activeSessionWritable = false + @Published public private(set) var readOnlyReason: String? @Published public private(set) var isStreaming: Bool = false @Published public private(set) var gatewayReady: Bool = false @Published public private(set) var gatewayRepairInFlight: Bool = false @@ -232,7 +238,7 @@ public final class HermesAgentStore: ObservableObject { guard let operation, isCurrent(operation) else { return } let result = try await rpc(operation, method: "session.list", params: ["limit": .number(200)]) guard isCurrent(operation) else { return } - sessions = Self.parseSessions(result) + sessions = sessionsWithActivity(Self.parseSessions(result), profile: profile) connectionState = .connected } catch { guard let operation, isCurrent(operation), !shuttingDown else { return } @@ -267,6 +273,7 @@ public final class HermesAgentStore: ObservableObject { messages = [] toolTraces = [] activeSessionKey = sessionKey + applyActiveOwnership(.ready) connectionState = .connected return HermesSessionReference( profileName: profile.name, @@ -286,6 +293,7 @@ public final class HermesAgentStore: ObservableObject { guard isCurrent(operation) else { throw HermesGatewayClientError.disconnected } + sessions = sessionsWithActivity(sessions, profile: profile) let result = try await rpc( operation, method: "session.resume", @@ -303,6 +311,7 @@ public final class HermesAgentStore: ObservableObject { title: session.title.isEmpty ? session.preview : session.title, preserveVisibleTranscript: false ) + _ = refreshActiveOwnership() return HermesSessionReference( profileName: profile.name, sessionID: activeSessionKey ?? session.id, @@ -342,6 +351,7 @@ public final class HermesAgentStore: ObservableObject { let operation = currentOperation(for: profile), !isStreaming else { return } + guard refreshActiveOwnership() else { return } messages.append(HermesTranscriptMessage(role: .user, text: text)) isStreaming = true do { @@ -370,6 +380,7 @@ public final class HermesAgentStore: ObservableObject { let profile = selectedProfile, let operation = currentOperation(for: profile) else { return } + guard refreshActiveOwnership() else { return } do { _ = try await rpc(operation, method: "session.interrupt", params: ["session_id": .string(sessionID)]) } catch { @@ -404,6 +415,7 @@ public final class HermesAgentStore: ObservableObject { activeSessionID = nil activeSessionKey = nil activeSessionTitle = nil + applyActiveOwnership(.ready) sessions = [] messages = [] toolTraces = [] @@ -435,6 +447,7 @@ public final class HermesAgentStore: ObservableObject { title: title, preserveVisibleTranscript: true ) + _ = refreshActiveOwnership() } public func refreshTerminalAgentState() { @@ -475,6 +488,7 @@ public final class HermesAgentStore: ObservableObject { activeSessionID = nil activeSessionKey = nil activeSessionTitle = nil + applyActiveOwnership(.ready) messages = [] toolTraces = [] isStreaming = false @@ -618,6 +632,7 @@ public final class HermesAgentStore: ObservableObject { activeSessionID = nil activeSessionKey = nil activeSessionTitle = nil + applyActiveOwnership(.ready) } } @@ -640,6 +655,77 @@ public final class HermesAgentStore: ObservableObject { connectionState = .connected } + private func sessionsWithActivity( + _ sessions: [HermesSavedSession], + profile: HermesProfile + ) -> [HermesSavedSession] { + sessions.map { session in + HermesSavedSession( + id: session.id, + title: session.title, + preview: session.preview, + startedAt: session.startedAt, + messageCount: session.messageCount, + source: session.source, + activity: HermesSessionActivityState(ownership: ownership(for: session.id, profile: profile)) + ) + } + } + + @discardableResult + private func refreshActiveOwnership() -> Bool { + guard let profile = selectedProfile, + let sessionID = activeSessionKey ?? activeSessionID + else { + applyActiveOwnership(.ready) + return false + } + let primary = ownership(for: sessionID, profile: profile) + let resolved: HermesSessionOwnership + if let liveID = activeSessionID, liveID != sessionID { + resolved = mostRestrictive(primary, ownership(for: liveID, profile: profile)) + } else { + resolved = primary + } + applyActiveOwnership(resolved) + return activeSessionWritable + } + + private func ownership(for sessionID: String, profile: HermesProfile) -> HermesSessionOwnership { + embeddedRuntime.sessionOwnership( + profile: profile, + sessionID: sessionID, + ownedSidecarPID: sidecar?.processIdentifier + ) + } + + private func mostRestrictive( + _ first: HermesSessionOwnership, + _ second: HermesSessionOwnership + ) -> HermesSessionOwnership { + if case .unknown = first { return first } + if case .unknown = second { return second } + if case .external = first { return first } + if case .external = second { return second } + if case .ownedByMTPLX = first { return first } + return second + } + + private func applyActiveOwnership(_ ownership: HermesSessionOwnership) { + activeSessionActivity = HermesSessionActivityState(ownership: ownership) + switch ownership { + case .ready, .ownedByMTPLX: + activeSessionWritable = activeSessionID != nil + readOnlyReason = nil + case .external: + activeSessionWritable = false + readOnlyReason = "This session is active in another Hermes surface." + case .unknown(let reason): + activeSessionWritable = false + readOnlyReason = reason + } + } + private func liveSessionKey(for sessionID: String, operation: GatewayOperation) async throws -> String? { let result = try await rpc( operation, diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index c3cf9fcad..96b31ac27 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -179,6 +179,73 @@ final class HermesAgentStoreTests: XCTestCase { ) } + @MainActor + func testExternalSessionRemainsReadableButCannotSubmitOrInterrupt() async throws { + let runtime = FakeHermesEmbeddedRuntime() + runtime.ownershipBySession["saved-external"] = .external(surface: "telegram") + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.resume"] = .object([ + "session_id": .string("live-external"), + "resumed": .string("saved-external"), + "messages": .array([.object(["role": .string("assistant"), "text": .string("Existing reply")])]), + ]) + let store = makeStore(runtime: runtime, clients: [client]) + let session = HermesSavedSession( + id: "saved-external", title: "External", preview: "", startedAt: 0, messageCount: 1, source: "" + ) + + _ = try await store.resume(session, profile: bernd, configuration: configuration) + await store.send("must not leave MTPLX") + await store.interrupt() + + XCTAssertEqual(store.messages.map(\.text), ["Existing reply"]) + XCTAssertFalse(store.activeSessionWritable) + XCTAssertEqual(store.activeSessionActivity, .externallyActive(surface: "telegram")) + XCTAssertNotNil(store.readOnlyReason) + XCTAssertFalse(client.calls.contains(where: { $0.method == "prompt.submit" || $0.method == "session.interrupt" })) + } + + @MainActor + func testSendRechecksOwnershipImmediatelyBeforeSubmit() async throws { + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.resume"] = resumeResult(liveID: "live-1", savedID: "saved-1") + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.resume( + HermesSavedSession(id: "saved-1", title: "Saved", preview: "", startedAt: 0, messageCount: 0, source: ""), + profile: bernd, + configuration: configuration + ) + XCTAssertTrue(store.activeSessionWritable) + + runtime.ownershipBySession["saved-1"] = .external(surface: "telegram") + await store.send("race check") + + XCTAssertFalse(store.activeSessionWritable) + XCTAssertEqual(store.activeSessionActivity, .externallyActive(surface: "telegram")) + XCTAssertFalse(client.calls.contains(where: { $0.method == "prompt.submit" })) + XCTAssertEqual(store.messages, []) + } + + @MainActor + func testListActivityAndDifferentSessionConcurrencyDoNotBlockFreshSession() async throws { + let runtime = FakeHermesEmbeddedRuntime() + runtime.ownershipBySession["telegram-session"] = .external(surface: "telegram") + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.list"] = sessionsResult(id: "telegram-session", title: "Telegram") + client.resultByMethod["session.create"] = .object(["session_id": .string("fresh-live")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + + await store.loadSessions(profile: bernd, configuration: configuration) + XCTAssertEqual(store.sessions.first?.activity, .externallyActive(surface: "telegram")) + + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + XCTAssertTrue(store.activeSessionWritable) + await store.send("new session is independent") + XCTAssertTrue(client.calls.contains(where: { $0.method == "prompt.submit" })) + } + @MainActor func testReconnectPreservesVisibleTranscriptAndResumesSelectedSession() async throws { let runtime = FakeHermesEmbeddedRuntime() @@ -496,6 +563,7 @@ final class HermesAgentStoreTests: XCTestCase { private final class FakeHermesEmbeddedRuntime: HermesEmbeddedRuntime, @unchecked Sendable { var routingByProfile: [String: HermesProfileRoutingState] = [:] + var ownershipBySession: [String: HermesSessionOwnership] = [:] var startCount = 0 var reapCount = 0 private(set) var sidecars: [FakeHermesSidecar] = [] @@ -519,7 +587,7 @@ private final class FakeHermesEmbeddedRuntime: HermesEmbeddedRuntime, @unchecked sessionID: String, ownedSidecarPID: Int32? ) -> HermesSessionOwnership { - .appOwned + ownershipBySession[sessionID] ?? .ready } @discardableResult diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift index 74ecf283c..02ed7a9fb 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift @@ -73,6 +73,88 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { XCTAssertFalse(spec.arguments.contains("-p")) } + func testActiveSessionRegistryMapsLiveOwnedAndExternalSessions() throws { + let profile = try makeProfile(named: "bernd", config: mtplxConfig()) + let registry = profile + .appendingPathComponent("runtime", isDirectory: true) + .appendingPathComponent("active_sessions.json") + try FileManager.default.createDirectory(at: registry.deletingLastPathComponent(), withIntermediateDirectories: true) + try """ + {"entries":[ + {"session_id":"ours","surface":"mtplx-app","pid":7001,"start_time":10}, + {"session_id":"telegram","surface":"telegram","pid":7002,"start_time":20} + ]} + """.write(to: registry, atomically: true, encoding: .utf8) + + let inspector = HermesActiveSessionRegistryInspector( + processIdentity: { pid in + switch pid { + case 7001: return .live(startedAt: 10) + case 7002: return .live(startedAt: 20) + default: return .dead + } + } + ) + + XCTAssertEqual(inspector.ownership(registryURL: registry, sessionID: "ours", ownedSidecarPID: 7001), .ownedByMTPLX) + XCTAssertEqual(inspector.ownership(registryURL: registry, sessionID: "telegram", ownedSidecarPID: 7001), .external(surface: "telegram")) + XCTAssertEqual(inspector.ownership(registryURL: registry, sessionID: "idle", ownedSidecarPID: 7001), .ready) + } + + func testActiveSessionRegistryFailsClosedForMalformedOrUninspectableEntries() throws { + let registry = root.appendingPathComponent("active_sessions.json") + try "{not json".write(to: registry, atomically: true, encoding: .utf8) + let inspector = HermesActiveSessionRegistryInspector(processIdentity: { _ in .unknown }) + + XCTAssertEqual( + inspector.ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), + .unknown("Session activity could not be inspected.") + ) + XCTAssertEqual( + HermesActiveSessionRegistryInspector( + processIdentity: { _ in .unknown }, + readData: { _ in throw CocoaError(.fileReadNoPermission) }, + fileExists: { _ in true } + ).ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), + .unknown("Session activity could not be inspected.") + ) + } + + func testActiveSessionRegistryIgnoresDeadAndPIDReusedEntries() throws { + let registry = root.appendingPathComponent("active_sessions.json") + try """ + {"entries":[ + {"session_id":"dead","surface":"telegram","pid":7001,"start_time":10}, + {"session_id":"reused","surface":"telegram","pid":7002,"start_time":10} + ]} + """.write(to: registry, atomically: true, encoding: .utf8) + let inspector = HermesActiveSessionRegistryInspector( + processIdentity: { pid in pid == 7001 ? .dead : .live(startedAt: 20) } + ) + + XCTAssertEqual(inspector.ownership(registryURL: registry, sessionID: "dead", ownedSidecarPID: nil), .ready) + XCTAssertEqual(inspector.ownership(registryURL: registry, sessionID: "reused", ownedSidecarPID: nil), .ready) + } + + func testIntegrationTreatsMissingProfileRegistryAsReady() throws { + let profile = try makeProfile(named: "bernd", config: mtplxConfig()) + let integration = HermesIntegration( + hermesHome: hermesHome, + executablePath: "/usr/bin/true", + environment: [:], + activeSessionRegistryInspector: HermesActiveSessionRegistryInspector(processIdentity: { _ in .unknown }) + ) + + XCTAssertEqual( + integration.sessionOwnership( + profile: HermesProfile(name: "bernd", path: profile.path, isDefault: false), + sessionID: "saved", + ownedSidecarPID: nil + ), + .ready + ) + } + func testEmbeddedLaunchDoesNotInheritRootMessagingCredentials() throws { try """ TELEGRAM_BOT_TOKEN=root-telegram-token From dab8b5235478668e2f9ec5a7869daca367581cf0 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:38:15 +0200 Subject: [PATCH 17/40] fix(hermes): parse native active session leases --- .../Services/HermesEmbeddedRuntime.swift | 27 ++++-- .../HermesEmbeddedRuntimeTests.swift | 82 +++++++++++++++++-- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift index c0a4eb883..248457460 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift @@ -330,16 +330,13 @@ public struct HermesActiveSessionRegistryInspector: @unchecked Sendable { private let processIdentity: ProcessIdentity private let readData: @Sendable (URL) throws -> Data - private let fileExists: @Sendable (URL) -> Bool public init( processIdentity: @escaping ProcessIdentity = HermesActiveSessionRegistryInspector.liveProcessIdentity, - readData: @escaping @Sendable (URL) throws -> Data = { try Data(contentsOf: $0) }, - fileExists: @escaping @Sendable (URL) -> Bool = { FileManager.default.fileExists(atPath: $0.path) } + readData: @escaping @Sendable (URL) throws -> Data = { try Data(contentsOf: $0) } ) { self.processIdentity = processIdentity self.readData = readData - self.fileExists = fileExists } public func ownership( @@ -350,11 +347,17 @@ public struct HermesActiveSessionRegistryInspector: @unchecked Sendable { guard !sessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return .unknown(Self.inspectionUnavailableReason) } - guard fileExists(registryURL) else { return .ready } + let data: Data + do { + data = try readData(registryURL) + } catch { + if Self.isNotFound(error) { return .ready } + return .unknown(Self.inspectionUnavailableReason) + } let entries: [Entry] do { - entries = try JSONDecoder().decode(Registry.self, from: readData(registryURL)).entries + entries = try JSONDecoder().decode(Registry.self, from: data).entries } catch { return .unknown(Self.inspectionUnavailableReason) } @@ -372,7 +375,7 @@ public struct HermesActiveSessionRegistryInspector: @unchecked Sendable { case .live(let processStart): // A live PID without the recorded start identity is a reused // PID, not an active Hermes writer for this session. - guard abs(processStart - entry.startedAt) < 1 else { continue } + guard abs(processStart - entry.startedAt) < 0.001 else { continue } liveEntries.append(entry) } } @@ -389,6 +392,14 @@ public struct HermesActiveSessionRegistryInspector: @unchecked Sendable { private static let inspectionUnavailableReason = "Session activity could not be inspected." + private static func isNotFound(_ error: Error) -> Bool { + let nsError = error as NSError + return (nsError.domain == NSCocoaErrorDomain + && (nsError.code == CocoaError.Code.fileNoSuchFile.rawValue + || nsError.code == CocoaError.Code.fileReadNoSuchFile.rawValue)) + || (nsError.domain == NSPOSIXErrorDomain && nsError.code == ENOENT) + } + private static func sanitizedSurface(_ surface: String) -> String { let trimmed = surface.trimmingCharacters(in: .whitespacesAndNewlines) let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: " -_")) @@ -413,7 +424,7 @@ public struct HermesActiveSessionRegistryInspector: @unchecked Sendable { case sessionID = "session_id" case surface case pid - case startedAt = "start_time" + case startedAt = "process_start_time" } init(from decoder: Decoder) throws { diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift index 02ed7a9fb..eab1b5824 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift @@ -81,8 +81,8 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { try FileManager.default.createDirectory(at: registry.deletingLastPathComponent(), withIntermediateDirectories: true) try """ {"entries":[ - {"session_id":"ours","surface":"mtplx-app","pid":7001,"start_time":10}, - {"session_id":"telegram","surface":"telegram","pid":7002,"start_time":20} + {"lease_id":"lease-ours","session_id":"ours","surface":"mtplx-app","pid":7001,"process_start_time":10.0,"started_at":100.0}, + {"lease_id":"lease-telegram","session_id":"telegram","surface":"telegram","pid":7002,"process_start_time":20.0,"started_at":200.0} ]} """.write(to: registry, atomically: true, encoding: .utf8) @@ -101,6 +101,22 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { XCTAssertEqual(inspector.ownership(registryURL: registry, sessionID: "idle", ownedSidecarPID: 7001), .ready) } + func testActiveSessionRegistryRejectsLegacyStartTimeSchema() throws { + let registry = root.appendingPathComponent("active_sessions.json") + try """ + {"entries":[ + {"session_id":"saved","surface":"telegram","pid":7001,"start_time":10.0} + ]} + """.write(to: registry, atomically: true, encoding: .utf8) + + let inspector = HermesActiveSessionRegistryInspector(processIdentity: { _ in .live(startedAt: 10) }) + + XCTAssertEqual( + inspector.ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), + .unknown("Session activity could not be inspected.") + ) + } + func testActiveSessionRegistryFailsClosedForMalformedOrUninspectableEntries() throws { let registry = root.appendingPathComponent("active_sessions.json") try "{not json".write(to: registry, atomically: true, encoding: .utf8) @@ -113,8 +129,7 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { XCTAssertEqual( HermesActiveSessionRegistryInspector( processIdentity: { _ in .unknown }, - readData: { _ in throw CocoaError(.fileReadNoPermission) }, - fileExists: { _ in true } + readData: { _ in throw CocoaError(.fileReadNoPermission) } ).ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), .unknown("Session activity could not be inspected.") ) @@ -124,8 +139,8 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { let registry = root.appendingPathComponent("active_sessions.json") try """ {"entries":[ - {"session_id":"dead","surface":"telegram","pid":7001,"start_time":10}, - {"session_id":"reused","surface":"telegram","pid":7002,"start_time":10} + {"lease_id":"lease-dead","session_id":"dead","surface":"telegram","pid":7001,"process_start_time":10.0,"started_at":100.0}, + {"lease_id":"lease-reused","session_id":"reused","surface":"telegram","pid":7002,"process_start_time":10.0,"started_at":100.0} ]} """.write(to: registry, atomically: true, encoding: .utf8) let inspector = HermesActiveSessionRegistryInspector( @@ -136,6 +151,61 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { XCTAssertEqual(inspector.ownership(registryURL: registry, sessionID: "reused", ownedSidecarPID: nil), .ready) } + func testActiveSessionRegistryRequiresNativeStartTimePrecisionAndOneLiveEntry() throws { + let registry = root.appendingPathComponent("active_sessions.json") + try """ + {"entries":[ + {"lease_id":"lease-close","session_id":"close","surface":"telegram","pid":7001,"process_start_time":10.0,"started_at":100.0}, + {"lease_id":"lease-edge","session_id":"edge","surface":"telegram","pid":7002,"process_start_time":20.0,"started_at":100.0}, + {"lease_id":"lease-first","session_id":"conflict","surface":"telegram","pid":7003,"process_start_time":30.0,"started_at":100.0}, + {"lease_id":"lease-second","session_id":"conflict","surface":"desktop","pid":7004,"process_start_time":40.0,"started_at":100.0} + ]} + """.write(to: registry, atomically: true, encoding: .utf8) + let inspector = HermesActiveSessionRegistryInspector( + processIdentity: { pid in + switch pid { + case 7001: return .live(startedAt: 10.0009) + case 7002: return .live(startedAt: 20.001) + case 7003: return .live(startedAt: 30) + case 7004: return .live(startedAt: 40) + default: return .dead + } + } + ) + + XCTAssertEqual(inspector.ownership(registryURL: registry, sessionID: "close", ownedSidecarPID: nil), .external(surface: "telegram")) + XCTAssertEqual(inspector.ownership(registryURL: registry, sessionID: "edge", ownedSidecarPID: nil), .ready) + XCTAssertEqual( + inspector.ownership(registryURL: registry, sessionID: "conflict", ownedSidecarPID: nil), + .unknown("Session activity could not be inspected.") + ) + } + + func testActiveSessionRegistryTreatsOnlyVerifiedNotFoundAsReady() throws { + let registry = root.appendingPathComponent("active_sessions.json") + let notFound = HermesActiveSessionRegistryInspector( + processIdentity: { _ in .unknown }, + readData: { _ in throw CocoaError(.fileNoSuchFile) } + ) + let unreadable = HermesActiveSessionRegistryInspector( + processIdentity: { _ in .unknown }, + readData: { _ in throw CocoaError(.fileReadNoPermission) } + ) + + XCTAssertEqual(notFound.ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), .ready) + XCTAssertEqual( + unreadable.ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), + .unknown("Session activity could not be inspected.") + ) + + try FileManager.default.createDirectory(at: registry, withIntermediateDirectories: true) + XCTAssertEqual( + HermesActiveSessionRegistryInspector(processIdentity: { _ in .unknown }) + .ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), + .unknown("Session activity could not be inspected.") + ) + } + func testIntegrationTreatsMissingProfileRegistryAsReady() throws { let profile = try makeProfile(named: "bernd", config: mtplxConfig()) let integration = HermesIntegration( From 099eb4ce28f484da75b1d25c8d6d9761a5c23df4 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:58:12 +0200 Subject: [PATCH 18/40] feat(hermes): handle native agent events and prompts --- .../Stores/HermesAgentStore.swift | 311 +++++++++++++++--- .../HermesAgentStoreTests.swift | 234 +++++++++++++ 2 files changed, 495 insertions(+), 50 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index d3a5c91ab..c5741688e 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -62,6 +62,27 @@ public struct HermesToolTrace: Identifiable, Equatable, Sendable { } } +public enum HermesPendingRequestKind: Equatable, Sendable { + case approval + case clarification + case sudo + case secret +} + +public struct HermesPendingRequest: Identifiable, Equatable, Sendable { + public let id: String + public let kind: HermesPendingRequestKind + public let prompt: String + public let choices: [String] + + public init(id: String, kind: HermesPendingRequestKind, prompt: String, choices: [String]) { + self.id = id + self.kind = kind + self.prompt = prompt + self.choices = choices + } +} + public struct HermesSavedSession: Identifiable, Equatable, Sendable { public let id: String public let title: String @@ -107,6 +128,7 @@ public final class HermesAgentStore: ObservableObject { @Published public private(set) var activeSessionWritable = false @Published public private(set) var readOnlyReason: String? @Published public private(set) var isStreaming: Bool = false + @Published public private(set) var pendingRequest: HermesPendingRequest? @Published public private(set) var gatewayReady: Bool = false @Published public private(set) var gatewayRepairInFlight: Bool = false @Published public private(set) var gatewayRepairMessage: String? @@ -122,6 +144,7 @@ public final class HermesAgentStore: ObservableObject { private var shuttingDown = false private var gatewayGeneration = 0 private var didReapOrphanedSidecars = false + private var hermesAutoApprove = false private struct GatewayOperation { let generation: Int @@ -129,6 +152,13 @@ public final class HermesAgentStore: ObservableObject { let clientIdentity: ObjectIdentifier } + private struct PendingRequestLease { + let id: String + let kind: HermesPendingRequestKind + let sessionID: String + let operation: GatewayOperation + } + public init( integration: HermesIntegration, embeddedRuntime: any HermesEmbeddedRuntime, @@ -272,6 +302,8 @@ public final class HermesAgentStore: ObservableObject { activeSessionTitle = "New Hermes Agent" messages = [] toolTraces = [] + endStreaming() + pendingRequest = nil activeSessionKey = sessionKey applyActiveOwnership(.ready) connectionState = .connected @@ -349,7 +381,8 @@ public final class HermesAgentStore: ObservableObject { let sessionID = activeSessionID, let profile = selectedProfile, let operation = currentOperation(for: profile), - !isStreaming + !isStreaming, + pendingRequest == nil else { return } guard refreshActiveOwnership() else { return } messages.append(HermesTranscriptMessage(role: .user, text: text)) @@ -365,7 +398,7 @@ public final class HermesAgentStore: ObservableObject { ) } catch { guard isCurrent(operation, sessionID: sessionID) else { return } - isStreaming = false + endStreaming() messages.append( HermesTranscriptMessage( role: .system, @@ -375,6 +408,70 @@ public final class HermesAgentStore: ObservableObject { } } + public func respondToPendingRequest(value: String) async { + guard let pendingRequest, + let sessionID = activeSessionID, + let profile = selectedProfile, + let operation = currentOperation(for: profile), + activeSessionWritable + else { return } + + let lease = PendingRequestLease( + id: pendingRequest.id, + kind: pendingRequest.kind, + sessionID: sessionID, + operation: operation + ) + let method: String + let params: [String: JSONValue] + switch lease.kind { + case .approval: + method = "approval.respond" + params = [ + "session_id": .string(sessionID), + "choice": .string(value), + ] + case .clarification: + method = "clarify.respond" + params = [ + "request_id": .string(lease.id), + "answer": .string(value), + ] + case .sudo: + method = "sudo.respond" + params = [ + "request_id": .string(lease.id), + "password": .string(value), + ] + case .secret: + method = "secret.respond" + params = [ + "request_id": .string(lease.id), + "value": .string(value), + ] + } + + do { + _ = try await rpc(operation, method: method, params: params) + guard isCurrent(lease.operation, sessionID: lease.sessionID), + self.pendingRequest?.id == lease.id, + self.pendingRequest?.kind == lease.kind + else { return } + self.pendingRequest = nil + } catch { + guard isCurrent(lease.operation, sessionID: lease.sessionID), + self.pendingRequest?.id == lease.id, + self.pendingRequest?.kind == lease.kind + else { return } + messages.append(HermesTranscriptMessage(role: .system, text: "Hermes could not accept the requested response.")) + } + } + + public func denyPendingApproval() async { + guard pendingRequest?.kind == .approval else { return } + await respondToPendingRequest(value: "deny") + } + public func interrupt() async { guard let sessionID = activeSessionID, let profile = selectedProfile, @@ -390,7 +487,7 @@ public final class HermesAgentStore: ObservableObject { ) } guard isCurrent(operation, sessionID: sessionID) else { return } - isStreaming = false + endStreaming() } public func createProfile(named name: String, configuration: MTPLXAppConfiguration) async { @@ -419,6 +516,7 @@ public final class HermesAgentStore: ObservableObject { sessions = [] messages = [] toolTraces = [] + pendingRequest = nil terminalAgentRunning = integration.hasLaunchedTerminalAgent() connectionState = .idle } @@ -459,6 +557,7 @@ public final class HermesAgentStore: ObservableObject { configuration: MTPLXAppConfiguration, preserveSession: Bool = false ) async throws -> GatewayOperation { + hermesAutoApprove = configuration.hermesAutoApprove let signature = Self.configurationSignature(configuration) if sidecarProfileName == profile.name, sidecarConfigurationSignature == signature, @@ -491,7 +590,8 @@ public final class HermesAgentStore: ObservableObject { applyActiveOwnership(.ready) messages = [] toolTraces = [] - isStreaming = false + endStreaming() + pendingRequest = nil } } gatewayReady = false @@ -517,8 +617,14 @@ public final class HermesAgentStore: ObservableObject { throw CancellationError() } let nextClient = clientFactory(nextSidecar.webSocketURL) - nextClient.onEvent = { [weak self] event in - guard let self, self.gatewayGeneration == generation, !self.shuttingDown else { return } + nextClient.onEvent = { [weak self, weak nextClient] event in + guard let self, + let nextClient, + self.gatewayGeneration == generation, + !self.shuttingDown, + let currentClient = self.client, + ObjectIdentifier(currentClient) == ObjectIdentifier(nextClient) + else { return } self.handle(event) } nextClient.onDisconnect = { [weak self, weak nextClient, weak nextSidecar] message in @@ -612,7 +718,8 @@ public final class HermesAgentStore: ObservableObject { sidecarProfileName = nil sidecarConfigurationSignature = nil gatewayReady = false - isStreaming = false + endStreaming() + pendingRequest = nil expectedClient.close() expectedSidecar.stop() return true @@ -627,7 +734,8 @@ public final class HermesAgentStore: ObservableObject { sidecarProfileName = nil sidecarConfigurationSignature = nil gatewayReady = false - isStreaming = false + endStreaming() + pendingRequest = nil if clearSession { activeSessionID = nil activeSessionKey = nil @@ -648,6 +756,8 @@ public final class HermesAgentStore: ObservableObject { activeSessionID = sessionID activeSessionKey = object["resumed"]?.stringValue ?? savedSessionID activeSessionTitle = title + endStreaming() + pendingRequest = nil if !preserveVisibleTranscript || messages.isEmpty { messages = Self.parseMessages(object["messages"]) } @@ -746,11 +856,7 @@ public final class HermesAgentStore: ObservableObject { if event.type == "gateway.ready" { return } - if let activeSessionID, - let eventSessionID = event.sessionID, - eventSessionID != activeSessionID { - return - } + guard let activeSessionID, event.sessionID == activeSessionID else { return } switch event.type { case "message.start": @@ -771,6 +877,8 @@ public final class HermesAgentStore: ObservableObject { text: event.payload["text"]?.stringValue, reasoning: event.payload["reasoning"]?.stringValue ) + case "reasoning.delta", "thinking.delta": + appendReasoningDelta(event.payload["text"]?.stringValue ?? "") case "tool.start": toolTraces.append( HermesToolTrace( @@ -792,45 +900,29 @@ public final class HermesAgentStore: ObservableObject { detail: Self.toolDetail(from: event.payload) ) case "approval.request": - toolTraces.append( - HermesToolTrace( - name: "Approval", - status: .approval, - detail: "Auto-approved by MTPLX Hermes mode." - ) - ) - if let sessionID = activeSessionID, - let profile = selectedProfile, - let operation = currentOperation(for: profile) { - Task { [weak self] in - guard let self, self.isCurrent(operation, sessionID: sessionID) else { return } - _ = try? await self.rpc( - operation, - method: "approval.respond", - params: [ - "session_id": .string(sessionID), - "choice": .string("allow"), - "all": .bool(true), - ] - ) - } - } - case "clarify.request", "sudo.request", "secret.request": - toolTraces.append( - HermesToolTrace( - name: event.type.replacingOccurrences(of: ".request", with: ""), - status: .waiting, - detail: Self.toolDetail(from: event.payload) - ) - ) + handleApprovalRequest(event.payload, sessionID: activeSessionID) + case "clarify.request": + setPendingRequest(kind: .clarification, payload: event.payload) + case "sudo.request": + setPendingRequest(kind: .sudo, payload: event.payload) + case "secret.request": + setPendingRequest(kind: .secret, payload: event.payload) + case "approval.expire": + expirePendingRequest(kind: .approval, payload: event.payload) + case "clarify.expire": + expirePendingRequest(kind: .clarification, payload: event.payload) + case "sudo.expire": + expirePendingRequest(kind: .sudo, payload: event.payload) + case "secret.expire": + expirePendingRequest(kind: .secret, payload: event.payload) case "error": messages.append( HermesTranscriptMessage( role: .system, - text: event.payload["message"]?.stringValue ?? "Hermes reported an error." + text: "Hermes reported an error." ) ) - isStreaming = false + endStreaming() case "session.info": if let key = event.payload["session_key"]?.stringValue { activeSessionKey = key @@ -840,6 +932,98 @@ public final class HermesAgentStore: ObservableObject { } } + private func handleApprovalRequest(_ payload: [String: JSONValue], sessionID: String) { + guard let profile = selectedProfile, + let operation = currentOperation(for: profile), + isCurrent(operation, sessionID: sessionID), + refreshActiveOwnership() + else { return } + + if hermesAutoApprove { + Task { [weak self] in + guard let self, + self.hermesAutoApprove, + self.refreshActiveOwnership(), + self.isCurrent(operation, sessionID: sessionID) + else { return } + do { + _ = try await self.rpc( + operation, + method: "approval.respond", + params: [ + "session_id": .string(sessionID), + "choice": .string("once"), + ] + ) + } catch { + guard self.isCurrent(operation, sessionID: sessionID) else { return } + self.messages.append( + HermesTranscriptMessage(role: .system, text: "Hermes could not accept the requested response.") + ) + } + } + return + } + + let requestID = payload["request_id"]?.stringValue ?? "approval-\(UUID().uuidString)" + pendingRequest = HermesPendingRequest( + id: requestID, + kind: .approval, + prompt: payload["command"]?.stringValue ?? "Hermes requires approval.", + choices: Self.choices(from: payload) + ) + toolTraces.append( + HermesToolTrace( + name: "Approval", + status: .approval, + detail: payload["command"]?.stringValue ?? "Approval required." + ) + ) + } + + private func setPendingRequest(kind: HermesPendingRequestKind, payload: [String: JSONValue]) { + guard let requestID = payload["request_id"]?.stringValue, !requestID.isEmpty else { return } + let prompt: String + switch kind { + case .approval: + prompt = payload["command"]?.stringValue ?? "Hermes requires approval." + case .clarification: + prompt = payload["question"]?.stringValue ?? "Hermes needs clarification." + case .sudo: + prompt = payload["prompt"]?.stringValue ?? "Hermes requires sudo authentication." + case .secret: + prompt = payload["prompt"]?.stringValue ?? "Hermes requires a secret." + } + pendingRequest = HermesPendingRequest( + id: requestID, + kind: kind, + prompt: prompt, + choices: Self.choices(from: payload) + ) + toolTraces.append( + HermesToolTrace( + name: Self.pendingToolName(for: kind), + status: .waiting, + detail: prompt + ) + ) + } + + private func expirePendingRequest(kind: HermesPendingRequestKind, payload: [String: JSONValue]) { + guard let requestID = payload["request_id"]?.stringValue, + pendingRequest?.id == requestID, + pendingRequest?.kind == kind + else { return } + pendingRequest = nil + } + + private func endStreaming() { + isStreaming = false + if messages.last?.role == .assistant, messages.last?.isStreaming == true { + messages[messages.count - 1].isStreaming = false + } + } + private func appendAssistantDelta(_ delta: String) { guard !delta.isEmpty else { return } if messages.last?.role != .assistant || messages.last?.isStreaming == false { @@ -852,11 +1036,25 @@ public final class HermesAgentStore: ObservableObject { isStreaming = true } + private func appendReasoningDelta(_ delta: String) { + guard !delta.isEmpty else { return } + if let index = toolTraces.lastIndex(where: { $0.name == "Thought" && $0.status == .running }) { + toolTraces[index].detail += delta + } else { + toolTraces.append(HermesToolTrace(name: "Thought", status: .running, detail: delta)) + } + } + private func completeAssistantMessage(text: String?, reasoning: String?) { if let reasoning, !reasoning.isEmpty { - toolTraces.append( - HermesToolTrace(name: "Thought", status: .complete, detail: reasoning) - ) + if let index = toolTraces.lastIndex(where: { $0.name == "Thought" && $0.status == .running }) { + toolTraces[index].detail = reasoning + toolTraces[index].status = .complete + } else { + toolTraces.append(HermesToolTrace(name: "Thought", status: .complete, detail: reasoning)) + } + } else if let index = toolTraces.lastIndex(where: { $0.name == "Thought" && $0.status == .running }) { + toolTraces[index].status = .complete } let finalText = text ?? "" if messages.last?.role == .assistant { @@ -869,7 +1067,7 @@ public final class HermesAgentStore: ObservableObject { HermesTranscriptMessage(role: .assistant, text: finalText, isStreaming: false) ) } - isStreaming = false + endStreaming() if let activeSessionID, let profile = selectedProfile, let operation = currentOperation(for: profile) { @@ -957,6 +1155,19 @@ public final class HermesAgentStore: ObservableObject { ?? "Tool" } + private static func choices(from payload: [String: JSONValue]) -> [String] { + payload["choices"]?.arrayValue?.compactMap(\.stringValue) ?? [] + } + + private static func pendingToolName(for kind: HermesPendingRequestKind) -> String { + switch kind { + case .approval: "Approval" + case .clarification: "Clarification" + case .sudo: "Sudo" + case .secret: "Secret" + } + } + private static func toolDetail(from payload: [String: JSONValue]) -> String { if let preview = payload["preview"]?.stringValue, !preview.isEmpty { return preview diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 96b31ac27..2c6fe8263 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -179,6 +179,236 @@ final class HermesAgentStoreTests: XCTestCase { ) } + @MainActor + func testStreamingReasoningToolsAndCompletionUpdateTranscript() async throws { + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + client.emit(.init(type: "message.start", sessionID: "live-1", payload: [:])) + client.emit(.init(type: "message.delta", sessionID: "live-1", payload: ["text": .string("Hel")])) + client.emit(.init(type: "tool.start", sessionID: "live-1", payload: ["name": .string("terminal")])) + client.emit(.init(type: "message.complete", sessionID: "live-1", payload: [ + "text": .string("Hello"), + "reasoning": .string("checked state"), + ])) + + XCTAssertEqual(store.messages.last?.text, "Hello") + XCTAssertTrue(store.toolTraces.contains(where: { $0.name == "Thought" })) + XCTAssertFalse(store.isStreaming) + } + + @MainActor + func testAskFirstSurfacesApprovalAndRespondsWithSelectedChoice() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: [ + "command": .string("git status"), + "choices": .array([.string("once"), .string("deny")]), + ])) + + XCTAssertEqual(store.pendingRequest?.kind, .approval) + XCTAssertEqual(store.pendingRequest?.choices, ["once", "deny"]) + await store.respondToPendingRequest(value: "once") + XCTAssertEqual(client.calls.last?.method, "approval.respond") + XCTAssertEqual(client.calls.last?.params, [ + "session_id": .string("live-1"), + "choice": .string("once"), + ]) + XCTAssertNil(store.pendingRequest) + } + + @MainActor + func testPendingApprovalPausesComposerAndDeniesWithNativeChoice() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("git status")])) + await store.send("must remain paused") + await store.denyPendingApproval() + + XCTAssertFalse(client.calls.contains(where: { $0.method == "prompt.submit" })) + XCTAssertEqual(client.calls.last?.method, "approval.respond") + XCTAssertEqual(client.calls.last?.params["choice"], .string("deny")) + } + + @MainActor + func testConfiguredAutoApproveRespondsOnceWithoutPersistentScope() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("git status")])) + await waitUntil { client.calls.contains(where: { $0.method == "approval.respond" }) } + + let response = try XCTUnwrap(client.calls.last(where: { $0.method == "approval.respond" })) + XCTAssertEqual(response.params["session_id"], .string("live-1")) + XCTAssertEqual(response.params["choice"], .string("once")) + XCTAssertNil(response.params["all"]) + XCTAssertNil(store.pendingRequest) + } + + @MainActor + func testClarificationResponseAndMatchingExpiryUseNativeRequestID() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + client.emit(.init(type: "clarify.request", sessionID: "live-1", payload: [ + "request_id": .string("clarify-1"), + "question": .string("Which target?"), + "choices": .array([.string("A"), .string("B")]), + ])) + client.emit(.init(type: "clarify.expire", sessionID: "live-1", payload: ["request_id": .string("stale")])) + XCTAssertEqual(store.pendingRequest?.id, "clarify-1") + + await store.respondToPendingRequest(value: "B") + XCTAssertEqual(client.calls.last?.method, "clarify.respond") + XCTAssertEqual(client.calls.last?.params, [ + "request_id": .string("clarify-1"), + "answer": .string("B"), + ]) + XCTAssertNil(store.pendingRequest) + + client.emit(.init(type: "clarify.request", sessionID: "live-1", payload: [ + "request_id": .string("clarify-2"), + "question": .string("Again?"), + ])) + client.emit(.init(type: "clarify.expire", sessionID: "live-1", payload: ["request_id": .string("clarify-2")])) + XCTAssertNil(store.pendingRequest) + } + + @MainActor + func testSudoAndSecretResponsesUseNativeFieldsWithoutPersistingInput() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + client.emit(.init(type: "sudo.request", sessionID: "live-1", payload: ["request_id": .string("sudo-1")])) + let sudoInput = UUID().uuidString + await store.respondToPendingRequest(value: sudoInput) + XCTAssertEqual(client.calls.last?.method, "sudo.respond") + XCTAssertEqual(client.calls.last?.params["request_id"], .string("sudo-1")) + XCTAssertEqual(client.calls.last?.params["password"], .string(sudoInput)) + + client.emit(.init(type: "secret.request", sessionID: "live-1", payload: [ + "request_id": .string("secret-1"), + "prompt": .string("Credential requested"), + ])) + let secretInput = UUID().uuidString + await store.respondToPendingRequest(value: secretInput) + XCTAssertEqual(client.calls.last?.method, "secret.respond") + XCTAssertEqual(client.calls.last?.params["request_id"], .string("secret-1")) + XCTAssertEqual(client.calls.last?.params["value"], .string(secretInput)) + XCTAssertFalse(store.messages.contains(where: { $0.text == sudoInput || $0.text == secretInput })) + } + + @MainActor + func testExpiredOrSupersededRequestCannotClearNewerPromptAfterResponseReturns() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.suspendedMethods = ["clarify.respond"] + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + client.emit(.init(type: "clarify.request", sessionID: "live-1", payload: [ + "request_id": .string("clarify-old"), "question": .string("Old prompt?"), + ])) + let response = Task { await store.respondToPendingRequest(value: "old") } + await waitUntil { client.calls.contains(where: { $0.method == "clarify.respond" }) } + client.emit(.init(type: "clarify.expire", sessionID: "live-1", payload: ["request_id": .string("clarify-old")])) + client.emit(.init(type: "clarify.request", sessionID: "live-1", payload: [ + "request_id": .string("clarify-new"), "question": .string("New prompt?"), + ])) + client.finishCall(method: "clarify.respond") + await response.value + + XCTAssertEqual(store.pendingRequest?.id, "clarify-new") + } + + @MainActor + func testErrorDisconnectAndSessionSwitchClearStreamingAndPendingState() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let firstClient = FakeHermesGatewayClient(readyImmediately: true) + let secondClient = FakeHermesGatewayClient(readyImmediately: true) + firstClient.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + firstClient.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + secondClient.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [firstClient, secondClient]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + firstClient.emit(.init(type: "message.start", sessionID: "live-1", payload: [:])) + firstClient.emit(.init(type: "clarify.request", sessionID: "live-1", payload: [ + "request_id": .string("clarify-1"), "question": .string("Continue?"), + ])) + firstClient.emit(.init(type: "error", sessionID: "live-1", payload: ["message": .string("turn failed")])) + XCTAssertFalse(store.isStreaming) + XCTAssertNotNil(store.pendingRequest) + + firstClient.disconnect("transport lost") + XCTAssertFalse(store.isStreaming) + XCTAssertNil(store.pendingRequest) + + await store.loadSessions(profile: researcher, configuration: configuration) + XCTAssertFalse(store.isStreaming) + XCTAssertNil(store.pendingRequest) + } + + @MainActor + func testEventsFromNonActiveSessionAndRetiredClientAreIgnored() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let firstClient = FakeHermesGatewayClient(readyImmediately: true) + let secondClient = FakeHermesGatewayClient(readyImmediately: true) + firstClient.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + firstClient.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + secondClient.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [firstClient, secondClient]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + firstClient.emit(.init(type: "message.delta", sessionID: "other-live", payload: ["text": .string("ignored")])) + firstClient.emit(.init(type: "clarify.request", sessionID: "other-live", payload: [ + "request_id": .string("other-request"), "question": .string("ignored"), + ])) + XCTAssertEqual(store.messages, []) + XCTAssertNil(store.pendingRequest) + + await store.loadSessions(profile: researcher, configuration: configuration) + firstClient.emit(.init(type: "message.delta", sessionID: "live-1", payload: ["text": .string("retired")])) + XCTAssertEqual(store.messages, []) + XCTAssertNil(store.pendingRequest) + } + @MainActor func testExternalSessionRemainsReadableButCannotSubmitOrInterrupt() async throws { let runtime = FakeHermesEmbeddedRuntime() @@ -686,6 +916,10 @@ private final class FakeHermesGatewayClient: HermesGatewayClientProtocol { onDisconnect?(message) } + func emit(_ event: HermesGatewayEvent) { + onEvent?(event) + } + func finishCall(method: String) { let value = resultByMethod[method] ?? .object([:]) let waiters = callWaiters.removeValue(forKey: method) ?? [] From 0b661bdebb0a9e93f9650802f1ab9bb55d0f008c Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:06:50 +0200 Subject: [PATCH 19/40] fix(hermes): harden native event and response handling --- .../Stores/HermesAgentStore.swift | 56 ++++++++++--- .../HermesAgentStoreTests.swift | 84 +++++++++++++++++++ 2 files changed, 127 insertions(+), 13 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index c5741688e..850dcb946 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -145,6 +145,7 @@ public final class HermesAgentStore: ObservableObject { private var gatewayGeneration = 0 private var didReapOrphanedSidecars = false private var hermesAutoApprove = false + private var pendingResponseLease: PendingRequestLease? private struct GatewayOperation { let generation: Int @@ -413,7 +414,8 @@ public final class HermesAgentStore: ObservableObject { let sessionID = activeSessionID, let profile = selectedProfile, let operation = currentOperation(for: profile), - activeSessionWritable + refreshActiveOwnership(), + pendingResponseLease == nil else { return } let lease = PendingRequestLease( @@ -422,6 +424,8 @@ public final class HermesAgentStore: ObservableObject { sessionID: sessionID, operation: operation ) + pendingResponseLease = lease + defer { releasePendingResponseLease(lease) } let method: String let params: [String: JSONValue] switch lease.kind { @@ -517,6 +521,7 @@ public final class HermesAgentStore: ObservableObject { messages = [] toolTraces = [] pendingRequest = nil + pendingResponseLease = nil terminalAgentRunning = integration.hasLaunchedTerminalAgent() connectionState = .idle } @@ -592,6 +597,7 @@ public final class HermesAgentStore: ObservableObject { toolTraces = [] endStreaming() pendingRequest = nil + pendingResponseLease = nil } } gatewayReady = false @@ -720,6 +726,7 @@ public final class HermesAgentStore: ObservableObject { gatewayReady = false endStreaming() pendingRequest = nil + pendingResponseLease = nil expectedClient.close() expectedSidecar.stop() return true @@ -736,6 +743,7 @@ public final class HermesAgentStore: ObservableObject { gatewayReady = false endStreaming() pendingRequest = nil + pendingResponseLease = nil if clearSession { activeSessionID = nil activeSessionKey = nil @@ -758,6 +766,7 @@ public final class HermesAgentStore: ObservableObject { activeSessionTitle = title endStreaming() pendingRequest = nil + pendingResponseLease = nil if !preserveVisibleTranscript || messages.isEmpty { messages = Self.parseMessages(object["messages"]) } @@ -873,11 +882,15 @@ public final class HermesAgentStore: ObservableObject { case "message.delta": appendAssistantDelta(event.payload["text"]?.stringValue ?? "") case "message.complete": - completeAssistantMessage( - text: event.payload["text"]?.stringValue, - reasoning: event.payload["reasoning"]?.stringValue - ) - case "reasoning.delta", "thinking.delta": + if event.payload["status"]?.stringValue == "error" { + recordGenericEventError() + } else { + completeAssistantMessage( + text: event.payload["text"]?.stringValue, + reasoning: event.payload["reasoning"]?.stringValue + ) + } + case "reasoning.available", "reasoning.delta", "thinking.delta": appendReasoningDelta(event.payload["text"]?.stringValue ?? "") case "tool.start": toolTraces.append( @@ -916,13 +929,7 @@ public final class HermesAgentStore: ObservableObject { case "secret.expire": expirePendingRequest(kind: .secret, payload: event.payload) case "error": - messages.append( - HermesTranscriptMessage( - role: .system, - text: "Hermes reported an error." - ) - ) - endStreaming() + recordGenericEventError() case "session.info": if let key = event.payload["session_key"]?.stringValue { activeSessionKey = key @@ -966,6 +973,9 @@ public final class HermesAgentStore: ObservableObject { } let requestID = payload["request_id"]?.stringValue ?? "approval-\(UUID().uuidString)" + if pendingRequest?.id != requestID || pendingRequest?.kind != .approval { + pendingResponseLease = nil + } pendingRequest = HermesPendingRequest( id: requestID, kind: .approval, @@ -983,6 +993,9 @@ public final class HermesAgentStore: ObservableObject { private func setPendingRequest(kind: HermesPendingRequestKind, payload: [String: JSONValue]) { guard let requestID = payload["request_id"]?.stringValue, !requestID.isEmpty else { return } + if pendingRequest?.id != requestID || pendingRequest?.kind != kind { + pendingResponseLease = nil + } let prompt: String switch kind { case .approval: @@ -1015,6 +1028,23 @@ public final class HermesAgentStore: ObservableObject { pendingRequest?.kind == kind else { return } pendingRequest = nil + pendingResponseLease = nil + } + + private func releasePendingResponseLease(_ lease: PendingRequestLease) { + guard let activeLease = pendingResponseLease, + activeLease.id == lease.id, + activeLease.kind == lease.kind, + activeLease.sessionID == lease.sessionID, + activeLease.operation.generation == lease.operation.generation, + activeLease.operation.clientIdentity == lease.operation.clientIdentity + else { return } + pendingResponseLease = nil + } + + private func recordGenericEventError() { + endStreaming() + messages.append(HermesTranscriptMessage(role: .system, text: "Hermes reported an error.")) } private func endStreaming() { diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 2c6fe8263..2f3a54975 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -201,6 +201,47 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertFalse(store.isStreaming) } + @MainActor + func testTerminalCompletionErrorNeverSurfacesRawPayload() async throws { + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + let rawTerminalDetail = UUID().uuidString + + client.emit(.init(type: "message.start", sessionID: "live-1", payload: [:])) + client.emit(.init(type: "message.complete", sessionID: "live-1", payload: [ + "status": .string("error"), + "text": .string(rawTerminalDetail), + "error": .string(rawTerminalDetail), + ])) + + XCTAssertEqual(store.messages.last?.role, .system) + XCTAssertEqual(store.messages.last?.text, "Hermes reported an error.") + XCTAssertFalse(store.messages.contains(where: { $0.text == rawTerminalDetail })) + XCTAssertFalse(store.toolTraces.contains(where: { $0.detail == rawTerminalDetail })) + XCTAssertFalse(store.isStreaming) + } + + @MainActor + func testReasoningAvailableAddsThoughtTraceWithoutPersistingVerboseMetadata() async throws { + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + client.emit(.init(type: "reasoning.available", sessionID: "live-1", payload: [ + "text": .string("checked state"), + "verbose": .bool(true), + ])) + + XCTAssertTrue(store.toolTraces.contains(where: { $0.name == "Thought" && $0.detail == "checked state" })) + } + @MainActor func testAskFirstSurfacesApprovalAndRespondsWithSelectedChoice() async throws { configuration.hermesAutoApprove = false @@ -246,6 +287,49 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertEqual(client.calls.last?.params["choice"], .string("deny")) } + @MainActor + func testOwnershipTakeoverBeforePendingResponsePreventsRPC() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("git status")])) + + runtime.ownershipBySession["live-1"] = .external(surface: "telegram") + await store.respondToPendingRequest(value: "once") + + XCTAssertFalse(client.calls.contains(where: { $0.method == "approval.respond" })) + XCTAssertEqual(store.pendingRequest?.kind, .approval) + XCTAssertFalse(store.activeSessionWritable) + } + + @MainActor + func testConcurrentApprovalResponsesIssueOnlyOneRPC() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("git status")])) + + let first = Task { await store.respondToPendingRequest(value: "once") } + await waitUntil { client.calls.contains(where: { $0.method == "approval.respond" }) } + let second = Task { await store.respondToPendingRequest(value: "once") } + await Task.yield() + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) + client.finishCall(method: "approval.respond") + await first.value + await second.value + + XCTAssertNil(store.pendingRequest) + } + @MainActor func testConfiguredAutoApproveRespondsOnceWithoutPersistentScope() async throws { configuration.hermesAutoApprove = true From 6446b6c1c78df9b15cdf6d70eb0f1c557d72792d Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:17:48 +0200 Subject: [PATCH 20/40] fix(hermes): deduplicate auto approval responses --- .../Stores/HermesAgentStore.swift | 32 ++++++- .../HermesAgentStoreTests.swift | 92 +++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index 850dcb946..921022c47 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -424,7 +424,7 @@ public final class HermesAgentStore: ObservableObject { sessionID: sessionID, operation: operation ) - pendingResponseLease = lease + guard reservePendingResponseLease(lease) else { return } defer { releasePendingResponseLease(lease) } let method: String let params: [String: JSONValue] @@ -947,9 +947,17 @@ public final class HermesAgentStore: ObservableObject { else { return } if hermesAutoApprove { + let lease = PendingRequestLease( + id: Self.autoApprovalRequestID(sessionID: sessionID, payload: payload), + kind: .approval, + sessionID: sessionID, + operation: operation + ) + guard reservePendingResponseLease(lease) else { return } Task { [weak self] in - guard let self, - self.hermesAutoApprove, + guard let self else { return } + defer { self.releasePendingResponseLease(lease) } + guard self.hermesAutoApprove, self.refreshActiveOwnership(), self.isCurrent(operation, sessionID: sessionID) else { return } @@ -1042,6 +1050,12 @@ public final class HermesAgentStore: ObservableObject { pendingResponseLease = nil } + private func reservePendingResponseLease(_ lease: PendingRequestLease) -> Bool { + guard pendingResponseLease == nil else { return false } + pendingResponseLease = lease + return true + } + private func recordGenericEventError() { endStreaming() messages.append(HermesTranscriptMessage(role: .system, text: "Hermes reported an error.")) @@ -1189,6 +1203,18 @@ public final class HermesAgentStore: ObservableObject { payload["choices"]?.arrayValue?.compactMap(\.stringValue) ?? [] } + private static func autoApprovalRequestID(sessionID: String, payload: [String: JSONValue]) -> String { + var hasher = Hasher() + hasher.combine(sessionID) + for key in ["command", "description", "pattern_key"] { + hasher.combine(payload[key]?.stringValue ?? "") + } + for choice in choices(from: payload) { + hasher.combine(choice) + } + return "auto-approval-\(hasher.finalize())" + } + private static func pendingToolName(for kind: HermesPendingRequestKind) -> String { switch kind { case .approval: "Approval" diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 2f3a54975..fb3ba65fc 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -350,6 +350,98 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertNil(store.pendingRequest) } + @MainActor + func testDuplicateAutoApproveEventsIssueOneOnceRPC() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + let event = HermesGatewayEvent(type: "approval.request", sessionID: "live-1", payload: [ + "command": .string("git status"), + "choices": .array([.string("once"), .string("deny")]), + ]) + + client.emit(event) + client.emit(event) + await waitUntil { client.calls.contains(where: { $0.method == "approval.respond" }) } + + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) + XCTAssertEqual(client.calls.last?.params["choice"], .string("once")) + XCTAssertNil(client.calls.last?.params["all"]) + client.finishCall(method: "approval.respond") + } + + @MainActor + func testLaterAutoApprovalCanRespondAfterEarlierLeaseCompletes() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("first")])) + await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 1 } + client.finishCall(method: "approval.respond") + for _ in 0..<8 { await Task.yield() } + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("later")])) + await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 2 } + client.finishCall(method: "approval.respond") + } + + @MainActor + func testAutoApprovalTakeoverPreventsRPC() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + runtime.ownershipBySession["live-1"] = .external(surface: "telegram") + + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("git status")])) + await Task.yield() + + XCTAssertFalse(client.calls.contains(where: { $0.method == "approval.respond" })) + XCTAssertFalse(store.activeSessionWritable) + } + + @MainActor + func testStaleAutoApprovalCompletionCannotReleaseNewGenerationLease() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let firstClient = FakeHermesGatewayClient(readyImmediately: true) + let secondClient = FakeHermesGatewayClient(readyImmediately: true) + firstClient.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + firstClient.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + secondClient.resultByMethod["session.create"] = .object(["session_id": .string("live-2")]) + secondClient.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + firstClient.suspendedMethods = ["approval.respond"] + secondClient.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [firstClient, secondClient]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + firstClient.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("old")])) + await waitUntil { firstClient.calls.contains(where: { $0.method == "approval.respond" }) } + + firstClient.disconnect("transport lost") + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + secondClient.emit(.init(type: "approval.request", sessionID: "live-2", payload: ["command": .string("new")])) + await waitUntil { secondClient.calls.contains(where: { $0.method == "approval.respond" }) } + firstClient.finishCall(method: "approval.respond") + for _ in 0..<8 { await Task.yield() } + secondClient.emit(.init(type: "approval.request", sessionID: "live-2", payload: ["command": .string("new")])) + + XCTAssertEqual(secondClient.calls.filter { $0.method == "approval.respond" }.count, 1) + secondClient.finishCall(method: "approval.respond") + } + @MainActor func testClarificationResponseAndMatchingExpiryUseNativeRequestID() async throws { configuration.hermesAutoApprove = false From 523169c415f852153bd148f256b63f52d56f63b8 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:34:02 +0200 Subject: [PATCH 21/40] fix(hermes): queue ambiguous auto approvals --- .../Stores/HermesAgentStore.swift | 145 +++++++++++++++++- .../HermesAgentStoreTests.swift | 144 +++++++++++++++++ 2 files changed, 284 insertions(+), 5 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index 921022c47..ba4c825df 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -146,6 +146,8 @@ public final class HermesAgentStore: ObservableObject { private var didReapOrphanedSidecars = false private var hermesAutoApprove = false private var pendingResponseLease: PendingRequestLease? + private var queuedAutoApprovals: [QueuedAutoApproval] = [] + private var retiredAutoApprovalFingerprints: [RetiredAutoApprovalFingerprint] = [] private struct GatewayOperation { let generation: Int @@ -160,6 +162,22 @@ public final class HermesAgentStore: ObservableObject { let operation: GatewayOperation } + private struct QueuedAutoApproval { + let id: String + let fingerprint: String + let sessionID: String + let operation: GatewayOperation + let prompt: String + let choices: [String] + } + + private struct RetiredAutoApprovalFingerprint { + let fingerprint: String + let sessionID: String + let operation: GatewayOperation + let retiredAt: Date + } + public init( integration: HermesIntegration, embeddedRuntime: any HermesEmbeddedRuntime, @@ -305,6 +323,7 @@ public final class HermesAgentStore: ObservableObject { toolTraces = [] endStreaming() pendingRequest = nil + clearAutoApprovalLifecycle() activeSessionKey = sessionKey applyActiveOwnership(.ready) connectionState = .connected @@ -424,8 +443,15 @@ public final class HermesAgentStore: ObservableObject { sessionID: sessionID, operation: operation ) + let completesQueuedAutoApproval = pendingRequest.kind == .approval + && pendingRequest.id.hasPrefix("queued-auto-approval-") guard reservePendingResponseLease(lease) else { return } - defer { releasePendingResponseLease(lease) } + defer { + releasePendingResponseLease(lease) + if completesQueuedAutoApproval { + presentNextQueuedAutoApprovalIfPossible() + } + } let method: String let params: [String: JSONValue] switch lease.kind { @@ -522,6 +548,7 @@ public final class HermesAgentStore: ObservableObject { toolTraces = [] pendingRequest = nil pendingResponseLease = nil + clearAutoApprovalLifecycle() terminalAgentRunning = integration.hasLaunchedTerminalAgent() connectionState = .idle } @@ -582,6 +609,8 @@ public final class HermesAgentStore: ObservableObject { let generation = gatewayGeneration client?.close() client = nil + pendingResponseLease = nil + clearAutoApprovalLifecycle() if !reuseSidecar { sidecar?.stop() sidecar = nil @@ -598,6 +627,7 @@ public final class HermesAgentStore: ObservableObject { endStreaming() pendingRequest = nil pendingResponseLease = nil + clearAutoApprovalLifecycle() } } gatewayReady = false @@ -727,6 +757,7 @@ public final class HermesAgentStore: ObservableObject { endStreaming() pendingRequest = nil pendingResponseLease = nil + clearAutoApprovalLifecycle() expectedClient.close() expectedSidecar.stop() return true @@ -744,6 +775,7 @@ public final class HermesAgentStore: ObservableObject { endStreaming() pendingRequest = nil pendingResponseLease = nil + clearAutoApprovalLifecycle() if clearSession { activeSessionID = nil activeSessionKey = nil @@ -767,6 +799,7 @@ public final class HermesAgentStore: ObservableObject { endStreaming() pendingRequest = nil pendingResponseLease = nil + clearAutoApprovalLifecycle() if !preserveVisibleTranscript || messages.isEmpty { messages = Self.parseMessages(object["messages"]) } @@ -942,13 +975,31 @@ public final class HermesAgentStore: ObservableObject { private func handleApprovalRequest(_ payload: [String: JSONValue], sessionID: String) { guard let profile = selectedProfile, let operation = currentOperation(for: profile), - isCurrent(operation, sessionID: sessionID), - refreshActiveOwnership() + isCurrent(operation, sessionID: sessionID) else { return } if hermesAutoApprove { + let queuedApproval = QueuedAutoApproval( + id: "queued-auto-approval-\(UUID().uuidString)", + fingerprint: Self.autoApprovalRequestID(sessionID: sessionID, payload: payload), + sessionID: sessionID, + operation: operation, + prompt: payload["command"]?.stringValue ?? "Hermes requires approval.", + choices: Self.choices(from: payload) + ) + guard refreshActiveOwnership() else { + enqueueAutoApprovalForManualResponse(queuedApproval) + return + } + guard pendingRequest == nil, + pendingResponseLease == nil, + !isRetiredAutoApprovalFingerprint(queuedApproval.fingerprint, for: operation, sessionID: sessionID) + else { + enqueueAutoApprovalForManualResponse(queuedApproval) + return + } let lease = PendingRequestLease( - id: Self.autoApprovalRequestID(sessionID: sessionID, payload: payload), + id: queuedApproval.id, kind: .approval, sessionID: sessionID, operation: operation @@ -956,7 +1007,10 @@ public final class HermesAgentStore: ObservableObject { guard reservePendingResponseLease(lease) else { return } Task { [weak self] in guard let self else { return } - defer { self.releasePendingResponseLease(lease) } + defer { + self.releasePendingResponseLease(lease) + self.presentNextQueuedAutoApprovalIfPossible() + } guard self.hermesAutoApprove, self.refreshActiveOwnership(), self.isCurrent(operation, sessionID: sessionID) @@ -970,6 +1024,8 @@ public final class HermesAgentStore: ObservableObject { "choice": .string("once"), ] ) + guard self.isCurrent(operation, sessionID: sessionID) else { return } + self.retireAutoApprovalFingerprint(queuedApproval.fingerprint, for: operation, sessionID: sessionID) } catch { guard self.isCurrent(operation, sessionID: sessionID) else { return } self.messages.append( @@ -1031,6 +1087,14 @@ public final class HermesAgentStore: ObservableObject { } private func expirePendingRequest(kind: HermesPendingRequestKind, payload: [String: JSONValue]) { + if kind == .approval, + payload["request_id"]?.stringValue == nil, + pendingRequest?.id.hasPrefix("queued-auto-approval-") == true { + pendingRequest = nil + pendingResponseLease = nil + presentNextQueuedAutoApprovalIfPossible() + return + } guard let requestID = payload["request_id"]?.stringValue, pendingRequest?.id == requestID, pendingRequest?.kind == kind @@ -1056,6 +1120,77 @@ public final class HermesAgentStore: ObservableObject { return true } + private func enqueueAutoApprovalForManualResponse(_ approval: QueuedAutoApproval) { + queuedAutoApprovals.append(approval) + presentNextQueuedAutoApprovalIfPossible() + } + + private func presentNextQueuedAutoApprovalIfPossible() { + guard pendingRequest == nil, + pendingResponseLease == nil, + let approval = queuedAutoApprovals.first, + isCurrent(approval.operation, sessionID: approval.sessionID) + else { return } + _ = refreshActiveOwnership() + queuedAutoApprovals.removeFirst() + pendingRequest = HermesPendingRequest( + id: approval.id, + kind: .approval, + prompt: approval.prompt, + choices: approval.choices + ) + toolTraces.append( + HermesToolTrace( + name: "Approval", + status: .approval, + detail: approval.prompt + ) + ) + } + + private func retireAutoApprovalFingerprint( + _ fingerprint: String, + for operation: GatewayOperation, + sessionID: String + ) { + pruneRetiredAutoApprovalFingerprints() + retiredAutoApprovalFingerprints.append( + RetiredAutoApprovalFingerprint( + fingerprint: fingerprint, + sessionID: sessionID, + operation: operation, + retiredAt: Date() + ) + ) + if retiredAutoApprovalFingerprints.count > 16 { + retiredAutoApprovalFingerprints.removeFirst(retiredAutoApprovalFingerprints.count - 16) + } + } + + private func isRetiredAutoApprovalFingerprint( + _ fingerprint: String, + for operation: GatewayOperation, + sessionID: String + ) -> Bool { + pruneRetiredAutoApprovalFingerprints() + return retiredAutoApprovalFingerprints.contains { + $0.fingerprint == fingerprint + && $0.sessionID == sessionID + && $0.operation.generation == operation.generation + && $0.operation.clientIdentity == operation.clientIdentity + } + } + + private func pruneRetiredAutoApprovalFingerprints() { + let cutoff = Date().addingTimeInterval(-30) + retiredAutoApprovalFingerprints.removeAll { $0.retiredAt < cutoff } + } + + private func clearAutoApprovalLifecycle() { + queuedAutoApprovals = [] + retiredAutoApprovalFingerprints = [] + } + private func recordGenericEventError() { endStreaming() messages.append(HermesTranscriptMessage(role: .system, text: "Hermes reported an error.")) diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index fb3ba65fc..9984ca305 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -373,6 +373,150 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertEqual(client.calls.last?.params["choice"], .string("once")) XCTAssertNil(client.calls.last?.params["all"]) client.finishCall(method: "approval.respond") + client.suspendedMethods = [] + + await waitUntil { store.pendingRequest?.kind == .approval } + XCTAssertEqual(store.pendingRequest?.prompt, "git status") + await store.respondToPendingRequest(value: "deny") + XCTAssertEqual( + client.calls.filter { $0.method == "approval.respond" }.map { $0.params["choice"] }, + [.string("once"), .string("deny")] + ) + XCTAssertNil(store.pendingRequest) + } + + @MainActor + func testQueuedAutoApprovalsDowngradeToManualFIFO() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 1 } + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("B")])) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("C")])) + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) + + client.finishCall(method: "approval.respond") + client.suspendedMethods = [] + await waitUntil { store.pendingRequest?.prompt == "B" } + await store.respondToPendingRequest(value: "deny") + await waitUntil { store.pendingRequest?.prompt == "C" } + await store.respondToPendingRequest(value: "once") + + XCTAssertEqual( + client.calls.filter { $0.method == "approval.respond" }.map { $0.params["choice"] }, + [.string("once"), .string("deny"), .string("once")] + ) + XCTAssertNil(store.pendingRequest) + } + + @MainActor + func testLateDuplicateOfAutoApprovedRequestDowngradesToManual() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + let event = HermesGatewayEvent(type: "approval.request", sessionID: "live-1", payload: ["command": .string("git status")]) + + client.emit(event) + await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 1 } + for _ in 0..<8 { await Task.yield() } + client.emit(event) + + await waitUntil { store.pendingRequest?.kind == .approval } + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) + XCTAssertEqual(store.pendingRequest?.prompt, "git status") + await store.respondToPendingRequest(value: "deny") + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 2) + } + + @MainActor + func testQueuedAutoApprovalTakeoverRemainsManualAndReadOnly() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 1 } + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("B")])) + runtime.ownershipBySession["live-1"] = .external(surface: "telegram") + client.finishCall(method: "approval.respond") + + await waitUntil { store.pendingRequest?.prompt == "B" } + XCTAssertFalse(store.activeSessionWritable) + await store.respondToPendingRequest(value: "deny") + XCTAssertFalse(store.activeSessionWritable) + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) + } + + @MainActor + func testDisconnectClearsQueuedAutoApprovals() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let firstClient = FakeHermesGatewayClient(readyImmediately: true) + let secondClient = FakeHermesGatewayClient(readyImmediately: true) + firstClient.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + firstClient.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + firstClient.suspendedMethods = ["approval.respond"] + secondClient.resultByMethod["session.create"] = .object(["session_id": .string("live-2")]) + secondClient.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [firstClient, secondClient]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + firstClient.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + await waitUntil { firstClient.calls.filter { $0.method == "approval.respond" }.count == 1 } + firstClient.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("B")])) + + firstClient.disconnect("transport lost") + firstClient.finishCall(method: "approval.respond") + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + for _ in 0..<8 { await Task.yield() } + + XCTAssertNil(store.pendingRequest) + XCTAssertFalse(secondClient.calls.contains(where: { $0.method == "approval.respond" })) + } + + @MainActor + func testExpiryDuringQueuedManualResponseAdvancesFIFO() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 1 } + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("B")])) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("C")])) + client.finishCall(method: "approval.respond") + client.suspendedMethods = [] + await waitUntil { store.pendingRequest?.prompt == "B" } + + client.suspendedMethods = ["approval.respond"] + let response = Task { await store.respondToPendingRequest(value: "deny") } + await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 2 } + client.emit(.init(type: "approval.expire", sessionID: "live-1", payload: [:])) + client.finishCall(method: "approval.respond") + client.suspendedMethods = [] + await response.value + + await waitUntil { store.pendingRequest?.prompt == "C" } + await store.respondToPendingRequest(value: "once") + XCTAssertNil(store.pendingRequest) } @MainActor From f081ed7480cc0659663fda6dd3a05afdcf4a59dc Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:46:59 +0200 Subject: [PATCH 22/40] fix(hermes): fail closed approval responses --- .../Stores/HermesAgentStore.swift | 272 ++++++++++-------- .../HermesAgentStoreTests.swift | 142 ++++++++- 2 files changed, 294 insertions(+), 120 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index ba4c825df..a40a71ab6 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -129,6 +129,9 @@ public final class HermesAgentStore: ObservableObject { @Published public private(set) var readOnlyReason: String? @Published public private(set) var isStreaming: Bool = false @Published public private(set) var pendingRequest: HermesPendingRequest? + /// An approval response may have reached Hermes even when its RPC fails. + /// Keep the local FIFO closed until a fresh owned sidecar is connected. + @Published public private(set) var approvalPipelineBlocked = false @Published public private(set) var gatewayReady: Bool = false @Published public private(set) var gatewayRepairInFlight: Bool = false @Published public private(set) var gatewayRepairMessage: String? @@ -146,8 +149,11 @@ public final class HermesAgentStore: ObservableObject { private var didReapOrphanedSidecars = false private var hermesAutoApprove = false private var pendingResponseLease: PendingRequestLease? - private var queuedAutoApprovals: [QueuedAutoApproval] = [] + private var approvalQueue: [QueuedAutoApproval] = [] private var retiredAutoApprovalFingerprints: [RetiredAutoApprovalFingerprint] = [] + private let monotonicClock: @Sendable () -> TimeInterval + private static let approvalQueueLimit = 64 + private static let retiredApprovalFingerprintLimit = 64 private struct GatewayOperation { let generation: Int @@ -169,23 +175,26 @@ public final class HermesAgentStore: ObservableObject { let operation: GatewayOperation let prompt: String let choices: [String] + let autoEligible: Bool } private struct RetiredAutoApprovalFingerprint { let fingerprint: String let sessionID: String let operation: GatewayOperation - let retiredAt: Date + let retiredAt: TimeInterval } public init( integration: HermesIntegration, embeddedRuntime: any HermesEmbeddedRuntime, - clientFactory: @escaping HermesGatewayClientFactory + clientFactory: @escaping HermesGatewayClientFactory, + monotonicClock: @escaping @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } ) { self.integration = integration self.embeddedRuntime = embeddedRuntime self.clientFactory = clientFactory + self.monotonicClock = monotonicClock } public convenience init(integration: HermesIntegration = HermesIntegration()) { @@ -434,7 +443,8 @@ public final class HermesAgentStore: ObservableObject { let profile = selectedProfile, let operation = currentOperation(for: profile), refreshActiveOwnership(), - pendingResponseLease == nil + pendingResponseLease == nil, + !(pendingRequest.kind == .approval && approvalPipelineBlocked) else { return } let lease = PendingRequestLease( @@ -443,14 +453,9 @@ public final class HermesAgentStore: ObservableObject { sessionID: sessionID, operation: operation ) - let completesQueuedAutoApproval = pendingRequest.kind == .approval - && pendingRequest.id.hasPrefix("queued-auto-approval-") guard reservePendingResponseLease(lease) else { return } defer { releasePendingResponseLease(lease) - if completesQueuedAutoApproval { - presentNextQueuedAutoApprovalIfPossible() - } } let method: String let params: [String: JSONValue] @@ -487,13 +492,21 @@ public final class HermesAgentStore: ObservableObject { self.pendingRequest?.id == lease.id, self.pendingRequest?.kind == lease.kind else { return } - self.pendingRequest = nil + if lease.kind == .approval { + self.completeApprovalHead(lease) + } else { + self.pendingRequest = nil + } } catch { guard isCurrent(lease.operation, sessionID: lease.sessionID), self.pendingRequest?.id == lease.id, self.pendingRequest?.kind == lease.kind else { return } - messages.append(HermesTranscriptMessage(role: .system, text: "Hermes could not accept the requested response.")) + if lease.kind == .approval { + blockApprovalPipeline(afterAmbiguousResponseFor: lease) + } else { + messages.append(HermesTranscriptMessage(role: .system, text: "Hermes could not accept the requested response.")) + } } } @@ -604,13 +617,16 @@ public final class HermesAgentStore: ObservableObject { let reuseSidecar = sidecarProfileName == profile.name && sidecarConfigurationSignature == signature && sidecar?.isRunning == true + let reconnectingBlockedApprovalPipeline = approvalPipelineBlocked shuttingDown = true gatewayGeneration += 1 let generation = gatewayGeneration client?.close() client = nil pendingResponseLease = nil - clearAutoApprovalLifecycle() + if !reconnectingBlockedApprovalPipeline { + clearAutoApprovalLifecycle() + } if !reuseSidecar { sidecar?.stop() sidecar = nil @@ -625,9 +641,13 @@ public final class HermesAgentStore: ObservableObject { messages = [] toolTraces = [] endStreaming() - pendingRequest = nil + if !reconnectingBlockedApprovalPipeline { + pendingRequest = nil + } pendingResponseLease = nil - clearAutoApprovalLifecycle() + if !reconnectingBlockedApprovalPipeline { + clearAutoApprovalLifecycle() + } } } gatewayReady = false @@ -696,6 +716,12 @@ public final class HermesAgentStore: ObservableObject { _ = releaseGatewayIfOwned(generation: generation, client: nextClient, sidecar: nextSidecar) throw CancellationError() } + if reconnectingBlockedApprovalPipeline { + // The failed transport was already torn down; only a completed + // fresh sidecar/client handshake may reopen approval handling. + if pendingRequest?.kind == .approval { pendingRequest = nil } + clearAutoApprovalLifecycle() + } gatewayReady = true connectionState = .connected return GatewayOperation( @@ -954,7 +980,8 @@ public final class HermesAgentStore: ObservableObject { case "secret.request": setPendingRequest(kind: .secret, payload: event.payload) case "approval.expire": - expirePendingRequest(kind: .approval, payload: event.payload) + // Hermes does not emit approval expiry. Keep synthetic FIFO state. + break case "clarify.expire": expirePendingRequest(kind: .clarification, payload: event.payload) case "sudo.expire": @@ -978,81 +1005,30 @@ public final class HermesAgentStore: ObservableObject { isCurrent(operation, sessionID: sessionID) else { return } - if hermesAutoApprove { - let queuedApproval = QueuedAutoApproval( - id: "queued-auto-approval-\(UUID().uuidString)", - fingerprint: Self.autoApprovalRequestID(sessionID: sessionID, payload: payload), + let fingerprint = Self.autoApprovalRequestID(sessionID: sessionID, payload: payload) + let safeAutoHead = hermesAutoApprove + && approvalQueue.isEmpty + && pendingRequest == nil + && pendingResponseLease == nil + && !approvalPipelineBlocked + && !isRetiredAutoApprovalFingerprint(fingerprint, for: operation, sessionID: sessionID) + && refreshActiveOwnership() + guard approvalQueue.count < Self.approvalQueueLimit else { + blockApprovalPipelineForOverflow() + return + } + approvalQueue.append( + QueuedAutoApproval( + id: "approval-\(UUID().uuidString)", + fingerprint: fingerprint, sessionID: sessionID, operation: operation, prompt: payload["command"]?.stringValue ?? "Hermes requires approval.", - choices: Self.choices(from: payload) - ) - guard refreshActiveOwnership() else { - enqueueAutoApprovalForManualResponse(queuedApproval) - return - } - guard pendingRequest == nil, - pendingResponseLease == nil, - !isRetiredAutoApprovalFingerprint(queuedApproval.fingerprint, for: operation, sessionID: sessionID) - else { - enqueueAutoApprovalForManualResponse(queuedApproval) - return - } - let lease = PendingRequestLease( - id: queuedApproval.id, - kind: .approval, - sessionID: sessionID, - operation: operation - ) - guard reservePendingResponseLease(lease) else { return } - Task { [weak self] in - guard let self else { return } - defer { - self.releasePendingResponseLease(lease) - self.presentNextQueuedAutoApprovalIfPossible() - } - guard self.hermesAutoApprove, - self.refreshActiveOwnership(), - self.isCurrent(operation, sessionID: sessionID) - else { return } - do { - _ = try await self.rpc( - operation, - method: "approval.respond", - params: [ - "session_id": .string(sessionID), - "choice": .string("once"), - ] - ) - guard self.isCurrent(operation, sessionID: sessionID) else { return } - self.retireAutoApprovalFingerprint(queuedApproval.fingerprint, for: operation, sessionID: sessionID) - } catch { - guard self.isCurrent(operation, sessionID: sessionID) else { return } - self.messages.append( - HermesTranscriptMessage(role: .system, text: "Hermes could not accept the requested response.") - ) - } - } - return - } - - let requestID = payload["request_id"]?.stringValue ?? "approval-\(UUID().uuidString)" - if pendingRequest?.id != requestID || pendingRequest?.kind != .approval { - pendingResponseLease = nil - } - pendingRequest = HermesPendingRequest( - id: requestID, - kind: .approval, - prompt: payload["command"]?.stringValue ?? "Hermes requires approval.", - choices: Self.choices(from: payload) - ) - toolTraces.append( - HermesToolTrace( - name: "Approval", - status: .approval, - detail: payload["command"]?.stringValue ?? "Approval required." + choices: Self.choices(from: payload), + autoEligible: safeAutoHead ) ) + processApprovalQueueIfPossible() } private func setPendingRequest(kind: HermesPendingRequestKind, payload: [String: JSONValue]) { @@ -1087,14 +1063,6 @@ public final class HermesAgentStore: ObservableObject { } private func expirePendingRequest(kind: HermesPendingRequestKind, payload: [String: JSONValue]) { - if kind == .approval, - payload["request_id"]?.stringValue == nil, - pendingRequest?.id.hasPrefix("queued-auto-approval-") == true { - pendingRequest = nil - pendingResponseLease = nil - presentNextQueuedAutoApprovalIfPossible() - return - } guard let requestID = payload["request_id"]?.stringValue, pendingRequest?.id == requestID, pendingRequest?.kind == kind @@ -1112,6 +1080,9 @@ public final class HermesAgentStore: ObservableObject { activeLease.operation.clientIdentity == lease.operation.clientIdentity else { return } pendingResponseLease = nil + if lease.kind == .approval { + processApprovalQueueIfPossible() + } } private func reservePendingResponseLease(_ lease: PendingRequestLease) -> Bool { @@ -1120,32 +1091,96 @@ public final class HermesAgentStore: ObservableObject { return true } - private func enqueueAutoApprovalForManualResponse(_ approval: QueuedAutoApproval) { - queuedAutoApprovals.append(approval) - presentNextQueuedAutoApprovalIfPossible() - } - - private func presentNextQueuedAutoApprovalIfPossible() { - guard pendingRequest == nil, + private func processApprovalQueueIfPossible() { + guard !approvalPipelineBlocked, pendingResponseLease == nil, - let approval = queuedAutoApprovals.first, + let approval = approvalQueue.first, isCurrent(approval.operation, sessionID: approval.sessionID) else { return } + if approval.autoEligible, pendingRequest == nil, hermesAutoApprove, refreshActiveOwnership() { + let lease = PendingRequestLease( + id: approval.id, kind: .approval, sessionID: approval.sessionID, operation: approval.operation + ) + guard reservePendingResponseLease(lease) else { return } + Task { [weak self] in + guard let self else { return } + defer { self.releasePendingResponseLease(lease) } + guard self.isCurrent(lease.operation, sessionID: lease.sessionID), + self.refreshActiveOwnership() + else { return } + do { + _ = try await self.rpc( + lease.operation, + method: "approval.respond", + params: ["session_id": .string(lease.sessionID), "choice": .string("once")] + ) + guard self.isCurrent(lease.operation, sessionID: lease.sessionID) else { return } + self.completeApprovalHead(lease) + } catch { + guard self.isCurrent(lease.operation, sessionID: lease.sessionID) else { return } + self.blockApprovalPipeline(afterAmbiguousResponseFor: lease) + } + } + return + } + guard pendingRequest == nil else { return } + presentManualApproval(approval) + } + + private func presentManualApproval(_ approval: QueuedAutoApproval) { _ = refreshActiveOwnership() - queuedAutoApprovals.removeFirst() pendingRequest = HermesPendingRequest( - id: approval.id, - kind: .approval, - prompt: approval.prompt, - choices: approval.choices - ) - toolTraces.append( - HermesToolTrace( - name: "Approval", - status: .approval, - detail: approval.prompt - ) + id: approval.id, kind: .approval, prompt: approval.prompt, choices: approval.choices ) + toolTraces.append(HermesToolTrace(name: "Approval", status: .approval, detail: approval.prompt)) + } + + private func completeApprovalHead(_ lease: PendingRequestLease) { + guard let head = approvalQueue.first, + head.id == lease.id, + head.sessionID == lease.sessionID, + head.operation.generation == lease.operation.generation, + head.operation.clientIdentity == lease.operation.clientIdentity + else { return } + approvalQueue.removeFirst() + retireAutoApprovalFingerprint(head.fingerprint, for: lease.operation, sessionID: lease.sessionID) + if pendingRequest?.id == lease.id, pendingRequest?.kind == .approval { pendingRequest = nil } + processApprovalQueueIfPossible() + } + + private func blockApprovalPipeline(afterAmbiguousResponseFor lease: PendingRequestLease) { + guard approvalQueue.first?.id == lease.id else { return } + pendingRequest = nil + if let head = approvalQueue.first { presentManualApproval(head) } + approvalPipelineBlocked = true + pendingResponseLease = nil + messages.append(HermesTranscriptMessage( + role: .system, + text: "Approval delivery is unknown. Reconnect Hermes before responding again." + )) + forceApprovalTransportReset() + } + + private func blockApprovalPipelineForOverflow() { + guard !approvalPipelineBlocked else { return } + approvalPipelineBlocked = true + messages.append(HermesTranscriptMessage( + role: .system, + text: "Too many approval requests are pending. Reconnect Hermes before responding again." + )) + forceApprovalTransportReset() + } + + private func forceApprovalTransportReset() { + gatewayGeneration += 1 + gatewayReady = false + client?.close() + client = nil + sidecar?.stop() + sidecar = nil + sidecarProfileName = nil + sidecarConfigurationSignature = nil + endStreaming() } private func retireAutoApprovalFingerprint( @@ -1159,11 +1194,11 @@ public final class HermesAgentStore: ObservableObject { fingerprint: fingerprint, sessionID: sessionID, operation: operation, - retiredAt: Date() + retiredAt: monotonicClock() ) ) - if retiredAutoApprovalFingerprints.count > 16 { - retiredAutoApprovalFingerprints.removeFirst(retiredAutoApprovalFingerprints.count - 16) + if retiredAutoApprovalFingerprints.count > Self.retiredApprovalFingerprintLimit { + retiredAutoApprovalFingerprints.removeFirst(retiredAutoApprovalFingerprints.count - Self.retiredApprovalFingerprintLimit) } } @@ -1182,13 +1217,14 @@ public final class HermesAgentStore: ObservableObject { } private func pruneRetiredAutoApprovalFingerprints() { - let cutoff = Date().addingTimeInterval(-30) + let cutoff = monotonicClock() - 30 retiredAutoApprovalFingerprints.removeAll { $0.retiredAt < cutoff } } private func clearAutoApprovalLifecycle() { - queuedAutoApprovals = [] + approvalQueue = [] retiredAutoApprovalFingerprints = [] + approvalPipelineBlocked = false } private func recordGenericEventError() { diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 9984ca305..175aed7c1 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -268,6 +268,134 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertNil(store.pendingRequest) } + @MainActor + func testManualApprovalFIFOMapsAThenBToNativeHeads() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("B")])) + XCTAssertEqual(store.pendingRequest?.prompt, "A") + await store.respondToPendingRequest(value: "deny") + XCTAssertEqual(store.pendingRequest?.prompt, "B") + await store.respondToPendingRequest(value: "once") + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.map { $0.params["choice"] }, [.string("deny"), .string("once")]) + } + + @MainActor + func testIdenticalManualApprovalRequestsRemainDistinctFIFOEntries() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + let event = HermesGatewayEvent(type: "approval.request", sessionID: "live-1", payload: ["command": .string("same")]) + client.emit(event); client.emit(event) + let firstID = store.pendingRequest?.id + await store.respondToPendingRequest(value: "deny") + XCTAssertEqual(store.pendingRequest?.prompt, "same") + XCTAssertNotEqual(store.pendingRequest?.id, firstID) + await store.respondToPendingRequest(value: "deny") + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 2) + } + + @MainActor + func testApprovalResponseFailureKeepsHeadAndBlocksUntilFreshSidecar() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let first = FakeHermesGatewayClient(readyImmediately: true) + let second = FakeHermesGatewayClient(readyImmediately: true) + for client in [first, second] { + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + } + let store = makeStore(runtime: runtime, clients: [first, second]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + first.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + first.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("B")])) + first.suspendedMethods = ["approval.respond"] + let answer = Task { await store.respondToPendingRequest(value: "once") } + await waitUntil { first.calls.contains { $0.method == "approval.respond" } } + first.failCall(method: "approval.respond") + await answer.value + XCTAssertTrue(store.approvalPipelineBlocked) + XCTAssertEqual(store.pendingRequest?.prompt, "A") + await store.respondToPendingRequest(value: "deny") + XCTAssertEqual(first.calls.filter { $0.method == "approval.respond" }.count, 1) + XCTAssertTrue(first.didClose) + XCTAssertEqual(runtime.sidecars.first?.stopCount, 1) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + XCTAssertFalse(store.approvalPipelineBlocked) + XCTAssertNil(store.pendingRequest) + } + + @MainActor + func testAutoApprovalFailureKeepsUnknownHeadAndRetainsLaterFIFOEntries() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 1 } + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("B")])) + client.failCall(method: "approval.respond") + await waitUntil { store.approvalPipelineBlocked } + XCTAssertEqual(store.pendingRequest?.prompt, "A") + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) + // A lifecycle reset is required before B may ever become actionable. + await store.respondToPendingRequest(value: "deny") + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) + } + + @MainActor + func testApprovalExpireIsIgnoredAndQueueOverflowFailsClosedWithoutRPC() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + for index in 0...64 { + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A\(index)")])) + } + client.emit(.init(type: "approval.expire", sessionID: "live-1", payload: [:])) + XCTAssertTrue(store.approvalPipelineBlocked) + XCTAssertEqual(store.pendingRequest?.prompt, "A0") + XCTAssertFalse(client.calls.contains { $0.method == "approval.respond" }) + } + + @MainActor + func testRetiredApprovalFingerprintUsesInjectedMonotonicClock() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let clock = TestMonotonicClock(now: 100) + let store = makeStore(runtime: runtime, clients: [client], monotonicClock: { clock.now }) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + let event = HermesGatewayEvent(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")]) + client.emit(event) + await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 1 } + client.emit(event) + XCTAssertEqual(store.pendingRequest?.prompt, "A") + await store.respondToPendingRequest(value: "deny") + clock.now += 31 + client.emit(event) + await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 3 } + } + @MainActor func testPendingApprovalPausesComposerAndDeniesWithNativeChoice() async throws { configuration.hermesAutoApprove = false @@ -1045,7 +1173,8 @@ final class HermesAgentStoreTests: XCTestCase { @MainActor private func makeStore( runtime: FakeHermesEmbeddedRuntime, - clients: [FakeHermesGatewayClient] + clients: [FakeHermesGatewayClient], + monotonicClock: @escaping @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } ) -> HermesAgentStore { var remaining = clients return HermesAgentStore( @@ -1054,7 +1183,8 @@ final class HermesAgentStoreTests: XCTestCase { clientFactory: { _ in guard !remaining.isEmpty else { fatalError("Missing fake client") } return remaining.removeFirst() - } + }, + monotonicClock: monotonicClock ) } @@ -1111,6 +1241,14 @@ final class HermesAgentStoreTests: XCTestCase { } } +private final class TestMonotonicClock: @unchecked Sendable { + var now: TimeInterval + + init(now: TimeInterval) { + self.now = now + } +} + private final class FakeHermesEmbeddedRuntime: HermesEmbeddedRuntime, @unchecked Sendable { var routingByProfile: [String: HermesProfileRoutingState] = [:] var ownershipBySession: [String: HermesSessionOwnership] = [:] From 2e3698c9b71be4e413416757419e4a7f091183b1 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:58:20 +0200 Subject: [PATCH 23/40] fix(hermes): arbitrate pending request FIFO --- .../Stores/HermesAgentStore.swift | 196 +++++++++++------- .../HermesAgentStoreTests.swift | 90 +++++++- 2 files changed, 205 insertions(+), 81 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index a40a71ab6..5476fcdfc 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -149,10 +149,10 @@ public final class HermesAgentStore: ObservableObject { private var didReapOrphanedSidecars = false private var hermesAutoApprove = false private var pendingResponseLease: PendingRequestLease? - private var approvalQueue: [QueuedAutoApproval] = [] + private var pendingRequestInbox: [PendingRequestInboxEntry] = [] private var retiredAutoApprovalFingerprints: [RetiredAutoApprovalFingerprint] = [] private let monotonicClock: @Sendable () -> TimeInterval - private static let approvalQueueLimit = 64 + private static let pendingRequestInboxLimit = 64 private static let retiredApprovalFingerprintLimit = 64 private struct GatewayOperation { @@ -168,14 +168,12 @@ public final class HermesAgentStore: ObservableObject { let operation: GatewayOperation } - private struct QueuedAutoApproval { - let id: String - let fingerprint: String + private struct PendingRequestInboxEntry { + let request: HermesPendingRequest let sessionID: String let operation: GatewayOperation - let prompt: String - let choices: [String] - let autoEligible: Bool + let approvalFingerprint: String? + let approvalAutoEligible: Bool } private struct RetiredAutoApprovalFingerprint { @@ -444,7 +442,8 @@ public final class HermesAgentStore: ObservableObject { let operation = currentOperation(for: profile), refreshActiveOwnership(), pendingResponseLease == nil, - !(pendingRequest.kind == .approval && approvalPipelineBlocked) + !approvalPipelineBlocked, + inboxHeadMatches(id: pendingRequest.id, kind: pendingRequest.kind, operation: operation, sessionID: sessionID) else { return } let lease = PendingRequestLease( @@ -492,11 +491,7 @@ public final class HermesAgentStore: ObservableObject { self.pendingRequest?.id == lease.id, self.pendingRequest?.kind == lease.kind else { return } - if lease.kind == .approval { - self.completeApprovalHead(lease) - } else { - self.pendingRequest = nil - } + self.completeInboxHead(lease) } catch { guard isCurrent(lease.operation, sessionID: lease.sessionID), self.pendingRequest?.id == lease.id, @@ -1006,36 +1001,27 @@ public final class HermesAgentStore: ObservableObject { else { return } let fingerprint = Self.autoApprovalRequestID(sessionID: sessionID, payload: payload) - let safeAutoHead = hermesAutoApprove - && approvalQueue.isEmpty - && pendingRequest == nil - && pendingResponseLease == nil + let hasEarlierApproval = pendingRequestInbox.contains { $0.request.kind == .approval } + let autoEligible = hermesAutoApprove + && !hasEarlierApproval && !approvalPipelineBlocked && !isRetiredAutoApprovalFingerprint(fingerprint, for: operation, sessionID: sessionID) - && refreshActiveOwnership() - guard approvalQueue.count < Self.approvalQueueLimit else { - blockApprovalPipelineForOverflow() - return - } - approvalQueue.append( - QueuedAutoApproval( + enqueuePendingRequest( + HermesPendingRequest( id: "approval-\(UUID().uuidString)", - fingerprint: fingerprint, - sessionID: sessionID, - operation: operation, + kind: .approval, prompt: payload["command"]?.stringValue ?? "Hermes requires approval.", - choices: Self.choices(from: payload), - autoEligible: safeAutoHead - ) + choices: Self.choices(from: payload) + ), + sessionID: sessionID, + operation: operation, + approvalFingerprint: fingerprint, + approvalAutoEligible: autoEligible ) - processApprovalQueueIfPossible() } private func setPendingRequest(kind: HermesPendingRequestKind, payload: [String: JSONValue]) { guard let requestID = payload["request_id"]?.stringValue, !requestID.isEmpty else { return } - if pendingRequest?.id != requestID || pendingRequest?.kind != kind { - pendingResponseLease = nil - } let prompt: String switch kind { case .approval: @@ -1047,28 +1033,31 @@ public final class HermesAgentStore: ObservableObject { case .secret: prompt = payload["prompt"]?.stringValue ?? "Hermes requires a secret." } - pendingRequest = HermesPendingRequest( - id: requestID, - kind: kind, - prompt: prompt, - choices: Self.choices(from: payload) - ) - toolTraces.append( - HermesToolTrace( - name: Self.pendingToolName(for: kind), - status: .waiting, - detail: prompt - ) + guard let profile = selectedProfile, + let operation = currentOperation(for: profile), + let sessionID = activeSessionID, + isCurrent(operation, sessionID: sessionID) + else { return } + enqueuePendingRequest( + HermesPendingRequest(id: requestID, kind: kind, prompt: prompt, choices: Self.choices(from: payload)), + sessionID: sessionID, + operation: operation, + approvalFingerprint: nil, + approvalAutoEligible: false ) } private func expirePendingRequest(kind: HermesPendingRequestKind, payload: [String: JSONValue]) { guard let requestID = payload["request_id"]?.stringValue, - pendingRequest?.id == requestID, - pendingRequest?.kind == kind + let index = pendingRequestInbox.firstIndex(where: { + $0.request.id == requestID && $0.request.kind == kind + }) else { return } - pendingRequest = nil - pendingResponseLease = nil + let expired = pendingRequestInbox.remove(at: index) + if pendingRequest?.id == expired.request.id, pendingRequest?.kind == expired.request.kind { + pendingRequest = nil + } + processNextPendingRequest() } private func releasePendingResponseLease(_ lease: PendingRequestLease) { @@ -1080,9 +1069,7 @@ public final class HermesAgentStore: ObservableObject { activeLease.operation.clientIdentity == lease.operation.clientIdentity else { return } pendingResponseLease = nil - if lease.kind == .approval { - processApprovalQueueIfPossible() - } + processNextPendingRequest() } private func reservePendingResponseLease(_ lease: PendingRequestLease) -> Bool { @@ -1091,15 +1078,45 @@ public final class HermesAgentStore: ObservableObject { return true } - private func processApprovalQueueIfPossible() { - guard !approvalPipelineBlocked, - pendingResponseLease == nil, - let approval = approvalQueue.first, - isCurrent(approval.operation, sessionID: approval.sessionID) + private func enqueuePendingRequest( + _ request: HermesPendingRequest, + sessionID: String, + operation: GatewayOperation, + approvalFingerprint: String?, + approvalAutoEligible: Bool + ) { + guard pendingRequestInbox.count < Self.pendingRequestInboxLimit else { + blockApprovalPipelineForOverflow() + return + } + pendingRequestInbox.append( + PendingRequestInboxEntry( + request: request, + sessionID: sessionID, + operation: operation, + approvalFingerprint: approvalFingerprint, + approvalAutoEligible: approvalAutoEligible + ) + ) + processNextPendingRequest() + } + + private func processNextPendingRequest() { + guard pendingResponseLease == nil, + let entry = pendingRequestInbox.first, + isCurrent(entry.operation, sessionID: entry.sessionID) else { return } - if approval.autoEligible, pendingRequest == nil, hermesAutoApprove, refreshActiveOwnership() { + guard entry.request.kind == .approval else { + projectInboxHead(entry) + return + } + guard !approvalPipelineBlocked else { + projectInboxHead(entry) + return + } + if entry.approvalAutoEligible, pendingRequest == nil, hermesAutoApprove, refreshActiveOwnership() { let lease = PendingRequestLease( - id: approval.id, kind: .approval, sessionID: approval.sessionID, operation: approval.operation + id: entry.request.id, kind: .approval, sessionID: entry.sessionID, operation: entry.operation ) guard reservePendingResponseLease(lease) else { return } Task { [weak self] in @@ -1115,7 +1132,7 @@ public final class HermesAgentStore: ObservableObject { params: ["session_id": .string(lease.sessionID), "choice": .string("once")] ) guard self.isCurrent(lease.operation, sessionID: lease.sessionID) else { return } - self.completeApprovalHead(lease) + self.completeInboxHead(lease) } catch { guard self.isCurrent(lease.operation, sessionID: lease.sessionID) else { return } self.blockApprovalPipeline(afterAmbiguousResponseFor: lease) @@ -1123,35 +1140,42 @@ public final class HermesAgentStore: ObservableObject { } return } - guard pendingRequest == nil else { return } - presentManualApproval(approval) + projectInboxHead(entry) } - private func presentManualApproval(_ approval: QueuedAutoApproval) { - _ = refreshActiveOwnership() - pendingRequest = HermesPendingRequest( - id: approval.id, kind: .approval, prompt: approval.prompt, choices: approval.choices + private func projectInboxHead(_ entry: PendingRequestInboxEntry) { + guard pendingRequest == nil else { return } + if entry.request.kind == .approval { _ = refreshActiveOwnership() } + pendingRequest = entry.request + toolTraces.append( + HermesToolTrace( + name: Self.pendingToolName(for: entry.request.kind), + status: entry.request.kind == .approval ? .approval : .waiting, + detail: entry.request.prompt + ) ) - toolTraces.append(HermesToolTrace(name: "Approval", status: .approval, detail: approval.prompt)) } - private func completeApprovalHead(_ lease: PendingRequestLease) { - guard let head = approvalQueue.first, - head.id == lease.id, + private func completeInboxHead(_ lease: PendingRequestLease) { + guard let head = pendingRequestInbox.first, + head.request.id == lease.id, + head.request.kind == lease.kind, head.sessionID == lease.sessionID, head.operation.generation == lease.operation.generation, head.operation.clientIdentity == lease.operation.clientIdentity else { return } - approvalQueue.removeFirst() - retireAutoApprovalFingerprint(head.fingerprint, for: lease.operation, sessionID: lease.sessionID) - if pendingRequest?.id == lease.id, pendingRequest?.kind == .approval { pendingRequest = nil } - processApprovalQueueIfPossible() + pendingRequestInbox.removeFirst() + if let fingerprint = head.approvalFingerprint { + retireAutoApprovalFingerprint(fingerprint, for: lease.operation, sessionID: lease.sessionID) + } + if pendingRequest?.id == lease.id, pendingRequest?.kind == lease.kind { pendingRequest = nil } + processNextPendingRequest() } private func blockApprovalPipeline(afterAmbiguousResponseFor lease: PendingRequestLease) { - guard approvalQueue.first?.id == lease.id else { return } + guard inboxHeadMatches(id: lease.id, kind: .approval, operation: lease.operation, sessionID: lease.sessionID) else { return } pendingRequest = nil - if let head = approvalQueue.first { presentManualApproval(head) } + if let head = pendingRequestInbox.first { projectInboxHead(head) } approvalPipelineBlocked = true pendingResponseLease = nil messages.append(HermesTranscriptMessage( @@ -1171,6 +1195,20 @@ public final class HermesAgentStore: ObservableObject { forceApprovalTransportReset() } + private func inboxHeadMatches( + id: String, + kind: HermesPendingRequestKind, + operation: GatewayOperation, + sessionID: String + ) -> Bool { + guard let head = pendingRequestInbox.first else { return false } + return head.request.id == id + && head.request.kind == kind + && head.sessionID == sessionID + && head.operation.generation == operation.generation + && head.operation.clientIdentity == operation.clientIdentity + } + private func forceApprovalTransportReset() { gatewayGeneration += 1 gatewayReady = false @@ -1222,7 +1260,7 @@ public final class HermesAgentStore: ObservableObject { } private func clearAutoApprovalLifecycle() { - approvalQueue = [] + pendingRequestInbox = [] retiredAutoApprovalFingerprints = [] approvalPipelineBlocked = false } diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 175aed7c1..d25758f68 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -286,6 +286,86 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.map { $0.params["choice"] }, [.string("deny"), .string("once")]) } + @MainActor + func testUnifiedInboxKeepsApprovalAheadOfLaterClarification() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + client.emit(.init(type: "clarify.request", sessionID: "live-1", payload: ["request_id": .string("B"), "question": .string("B?")])) + XCTAssertEqual(store.pendingRequest?.kind, .approval) + XCTAssertEqual(store.pendingRequest?.prompt, "A") + await store.respondToPendingRequest(value: "deny") + XCTAssertEqual(store.pendingRequest?.id, "B") + await store.respondToPendingRequest(value: "answer") + XCTAssertEqual(client.calls.suffix(2).map(\.method), ["approval.respond", "clarify.respond"]) + } + + @MainActor + func testUnifiedInboxDefersAutoApprovalUntilEarlierClarificationResolves() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "clarify.request", sessionID: "live-1", payload: ["request_id": .string("B"), "question": .string("B?")])) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + XCTAssertEqual(store.pendingRequest?.id, "B") + XCTAssertFalse(client.calls.contains { $0.method == "approval.respond" }) + await store.respondToPendingRequest(value: "answer") + await waitUntil { client.calls.contains { $0.method == "approval.respond" } } + XCTAssertEqual(client.calls.last?.params["choice"], .string("once")) + } + + @MainActor + func testUnifiedInboxAutoApprovalThenClarificationDoesNotOverwriteHead() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + await waitUntil { client.calls.contains { $0.method == "approval.respond" } } + client.emit(.init(type: "clarify.request", sessionID: "live-1", payload: ["request_id": .string("B"), "question": .string("B?")])) + XCTAssertNil(store.pendingRequest) + client.finishCall(method: "approval.respond") + client.suspendedMethods = [] + await waitUntil { store.pendingRequest?.id == "B" } + } + + @MainActor + func testUnifiedInboxPreservesMixedArrivalOrderAndQueuedExpiry() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + client.emit(.init(type: "clarify.request", sessionID: "live-1", payload: ["request_id": .string("B"), "question": .string("B?")])) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("C")])) + client.emit(.init(type: "secret.request", sessionID: "live-1", payload: ["request_id": .string("D"), "prompt": .string("D?")])) + XCTAssertEqual(store.pendingRequest?.prompt, "A") + await store.respondToPendingRequest(value: "deny") + XCTAssertEqual(store.pendingRequest?.id, "B") + await store.respondToPendingRequest(value: "answer") + XCTAssertEqual(store.pendingRequest?.prompt, "C") + client.emit(.init(type: "secret.expire", sessionID: "live-1", payload: ["request_id": .string("D")])) + await store.respondToPendingRequest(value: "deny") + XCTAssertNil(store.pendingRequest) + XCTAssertEqual(client.calls.suffix(3).map(\.method), ["approval.respond", "clarify.respond", "approval.respond"]) + } + @MainActor func testIdenticalManualApprovalRequestsRemainDistinctFIFOEntries() async throws { configuration.hermesAutoApprove = false @@ -347,7 +427,7 @@ final class HermesAgentStoreTests: XCTestCase { _ = try await store.startNewAgent(profile: bernd, configuration: configuration) client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 1 } - client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("B")])) + client.emit(.init(type: "clarify.request", sessionID: "live-1", payload: ["request_id": .string("B"), "question": .string("B?")])) client.failCall(method: "approval.respond") await waitUntil { store.approvalPipelineBlocked } XCTAssertEqual(store.pendingRequest?.prompt, "A") @@ -367,7 +447,13 @@ final class HermesAgentStoreTests: XCTestCase { let store = makeStore(runtime: runtime, clients: [client]) _ = try await store.startNewAgent(profile: bernd, configuration: configuration) for index in 0...64 { - client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A\(index)")])) + if index.isMultiple(of: 2) { + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A\(index)")])) + } else { + client.emit(.init(type: "clarify.request", sessionID: "live-1", payload: [ + "request_id": .string("clarify-\(index)"), "question": .string("Q\(index)?"), + ])) + } } client.emit(.init(type: "approval.expire", sessionID: "live-1", payload: [:])) XCTAssertTrue(store.approvalPipelineBlocked) From ec1aef7b06ca83d2c69f6c51737eab59077acaec Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:21:34 +0200 Subject: [PATCH 24/40] docs: plan Hermes approval recovery repair --- ...mbedded-hermes-task6b-structural-repair.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-01-embedded-hermes-task6b-structural-repair.md diff --git a/docs/superpowers/plans/2026-08-01-embedded-hermes-task6b-structural-repair.md b/docs/superpowers/plans/2026-08-01-embedded-hermes-task6b-structural-repair.md new file mode 100644 index 000000000..50bcbe7f7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-embedded-hermes-task6b-structural-repair.md @@ -0,0 +1,74 @@ +# Embedded Hermes Task 6B: Structural Approval Recovery Repair + +**Goal:** Preserve a visible, fail-closed Hermes approval FIFO across transport-loss and overflow ordering, while keeping ordinary disconnect cleanup unchanged. + +**Authorized context:** This addendum follows the Task 6 five-round breaker. The user explicitly authorized the recommended structural replan. It does not broaden the feature beyond the approved embedded Hermes design. + +**Constraints:** + +- Keep Hermes' native session-scoped, head-only approval FIFO contract. +- Never retry an approval whose delivery is ambiguous. +- Tear down only the currently owned embedded client and sidecar, exactly once. +- Preserve the blocked inbox head until a successful fresh sidecar/client handshake. +- Ordinary disconnects without an in-flight approval response continue to clear transient pending state. +- No GUI, profile, registry, root-gateway, Telegram, or native Hermes changes. + +### Task 1: Separate Transport Teardown from Approval Recovery State + +**Files:** + +- Modify: `apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift` +- Modify: `apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift` + +**Step 1: Add failing ordering regressions** + +- Model the real client order: fail pending approval RPC continuations, then invoke `onDisconnect` in the same transport termination turn. +- Assert suspended approval A plus later B remain in the inbox, A is visibly projected, the pipeline is blocked, no retry occurs, and the owned client/sidecar are closed/stopped exactly once. +- Assert the old RPC completion cannot clear or advance the retained head. +- Assert a reconnect that has not reached `gateway.ready` preserves blocked A; only a successful fresh sidecar/client handshake clears it. +- In auto mode, suspend A and overflow the unified 64-entry inbox. Assert A is projected before reset, the pipeline is blocked, no additional RPC is sent, and teardown is exact-once. +- Preserve ordinary disconnect coverage for clarification/sudo/secret with no in-flight approval response. + +**Step 2: Implement transport-only ownership release** + +- Split gateway detachment/close/owned-sidecar stop from pending-request lifecycle disposal. +- Before an owned disconnect releases transport, inspect the current response lease. If it is the matching approval inbox head, atomically retain/project the head, clear only the transient lease, mark the pipeline blocked, add a generic error, and then release transport. +- For ordinary disconnects, explicitly dispose the pending-request lifecycle before transport release. +- Ensure the later approval task catch is harmless and cannot retry, advance, clear, or stop again. + +**Step 3: Unify ambiguous-response and overflow blocking** + +- Factor a helper that marks the pipeline blocked, projects the retained inbox head, clears the matching transient response lease, and emits only a generic message. +- Call it before transport reset for both ambiguous response failures and inbox overflow. +- Keep inbox, fingerprints, and blocked state across failed/pre-ready reconnects. +- Clear them only after a successful fresh sidecar/client handshake or an explicit profile/session/stop lifecycle boundary. + +**Step 4: Verify** + +Run from `apps/MTPLXApp` with: + +```bash +DEVELOPER_DIR=/Volumes/nugly/Applications/Xcode-beta.app/Contents/Developer \ +COPYFILE_DISABLE=1 swift test \ + --scratch-path /tmp/mtplx-embedded-hermes-task6b-swiftpm \ + --filter HermesAgentStoreTests + +DEVELOPER_DIR=/Volumes/nugly/Applications/Xcode-beta.app/Contents/Developer \ +COPYFILE_DISABLE=1 swift test \ + --scratch-path /tmp/mtplx-embedded-hermes-task6b-swiftpm \ + --filter HermesGatewayClientTests + +DEVELOPER_DIR=/Volumes/nugly/Applications/Xcode-beta.app/Contents/Developer \ +COPYFILE_DISABLE=1 swift test \ + --scratch-path /tmp/mtplx-embedded-hermes-task6b-swiftpm --quiet +``` + +Expected: focused and full suites pass, `git diff --check` is clean, and no new warnings originate from the changed files. + +**Step 5: Commit** + +```bash +git add apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift \ + apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +git commit -m "fix(hermes): preserve blocked approval recovery" +``` From 0f417d4d00914b94e69313e048c4c6d30f09158b Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:28:53 +0200 Subject: [PATCH 25/40] fix(hermes): preserve blocked approval recovery --- .../Stores/HermesAgentStore.swift | 102 ++++++++++---- .../HermesAgentStoreTests.swift | 133 ++++++++++++++++++ 2 files changed, 211 insertions(+), 24 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index 5476fcdfc..4ab84a406 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -714,7 +714,7 @@ public final class HermesAgentStore: ObservableObject { if reconnectingBlockedApprovalPipeline { // The failed transport was already torn down; only a completed // fresh sidecar/client handshake may reopen approval handling. - if pendingRequest?.kind == .approval { pendingRequest = nil } + pendingRequest = nil clearAutoApprovalLifecycle() } gatewayReady = true @@ -764,8 +764,49 @@ public final class HermesAgentStore: ObservableObject { client expectedClient: any HermesGatewayClientProtocol, sidecar expectedSidecar: any HermesSidecarControlling ) -> Bool { - guard gatewayGeneration == generation, - let client, + guard ownsGateway(generation: generation, client: expectedClient, sidecar: expectedSidecar) else { return false } + if shouldRetainApprovalRecovery(for: expectedClient) { + retainBlockedApprovalRecovery() + } else { + disposePendingRequestLifecycle() + } + return releaseOwnedTransport(client: expectedClient, sidecar: expectedSidecar) + } + + private func ownsGateway( + generation: Int, + client expectedClient: any HermesGatewayClientProtocol, + sidecar expectedSidecar: any HermesSidecarControlling + ) -> Bool { + gatewayGeneration == generation + && client.map { ObjectIdentifier($0) == ObjectIdentifier(expectedClient) } ?? false + && sidecar.map { ObjectIdentifier($0) == ObjectIdentifier(expectedSidecar) } ?? false + } + + /// A URLSession transport first resumes outstanding RPC continuations and + /// then calls `onDisconnect` on this actor. Preserve the approval head here + /// because the resumed task cannot safely recover after transport release. + private func shouldRetainApprovalRecovery(for expectedClient: any HermesGatewayClientProtocol) -> Bool { + guard let client, ObjectIdentifier(client) == ObjectIdentifier(expectedClient) else { return false } + if approvalPipelineBlocked { return true } + guard let lease = pendingResponseLease, + lease.kind == .approval, + lease.operation.generation == gatewayGeneration, + lease.operation.clientIdentity == ObjectIdentifier(expectedClient) + else { return false } + return inboxHeadMatches( + id: lease.id, + kind: .approval, + operation: lease.operation, + sessionID: lease.sessionID + ) + } + + private func releaseOwnedTransport( + client expectedClient: any HermesGatewayClientProtocol, + sidecar expectedSidecar: any HermesSidecarControlling + ) -> Bool { + guard let client, ObjectIdentifier(client) == ObjectIdentifier(expectedClient), let sidecar, ObjectIdentifier(sidecar) == ObjectIdentifier(expectedSidecar) @@ -776,14 +817,17 @@ public final class HermesAgentStore: ObservableObject { sidecarConfigurationSignature = nil gatewayReady = false endStreaming() - pendingRequest = nil - pendingResponseLease = nil - clearAutoApprovalLifecycle() expectedClient.close() expectedSidecar.stop() return true } + private func disposePendingRequestLifecycle() { + pendingRequest = nil + pendingResponseLease = nil + clearAutoApprovalLifecycle() + } + private func tearDownGateway(clearSession: Bool) { gatewayGeneration += 1 client?.close() @@ -1174,27 +1218,35 @@ public final class HermesAgentStore: ObservableObject { private func blockApprovalPipeline(afterAmbiguousResponseFor lease: PendingRequestLease) { guard inboxHeadMatches(id: lease.id, kind: .approval, operation: lease.operation, sessionID: lease.sessionID) else { return } - pendingRequest = nil - if let head = pendingRequestInbox.first { projectInboxHead(head) } - approvalPipelineBlocked = true - pendingResponseLease = nil - messages.append(HermesTranscriptMessage( - role: .system, - text: "Approval delivery is unknown. Reconnect Hermes before responding again." - )) + retainBlockedApprovalRecovery() forceApprovalTransportReset() } private func blockApprovalPipelineForOverflow() { guard !approvalPipelineBlocked else { return } - approvalPipelineBlocked = true - messages.append(HermesTranscriptMessage( - role: .system, - text: "Too many approval requests are pending. Reconnect Hermes before responding again." - )) + retainBlockedApprovalRecovery() forceApprovalTransportReset() } + /// Atomically make the retained FIFO head actionable before releasing a + /// transport whose approval delivery is ambiguous (or whose inbox overflowed). + private func retainBlockedApprovalRecovery() { + let wasBlocked = approvalPipelineBlocked + approvalPipelineBlocked = true + pendingResponseLease = nil + if let head = pendingRequestInbox.first, + pendingRequest?.id != head.request.id || pendingRequest?.kind != head.request.kind { + pendingRequest = nil + projectInboxHead(head) + } + if !wasBlocked { + messages.append(HermesTranscriptMessage( + role: .system, + text: "Hermes approval recovery is required. Reconnect before responding again." + )) + } + } + private func inboxHeadMatches( id: String, kind: HermesPendingRequestKind, @@ -1211,14 +1263,16 @@ public final class HermesAgentStore: ObservableObject { private func forceApprovalTransportReset() { gatewayGeneration += 1 - gatewayReady = false - client?.close() - client = nil - sidecar?.stop() - sidecar = nil + let closingClient = client + let stoppingSidecar = sidecar + self.client = nil + self.sidecar = nil sidecarProfileName = nil sidecarConfigurationSignature = nil + gatewayReady = false endStreaming() + closingClient?.close() + stoppingSidecar?.stop() } private func retireAutoApprovalFingerprint( diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index d25758f68..3837432bd 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -415,6 +415,128 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertNil(store.pendingRequest) } + @MainActor + func testTransportTerminationRetainsApprovalHeadBeforeOldResponseFailureRuns() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("B")])) + + let response = Task { await store.respondToPendingRequest(value: "once") } + await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 1 } + client.terminatePendingCallsThenDisconnect("transport lost") + + XCTAssertTrue(store.approvalPipelineBlocked) + XCTAssertEqual(store.pendingRequest?.prompt, "A") + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) + XCTAssertTrue(client.didClose) + XCTAssertEqual(runtime.sidecars.first?.stopCount, 1) + XCTAssertEqual(store.messages.filter { $0.text.contains("approval recovery") }.count, 1) + + await response.value + XCTAssertTrue(store.approvalPipelineBlocked) + XCTAssertEqual(store.pendingRequest?.prompt, "A") + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) + XCTAssertEqual(runtime.sidecars.first?.stopCount, 1) + XCTAssertEqual(store.messages.filter { $0.text.contains("approval recovery") }.count, 1) + } + + @MainActor + func testBlockedApprovalSurvivesPreReadyReconnectUntilFreshHandshakeSucceeds() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let first = FakeHermesGatewayClient(readyImmediately: true) + let second = FakeHermesGatewayClient() + let third = FakeHermesGatewayClient(readyImmediately: true) + first.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + first.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + first.suspendedMethods = ["approval.respond"] + second.resultByMethod["session.list"] = .object(["sessions": .array([])]) + third.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [first, second, third]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + first.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + let response = Task { await store.respondToPendingRequest(value: "once") } + await waitUntil { first.calls.contains { $0.method == "approval.respond" } } + first.terminatePendingCallsThenDisconnect("transport lost") + await response.value + + let reconnect = Task { await store.loadSessions(profile: bernd, configuration: configuration) } + await waitUntil { second.hasDisconnectHandler } + XCTAssertTrue(store.approvalPipelineBlocked) + XCTAssertEqual(store.pendingRequest?.prompt, "A") + + second.disconnect("reconnect lost before ready") + await reconnect.value + XCTAssertTrue(store.approvalPipelineBlocked) + XCTAssertEqual(store.pendingRequest?.prompt, "A") + + await store.loadSessions(profile: bernd, configuration: configuration) + XCTAssertFalse(store.approvalPipelineBlocked) + XCTAssertNil(store.pendingRequest) + } + + @MainActor + func testAutoApprovalOverflowProjectsSuspendedHeadBeforeExactOnceTeardown() async throws { + configuration.hermesAutoApprove = true + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + await waitUntil { client.calls.filter { $0.method == "approval.respond" }.count == 1 } + + for index in 1...64 { + switch index % 3 { + case 0: + client.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("approval-\(index)")])) + case 1: + client.emit(.init(type: "clarify.request", sessionID: "live-1", payload: [ + "request_id": .string("clarify-\(index)"), "question": .string("Q\(index)?"), + ])) + default: + client.emit(.init(type: "secret.request", sessionID: "live-1", payload: [ + "request_id": .string("secret-\(index)"), "prompt": .string("S\(index)?"), + ])) + } + } + + XCTAssertTrue(store.approvalPipelineBlocked) + XCTAssertEqual(store.pendingRequest?.prompt, "A") + XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) + XCTAssertTrue(client.didClose) + XCTAssertEqual(runtime.sidecars.first?.stopCount, 1) + } + + @MainActor + func testOrdinaryDisconnectStillClearsNonApprovalPendingRequests() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.emit(.init(type: "clarify.request", sessionID: "live-1", payload: ["request_id": .string("clarify"), "question": .string("Q?")])) + client.emit(.init(type: "sudo.request", sessionID: "live-1", payload: ["request_id": .string("sudo"), "prompt": .string("S?")])) + client.emit(.init(type: "secret.request", sessionID: "live-1", payload: ["request_id": .string("secret"), "prompt": .string("Secret?")])) + + client.disconnect("transport lost") + + XCTAssertNil(store.pendingRequest) + XCTAssertFalse(store.approvalPipelineBlocked) + XCTAssertEqual(runtime.sidecars.first?.stopCount, 1) + } + @MainActor func testAutoApprovalFailureKeepsUnknownHeadAndRetainsLaterFIFOEntries() async throws { configuration.hermesAutoApprove = true @@ -1474,4 +1596,15 @@ private final class FakeHermesGatewayClient: HermesGatewayClientProtocol { let waiters = callWaiters.removeValue(forKey: method) ?? [] waiters.forEach { $0.resume(throwing: HermesGatewayClientError.disconnected) } } + + /// Matches URLSessionHermesGatewayClient's termination order: fail RPCs, + /// then synchronously notify its owner during the same termination turn. + func terminatePendingCallsThenDisconnect(_ message: String) { + let waiters = callWaiters + callWaiters.removeAll() + for methodWaiters in waiters.values { + methodWaiters.forEach { $0.resume(throwing: HermesGatewayClientError.disconnected) } + } + onDisconnect?(message) + } } From 2d052097549d6df60d6ae2db8e9c3af5b304f56c Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:37:34 +0200 Subject: [PATCH 26/40] fix(hermes): scope approval recovery to session --- .../Stores/HermesAgentStore.swift | 75 ++++++++++++++--- .../HermesAgentStoreTests.swift | 83 +++++++++++++++++-- 2 files changed, 139 insertions(+), 19 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index 4ab84a406..b13c7cb4d 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -151,6 +151,7 @@ public final class HermesAgentStore: ObservableObject { private var pendingResponseLease: PendingRequestLease? private var pendingRequestInbox: [PendingRequestInboxEntry] = [] private var retiredAutoApprovalFingerprints: [RetiredAutoApprovalFingerprint] = [] + private var blockedApprovalRecovery: BlockedApprovalRecovery? private let monotonicClock: @Sendable () -> TimeInterval private static let pendingRequestInboxLimit = 64 private static let retiredApprovalFingerprintLimit = 64 @@ -183,6 +184,14 @@ public final class HermesAgentStore: ObservableObject { let retiredAt: TimeInterval } + private struct BlockedApprovalRecovery { + let profileID: String + let sessionID: String + } + + /// Test-only safe observability for FIFO retention; request content remains private. + var pendingRequestInboxCount: Int { pendingRequestInbox.count } + public init( integration: HermesIntegration, embeddedRuntime: any HermesEmbeddedRuntime, @@ -287,10 +296,13 @@ public final class HermesAgentStore: ObservableObject { profile: HermesProfile, configuration: MTPLXAppConfiguration ) async { - selectedProfile = profile var operation: GatewayOperation? do { - operation = try await ensureGateway(profile: profile, configuration: configuration) + operation = try await ensureGateway( + profile: profile, + configuration: configuration, + preserveBlockedApprovalRecovery: preservesBlockedApprovalRecovery(for: profile) + ) guard let operation, isCurrent(operation) else { return } let result = try await rpc(operation, method: "session.list", params: ["limit": .number(200)]) guard isCurrent(operation) else { return } @@ -308,8 +320,11 @@ public final class HermesAgentStore: ObservableObject { profile: HermesProfile, configuration: MTPLXAppConfiguration ) async throws -> HermesSessionReference { - selectedProfile = profile - let operation = try await ensureGateway(profile: profile, configuration: configuration) + let operation = try await ensureGateway( + profile: profile, + configuration: configuration, + preserveBlockedApprovalRecovery: false + ) guard isCurrent(operation) else { throw HermesGatewayClientError.disconnected } @@ -347,8 +362,11 @@ public final class HermesAgentStore: ObservableObject { profile: HermesProfile, configuration: MTPLXAppConfiguration ) async throws -> HermesSessionReference { - selectedProfile = profile - let operation = try await ensureGateway(profile: profile, configuration: configuration) + let operation = try await ensureGateway( + profile: profile, + configuration: configuration, + preserveBlockedApprovalRecovery: preservesBlockedApprovalRecovery(for: profile, savedSessionID: session.id) + ) guard isCurrent(operation) else { throw HermesGatewayClientError.disconnected } @@ -537,8 +555,8 @@ public final class HermesAgentStore: ObservableObject { (profile.id, embeddedRuntime.routingState(for: profile, configuration: configuration)) } ) - selectedProfile = profiles.first(where: { $0.name == profile.name }) ?? profile - await loadSessions(profile: selectedProfile ?? profile, configuration: configuration) + let selected = profiles.first(where: { $0.name == profile.name }) ?? profile + await loadSessions(profile: selected, configuration: configuration) } catch { connectionState = .failed(Self.message(for: error)) } @@ -568,7 +586,12 @@ public final class HermesAgentStore: ObservableObject { guard let profile = selectedProfile else { throw HermesGatewayClientError.disconnected } let savedSessionID = activeSessionKey ?? activeSessionID ?? configuration.lastHermesSessionID let title = activeSessionTitle ?? configuration.lastHermesSessionTitle - let operation = try await ensureGateway(profile: profile, configuration: configuration, preserveSession: true) + let operation = try await ensureGateway( + profile: profile, + configuration: configuration, + preserveSession: true, + preserveBlockedApprovalRecovery: preservesBlockedApprovalRecovery(for: profile) + ) guard isCurrent(operation) else { throw CancellationError() } guard let savedSessionID else { return } let result = try await rpc( @@ -595,7 +618,8 @@ public final class HermesAgentStore: ObservableObject { private func ensureGateway( profile: HermesProfile, configuration: MTPLXAppConfiguration, - preserveSession: Bool = false + preserveSession: Bool = false, + preserveBlockedApprovalRecovery: Bool = false ) async throws -> GatewayOperation { hermesAutoApprove = configuration.hermesAutoApprove let signature = Self.configurationSignature(configuration) @@ -609,10 +633,15 @@ public final class HermesAgentStore: ObservableObject { } return operation } + let reconnectingBlockedApprovalPipeline = approvalPipelineBlocked && preserveBlockedApprovalRecovery + if approvalPipelineBlocked && !reconnectingBlockedApprovalPipeline { + // An explicit profile/session boundary must discard old recovery + // before the new profile is published or a sidecar is started. + disposePendingRequestLifecycle() + } let reuseSidecar = sidecarProfileName == profile.name && sidecarConfigurationSignature == signature && sidecar?.isRunning == true - let reconnectingBlockedApprovalPipeline = approvalPipelineBlocked shuttingDown = true gatewayGeneration += 1 let generation = gatewayGeneration @@ -788,7 +817,7 @@ public final class HermesAgentStore: ObservableObject { /// because the resumed task cannot safely recover after transport release. private func shouldRetainApprovalRecovery(for expectedClient: any HermesGatewayClientProtocol) -> Bool { guard let client, ObjectIdentifier(client) == ObjectIdentifier(expectedClient) else { return false } - if approvalPipelineBlocked { return true } + if approvalPipelineBlocked { return blockedApprovalRecovery != nil } guard let lease = pendingResponseLease, lease.kind == .approval, lease.operation.generation == gatewayGeneration, @@ -1234,6 +1263,12 @@ public final class HermesAgentStore: ObservableObject { let wasBlocked = approvalPipelineBlocked approvalPipelineBlocked = true pendingResponseLease = nil + if let head = pendingRequestInbox.first { + blockedApprovalRecovery = BlockedApprovalRecovery( + profileID: head.operation.profileID, + sessionID: head.sessionID + ) + } if let head = pendingRequestInbox.first, pendingRequest?.id != head.request.id || pendingRequest?.kind != head.request.kind { pendingRequest = nil @@ -1317,6 +1352,22 @@ public final class HermesAgentStore: ObservableObject { pendingRequestInbox = [] retiredAutoApprovalFingerprints = [] approvalPipelineBlocked = false + blockedApprovalRecovery = nil + } + + private func preservesBlockedApprovalRecovery( + for profile: HermesProfile, + savedSessionID: String? = nil + ) -> Bool { + guard approvalPipelineBlocked, + let blockedApprovalRecovery, + blockedApprovalRecovery.profileID == profile.id + else { return false } + if let savedSessionID { + return savedSessionID == blockedApprovalRecovery.sessionID + || savedSessionID == activeSessionKey + } + return blockedApprovalRecovery.sessionID == activeSessionID } private func recordGenericEventError() { diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 3837432bd..48909f2b0 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -73,7 +73,7 @@ final class HermesAgentStoreTests: XCTestCase { await store.loadSessions(profile: bernd, configuration: configuration) await store.loadSessions(profile: researcher, configuration: configuration) - XCTAssertTrue(firstClient.didClose) + XCTAssertEqual(firstClient.closeCount, 1) XCTAssertEqual(runtime.sidecars[0].stopCount, 1) XCTAssertEqual(runtime.sidecars[1].stopCount, 0) XCTAssertEqual(store.selectedProfile?.name, "researcher") @@ -408,7 +408,7 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertEqual(store.pendingRequest?.prompt, "A") await store.respondToPendingRequest(value: "deny") XCTAssertEqual(first.calls.filter { $0.method == "approval.respond" }.count, 1) - XCTAssertTrue(first.didClose) + XCTAssertEqual(first.closeCount, 1) XCTAssertEqual(runtime.sidecars.first?.stopCount, 1) _ = try await store.startNewAgent(profile: bernd, configuration: configuration) XCTAssertFalse(store.approvalPipelineBlocked) @@ -434,15 +434,18 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertTrue(store.approvalPipelineBlocked) XCTAssertEqual(store.pendingRequest?.prompt, "A") + XCTAssertEqual(store.pendingRequestInboxCount, 2) XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) - XCTAssertTrue(client.didClose) + XCTAssertEqual(client.closeCount, 1) XCTAssertEqual(runtime.sidecars.first?.stopCount, 1) XCTAssertEqual(store.messages.filter { $0.text.contains("approval recovery") }.count, 1) await response.value XCTAssertTrue(store.approvalPipelineBlocked) XCTAssertEqual(store.pendingRequest?.prompt, "A") + XCTAssertEqual(store.pendingRequestInboxCount, 2) XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) + XCTAssertEqual(client.closeCount, 1) XCTAssertEqual(runtime.sidecars.first?.stopCount, 1) XCTAssertEqual(store.messages.filter { $0.text.contains("approval recovery") }.count, 1) } @@ -512,8 +515,9 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertTrue(store.approvalPipelineBlocked) XCTAssertEqual(store.pendingRequest?.prompt, "A") + XCTAssertEqual(store.pendingRequestInboxCount, 64) XCTAssertEqual(client.calls.filter { $0.method == "approval.respond" }.count, 1) - XCTAssertTrue(client.didClose) + XCTAssertEqual(client.closeCount, 1) XCTAssertEqual(runtime.sidecars.first?.stopCount, 1) } @@ -537,6 +541,71 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertEqual(runtime.sidecars.first?.stopCount, 1) } + @MainActor + func testFailedPreReadyProfileSwitchClearsBlockedApprovalBeforePublishingNewProfile() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let first = FakeHermesGatewayClient(readyImmediately: true) + let second = FakeHermesGatewayClient() + first.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + first.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + first.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [first, second]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + first.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + let response = Task { await store.respondToPendingRequest(value: "once") } + await waitUntil { first.calls.contains { $0.method == "approval.respond" } } + first.terminatePendingCallsThenDisconnect("transport lost") + await response.value + + let switchProfile = Task { await store.loadSessions(profile: researcher, configuration: configuration) } + await waitUntil { second.hasDisconnectHandler } + XCTAssertEqual(store.selectedProfile?.name, "researcher") + XCTAssertFalse(store.approvalPipelineBlocked) + XCTAssertNil(store.pendingRequest) + XCTAssertEqual(store.pendingRequestInboxCount, 0) + + second.disconnect("researcher not ready") + await switchProfile.value + XCTAssertFalse(store.approvalPipelineBlocked) + XCTAssertNil(store.pendingRequest) + } + + @MainActor + func testFailedPreReadyDifferentSessionSwitchClearsBlockedApproval() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let first = FakeHermesGatewayClient(readyImmediately: true) + let second = FakeHermesGatewayClient() + first.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + first.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + first.suspendedMethods = ["approval.respond"] + let store = makeStore(runtime: runtime, clients: [first, second]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + first.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + let response = Task { await store.respondToPendingRequest(value: "once") } + await waitUntil { first.calls.contains { $0.method == "approval.respond" } } + first.terminatePendingCallsThenDisconnect("transport lost") + await response.value + + let saved = HermesSavedSession(id: "saved-2", title: "Other", preview: "", startedAt: 0, messageCount: 0, source: "") + let switchSession = Task { try await store.resume(saved, profile: bernd, configuration: configuration) } + await waitUntil { second.hasDisconnectHandler } + XCTAssertFalse(store.approvalPipelineBlocked) + XCTAssertNil(store.pendingRequest) + XCTAssertEqual(store.pendingRequestInboxCount, 0) + + second.disconnect("session not ready") + do { + _ = try await switchSession.value + XCTFail("Pre-ready session switch must fail") + } catch { + // Expected: no fresh client reached gateway.ready. + } + XCTAssertFalse(store.approvalPipelineBlocked) + XCTAssertNil(store.pendingRequest) + } + @MainActor func testAutoApprovalFailureKeepsUnknownHeadAndRetainsLaterFIFOEntries() async throws { configuration.hermesAutoApprove = true @@ -1277,7 +1346,7 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertEqual(store.activeSessionID, "new-live") XCTAssertEqual(store.activeReference?.sessionID, "saved") - XCTAssertTrue(slowReconnect.didClose) + XCTAssertEqual(slowReconnect.closeCount, 1) XCTAssertEqual(runtime.startCount, 2) } @@ -1527,7 +1596,7 @@ private final class FakeHermesGatewayClient: HermesGatewayClientProtocol { var onDisconnect: ((String) -> Void)? var resultByMethod: [String: JSONValue] = [:] private(set) var calls: [Call] = [] - private(set) var didClose = false + private(set) var closeCount = 0 private var ready = false private var readinessWaiters: [CheckedContinuation] = [] var suspendedMethods: Set = [] @@ -1556,7 +1625,7 @@ private final class FakeHermesGatewayClient: HermesGatewayClientProtocol { } func close() { - didClose = true + closeCount += 1 readinessWaiters.forEach { $0.resume(throwing: HermesGatewayClientError.disconnected) } readinessWaiters.removeAll() } From 039ca243eebd9ac663a8922fa60da6aa71b48ee7 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:45:16 +0200 Subject: [PATCH 27/40] fix(hermes): retain recovery across failed reconnects --- .../Stores/HermesAgentStore.swift | 17 ++-- .../HermesAgentStoreTests.swift | 78 +++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index b13c7cb4d..018d058b7 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -186,7 +186,8 @@ public final class HermesAgentStore: ObservableObject { private struct BlockedApprovalRecovery { let profileID: String - let sessionID: String + let nativeSessionID: String + let savedSessionID: String? } /// Test-only safe observability for FIFO retention; request content remains private. @@ -1263,10 +1264,11 @@ public final class HermesAgentStore: ObservableObject { let wasBlocked = approvalPipelineBlocked approvalPipelineBlocked = true pendingResponseLease = nil - if let head = pendingRequestInbox.first { + if blockedApprovalRecovery == nil, let head = pendingRequestInbox.first { blockedApprovalRecovery = BlockedApprovalRecovery( profileID: head.operation.profileID, - sessionID: head.sessionID + nativeSessionID: head.sessionID, + savedSessionID: activeSessionKey ) } if let head = pendingRequestInbox.first, @@ -1364,10 +1366,13 @@ public final class HermesAgentStore: ObservableObject { blockedApprovalRecovery.profileID == profile.id else { return false } if let savedSessionID { - return savedSessionID == blockedApprovalRecovery.sessionID - || savedSessionID == activeSessionKey + return savedSessionID == blockedApprovalRecovery.nativeSessionID + || savedSessionID == blockedApprovalRecovery.savedSessionID } - return blockedApprovalRecovery.sessionID == activeSessionID + // `loadSessions` and `reconnect` express same-profile recovery without + // an explicit session transition. Do not depend on transient active + // session fields, which failed attempts intentionally clear. + return true } private func recordGenericEventError() { diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 48909f2b0..33430aaa3 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -606,6 +606,75 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertNil(store.pendingRequest) } + @MainActor + func testConsecutiveFailedPreReadySameProfileRecoveriesRetainBlockedFIFOUntilSuccess() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let first = FakeHermesGatewayClient(readyImmediately: true) + let second = FakeHermesGatewayClient() + let third = FakeHermesGatewayClient() + let fourth = FakeHermesGatewayClient(readyImmediately: true) + first.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + first.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + first.suspendedMethods = ["approval.respond"] + fourth.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [first, second, third, fourth]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + first.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + first.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("B")])) + let response = Task { await store.respondToPendingRequest(value: "once") } + await waitUntil { first.calls.contains { $0.method == "approval.respond" } } + first.terminatePendingCallsThenDisconnect("transport lost") + await response.value + + for failedClient in [second, third] { + let reconnect = Task { await store.loadSessions(profile: bernd, configuration: configuration) } + await waitUntil { failedClient.hasDisconnectHandler } + failedClient.disconnect("same profile not ready") + await reconnect.value + XCTAssertTrue(store.approvalPipelineBlocked) + XCTAssertEqual(store.pendingRequest?.prompt, "A") + XCTAssertEqual(store.pendingRequestInboxCount, 2) + } + + await store.loadSessions(profile: bernd, configuration: configuration) + XCTAssertFalse(store.approvalPipelineBlocked) + XCTAssertNil(store.pendingRequest) + XCTAssertEqual(store.pendingRequestInboxCount, 0) + } + + @MainActor + func testConsecutiveSameProfileSidecarStartFailuresRetainBlockedFIFOUntilSuccess() async throws { + configuration.hermesAutoApprove = false + let runtime = FakeHermesEmbeddedRuntime() + let first = FakeHermesGatewayClient(readyImmediately: true) + let second = FakeHermesGatewayClient(readyImmediately: true) + first.resultByMethod["session.create"] = .object(["session_id": .string("live-1")]) + first.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + first.suspendedMethods = ["approval.respond"] + second.resultByMethod["session.list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [first, second]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + first.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("A")])) + first.emit(.init(type: "approval.request", sessionID: "live-1", payload: ["command": .string("B")])) + let response = Task { await store.respondToPendingRequest(value: "once") } + await waitUntil { first.calls.contains { $0.method == "approval.respond" } } + first.terminatePendingCallsThenDisconnect("transport lost") + await response.value + + runtime.startFailuresRemaining = 2 + await store.loadSessions(profile: bernd, configuration: configuration) + await store.loadSessions(profile: bernd, configuration: configuration) + XCTAssertTrue(store.approvalPipelineBlocked) + XCTAssertEqual(store.pendingRequest?.prompt, "A") + XCTAssertEqual(store.pendingRequestInboxCount, 2) + + await store.loadSessions(profile: bernd, configuration: configuration) + XCTAssertFalse(store.approvalPipelineBlocked) + XCTAssertNil(store.pendingRequest) + XCTAssertEqual(store.pendingRequestInboxCount, 0) + } + @MainActor func testAutoApprovalFailureKeepsUnknownHeadAndRetainsLaterFIFOEntries() async throws { configuration.hermesAutoApprove = true @@ -1530,6 +1599,7 @@ private final class FakeHermesEmbeddedRuntime: HermesEmbeddedRuntime, @unchecked var routingByProfile: [String: HermesProfileRoutingState] = [:] var ownershipBySession: [String: HermesSessionOwnership] = [:] var startCount = 0 + var startFailuresRemaining = 0 var reapCount = 0 private(set) var sidecars: [FakeHermesSidecar] = [] @@ -1541,6 +1611,10 @@ private final class FakeHermesEmbeddedRuntime: HermesEmbeddedRuntime, @unchecked profile: HermesProfile, configuration: MTPLXAppConfiguration ) async throws -> any HermesSidecarControlling { + if startFailuresRemaining > 0 { + startFailuresRemaining -= 1 + throw FakeHermesRuntimeError.startFailed + } startCount += 1 let sidecar = FakeHermesSidecar(index: startCount) sidecars.append(sidecar) @@ -1566,6 +1640,10 @@ private final class FakeHermesEmbeddedRuntime: HermesEmbeddedRuntime, @unchecked } } +private enum FakeHermesRuntimeError: Error { + case startFailed +} + private final class FakeHermesSidecar: HermesSidecarControlling, @unchecked Sendable { let processIdentifier: Int32 var isRunning = true From fd27cc4a3fb353e343c2cbcdd56e45c0b9ac6cb4 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:56:47 +0200 Subject: [PATCH 28/40] feat(app): embed Hermes profile sessions --- .../Views/Hermes/HermesOverlay.swift | 474 +++++++++++++----- 1 file changed, 346 insertions(+), 128 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift b/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift index a16998fe9..aced2576f 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift @@ -4,6 +4,7 @@ import MTPLXAppCore struct HermesOverlay: View { @EnvironmentObject private var backend: MTPLXBackendStore + @EnvironmentObject private var hermes: HermesAgentStore let onCollapse: () -> Void var body: some View { @@ -12,11 +13,16 @@ struct HermesOverlay: View { HermesPanel() .background(Brand.bgOuter) } + // This is the root lifetime of the overlay, rather than a transient + // child view that SwiftUI may recreate while updating the surface. + .onDisappear { + Task { await hermes.stop() } + } } private var closeBar: some View { HStack(spacing: 8) { - ChatCloseButton(action: onCollapse) + ChatCloseButton(action: collapseAndStop) Spacer() approvalToggle } @@ -33,6 +39,13 @@ struct HermesOverlay: View { ) } + private func collapseAndStop() { + Task { + await hermes.stop() + onCollapse() + } + } + /// Auto-approve ("YOLO") toggle. On = Hermes runs its tools without /// asking; off = Hermes pauses for your approval. Persisted, and /// applies the next time Hermes is started. @@ -77,6 +90,7 @@ struct HermesPanel: View { @EnvironmentObject private var router: AppRouter @State private var composerText = "" + @State private var pendingResponseText = "" @State private var createProfileName = "" @State private var creatingProfile = false @State private var localError: String? @@ -118,6 +132,9 @@ struct HermesPanel: View { remember(reference) } } + .onChange(of: hermes.pendingRequest?.id) { _, _ in + pendingResponseText = "" + } } private var sidebar: some View { @@ -137,8 +154,6 @@ struct HermesPanel: View { if case .needsSetup = hermes.connectionState { setupBlock - } else if !HermesIntegration.nativeDashboardSupported { - terminalHandoffBlock } else { profileList sessionList @@ -173,8 +188,8 @@ struct HermesPanel: View { Text("Gateway connected") .font(.caption) .foregroundStyle(Brand.success) - case .failed(let message): - Text(message) + case .failed: + Text("Hermes connection failed") .font(.caption) .foregroundStyle(Brand.warning) .fixedSize(horizontal: false, vertical: true) @@ -355,37 +370,6 @@ struct HermesPanel: View { } } - private var terminalHandoffBlock: some View { - VStack(alignment: .leading, spacing: 8) { - sectionLabel("Agent") - capabilityRow( - "Status", - hermes.terminalAgentRunning - ? "Chatting in your Terminal window" - : "Ready to start" - ) - capabilityRow( - "Where", - "Hermes chats in a Terminal window. This panel shows its tools, messaging, and status." - ) - capabilityRow( - "Messaging", - "To text Hermes from Telegram, set it up once with Hermes, then check the status above.", - color: Brand.typeSecondary - ) - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Brand.cardSurface.opacity(0.72)) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(Brand.separator, lineWidth: 0.75) - ) - ) - .padding(.horizontal, 16) - } - private func capabilityRow( _ label: String, _ value: String, @@ -427,12 +411,20 @@ struct HermesPanel: View { .truncationMode(.middle) } Spacer(minLength: 0) + Text(routeLabel(for: hermes.profileRoutingStates[profile.id] ?? .external)) + .font(.system(size: 9, weight: .heavy, design: .monospaced)) + .foregroundStyle(routeColor(for: profile)) } .padding(.horizontal, 10) .padding(.vertical, 8) .background(profileSelectionBackground(profile)) } .buttonStyle(.plain) + .disabled(profileUnavailable(profile)) + .help(profileUnavailable(profile) + ? "This profile cannot be started safely." + : "Select \(profile.name) profile") + .accessibilityLabel("\(profile.name), \(routeLabel(for: hermes.profileRoutingStates[profile.id] ?? .external))") } } .padding(.horizontal, 12) @@ -454,6 +446,8 @@ struct HermesPanel: View { } .buttonStyle(.plain) .help("New Agent") + .accessibilityLabel("Create a new Hermes agent") + .disabled(hermes.selectedProfile.map(profileUnavailable) ?? true) } if hermes.sessions.isEmpty { Text("No saved agents") @@ -471,6 +465,8 @@ struct HermesPanel: View { sessionRow(session) } .buttonStyle(.plain) + .help("Resume \(session.title.isEmpty ? "Untitled Agent" : session.title)") + .accessibilityLabel("\(session.title.isEmpty ? "Untitled Agent" : session.title), \(activityLabel(session.activity))") } } .padding(.bottom, 4) @@ -536,6 +532,14 @@ struct HermesPanel: View { .foregroundStyle(Brand.typeTertiary) } Spacer() + if let profile = hermes.selectedProfile { + Text(routeLabel(for: hermes.profileRoutingStates[profile.id] ?? .external)) + .font(.system(size: 9, weight: .heavy, design: .monospaced)) + .foregroundStyle(routeColor(for: profile)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Brand.cardSurface)) + } if let value = backend.headlineDecode.value { Text(String(format: "%.1f tok/s", value)) .font(.system(.caption, design: .monospaced).weight(.bold)) @@ -601,79 +605,228 @@ struct HermesPanel: View { @ViewBuilder private var composer: some View { - if !HermesIntegration.nativeDashboardSupported { - HStack(spacing: 9) { - Image(systemName: "terminal") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(Brand.typeSecondary) - Text("Hermes is chatting in your Terminal window — this panel shows its status and setup.") - .font(.system(.callout, design: .rounded)) - .foregroundStyle(Brand.typeSecondary) - Spacer(minLength: 0) - } - .padding(.horizontal, 24) - .padding(.vertical, 14) - .background( - Brand.bgInner - .overlay( - Rectangle() - .fill(Brand.separator) - .frame(height: Brand.hairline), - alignment: .top - ) - ) + if let pendingRequest = hermes.pendingRequest { + pendingRequestCard(pendingRequest) + } else if case .failed = hermes.connectionState { + reconnectBlock + } else if let reason = hermes.readOnlyReason { + readOnlyComposer(reason: reason) } else { - HStack(alignment: .bottom, spacing: 10) { - TextEditor(text: $composerText) - .font(.system(.body, design: .rounded)) - .foregroundStyle(Brand.typeBody) - .scrollContentBackground(.hidden) - .frame(minHeight: 54, maxHeight: 110) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(Brand.bgInner) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Brand.separatorStrong, lineWidth: 1) - ) - ) - .disabled(hermes.activeSessionID == nil || !hermes.gatewayReady) - Button { - Task { await send() } - } label: { - Image(systemName: hermes.isStreaming ? "stop.fill" : "arrow.up") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(.white) - .frame(width: 36, height: 36) - .background(Circle().fill(canSend ? Brand.accentChrome : Brand.typeTertiary.opacity(0.45))) + HStack(alignment: .bottom, spacing: 10) { + TextEditor(text: $composerText) + .font(.system(.body, design: .rounded)) + .foregroundStyle(Brand.typeBody) + .scrollContentBackground(.hidden) + .frame(minHeight: 54, maxHeight: 110) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Brand.bgInner) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Brand.separatorStrong, lineWidth: 1) + ) + ) + .disabled(!hermes.activeSessionWritable || !hermes.gatewayReady || hermes.pendingRequest != nil) + .accessibilityLabel("Message Hermes") + Button { + Task { await send() } + } label: { + Image(systemName: hermes.isStreaming ? "stop.fill" : "arrow.up") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(Circle().fill(canSend ? Brand.accentChrome : Brand.typeTertiary.opacity(0.45))) + } + .buttonStyle(.plain) + .disabled(!canSend && !hermes.isStreaming) + .help(hermes.isStreaming ? "Stop Hermes" : composerDisabledHelp) + .accessibilityLabel(hermes.isStreaming ? "Stop Hermes" : "Send message") } - .buttonStyle(.plain) - .disabled(!canSend && !hermes.isStreaming) - .help(hermes.isStreaming ? "Stop" : "Send") - } - .padding(.horizontal, 24) - .padding(.vertical, 16) - .background( - Brand.bgInner - .overlay( - Rectangle() - .fill(Brand.separator) - .frame(height: Brand.hairline), - alignment: .top - ) - ) + .padding(.horizontal, 24) + .padding(.vertical, 16) + .background(composerBackground) } } private var canSend: Bool { if hermes.isStreaming { return true } - return hermes.activeSessionID != nil + return hermes.activeSessionWritable && hermes.gatewayReady + && hermes.pendingRequest == nil && !composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private var composerBackground: some View { + Brand.bgInner + .overlay( + Rectangle() + .fill(Brand.separator) + .frame(height: Brand.hairline), + alignment: .top + ) + } + + private var composerDisabledHelp: String { + if !hermes.gatewayReady { return "Hermes is not connected yet." } + if hermes.pendingRequest != nil { return "Respond to the pending Hermes request first." } + if hermes.readOnlyReason != nil { return "This session is read-only. Create a new agent to continue." } + if composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "Enter a message to send." } + return "A writable Hermes agent is required." + } + + private func readOnlyComposer(reason: String) -> some View { + HStack(spacing: 12) { + Image(systemName: "lock.fill") + .foregroundStyle(Brand.warning) + Text(reason) + .font(.callout) + .foregroundStyle(Brand.typeSecondary) + Spacer(minLength: 0) + Button("New Agent") { + Task { await startNew() } + } + .buttonStyle(.bordered) + .disabled(hermes.selectedProfile.map(profileUnavailable) ?? true) + .help("Create a new writable Hermes agent") + .accessibilityLabel("Create a new writable Hermes agent") + } + .padding(.horizontal, 24) + .padding(.vertical, 14) + .background(composerBackground) + } + + private var reconnectBlock: some View { + HStack(spacing: 12) { + Image(systemName: "wifi.exclamationmark") + .foregroundStyle(Brand.warning) + Text("Hermes disconnected. Your transcript is still available.") + .font(.callout) + .foregroundStyle(Brand.typeSecondary) + Spacer(minLength: 0) + Button("Reconnect") { + Task { await reconnect() } + } + .buttonStyle(.bordered) + .help("Reconnect to the selected Hermes profile") + .accessibilityLabel("Reconnect to Hermes") + } + .padding(.horizontal, 24) + .padding(.vertical, 14) + .background(composerBackground) + } + + @ViewBuilder + private func pendingRequestCard(_ request: HermesPendingRequest) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: pendingRequestIcon(request.kind)) + .foregroundStyle(Brand.warning) + Text(pendingRequestTitle(request.kind)) + .font(.system(.callout, design: .rounded).weight(.semibold)) + .foregroundStyle(Brand.typeBody) + } + Text(request.prompt) + .font(.callout) + .foregroundStyle(Brand.typeSecondary) + .fixedSize(horizontal: false, vertical: true) + + if hermes.approvalPipelineBlocked { + HStack(spacing: 10) { + Text("Approval recovery is required before another response can be sent.") + .font(.caption) + .foregroundStyle(Brand.warning) + Spacer(minLength: 0) + Button("Reconnect") { Task { await reconnect() } } + .buttonStyle(.bordered) + .help("Reconnect before responding to this approval") + } + } else { + pendingRequestControls(request) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 14) + .background(composerBackground) + .accessibilityElement(children: .contain) + .accessibilityLabel("Pending \(pendingRequestTitle(request.kind)) request") + } + + @ViewBuilder + private func pendingRequestControls(_ request: HermesPendingRequest) -> some View { + switch request.kind { + case .approval: + HStack(spacing: 8) { + ForEach(request.choices.filter { $0.lowercased() != "deny" }, id: \.self) { choice in + Button(choice.capitalized) { + Task { await hermes.respondToPendingRequest(value: choice) } + } + .buttonStyle(.borderedProminent) + .help("Allow this Hermes action: \(choice)") + .accessibilityLabel("Allow action: \(choice)") + } + Button("Deny") { + Task { await hermes.denyPendingApproval() } + } + .buttonStyle(.bordered) + .help("Deny this Hermes action") + .accessibilityLabel("Deny Hermes action") + } + case .clarification: + pendingTextResponse( + label: "Clarification", + placeholder: "Type your answer", + sensitive: false + ) + case .sudo: + pendingTextResponse( + label: "Sudo password", + placeholder: "Enter password", + sensitive: true + ) + case .secret: + pendingTextResponse( + label: "Secret", + placeholder: "Enter secret", + sensitive: true + ) + } + } + + @ViewBuilder + private func pendingTextResponse( + label: String, + placeholder: String, + sensitive: Bool + ) -> some View { + HStack(spacing: 8) { + if sensitive { + SecureField(placeholder, text: $pendingResponseText) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(label) + } else { + TextField(placeholder, text: $pendingResponseText) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(label) + } + Button("Submit") { + submitPendingTextResponse() + } + .buttonStyle(.borderedProminent) + .disabled(pendingResponseText.isEmpty) + .keyboardShortcut(.defaultAction) + .help("Submit \(label.lowercased()) to Hermes") + .accessibilityLabel("Submit \(label.lowercased())") + } + } + + private func submitPendingTextResponse() { + let value = pendingResponseText + pendingResponseText = "" + Task { await hermes.respondToPendingRequest(value: value) } + } + private var emptyTranscript: some View { VStack(spacing: 14) { ZStack { @@ -682,19 +835,19 @@ struct HermesPanel: View { .overlay { Circle().strokeBorder(Brand.accentChrome.opacity(0.30), lineWidth: Brand.hairline) } - Image(systemName: "terminal") + Image(systemName: "bubble.left.and.bubble.right") .font(.system(size: 26, weight: .semibold)) .foregroundStyle(Brand.accentChrome) } .frame(width: 72, height: 72) - Text(hermes.terminalAgentRunning ? "Hermes is in your Terminal" : "Start Hermes") + Text(hermes.selectedProfile == nil ? "Select a Hermes Profile" : "Start a Hermes Agent") .font(.system(.title3, design: .rounded).weight(.semibold)) .foregroundStyle(Brand.typeBody) - Text(hermes.terminalAgentRunning - ? "Your Hermes agent is chatting in a Terminal window. Switch to it to keep going, or open a fresh one." - : "Hermes runs in a Terminal window with file, web, browser, and messaging tools. Open it to start chatting.") + Text(hermes.selectedProfile == nil + ? "Choose a readable profile to load its saved agents." + : "Create a new agent or resume a saved one to chat directly in MTPLX.") .font(.callout) .foregroundStyle(Brand.typeSecondary) .multilineTextAlignment(.center) @@ -702,34 +855,23 @@ struct HermesPanel: View { .frame(maxWidth: 420) Button { - Task { await openTerminal() } + Task { await startNew() } } label: { HStack(spacing: 8) { - Image(systemName: "arrow.up.forward.app") + Image(systemName: "plus") .font(.system(size: 12, weight: .bold)) - Text(hermes.terminalAgentRunning ? "Open a new Terminal" : "Open Hermes in Terminal") + Text("New Agent") } } .buttonStyle(.mtplxPrimary) .padding(.top, 2) + .disabled(hermes.selectedProfile.map(profileUnavailable) ?? true) + .help(hermes.selectedProfile == nil ? "Select a profile first." : "Create a new Hermes agent") } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .padding(40) } - /// Open (or re-open) Hermes in a Terminal window. When the daemon is - /// stopped, starting it with the Hermes target already spawns the - /// Terminal handoff, so we just start; when it's already running we - /// launch a fresh Terminal directly. - private func openTerminal() async { - localError = nil - if backend.daemonState.kind != .running { - guard await ensureDaemonReady() else { return } - return - } - hermes.openTerminal(configuration: backend.configuration) - } - private var toolTimeline: some View { VStack(alignment: .leading, spacing: 8) { ForEach(hermes.toolTraces) { trace in @@ -767,7 +909,6 @@ struct HermesPanel: View { private func prepare() async { await hermes.prepare(configuration: backend.configuration) guard case .needsSetup = hermes.connectionState else { - guard HermesIntegration.nativeDashboardSupported else { return } guard router.hermesLaunchIntent == .resumeLast else { if let profile = hermes.selectedProfile, await ensureDaemonReady() { @@ -784,23 +925,31 @@ struct HermesPanel: View { private func selectProfile(_ profile: HermesProfile) async { localError = nil + guard !profileUnavailable(profile) else { + localError = "This Hermes profile is unavailable." + return + } guard await ensureDaemonReady() else { return } await hermes.loadSessions(profile: profile, configuration: backend.configuration) } private func ensureDaemonReady() async -> Bool { guard backend.daemonState.kind != .running else { return true } - await backend.startDaemon(target: .hermes) - if backend.daemonState.kind == .running { - return true + await backend.startDaemon(target: nil) + guard backend.daemonState.kind == .running else { + localError = "MTPLX is not ready yet." + return false } - localError = "MTPLX is not ready yet." - return false + return true } private func startNew() async { guard let profile = hermes.selectedProfile else { return } localError = nil + guard !profileUnavailable(profile) else { + localError = "This Hermes profile is unavailable." + return + } guard await ensureDaemonReady() else { return } do { let reference = try await hermes.startNewAgent( @@ -809,7 +958,7 @@ struct HermesPanel: View { ) remember(reference) } catch { - localError = error.localizedDescription + localError = "Hermes could not create a new agent. Try again." } } @@ -825,7 +974,17 @@ struct HermesPanel: View { ) remember(reference) } catch { - localError = error.localizedDescription + localError = "Hermes could not resume this agent. Try again." + } + } + + private func reconnect() async { + localError = nil + guard await ensureDaemonReady() else { return } + do { + try await hermes.reconnect(configuration: backend.configuration) + } catch { + localError = "Hermes could not reconnect. Try again." } } @@ -836,7 +995,7 @@ struct HermesPanel: View { let reference = try await hermes.resumeLast(configuration: backend.configuration) remember(reference) } catch { - localError = error.localizedDescription + localError = "Hermes could not resume the last agent. Try again." router.hermesLaunchIntent = .browse } } @@ -880,6 +1039,62 @@ struct HermesPanel: View { ) } + private func profileUnavailable(_ profile: HermesProfile) -> Bool { + if case .unavailable = hermes.profileRoutingStates[profile.id] { return true } + return false + } + + private func routeLabel(for route: HermesProfileRoutingState) -> String { + switch route { + case .mtplx: "MTPLX" + case .external: "External" + case .unavailable: "Unavailable" + } + } + + private func routeColor(for profile: HermesProfile) -> Color { + switch hermes.profileRoutingStates[profile.id] ?? .external { + case .mtplx: Brand.success + case .external: Brand.typeSecondary + case .unavailable: Brand.warning + } + } + + private func activityLabel(_ activity: HermesSessionActivityState) -> String { + switch activity { + case .ready: "Ready" + case .runningInMTPLX: "Running in MTPLX" + case .externallyActive: "Externally active" + case .ownershipUnknown: "Ownership unknown" + } + } + + private func activityColor(_ activity: HermesSessionActivityState) -> Color { + switch activity { + case .ready: Brand.success + case .runningInMTPLX: Brand.accentChrome + case .externallyActive, .ownershipUnknown: Brand.warning + } + } + + private func pendingRequestTitle(_ kind: HermesPendingRequestKind) -> String { + switch kind { + case .approval: "Approval needed" + case .clarification: "Clarification needed" + case .sudo: "Sudo authentication needed" + case .secret: "Secret needed" + } + } + + private func pendingRequestIcon(_ kind: HermesPendingRequestKind) -> String { + switch kind { + case .approval: "checkmark.shield" + case .clarification: "questionmark.bubble" + case .sudo: "lock.shield" + case .secret: "key.fill" + } + } + private func sessionRow(_ session: HermesSavedSession) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { @@ -892,6 +1107,9 @@ struct HermesPanel: View { .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundStyle(Brand.typeTertiary) } + Text(activityLabel(session.activity)) + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(activityColor(session.activity)) if !session.preview.isEmpty { Text(session.preview) .font(.caption) From 5acff5766aff0b862b342f455e333057b0e858d2 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:04:41 +0200 Subject: [PATCH 29/40] fix(hermes): guard embedded request lifecycle --- .../Stores/HermesAgentStore.swift | 22 +++++++ .../Views/Hermes/HermesOverlay.swift | 61 ++++++++++++++++--- .../HermesAgentStoreTests.swift | 46 ++++++++++++++ 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index 018d058b7..41c1da549 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -145,6 +145,9 @@ public final class HermesAgentStore: ObservableObject { private var sidecarConfigurationSignature: String? private var client: (any HermesGatewayClientProtocol)? private var shuttingDown = false + /// Separates installation/profile discovery from gateway generations so a + /// cancelled overlay task cannot republish state after `stop()`. + private var lifecycleGeneration = 0 private var gatewayGeneration = 0 private var didReapOrphanedSidecars = false private var hermesAutoApprove = false @@ -225,27 +228,41 @@ public final class HermesAgentStore: ObservableObject { } public func prepare(configuration: MTPLXAppConfiguration) async { + lifecycleGeneration += 1 + let generation = lifecycleGeneration + // A later explicit prepare is a new overlay lifetime. `stop()` + // invalidates the old generation but must not poison this one. + shuttingDown = false + guard isCurrentPrepare(generation) else { return } connectionState = .checkingInstall + guard isCurrentPrepare(generation) else { return } gatewayRepairMessage = nil + guard isCurrentPrepare(generation) else { return } if !didReapOrphanedSidecars { _ = embeddedRuntime.reapOrphanedEmbeddedSidecars() didReapOrphanedSidecars = true } let status = await integration.installStatus() + guard isCurrentPrepare(generation) else { return } installStatus = status + guard isCurrentPrepare(generation) else { return } terminalAgentRunning = integration.hasLaunchedTerminalAgent() + guard isCurrentPrepare(generation) else { return } profiles = integration.discoverProfiles() + guard isCurrentPrepare(generation) else { return } profileRoutingStates = Dictionary( uniqueKeysWithValues: profiles.map { profile in (profile.id, embeddedRuntime.routingState(for: profile, configuration: configuration)) } ) + guard isCurrentPrepare(generation) else { return } if let remembered = configuration.lastHermesProfile, let profile = profiles.first(where: { $0.name == remembered }) { selectedProfile = profile } else if selectedProfile == nil { selectedProfile = profiles.first } + guard isCurrentPrepare(generation) else { return } switch status.kind { case .ready: connectionState = .idle @@ -565,6 +582,7 @@ public final class HermesAgentStore: ObservableObject { public func stop() async { shuttingDown = true + lifecycleGeneration += 1 tearDownGateway(clearSession: true) activeSessionID = nil activeSessionKey = nil @@ -756,6 +774,10 @@ public final class HermesAgentStore: ObservableObject { ) } + private func isCurrentPrepare(_ generation: Int) -> Bool { + !Task.isCancelled && lifecycleGeneration == generation && !shuttingDown + } + private func rpc( _ operation: GatewayOperation, method: String, diff --git a/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift b/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift index aced2576f..e9323e1e1 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift @@ -91,6 +91,7 @@ struct HermesPanel: View { @State private var composerText = "" @State private var pendingResponseText = "" + @State private var pendingSubmissionID: String? @State private var createProfileName = "" @State private var creatingProfile = false @State private var localError: String? @@ -760,16 +761,18 @@ struct HermesPanel: View { HStack(spacing: 8) { ForEach(request.choices.filter { $0.lowercased() != "deny" }, id: \.self) { choice in Button(choice.capitalized) { - Task { await hermes.respondToPendingRequest(value: choice) } + Task { await submitPendingRequest(request, value: choice) } } .buttonStyle(.borderedProminent) + .disabled(!pendingRequestActionsEnabled(request)) .help("Allow this Hermes action: \(choice)") .accessibilityLabel("Allow action: \(choice)") } Button("Deny") { - Task { await hermes.denyPendingApproval() } + Task { await submitPendingRequest(request, value: "deny", isDenial: true) } } .buttonStyle(.bordered) + .disabled(!pendingRequestActionsEnabled(request)) .help("Deny this Hermes action") .accessibilityLabel("Deny Hermes action") } @@ -777,19 +780,22 @@ struct HermesPanel: View { pendingTextResponse( label: "Clarification", placeholder: "Type your answer", - sensitive: false + sensitive: false, + request: request ) case .sudo: pendingTextResponse( label: "Sudo password", placeholder: "Enter password", - sensitive: true + sensitive: true, + request: request ) case .secret: pendingTextResponse( label: "Secret", placeholder: "Enter secret", - sensitive: true + sensitive: true, + request: request ) } } @@ -798,33 +804,68 @@ struct HermesPanel: View { private func pendingTextResponse( label: String, placeholder: String, - sensitive: Bool + sensitive: Bool, + request: HermesPendingRequest ) -> some View { HStack(spacing: 8) { if sensitive { SecureField(placeholder, text: $pendingResponseText) .textFieldStyle(.roundedBorder) + .disabled(!pendingRequestActionsEnabled(request)) .accessibilityLabel(label) } else { TextField(placeholder, text: $pendingResponseText) .textFieldStyle(.roundedBorder) + .disabled(!pendingRequestActionsEnabled(request)) .accessibilityLabel(label) } Button("Submit") { - submitPendingTextResponse() + submitPendingTextResponse(request) } .buttonStyle(.borderedProminent) - .disabled(pendingResponseText.isEmpty) + .disabled(pendingResponseText.isEmpty || !pendingRequestActionsEnabled(request)) .keyboardShortcut(.defaultAction) .help("Submit \(label.lowercased()) to Hermes") .accessibilityLabel("Submit \(label.lowercased())") } } - private func submitPendingTextResponse() { + private func submitPendingTextResponse(_ request: HermesPendingRequest) { + guard pendingRequestActionsEnabled(request) else { return } let value = pendingResponseText pendingResponseText = "" - Task { await hermes.respondToPendingRequest(value: value) } + Task { await submitPendingRequest(request, value: value) } + } + + private func pendingRequestActionsEnabled(_ request: HermesPendingRequest) -> Bool { + pendingSubmissionID == nil + && hermes.pendingRequest?.id == request.id + && hermes.pendingRequest?.kind == request.kind + && hermes.activeSessionWritable + && hermes.gatewayReady + && !hermes.approvalPipelineBlocked + } + + private func submitPendingRequest( + _ request: HermesPendingRequest, + value: String, + isDenial: Bool = false + ) async { + guard pendingRequestActionsEnabled(request) else { return } + let submissionID = request.id + pendingSubmissionID = submissionID + defer { + // A completion from an old request must never mutate the state of + // a newer card. Its ID is the local submission lifecycle token. + if pendingSubmissionID == submissionID { + pendingSubmissionID = nil + } + } + if isDenial { + await hermes.denyPendingApproval() + } else { + await hermes.respondToPendingRequest(value: value) + } } private var emptyTranscript: some View { diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 33430aaa3..3c112a5c4 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -1516,6 +1516,52 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertEqual(store.profileRoutingStates["researcher"], .unavailable("invalid")) } + @MainActor + func testStoppedOrCancelledPrepareCannotRepublishAfterDelayedInstallStatus() async throws { + let script = root.appendingPathComponent("delayed-hermes") + let source = """ + #!/bin/sh + sleep 0.2 + case \"$1\" in + --version) echo \"Hermes 0.19.1\" ;; + gateway) echo \"running\" ;; + chat) echo \"--query --source\" ;; + esac + """ + try source.write(to: script, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) + let delayedIntegration = HermesIntegration( + hermesHome: root.appendingPathComponent(".hermes", isDirectory: true), + executablePath: script.path, + environment: ["HOME": root.path, "PATH": "/usr/bin:/bin"], + sidecarRuntimeDirectory: root.appendingPathComponent("sidecars", isDirectory: true) + ) + let runtime = FakeHermesEmbeddedRuntime() + let store = HermesAgentStore( + integration: delayedIntegration, + embeddedRuntime: runtime, + clientFactory: { _ in FakeHermesGatewayClient(readyImmediately: true) } + ) + + let cancelledPrepare = Task { await store.prepare(configuration: self.configuration) } + await waitUntil { store.connectionState == .checkingInstall } + cancelledPrepare.cancel() + await store.stop() + await cancelledPrepare.value + + XCTAssertNil(store.installStatus) + XCTAssertTrue(store.profiles.isEmpty) + XCTAssertNil(store.selectedProfile) + XCTAssertEqual(store.connectionState, .idle) + + await store.prepare(configuration: configuration) + + XCTAssertNotNil(store.installStatus) + XCTAssertFalse(store.profiles.isEmpty) + XCTAssertEqual(store.selectedProfile?.name, "default") + XCTAssertEqual(store.connectionState, .idle) + } + @MainActor private func makeStore( runtime: FakeHermesEmbeddedRuntime, From 9b9278b34d53b415060fddbe2290e50d7310a724 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:28:16 +0200 Subject: [PATCH 30/40] fix(hermes): recognize installed console sidecar --- .../Services/HermesEmbeddedRuntime.swift | 78 +++++++- .../Services/HermesIntegration.swift | 10 +- .../HermesEmbeddedRuntimeTests.swift | 186 ++++++++++++++++++ 3 files changed, 264 insertions(+), 10 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift index 248457460..b1f09a46c 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift @@ -128,10 +128,11 @@ enum HermesBackendReadyParser { } } -/// Pure command matching used by orphan recovery. It intentionally does not -/// attempt to infer ownership from a generic Hermes command: a persisted MTPLX -/// record, exact PID, dead recorded parent, canonical executable, and complete -/// argument vector are all required before the caller sends a signal. +/// Command and process-identity matching used by isolated sidecar startup and +/// orphan recovery. It intentionally does not infer ownership from a generic +/// Hermes command: a persisted MTPLX record, exact PID, dead recorded parent, +/// canonical executable, and complete argument vector are all required before +/// the caller sends a signal. enum HermesOrphanSidecarScanner { static func orphanPIDs( records: [HermesSidecarOwnershipRecord], @@ -165,13 +166,56 @@ enum HermesOrphanSidecarScanner { process.argv0 == record.argv0, process.arguments == record.arguments else { return false } - return hasCanonicalArguments( + return hasVerifiedArguments( record.arguments, profileName: record.profileName, launchID: record.launchID ) } + /// The normal binary form has exactly the launch spec. Python console + /// scripts are the sole supported exception: KERN_PROCARGS2 exposes the + /// script as the one argument between the interpreter argv0 and the exact + /// spec. No other prefix, suffix, or flag is accepted. + static func hasVerifiedArguments( + _ arguments: [String], + profileName: String, + launchID: String + ) -> Bool { + if hasCanonicalArguments(arguments, profileName: profileName, launchID: launchID) { + return true + } + guard arguments.count == canonicalArgumentCount(profileName: profileName) + 1, + let entrypoint = arguments.first, + isTrustedConsoleEntrypoint(entrypoint) + else { return false } + return hasCanonicalArguments( + Array(arguments.dropFirst()), + profileName: profileName, + launchID: launchID + ) + } + + /// Startup has stronger evidence than recovery: the console entrypoint + /// must also be contained in the selected Hermes installation. This keeps + /// an arbitrary local `hermes` script from becoming an ownership record. + static func isVerifiedPostLaunchIdentity( + _ process: HermesSidecarProcessSnapshot, + spec: HermesServeLaunchSpec, + profileName: String, + hermesHome: URL + ) -> Bool { + guard !process.executablePath.isEmpty, + !process.argv0.isEmpty, + hasCanonicalArguments(spec.arguments, profileName: profileName, launchID: spec.launchID), + hasVerifiedArguments(process.arguments, profileName: profileName, launchID: spec.launchID) + else { return false } + + guard process.arguments != spec.arguments else { return true } + guard let entrypoint = process.arguments.first else { return false } + return isPath(entrypoint, containedIn: hermesHome) + } + static func hasCanonicalArguments( _ arguments: [String], profileName: String, @@ -183,6 +227,30 @@ enum HermesOrphanSidecarScanner { "--port", "0", "--ssh-owner-nonce", launchID, ] } + + private static func canonicalArgumentCount(profileName: String) -> Int { + (profileName == "default" ? 0 : 2) + 8 + } + + private static func isTrustedConsoleEntrypoint(_ path: String) -> Bool { + guard path.hasPrefix("/") else { return false } + let url = URL(fileURLWithPath: path) + let canonical = url.standardizedFileURL.resolvingSymlinksInPath() + guard canonical.path == path, + canonical.lastPathComponent == "hermes", + FileManager.default.isReadableFile(atPath: canonical.path), + FileManager.default.isExecutableFile(atPath: canonical.path), + let attributes = try? FileManager.default.attributesOfItem(atPath: canonical.path), + attributes[.type] as? FileAttributeType == .typeRegular + else { return false } + return true + } + + private static func isPath(_ path: String, containedIn directory: URL) -> Bool { + let canonicalPath = URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath().path + let canonicalDirectory = directory.standardizedFileURL.resolvingSymlinksInPath().path + return canonicalPath.hasPrefix(canonicalDirectory + "/") + } } /// Redacts secrets while bytes arrive from stderr. It never emits a suffix diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift index 1f389a7c3..c01aa8da2 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift @@ -965,11 +965,11 @@ public struct HermesIntegration: Sendable { let recordURL = sidecarRuntimeDirectory .appendingPathComponent("\(spec.launchID).json", isDirectory: false) guard let identity = Self.processSnapshot(pid: process.processIdentifier), - identity.arguments == spec.arguments, - HermesOrphanSidecarScanner.hasCanonicalArguments( - spec.arguments, + HermesOrphanSidecarScanner.isVerifiedPostLaunchIdentity( + identity, + spec: spec, profileName: profile.name, - launchID: spec.launchID + hermesHome: hermesHome ) else { throw HermesIntegrationError.launchFailed( @@ -984,7 +984,7 @@ public struct HermesIntegration: Sendable { createdAt: Date(), executablePath: identity.executablePath, argv0: identity.argv0, - arguments: spec.arguments + arguments: identity.arguments ) do { try Self.writeOwnershipRecord(record, to: recordURL) diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift index eab1b5824..88a86bac5 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift @@ -423,6 +423,149 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { XCTAssertEqual(Array(record.arguments.suffix(6)), ["--host", "127.0.0.1", "--port", "0", "--ssh-owner-nonce", record.launchID]) } + func testConsoleScriptPrefixStartsAndRecordsFullVerifiedPostLaunchIdentity() async throws { + let fixture = try makeConsoleScriptLauncherFixture() + let consoleIntegration = HermesIntegration( + hermesHome: hermesHome, + executablePath: fixture.launcher.path, + environment: ["HOME": root.path, "PATH": "/usr/bin:/bin"], + sidecarRuntimeDirectory: sidecarRuntimeDirectory + ) + + let sidecar = try await consoleIntegration.startEmbeddedSidecar( + profile: HermesProfile(name: "default", path: hermesHome.path, isDefault: true), + configuration: configuration + ) + defer { sidecar.stop() } + let record = try JSONDecoder().decode( + HermesSidecarOwnershipRecord.self, + from: Data(contentsOf: sidecar.ownershipRecordURL) + ) + + XCTAssertEqual(record.executablePath, fixture.interpreter.standardizedFileURL.resolvingSymlinksInPath().path) + XCTAssertEqual(record.argv0, fixture.interpreter.path) + XCTAssertEqual(record.arguments.first, fixture.consoleScript.standardizedFileURL.resolvingSymlinksInPath().path) + XCTAssertEqual(Array(record.arguments.dropFirst()), [ + "serve", "--isolated", "--host", "127.0.0.1", + "--port", "0", "--ssh-owner-nonce", record.launchID, + ]) + } + + func testConsoleEntrypointPrefixRejectsUnexpectedArgumentsAndUnsafePaths() throws { + let fixture = try makeConsoleScriptLauncherFixture() + let launchID = "1111111111111111" + let canonical = [ + "serve", "--isolated", "--host", "127.0.0.1", + "--port", "0", "--ssh-owner-nonce", launchID, + ] + let valid = [fixture.consoleScript.standardizedFileURL.resolvingSymlinksInPath().path] + canonical + + XCTAssertTrue(HermesOrphanSidecarScanner.hasVerifiedArguments( + valid, + profileName: "default", + launchID: launchID + )) + XCTAssertFalse(HermesOrphanSidecarScanner.hasVerifiedArguments( + [fixture.consoleScript.path, "--unexpected"] + canonical, + profileName: "default", + launchID: launchID + )) + XCTAssertFalse(HermesOrphanSidecarScanner.hasVerifiedArguments( + [fixture.consoleScript.path] + canonical + ["--unexpected"], + profileName: "default", + launchID: launchID + )) + let wrongBasename = fixture.consoleScript.deletingLastPathComponent().appendingPathComponent("not-hermes") + try FileManager.default.copyItem(at: fixture.consoleScript, to: wrongBasename) + XCTAssertFalse(HermesOrphanSidecarScanner.hasVerifiedArguments( + [wrongBasename.path] + canonical, + profileName: "default", + launchID: launchID + )) + XCTAssertFalse(HermesOrphanSidecarScanner.hasVerifiedArguments( + ["relative/hermes"] + canonical, + profileName: "default", + launchID: launchID + )) + + let identity = HermesSidecarProcessSnapshot( + pid: 7101, + executablePath: fixture.interpreter.standardizedFileURL.resolvingSymlinksInPath().path, + argv0: fixture.interpreter.path, + arguments: valid + ) + let spec = HermesServeLaunchSpec( + executableURL: fixture.launcher, + arguments: canonical, + environment: [:], + token: "test-token", + launchID: launchID, + parentPID: 8001 + ) + XCTAssertTrue(HermesOrphanSidecarScanner.isVerifiedPostLaunchIdentity( + identity, + spec: spec, + profileName: "default", + hermesHome: hermesHome + )) + let outsideEntrypoint = root.appendingPathComponent("outside/hermes") + try FileManager.default.createDirectory(at: outsideEntrypoint.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.copyItem(at: fixture.consoleScript, to: outsideEntrypoint) + XCTAssertFalse(HermesOrphanSidecarScanner.isVerifiedPostLaunchIdentity( + HermesSidecarProcessSnapshot( + pid: 7101, + executablePath: identity.executablePath, + argv0: identity.argv0, + arguments: [outsideEntrypoint.path] + canonical + ), + spec: spec, + profileName: "default", + hermesHome: hermesHome + )) + } + + func testConsoleScriptOrphanScannerRequiresExactRecordAndRejectsGenericNearMiss() throws { + let fixture = try makeConsoleScriptLauncherFixture() + let launchID = "1111111111111111" + let arguments = [fixture.consoleScript.standardizedFileURL.resolvingSymlinksInPath().path, + "serve", "--isolated", "--host", "127.0.0.1", + "--port", "0", "--ssh-owner-nonce", launchID, + ] + let record = HermesSidecarOwnershipRecord( + launchID: launchID, + pid: 7101, + parentPID: 8001, + profileName: "default", + createdAt: .now, + executablePath: fixture.interpreter.standardizedFileURL.resolvingSymlinksInPath().path, + argv0: fixture.interpreter.path, + arguments: arguments + ) + let exact = HermesSidecarProcessSnapshot( + pid: 7101, + executablePath: record.executablePath, + argv0: record.argv0, + arguments: record.arguments + ) + let genericNearMiss = HermesSidecarProcessSnapshot( + pid: 7101, + executablePath: record.executablePath, + argv0: record.argv0, + arguments: Array(record.arguments.dropFirst()) + ) + + XCTAssertEqual(HermesOrphanSidecarScanner.orphanPIDs( + records: [record], + processes: [exact], + livePIDs: [] + ), [7101]) + XCTAssertEqual(HermesOrphanSidecarScanner.orphanPIDs( + records: [record], + processes: [genericNearMiss], + livePIDs: [] + ), []) + } + func testStartupFailureRedactsTokenSplitAcrossStderrWrites() async throws { let tokenCaptureURL = root.appendingPathComponent("split-token.txt") let fixture = try makeSplitSecretFailureFixture(tokenCaptureURL: tokenCaptureURL) @@ -613,6 +756,49 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { return (wrapper, binary) } + private func makeConsoleScriptLauncherFixture() throws -> (launcher: URL, interpreter: URL, consoleScript: URL) { + let interpreter = try makeSidecarBinary() + let consoleDirectory = hermesHome + .appendingPathComponent("hermes-agent/venv/bin", isDirectory: true) + try FileManager.default.createDirectory(at: consoleDirectory, withIntermediateDirectories: true) + let consoleScript = consoleDirectory.appendingPathComponent("hermes") + try "#!/bin/sh\n# Hermes console entrypoint fixture\n".write( + to: consoleScript, + atomically: true, + encoding: .utf8 + ) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: consoleScript.path) + + let source = root.appendingPathComponent("console-script-launcher.c") + let launcher = root.appendingPathComponent("console-script-launcher") + let code = """ + #include + #include + + int main(int argc, char **argv) { + char **arguments = calloc((size_t)argc + 2, sizeof(char *)); + if (arguments == NULL) return 2; + arguments[0] = \"\(interpreter.path)\"; + arguments[1] = \"\(consoleScript.path)\"; + for (int index = 1; index < argc; index++) arguments[index + 1] = argv[index]; + execv(arguments[0], arguments); + return 3; + } + """ + try code.write(to: source, atomically: true, encoding: .utf8) + let compiler = Process() + compiler.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + compiler.arguments = ["clang", source.path, "-o", launcher.path] + try compiler.run() + compiler.waitUntilExit() + guard compiler.terminationStatus == 0 else { + throw NSError(domain: "HermesEmbeddedRuntimeTests", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Could not compile console-script launcher fixture.", + ]) + } + return (launcher, interpreter, consoleScript) + } + private func makeSidecarBinary() throws -> URL { let source = root.appendingPathComponent("hermes-sidecar-fixture.c") let binary = root.appendingPathComponent("hermes-sidecar-fixture") From 15fef77840de59ed309ac536216cdd7d36253569 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:42:37 +0200 Subject: [PATCH 31/40] fix(app): keep embedded Hermes launch isolated --- .../Models/AppConfiguration.swift | 9 ++++++++ .../Views/Hermes/HermesOverlay.swift | 5 +---- .../MTPLXAppCoreTests/MTPLXAppCoreTests.swift | 22 +++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Models/AppConfiguration.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Models/AppConfiguration.swift index 64a7f0c19..28da1597a 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Models/AppConfiguration.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Models/AppConfiguration.swift @@ -306,6 +306,15 @@ public struct MTPLXAppConfiguration: Codable, Equatable, Sendable { self.hfEndpoint = hfEndpoint } + /// Records the app-owned embedded Hermes session to offer on the next + /// overlay visit. This deliberately leaves the generic daemon launch + /// target alone: embedded Hermes is not a `mtplx start hermes` handoff. + public mutating func rememberEmbeddedHermesSession(_ reference: HermesSessionReference) { + lastHermesProfile = reference.profileName + lastHermesSessionID = reference.sessionID + lastHermesSessionTitle = reference.title + } + /// Fresh installs must be portable. Installed local copies are discovered /// by the model catalog; the default configuration should never point at /// a developer machine path. diff --git a/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift b/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift index e9323e1e1..fea09c0e6 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift @@ -1053,10 +1053,7 @@ struct HermesPanel: View { private func remember(_ reference: HermesSessionReference) { var config = backend.configuration - config.lastLaunchTarget = LaunchTarget.hermes.rawValue - config.lastHermesProfile = reference.profileName - config.lastHermesSessionID = reference.sessionID - config.lastHermesSessionTitle = reference.title + config.rememberEmbeddedHermesSession(reference) try? backend.saveSettings(config) } diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift index 1aa95400a..02819afa5 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift @@ -3705,6 +3705,28 @@ final class MTPLXAppCoreTests: XCTestCase { XCTAssertEqual(backend.settingsURL, expected) } + func testRememberingEmbeddedHermesSessionPreservesExistingLaunchTarget() { + var configuration = MTPLXAppConfiguration( + lastLaunchTarget: LaunchTarget.openCode.rawValue, + lastHermesProfile: "old-profile", + lastHermesSessionID: "old-session", + lastHermesSessionTitle: "Old agent" + ) + + configuration.rememberEmbeddedHermesSession( + HermesSessionReference( + profileName: "bernd", + sessionID: "session-123", + title: "Fix the app" + ) + ) + + XCTAssertEqual(configuration.lastLaunchTarget, LaunchTarget.openCode.rawValue) + XCTAssertEqual(configuration.lastHermesProfile, "bernd") + XCTAssertEqual(configuration.lastHermesSessionID, "session-123") + XCTAssertEqual(configuration.lastHermesSessionTitle, "Fix the app") + } + func testAppConfigurationPersistsHermesResumeState() throws { let url = temporaryDirectory().appendingPathComponent("settings.json") let store = MTPLXSettingsStore(settingsURL: url) From 63d15f8e1249022d57691373d3de0e6c31a80cfd Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:59:37 +0200 Subject: [PATCH 32/40] fix(hermes): fail closed without ownership registry --- .../MTPLXAppCore/Services/HermesEmbeddedRuntime.swift | 9 --------- .../HermesEmbeddedRuntimeTests.swift | 11 +++++++---- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift index b1f09a46c..daffedc56 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift @@ -419,7 +419,6 @@ public struct HermesActiveSessionRegistryInspector: @unchecked Sendable { do { data = try readData(registryURL) } catch { - if Self.isNotFound(error) { return .ready } return .unknown(Self.inspectionUnavailableReason) } @@ -460,14 +459,6 @@ public struct HermesActiveSessionRegistryInspector: @unchecked Sendable { private static let inspectionUnavailableReason = "Session activity could not be inspected." - private static func isNotFound(_ error: Error) -> Bool { - let nsError = error as NSError - return (nsError.domain == NSCocoaErrorDomain - && (nsError.code == CocoaError.Code.fileNoSuchFile.rawValue - || nsError.code == CocoaError.Code.fileReadNoSuchFile.rawValue)) - || (nsError.domain == NSPOSIXErrorDomain && nsError.code == ENOENT) - } - private static func sanitizedSurface(_ surface: String) -> String { let trimmed = surface.trimmingCharacters(in: .whitespacesAndNewlines) let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: " -_")) diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift index 88a86bac5..ce05732a6 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift @@ -181,7 +181,7 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { ) } - func testActiveSessionRegistryTreatsOnlyVerifiedNotFoundAsReady() throws { + func testActiveSessionRegistryFailsClosedWhenRegistryCannotBeRead() throws { let registry = root.appendingPathComponent("active_sessions.json") let notFound = HermesActiveSessionRegistryInspector( processIdentity: { _ in .unknown }, @@ -192,7 +192,10 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { readData: { _ in throw CocoaError(.fileReadNoPermission) } ) - XCTAssertEqual(notFound.ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), .ready) + XCTAssertEqual( + notFound.ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), + .unknown("Session activity could not be inspected.") + ) XCTAssertEqual( unreadable.ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), .unknown("Session activity could not be inspected.") @@ -206,7 +209,7 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { ) } - func testIntegrationTreatsMissingProfileRegistryAsReady() throws { + func testIntegrationFailsClosedWhenProfileRegistryIsMissing() throws { let profile = try makeProfile(named: "bernd", config: mtplxConfig()) let integration = HermesIntegration( hermesHome: hermesHome, @@ -221,7 +224,7 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { sessionID: "saved", ownedSidecarPID: nil ), - .ready + .unknown("Session activity could not be inspected.") ) } From cd550aa757b0042298f453fa3d42d254207cdb22 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:07:12 +0200 Subject: [PATCH 33/40] fix(hermes): permit fresh session without registry --- .../Stores/HermesAgentStore.swift | 54 +++++++++++- .../HermesAgentStoreTests.swift | 87 +++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index 41c1da549..7057a7869 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -152,6 +152,7 @@ public final class HermesAgentStore: ObservableObject { private var didReapOrphanedSidecars = false private var hermesAutoApprove = false private var pendingResponseLease: PendingRequestLease? + private var freshSessionLease: FreshSessionLease? private var pendingRequestInbox: [PendingRequestInboxEntry] = [] private var retiredAutoApprovalFingerprints: [RetiredAutoApprovalFingerprint] = [] private var blockedApprovalRecovery: BlockedApprovalRecovery? @@ -165,6 +166,18 @@ public final class HermesAgentStore: ObservableObject { let clientIdentity: ObjectIdentifier } + /// A session ID returned by `session.create` through this store's current + /// owned sidecar. Hermes may create `active_sessions.json` lazily, so the + /// first ownership read can be unavailable even though this exact session + /// did not exist before MTPLX created it. This lease is deliberately local + /// to one gateway/session lifecycle; it is never persisted or reused for a + /// resumed session. + private struct FreshSessionLease { + let sessionID: String + let sessionKey: String + let operation: GatewayOperation + } + private struct PendingRequestLease { let id: String let kind: HermesPendingRequestKind @@ -228,6 +241,7 @@ public final class HermesAgentStore: ObservableObject { } public func prepare(configuration: MTPLXAppConfiguration) async { + invalidateFreshSessionLease() lifecycleGeneration += 1 let generation = lifecycleGeneration // A later explicit prepare is a new overlay lifetime. `stop()` @@ -338,6 +352,9 @@ public final class HermesAgentStore: ObservableObject { profile: HermesProfile, configuration: MTPLXAppConfiguration ) async throws -> HermesSessionReference { + // A new agent selection is an explicit session boundary. Do not let a + // prior fresh-session lease survive if creation fails or is cancelled. + invalidateFreshSessionLease() let operation = try await ensureGateway( profile: profile, configuration: configuration, @@ -365,6 +382,11 @@ public final class HermesAgentStore: ObservableObject { pendingRequest = nil clearAutoApprovalLifecycle() activeSessionKey = sessionKey + freshSessionLease = FreshSessionLease( + sessionID: sessionID, + sessionKey: sessionKey, + operation: operation + ) applyActiveOwnership(.ready) connectionState = .connected return HermesSessionReference( @@ -380,6 +402,8 @@ public final class HermesAgentStore: ObservableObject { profile: HermesProfile, configuration: MTPLXAppConfiguration ) async throws -> HermesSessionReference { + // Saved/native sessions never inherit the new-session exception. + invalidateFreshSessionLease() let operation = try await ensureGateway( profile: profile, configuration: configuration, @@ -652,6 +676,9 @@ public final class HermesAgentStore: ObservableObject { } return operation } + // Replacing the sidecar/client ends any local proof that a fresh + // session remains exclusive to this store. + invalidateFreshSessionLease() let reconnectingBlockedApprovalPipeline = approvalPipelineBlocked && preserveBlockedApprovalRecovery if approvalPipelineBlocked && !reconnectingBlockedApprovalPipeline { // An explicit profile/session boundary must discard old recovery @@ -867,6 +894,7 @@ public final class HermesAgentStore: ObservableObject { self.sidecar = nil sidecarProfileName = nil sidecarConfigurationSignature = nil + invalidateFreshSessionLease() gatewayReady = false endStreaming() expectedClient.close() @@ -888,6 +916,7 @@ public final class HermesAgentStore: ObservableObject { sidecar = nil sidecarProfileName = nil sidecarConfigurationSignature = nil + invalidateFreshSessionLease() gatewayReady = false endStreaming() pendingRequest = nil @@ -912,6 +941,7 @@ public final class HermesAgentStore: ObservableObject { else { throw HermesGatewayClientError.malformedResponse } activeSessionID = sessionID activeSessionKey = object["resumed"]?.stringValue ?? savedSessionID + invalidateFreshSessionLease() activeSessionTitle = title endStreaming() pendingRequest = nil @@ -950,16 +980,38 @@ public final class HermesAgentStore: ObservableObject { return false } let primary = ownership(for: sessionID, profile: profile) - let resolved: HermesSessionOwnership + var resolved: HermesSessionOwnership if let liveID = activeSessionID, liveID != sessionID { resolved = mostRestrictive(primary, ownership(for: liveID, profile: profile)) } else { resolved = primary } + if case .unknown = resolved, + hasFreshSessionLease(for: profile) { + // `session.create` allocated this exact session through the + // currently-owned sidecar. A missing lazy registry must not block + // its own prompts, but explicit external activity remains more + // restrictive above and is never overridden here. + resolved = .ownedByMTPLX + } applyActiveOwnership(resolved) return activeSessionWritable } + private func hasFreshSessionLease(for profile: HermesProfile) -> Bool { + guard let lease = freshSessionLease, + activeSessionID == lease.sessionID, + activeSessionKey == lease.sessionKey, + profile.id == lease.operation.profileID, + isCurrent(lease.operation, sessionID: lease.sessionID) + else { return false } + return true + } + + private func invalidateFreshSessionLease() { + freshSessionLease = nil + } + private func ownership(for sessionID: String, profile: HermesProfile) -> HermesSessionOwnership { embeddedRuntime.sessionOwnership( profile: profile, diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 3c112a5c4..9f3bda1b3 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -179,6 +179,93 @@ final class HermesAgentStoreTests: XCTestCase { ) } + @MainActor + func testFreshMTPLXSessionCanSubmitMultiplePromptsWhenOwnershipRegistryIsUnavailable() async throws { + let runtime = FakeHermesEmbeddedRuntime() + runtime.ownershipBySession["fresh-live"] = .unknown("Session activity could not be inspected.") + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("fresh-live")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + await store.send("first prompt") + client.emit(.init(type: "message.complete", sessionID: "fresh-live", payload: ["text": .string("first reply")])) + await store.send("second prompt") + + XCTAssertEqual( + client.calls.filter { $0.method == "prompt.submit" }.map(\.params), + [ + ["session_id": .string("fresh-live"), "text": .string("first prompt")], + ["session_id": .string("fresh-live"), "text": .string("second prompt")], + ] + ) + XCTAssertTrue(store.activeSessionWritable) + XCTAssertNil(store.readOnlyReason) + } + + @MainActor + func testMissingOwnershipRegistryKeepsSavedSessionReadOnlyAfterFreshSessionSwitch() async throws { + let runtime = FakeHermesEmbeddedRuntime() + runtime.ownershipBySession["fresh-live"] = .unknown("Session activity could not be inspected.") + runtime.ownershipBySession["saved-session"] = .unknown("Session activity could not be inspected.") + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("fresh-live")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.resultByMethod["session.resume"] = resumeResult(liveID: "saved-live", savedID: "saved-session") + let store = makeStore(runtime: runtime, clients: [client]) + + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + await store.send("fresh session is allowed") + client.emit(.init(type: "message.complete", sessionID: "fresh-live", payload: ["text": .string("done")])) + + _ = try await store.resume( + HermesSavedSession(id: "saved-session", title: "Saved", preview: "", startedAt: 0, messageCount: 1, source: ""), + profile: bernd, + configuration: configuration + ) + await store.send("saved session must remain blocked") + + XCTAssertEqual(client.calls.filter { $0.method == "prompt.submit" }.count, 1) + XCTAssertFalse(store.activeSessionWritable) + XCTAssertEqual(store.activeSessionActivity, .ownershipUnknown("Session activity could not be inspected.")) + XCTAssertNotNil(store.readOnlyReason) + } + + @MainActor + func testFreshSessionExceptionNeverOverridesExplicitExternalOwnership() async throws { + let runtime = FakeHermesEmbeddedRuntime() + runtime.ownershipBySession["fresh-live"] = .external(surface: "telegram") + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("fresh-live")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + await store.send("must not bypass explicit ownership") + + XCTAssertFalse(store.activeSessionWritable) + XCTAssertEqual(store.activeSessionActivity, .externallyActive(surface: "telegram")) + XCTAssertFalse(client.calls.contains(where: { $0.method == "prompt.submit" })) + } + + @MainActor + func testPrepareInvalidatesFreshSessionRegistryException() async throws { + let runtime = FakeHermesEmbeddedRuntime() + runtime.ownershipBySession["fresh-live"] = .unknown("Session activity could not be inspected.") + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("fresh-live")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + await store.prepare(configuration: configuration) + await store.send("must not survive a new overlay lifecycle") + + XCTAssertFalse(store.activeSessionWritable) + XCTAssertFalse(client.calls.contains(where: { $0.method == "prompt.submit" })) + } + @MainActor func testStreamingReasoningToolsAndCompletionUpdateTranscript() async throws { let runtime = FakeHermesEmbeddedRuntime() From b1b3f1e52e78907544a81ed229b181aa02a68579 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:18:41 +0200 Subject: [PATCH 34/40] fix(hermes): harden fresh session ownership --- .../Services/HermesEmbeddedRuntime.swift | 8 +- .../Stores/HermesAgentStore.swift | 106 +++++++++++---- .../HermesAgentStoreTests.swift | 127 +++++++++++++++++- .../HermesEmbeddedRuntimeTests.swift | 26 +++- 4 files changed, 228 insertions(+), 39 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift index daffedc56..c014db3b6 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesEmbeddedRuntime.swift @@ -362,6 +362,11 @@ public enum HermesSessionOwnership: Equatable, Sendable { case ready case ownedByMTPLX case external(surface: String) + /// The registry itself could not be read. Hermes creates this file lazily, + /// so a just-created local session may use a narrowly scoped store lease. + case registryUnavailable(String) + /// The registry was readable but its contents or process ownership could + /// not be proven. This state must always remain fail-closed. case unknown(String) } @@ -376,6 +381,7 @@ public enum HermesSessionActivityState: Equatable, Sendable { case .ready: self = .ready case .ownedByMTPLX: self = .runningInMTPLX case .external(let surface): self = .externallyActive(surface: surface) + case .registryUnavailable(let reason): self = .ownershipUnknown(reason) case .unknown(let reason): self = .ownershipUnknown(reason) } } @@ -419,7 +425,7 @@ public struct HermesActiveSessionRegistryInspector: @unchecked Sendable { do { data = try readData(registryURL) } catch { - return .unknown(Self.inspectionUnavailableReason) + return .registryUnavailable(Self.inspectionUnavailableReason) } let entries: [Entry] diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift index 7057a7869..595d9d5fb 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Stores/HermesAgentStore.swift @@ -148,6 +148,9 @@ public final class HermesAgentStore: ObservableObject { /// Separates installation/profile discovery from gateway generations so a /// cancelled overlay task cannot republish state after `stop()`. private var lifecycleGeneration = 0 + /// Invalidates session-selection tasks even when they share the same + /// already-connected sidecar/client generation. + private var sessionSelectionGeneration = 0 private var gatewayGeneration = 0 private var didReapOrphanedSidecars = false private var hermesAutoApprove = false @@ -176,6 +179,7 @@ public final class HermesAgentStore: ObservableObject { let sessionID: String let sessionKey: String let operation: GatewayOperation + let selectionGeneration: Int } private struct PendingRequestLease { @@ -241,42 +245,42 @@ public final class HermesAgentStore: ObservableObject { } public func prepare(configuration: MTPLXAppConfiguration) async { - invalidateFreshSessionLease() + let selectionGeneration = beginSessionSelection() lifecycleGeneration += 1 let generation = lifecycleGeneration // A later explicit prepare is a new overlay lifetime. `stop()` // invalidates the old generation but must not poison this one. shuttingDown = false - guard isCurrentPrepare(generation) else { return } + guard isCurrentPrepare(generation, selectionGeneration: selectionGeneration) else { return } connectionState = .checkingInstall - guard isCurrentPrepare(generation) else { return } + guard isCurrentPrepare(generation, selectionGeneration: selectionGeneration) else { return } gatewayRepairMessage = nil - guard isCurrentPrepare(generation) else { return } + guard isCurrentPrepare(generation, selectionGeneration: selectionGeneration) else { return } if !didReapOrphanedSidecars { _ = embeddedRuntime.reapOrphanedEmbeddedSidecars() didReapOrphanedSidecars = true } let status = await integration.installStatus() - guard isCurrentPrepare(generation) else { return } + guard isCurrentPrepare(generation, selectionGeneration: selectionGeneration) else { return } installStatus = status - guard isCurrentPrepare(generation) else { return } + guard isCurrentPrepare(generation, selectionGeneration: selectionGeneration) else { return } terminalAgentRunning = integration.hasLaunchedTerminalAgent() - guard isCurrentPrepare(generation) else { return } + guard isCurrentPrepare(generation, selectionGeneration: selectionGeneration) else { return } profiles = integration.discoverProfiles() - guard isCurrentPrepare(generation) else { return } + guard isCurrentPrepare(generation, selectionGeneration: selectionGeneration) else { return } profileRoutingStates = Dictionary( uniqueKeysWithValues: profiles.map { profile in (profile.id, embeddedRuntime.routingState(for: profile, configuration: configuration)) } ) - guard isCurrentPrepare(generation) else { return } + guard isCurrentPrepare(generation, selectionGeneration: selectionGeneration) else { return } if let remembered = configuration.lastHermesProfile, let profile = profiles.first(where: { $0.name == remembered }) { selectedProfile = profile } else if selectedProfile == nil { selectedProfile = profiles.first } - guard isCurrentPrepare(generation) else { return } + guard isCurrentPrepare(generation, selectionGeneration: selectionGeneration) else { return } switch status.kind { case .ready: connectionState = .idle @@ -328,6 +332,7 @@ public final class HermesAgentStore: ObservableObject { profile: HermesProfile, configuration: MTPLXAppConfiguration ) async { + let selectionGeneration = beginSessionSelection() var operation: GatewayOperation? do { operation = try await ensureGateway( @@ -335,13 +340,22 @@ public final class HermesAgentStore: ObservableObject { configuration: configuration, preserveBlockedApprovalRecovery: preservesBlockedApprovalRecovery(for: profile) ) - guard let operation, isCurrent(operation) else { return } + guard let operation, + isCurrent(operation), + isCurrentSessionSelection(selectionGeneration) + else { return } let result = try await rpc(operation, method: "session.list", params: ["limit": .number(200)]) - guard isCurrent(operation) else { return } + guard isCurrent(operation), + isCurrentSessionSelection(selectionGeneration) + else { return } sessions = sessionsWithActivity(Self.parseSessions(result), profile: profile) connectionState = .connected } catch { - guard let operation, isCurrent(operation), !shuttingDown else { return } + guard let operation, + isCurrent(operation), + isCurrentSessionSelection(selectionGeneration), + !shuttingDown + else { return } sessions = [] connectionState = .failed(Self.message(for: error)) } @@ -354,24 +368,30 @@ public final class HermesAgentStore: ObservableObject { ) async throws -> HermesSessionReference { // A new agent selection is an explicit session boundary. Do not let a // prior fresh-session lease survive if creation fails or is cancelled. - invalidateFreshSessionLease() + let selectionGeneration = beginSessionSelection() let operation = try await ensureGateway( profile: profile, configuration: configuration, preserveBlockedApprovalRecovery: false ) - guard isCurrent(operation) else { + guard isCurrent(operation), + isCurrentSessionSelection(selectionGeneration) + else { throw HermesGatewayClientError.disconnected } let result = try await rpc(operation, method: "session.create", params: ["cols": .number(100)]) - guard isCurrent(operation) else { + guard isCurrent(operation), + isCurrentSessionSelection(selectionGeneration) + else { throw CancellationError() } guard let sessionID = result.objectValue?["session_id"]?.stringValue else { throw HermesGatewayClientError.malformedResponse } let sessionKey = (try? await liveSessionKey(for: sessionID, operation: operation)) ?? sessionID - guard isCurrent(operation) else { + guard isCurrent(operation), + isCurrentSessionSelection(selectionGeneration) + else { throw CancellationError() } activeSessionID = sessionID @@ -385,7 +405,8 @@ public final class HermesAgentStore: ObservableObject { freshSessionLease = FreshSessionLease( sessionID: sessionID, sessionKey: sessionKey, - operation: operation + operation: operation, + selectionGeneration: selectionGeneration ) applyActiveOwnership(.ready) connectionState = .connected @@ -403,13 +424,15 @@ public final class HermesAgentStore: ObservableObject { configuration: MTPLXAppConfiguration ) async throws -> HermesSessionReference { // Saved/native sessions never inherit the new-session exception. - invalidateFreshSessionLease() + let selectionGeneration = beginSessionSelection() let operation = try await ensureGateway( profile: profile, configuration: configuration, preserveBlockedApprovalRecovery: preservesBlockedApprovalRecovery(for: profile, savedSessionID: session.id) ) - guard isCurrent(operation) else { + guard isCurrent(operation), + isCurrentSessionSelection(selectionGeneration) + else { throw HermesGatewayClientError.disconnected } sessions = sessionsWithActivity(sessions, profile: profile) @@ -421,7 +444,9 @@ public final class HermesAgentStore: ObservableObject { "cols": .number(100), ] ) - guard isCurrent(operation) else { + guard isCurrent(operation), + isCurrentSessionSelection(selectionGeneration) + else { throw CancellationError() } try applyResumedSession( @@ -606,6 +631,7 @@ public final class HermesAgentStore: ObservableObject { public func stop() async { shuttingDown = true + _ = beginSessionSelection() lifecycleGeneration += 1 tearDownGateway(clearSession: true) activeSessionID = nil @@ -626,6 +652,7 @@ public final class HermesAgentStore: ObservableObject { /// reopens the selected persisted session without discarding the locally /// visible transcript while transport recovery is in progress. public func reconnect(configuration: MTPLXAppConfiguration) async throws { + let selectionGeneration = beginSessionSelection() guard let profile = selectedProfile else { throw HermesGatewayClientError.disconnected } let savedSessionID = activeSessionKey ?? activeSessionID ?? configuration.lastHermesSessionID let title = activeSessionTitle ?? configuration.lastHermesSessionTitle @@ -635,14 +662,18 @@ public final class HermesAgentStore: ObservableObject { preserveSession: true, preserveBlockedApprovalRecovery: preservesBlockedApprovalRecovery(for: profile) ) - guard isCurrent(operation) else { throw CancellationError() } + guard isCurrent(operation), + isCurrentSessionSelection(selectionGeneration) + else { throw CancellationError() } guard let savedSessionID else { return } let result = try await rpc( operation, method: "session.resume", params: ["session_id": .string(savedSessionID), "cols": .number(100)] ) - guard isCurrent(operation) else { + guard isCurrent(operation), + isCurrentSessionSelection(selectionGeneration) + else { throw CancellationError() } try applyResumedSession( @@ -801,8 +832,11 @@ public final class HermesAgentStore: ObservableObject { ) } - private func isCurrentPrepare(_ generation: Int) -> Bool { - !Task.isCancelled && lifecycleGeneration == generation && !shuttingDown + private func isCurrentPrepare(_ generation: Int, selectionGeneration: Int) -> Bool { + !Task.isCancelled + && lifecycleGeneration == generation + && isCurrentSessionSelection(selectionGeneration) + && !shuttingDown } private func rpc( @@ -986,7 +1020,7 @@ public final class HermesAgentStore: ObservableObject { } else { resolved = primary } - if case .unknown = resolved, + if case .registryUnavailable = resolved, hasFreshSessionLease(for: profile) { // `session.create` allocated this exact session through the // currently-owned sidecar. A missing lazy registry must not block @@ -1003,6 +1037,7 @@ public final class HermesAgentStore: ObservableObject { activeSessionID == lease.sessionID, activeSessionKey == lease.sessionKey, profile.id == lease.operation.profileID, + isCurrentSessionSelection(lease.selectionGeneration), isCurrent(lease.operation, sessionID: lease.sessionID) else { return false } return true @@ -1012,6 +1047,17 @@ public final class HermesAgentStore: ObservableObject { freshSessionLease = nil } + @discardableResult + private func beginSessionSelection() -> Int { + sessionSelectionGeneration += 1 + invalidateFreshSessionLease() + return sessionSelectionGeneration + } + + private func isCurrentSessionSelection(_ generation: Int) -> Bool { + !Task.isCancelled && sessionSelectionGeneration == generation + } + private func ownership(for sessionID: String, profile: HermesProfile) -> HermesSessionOwnership { embeddedRuntime.sessionOwnership( profile: profile, @@ -1024,10 +1070,12 @@ public final class HermesAgentStore: ObservableObject { _ first: HermesSessionOwnership, _ second: HermesSessionOwnership ) -> HermesSessionOwnership { - if case .unknown = first { return first } - if case .unknown = second { return second } if case .external = first { return first } if case .external = second { return second } + if case .unknown = first { return first } + if case .unknown = second { return second } + if case .registryUnavailable = first { return first } + if case .registryUnavailable = second { return second } if case .ownedByMTPLX = first { return first } return second } @@ -1041,7 +1089,7 @@ public final class HermesAgentStore: ObservableObject { case .external: activeSessionWritable = false readOnlyReason = "This session is active in another Hermes surface." - case .unknown(let reason): + case .registryUnavailable(let reason), .unknown(let reason): activeSessionWritable = false readOnlyReason = reason } diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift index 9f3bda1b3..7c6a36bce 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesAgentStoreTests.swift @@ -182,7 +182,7 @@ final class HermesAgentStoreTests: XCTestCase { @MainActor func testFreshMTPLXSessionCanSubmitMultiplePromptsWhenOwnershipRegistryIsUnavailable() async throws { let runtime = FakeHermesEmbeddedRuntime() - runtime.ownershipBySession["fresh-live"] = .unknown("Session activity could not be inspected.") + runtime.ownershipBySession["fresh-live"] = .registryUnavailable("Session activity could not be inspected.") let client = FakeHermesGatewayClient(readyImmediately: true) client.resultByMethod["session.create"] = .object(["session_id": .string("fresh-live")]) client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) @@ -207,8 +207,8 @@ final class HermesAgentStoreTests: XCTestCase { @MainActor func testMissingOwnershipRegistryKeepsSavedSessionReadOnlyAfterFreshSessionSwitch() async throws { let runtime = FakeHermesEmbeddedRuntime() - runtime.ownershipBySession["fresh-live"] = .unknown("Session activity could not be inspected.") - runtime.ownershipBySession["saved-session"] = .unknown("Session activity could not be inspected.") + runtime.ownershipBySession["fresh-live"] = .registryUnavailable("Session activity could not be inspected.") + runtime.ownershipBySession["saved-session"] = .registryUnavailable("Session activity could not be inspected.") let client = FakeHermesGatewayClient(readyImmediately: true) client.resultByMethod["session.create"] = .object(["session_id": .string("fresh-live")]) client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) @@ -250,7 +250,7 @@ final class HermesAgentStoreTests: XCTestCase { } @MainActor - func testPrepareInvalidatesFreshSessionRegistryException() async throws { + func testFreshSessionExceptionDoesNotOverrideConflictOrUninspectableOwnership() async throws { let runtime = FakeHermesEmbeddedRuntime() runtime.ownershipBySession["fresh-live"] = .unknown("Session activity could not be inspected.") let client = FakeHermesGatewayClient(readyImmediately: true) @@ -258,6 +258,59 @@ final class HermesAgentStoreTests: XCTestCase { client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + await store.send("must remain read-only") + + XCTAssertFalse(store.activeSessionWritable) + XCTAssertEqual(store.activeSessionActivity, .ownershipUnknown("Session activity could not be inspected.")) + XCTAssertFalse(client.calls.contains(where: { $0.method == "prompt.submit" })) + } + + @MainActor + func testExplicitExternalAliasDominatesUnknownLiveSessionInEitherOrder() async throws { + for (keyOwnership, liveOwnership, expectedSurface) in [ + ( + HermesSessionOwnership.external(surface: "telegram"), + HermesSessionOwnership.unknown("Session activity could not be inspected."), + "telegram" + ), + ( + HermesSessionOwnership.unknown("Session activity could not be inspected."), + HermesSessionOwnership.external(surface: "desktop"), + "desktop" + ), + ] { + let runtime = FakeHermesEmbeddedRuntime() + runtime.ownershipBySession["fresh-key"] = keyOwnership + runtime.ownershipBySession["fresh-live"] = liveOwnership + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("fresh-live")]) + client.resultByMethod["session.active_list"] = .object([ + "sessions": .array([ + .object(["id": .string("fresh-live"), "session_key": .string("fresh-key")]), + ]), + ]) + let store = makeStore(runtime: runtime, clients: [client]) + + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + await store.send("must remain externally owned") + + XCTAssertFalse(store.activeSessionWritable) + XCTAssertEqual(store.activeSessionActivity, .externallyActive(surface: expectedSurface)) + XCTAssertFalse(client.calls.contains(where: { $0.method == "prompt.submit" })) + await store.stop() + } + } + + @MainActor + func testPrepareInvalidatesFreshSessionRegistryException() async throws { + let runtime = FakeHermesEmbeddedRuntime() + runtime.ownershipBySession["fresh-live"] = .registryUnavailable("Session activity could not be inspected.") + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("fresh-live")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) await store.prepare(configuration: configuration) await store.send("must not survive a new overlay lifecycle") @@ -266,6 +319,72 @@ final class HermesAgentStoreTests: XCTestCase { XCTAssertFalse(client.calls.contains(where: { $0.method == "prompt.submit" })) } + @MainActor + func testCreateFinishingAfterPrepareCannotPublishSessionOrFreshLease() async { + let runtime = FakeHermesEmbeddedRuntime() + runtime.ownershipBySession["stale-live"] = .registryUnavailable("Session activity could not be inspected.") + let client = FakeHermesGatewayClient(readyImmediately: true) + client.suspendedMethods = ["session.create"] + client.resultByMethod["session.create"] = .object(["session_id": .string("stale-live")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + let store = makeStore(runtime: runtime, clients: [client]) + + let create = Task { try await store.startNewAgent(profile: bernd, configuration: configuration) } + await waitUntil { client.calls.contains(where: { $0.method == "session.create" }) } + await store.prepare(configuration: configuration) + client.finishCall(method: "session.create") + + await assertCancellation(create) + XCTAssertNil(store.activeSessionID) + XCTAssertFalse(store.activeSessionWritable) + } + + @MainActor + func testCreateFinishingAfterResumeCannotOverwriteSelectedSavedSession() async throws { + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.suspendedMethods = ["session.create"] + client.resultByMethod["session.create"] = .object(["session_id": .string("stale-live")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.resultByMethod["session.resume"] = resumeResult(liveID: "saved-live", savedID: "saved-key") + let store = makeStore(runtime: runtime, clients: [client]) + + let create = Task { try await store.startNewAgent(profile: bernd, configuration: configuration) } + await waitUntil { client.calls.contains(where: { $0.method == "session.create" }) } + _ = try await store.resume( + HermesSavedSession(id: "saved-key", title: "Saved", preview: "", startedAt: 0, messageCount: 1, source: ""), + profile: bernd, + configuration: configuration + ) + client.finishCall(method: "session.create") + + await assertCancellation(create) + XCTAssertEqual(store.activeSessionID, "saved-live") + XCTAssertEqual(store.activeSessionKey, "saved-key") + } + + @MainActor + func testCreateSuspendedInActiveListCannotOverwriteNewerCreateOnSameGateway() async throws { + let runtime = FakeHermesEmbeddedRuntime() + let client = FakeHermesGatewayClient(readyImmediately: true) + client.resultByMethod["session.create"] = .object(["session_id": .string("stale-live")]) + client.resultByMethod["session.active_list"] = .object(["sessions": .array([])]) + client.suspendedMethods = ["session.active_list"] + let store = makeStore(runtime: runtime, clients: [client]) + + let staleCreate = Task { try await store.startNewAgent(profile: bernd, configuration: configuration) } + await waitUntil { client.calls.contains(where: { $0.method == "session.active_list" }) } + + client.suspendedMethods.remove("session.active_list") + client.resultByMethod["session.create"] = .object(["session_id": .string("new-live")]) + _ = try await store.startNewAgent(profile: bernd, configuration: configuration) + client.finishCall(method: "session.active_list") + + await assertCancellation(staleCreate) + XCTAssertEqual(store.activeSessionID, "new-live") + XCTAssertEqual(store.activeSessionKey, "new-live") + } + @MainActor func testStreamingReasoningToolsAndCompletionUpdateTranscript() async throws { let runtime = FakeHermesEmbeddedRuntime() diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift index ce05732a6..4bda5f0f8 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift @@ -131,7 +131,7 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { processIdentity: { _ in .unknown }, readData: { _ in throw CocoaError(.fileReadNoPermission) } ).ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), - .unknown("Session activity could not be inspected.") + .registryUnavailable("Session activity could not be inspected.") ) } @@ -151,6 +151,22 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { XCTAssertEqual(inspector.ownership(registryURL: registry, sessionID: "reused", ownedSidecarPID: nil), .ready) } + func testActiveSessionRegistryKeepsUnknownProcessIdentityFailClosed() throws { + let registry = root.appendingPathComponent("active_sessions.json") + try """ + {"entries":[ + {"lease_id":"lease-unknown","session_id":"saved","surface":"telegram","pid":7001,"process_start_time":10.0,"started_at":100.0} + ]} + """.write(to: registry, atomically: true, encoding: .utf8) + + let inspector = HermesActiveSessionRegistryInspector(processIdentity: { _ in .unknown }) + + XCTAssertEqual( + inspector.ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: 7001), + .unknown("Session activity could not be inspected.") + ) + } + func testActiveSessionRegistryRequiresNativeStartTimePrecisionAndOneLiveEntry() throws { let registry = root.appendingPathComponent("active_sessions.json") try """ @@ -194,18 +210,18 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { XCTAssertEqual( notFound.ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), - .unknown("Session activity could not be inspected.") + .registryUnavailable("Session activity could not be inspected.") ) XCTAssertEqual( unreadable.ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), - .unknown("Session activity could not be inspected.") + .registryUnavailable("Session activity could not be inspected.") ) try FileManager.default.createDirectory(at: registry, withIntermediateDirectories: true) XCTAssertEqual( HermesActiveSessionRegistryInspector(processIdentity: { _ in .unknown }) .ownership(registryURL: registry, sessionID: "saved", ownedSidecarPID: nil), - .unknown("Session activity could not be inspected.") + .registryUnavailable("Session activity could not be inspected.") ) } @@ -224,7 +240,7 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { sessionID: "saved", ownedSidecarPID: nil ), - .unknown("Session activity could not be inspected.") + .registryUnavailable("Session activity could not be inspected.") ) } From 50300c799bb9013d57f7e367038b2ea8b34042bf Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:30:25 +0200 Subject: [PATCH 35/40] fix(hermes): persist external profile selection --- .../Models/AppConfiguration.swift | 11 +++++ .../Services/HermesIntegration.swift | 4 +- .../Views/Hermes/HermesOverlay.swift | 7 +++ .../HermesEmbeddedRuntimeTests.swift | 43 ++++++++++++++++++- .../MTPLXAppCoreTests/MTPLXAppCoreTests.swift | 20 +++++++++ 5 files changed, 81 insertions(+), 4 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Models/AppConfiguration.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Models/AppConfiguration.swift index 28da1597a..ce2d721c2 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Models/AppConfiguration.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Models/AppConfiguration.swift @@ -315,6 +315,17 @@ public struct MTPLXAppConfiguration: Codable, Equatable, Sendable { lastHermesSessionTitle = reference.title } + /// Persists profile-only navigation independently from the generic launch + /// target. A session belongs to its profile, so crossing that boundary + /// must discard the old resume identity while reselecting the same profile + /// keeps its remembered session intact. + public mutating func rememberHermesProfileSelection(_ profileName: String) { + guard lastHermesProfile != profileName else { return } + lastHermesProfile = profileName + lastHermesSessionID = nil + lastHermesSessionTitle = nil + } + /// Fresh installs must be portable. Installed local copies are discovered /// by the model catalog; the default configuration should never point at /// a developer machine path. diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift index c01aa8da2..643624638 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift @@ -1180,7 +1180,7 @@ public struct HermesIntegration: Sendable { private struct HermesEffectiveProfileConfiguration { let provider: String - let baseURL: String + let baseURL: String? let modelReference: String } @@ -1223,7 +1223,7 @@ public struct HermesIntegration: Sendable { guard customBaseURLs.count <= 1 else { return nil } let baseURL = values["base_url"] ?? customBaseURLs.first - guard let baseURL, !baseURL.isEmpty else { return nil } + if let baseURL, baseURL.isEmpty { return nil } return HermesEffectiveProfileConfiguration( provider: provider, baseURL: baseURL, diff --git a/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift b/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift index fea09c0e6..fd78fac19 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift @@ -970,6 +970,7 @@ struct HermesPanel: View { localError = "This Hermes profile is unavailable." return } + rememberProfileSelection(profile) guard await ensureDaemonReady() else { return } await hermes.loadSessions(profile: profile, configuration: backend.configuration) } @@ -1057,6 +1058,12 @@ struct HermesPanel: View { try? backend.saveSettings(config) } + private func rememberProfileSelection(_ profile: HermesProfile) { + var config = backend.configuration + config.rememberHermesProfileSelection(profile.name) + try? backend.saveSettings(config) + } + private func sectionLabel(_ text: String) -> some View { Text(text.uppercased()) .font(.system(size: 9, weight: .heavy, design: .monospaced)) diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift index 4bda5f0f8..55c8e59b2 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesEmbeddedRuntimeTests.swift @@ -310,7 +310,7 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { name: "external", path: try makeProfile( named: "external", - config: "model:\n default: external-model\n provider: anthropic\n base_url: https://api.example.test/v1\n" + config: "model:\n default: openrouter/cloud-model\n provider: openrouter\n" ).path, isDefault: false ) @@ -345,8 +345,47 @@ final class HermesEmbeddedRuntimeTests: XCTestCase { ).path, isDefault: false ) + let duplicateProvider = HermesProfile( + name: "duplicate-provider", + path: try makeProfile( + named: "duplicate-provider", + config: "model:\n default: openrouter/cloud-model\n provider: openrouter\n provider: anthropic\n" + ).path, + isDefault: false + ) + let missingProvider = HermesProfile( + name: "missing-provider", + path: try makeProfile( + named: "missing-provider", + config: "model:\n default: openrouter/cloud-model\n" + ).path, + isDefault: false + ) + let missingDefault = HermesProfile( + name: "missing-default", + path: try makeProfile( + named: "missing-default", + config: "model:\n provider: openrouter\n" + ).path, + isDefault: false + ) + let malformedBaseURL = HermesProfile( + name: "malformed-base-url", + path: try makeProfile( + named: "malformed-base-url", + config: "model:\n default: openrouter/cloud-model\n provider: openrouter\n base_url:\n" + ).path, + isDefault: false + ) - for profile in [duplicateModel, duplicateBaseURL] { + for profile in [ + duplicateModel, + duplicateBaseURL, + duplicateProvider, + missingProvider, + missingDefault, + malformedBaseURL, + ] { guard case .unavailable = integration.routingState(for: profile, configuration: configuration) else { return XCTFail("Duplicate routing configuration must fail closed") } diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift index 02819afa5..1e8728bc7 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift @@ -3727,6 +3727,26 @@ final class MTPLXAppCoreTests: XCTestCase { XCTAssertEqual(configuration.lastHermesSessionTitle, "Fix the app") } + func testRememberingHermesProfileSelectionClearsOnlyCrossProfileSessionState() { + var configuration = MTPLXAppConfiguration( + lastLaunchTarget: LaunchTarget.openCode.rawValue, + lastHermesProfile: "bernd", + lastHermesSessionID: "session-123", + lastHermesSessionTitle: "Fix the app" + ) + + configuration.rememberHermesProfileSelection("bernd") + XCTAssertEqual(configuration.lastHermesSessionID, "session-123") + XCTAssertEqual(configuration.lastHermesSessionTitle, "Fix the app") + + configuration.rememberHermesProfileSelection("researcher") + + XCTAssertEqual(configuration.lastLaunchTarget, LaunchTarget.openCode.rawValue) + XCTAssertEqual(configuration.lastHermesProfile, "researcher") + XCTAssertNil(configuration.lastHermesSessionID) + XCTAssertNil(configuration.lastHermesSessionTitle) + } + func testAppConfigurationPersistsHermesResumeState() throws { let url = temporaryDirectory().appendingPathComponent("settings.json") let store = MTPLXSettingsStore(settingsURL: url) From 8f99a7279ed1dcbd7c2813173cbe070a19ec1576 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:59:35 +0200 Subject: [PATCH 36/40] feat(settings): configure Hermes profile --- .../MTPLXAppHost/Views/Tabs/SettingsTab.swift | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Tabs/SettingsTab.swift b/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Tabs/SettingsTab.swift index f92a71e9f..ce6cf8ec8 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Tabs/SettingsTab.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Tabs/SettingsTab.swift @@ -1333,6 +1333,53 @@ struct SettingsTab: View { } } content: { VStack(alignment: .leading, spacing: 4) { + FormRow( + label: "Profile", + caption: "Default profile for the embedded Hermes agent. Changing profiles clears a remembered session from the previous profile." + ) { + if hermes.profiles.isEmpty { + Text("No profiles found") + .font(.callout) + .foregroundStyle(Brand.typeSecondary) + } else { + HStack(spacing: 8) { + Picker("Hermes profile", selection: hermesProfileSelectionBinding) { + if let missingName = missingHermesProfileName { + Text("\(missingName) (Missing)") + .tag(missingName) + .disabled(true) + } + ForEach(hermes.profiles) { profile in + Text(profile.isDefault ? "Default" : profile.name) + .tag(profile.name) + .disabled(hermesProfileUnavailable(profile)) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(maxWidth: 220, alignment: .leading) + + if let profile = configuredHermesProfile { + PillBadge( + text: hermesRouteLabel(for: profile), + systemImage: hermesProfileUnavailable(profile) + ? "exclamationmark.triangle.fill" + : "checkmark.circle.fill", + tint: hermesRouteColor(for: profile) + ) + } else if missingHermesProfileName != nil { + PillBadge( + text: "Missing", + systemImage: "exclamationmark.triangle.fill", + tint: Brand.warning + ) + } + } + } + } + + Divider().overlay(Brand.separator) + if let status = hermes.installStatus { FormRow(label: "Tools") { statusText(status.enabledToolsets.joined(separator: ", ")) @@ -1396,6 +1443,85 @@ struct SettingsTab: View { } } + private var configuredHermesProfile: HermesProfile? { + if let remembered = draftConfig.lastHermesProfile { + return hermes.profiles.first(where: { $0.name == remembered }) + } + if let selected = hermes.selectedProfile, + let discovered = hermes.profiles.first(where: { $0.id == selected.id }) { + return discovered + } + return hermes.profiles.first + } + + private var missingHermesProfileName: String? { + guard let remembered = draftConfig.lastHermesProfile, + !hermes.profiles.contains(where: { $0.name == remembered }) + else { return nil } + return remembered + } + + private var hermesProfileSelectionBinding: Binding { + Binding( + get: { + configuredHermesProfile?.name + ?? draftConfig.lastHermesProfile + ?? "default" + }, + set: { profileName in + persistHermesProfileSelection(profileName) + } + ) + } + + private func persistHermesProfileSelection(_ profileName: String) { + guard let profile = hermes.profiles.first(where: { $0.name == profileName }), + !hermesProfileUnavailable(profile) + else { return } + + // Persist only the Hermes selection. Other unsaved Settings edits stay + // in `draftConfig` until the user explicitly applies them. + var persisted = backend.configuration + persisted.rememberHermesProfileSelection(profile.name) + do { + try backend.saveSettings(persisted) + draftConfig.lastHermesProfile = persisted.lastHermesProfile + draftConfig.lastHermesSessionID = persisted.lastHermesSessionID + draftConfig.lastHermesSessionTitle = persisted.lastHermesSessionTitle + lastSyncedConfig = persisted + Task { await hermes.prepare(configuration: persisted) } + } catch { + lastSaveError = "Hermes profile could not be saved: \(error)" + } + } + + private func hermesProfileUnavailable(_ profile: HermesProfile) -> Bool { + if case .unavailable = hermes.profileRoutingStates[profile.id] { return true } + return false + } + + private func hermesRouteLabel(for profile: HermesProfile) -> String { + switch hermes.profileRoutingStates[profile.id] ?? .external { + case .mtplx: + return "MTPLX" + case .external: + return "External" + case .unavailable: + return "Unavailable" + } + } + + private func hermesRouteColor(for profile: HermesProfile) -> Color { + switch hermes.profileRoutingStates[profile.id] ?? .external { + case .mtplx: + return Brand.success + case .external: + return Brand.typeSecondary + case .unavailable: + return Brand.warning + } + } + private func hermesGatewayColor(for health: HermesInstallStatus.GatewayHealth?) -> Color { switch health { case .healthy: From d52ebdc2628db98634004042620316a2f341441c Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:41:19 +0200 Subject: [PATCH 37/40] fix(hermes): enable first-prompt composer --- .../Services/HermesComposerPolicy.swift | 15 +++++ .../Views/Hermes/HermesOverlay.swift | 41 ++++++++++--- .../HermesComposerPolicyTests.swift | 58 +++++++++++++++++++ 3 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesComposerPolicy.swift create mode 100644 apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesComposerPolicyTests.swift diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesComposerPolicy.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesComposerPolicy.swift new file mode 100644 index 000000000..c1491df08 --- /dev/null +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesComposerPolicy.swift @@ -0,0 +1,15 @@ +public enum HermesComposerPolicy { + /// The composer may accept text for an existing writable session or for a + /// selected profile that can create a fresh session on first send. + public static func acceptsInput( + gatewayReady: Bool, + hasPendingRequest: Bool, + activeSessionWritable: Bool, + hasActiveSession: Bool, + hasAvailableProfile: Bool + ) -> Bool { + guard gatewayReady, !hasPendingRequest else { return false } + if activeSessionWritable { return true } + return !hasActiveSession && hasAvailableProfile + } +} diff --git a/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift b/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift index fd78fac19..d980c0352 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppHost/Views/Hermes/HermesOverlay.swift @@ -629,7 +629,7 @@ struct HermesPanel: View { .stroke(Brand.separatorStrong, lineWidth: 1) ) ) - .disabled(!hermes.activeSessionWritable || !hermes.gatewayReady || hermes.pendingRequest != nil) + .disabled(!composerAcceptsInput) .accessibilityLabel("Message Hermes") Button { Task { await send() } @@ -653,12 +653,23 @@ struct HermesPanel: View { private var canSend: Bool { if hermes.isStreaming { return true } - return hermes.activeSessionWritable - && hermes.gatewayReady - && hermes.pendingRequest == nil + return composerAcceptsInput && !composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + /// A selected profile is enough to begin composing. If no agent is active + /// yet, `send()` creates one before submitting the first prompt. Existing + /// sessions still have to pass the ownership checks in `HermesAgentStore`. + private var composerAcceptsInput: Bool { + HermesComposerPolicy.acceptsInput( + gatewayReady: hermes.gatewayReady, + hasPendingRequest: hermes.pendingRequest != nil, + activeSessionWritable: hermes.activeSessionWritable, + hasActiveSession: hermes.activeSessionID != nil, + hasAvailableProfile: hermes.selectedProfile.map { !profileUnavailable($0) } ?? false + ) + } + private var composerBackground: some View { Brand.bgInner .overlay( @@ -673,6 +684,8 @@ struct HermesPanel: View { if !hermes.gatewayReady { return "Hermes is not connected yet." } if hermes.pendingRequest != nil { return "Respond to the pending Hermes request first." } if hermes.readOnlyReason != nil { return "This session is read-only. Create a new agent to continue." } + if hermes.activeSessionID == nil, hermes.selectedProfile == nil { return "Select a Hermes profile first." } + if hermes.activeSessionID == nil { return "Enter a message to start a new Hermes agent." } if composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "Enter a message to send." } return "A writable Hermes agent is required." } @@ -986,21 +999,28 @@ struct HermesPanel: View { } private func startNew() async { - guard let profile = hermes.selectedProfile else { return } + _ = await startNewIfPossible() + } + + @discardableResult + private func startNewIfPossible() async -> Bool { + guard let profile = hermes.selectedProfile else { return false } localError = nil guard !profileUnavailable(profile) else { localError = "This Hermes profile is unavailable." - return + return false } - guard await ensureDaemonReady() else { return } + guard await ensureDaemonReady() else { return false } do { let reference = try await hermes.startNewAgent( profile: profile, configuration: backend.configuration ) remember(reference) + return true } catch { localError = "Hermes could not create a new agent. Try again." + return false } } @@ -1048,6 +1068,13 @@ struct HermesPanel: View { return } let text = composerText + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + if !hermes.activeSessionWritable { + guard hermes.activeSessionID == nil, + await startNewIfPossible() + else { return } + } + guard hermes.activeSessionWritable, hermes.gatewayReady else { return } composerText = "" await hermes.send(text) } diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesComposerPolicyTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesComposerPolicyTests.swift new file mode 100644 index 000000000..d58b1f390 --- /dev/null +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/HermesComposerPolicyTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import MTPLXAppCore + +final class HermesComposerPolicyTests: XCTestCase { + func testSelectedProfileAcceptsFirstPromptBeforeSessionExists() { + XCTAssertTrue(HermesComposerPolicy.acceptsInput( + gatewayReady: true, + hasPendingRequest: false, + activeSessionWritable: false, + hasActiveSession: false, + hasAvailableProfile: true + )) + } + + func testExistingWritableSessionAcceptsInput() { + XCTAssertTrue(HermesComposerPolicy.acceptsInput( + gatewayReady: true, + hasPendingRequest: false, + activeSessionWritable: true, + hasActiveSession: true, + hasAvailableProfile: true + )) + } + + func testExistingReadOnlySessionCannotUseFreshSessionFallback() { + XCTAssertFalse(HermesComposerPolicy.acceptsInput( + gatewayReady: true, + hasPendingRequest: false, + activeSessionWritable: false, + hasActiveSession: true, + hasAvailableProfile: true + )) + } + + func testGatewayPendingRequestAndMissingProfileRemainDisabled() { + XCTAssertFalse(HermesComposerPolicy.acceptsInput( + gatewayReady: false, + hasPendingRequest: false, + activeSessionWritable: false, + hasActiveSession: false, + hasAvailableProfile: true + )) + XCTAssertFalse(HermesComposerPolicy.acceptsInput( + gatewayReady: true, + hasPendingRequest: true, + activeSessionWritable: true, + hasActiveSession: true, + hasAvailableProfile: true + )) + XCTAssertFalse(HermesComposerPolicy.acceptsInput( + gatewayReady: true, + hasPendingRequest: false, + activeSessionWritable: false, + hasActiveSession: false, + hasAvailableProfile: false + )) + } +} From d8443d23095432dfb7f242a0af46cd396bad3f64 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:12:23 +0200 Subject: [PATCH 38/40] fix(hermes): bound live session cache retention --- .../Services/MTPLXCommandBuilder.swift | 8 ++++++++ .../MTPLXAppCoreTests/MTPLXAppCoreTests.swift | 4 +++- mtplx/server/openai.py | 18 ++++++++++++++++++ tests/test_server_openai.py | 18 ++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/MTPLXCommandBuilder.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/MTPLXCommandBuilder.swift index e36221a44..a809f351a 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/MTPLXCommandBuilder.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/MTPLXCommandBuilder.swift @@ -1509,6 +1509,14 @@ private struct TargetPreset { processEnvironment: processEnvironment ) env["MTPLX_CLIENT"] = "hermes" + // Hermes persists the authoritative transcript itself. Keep a + // bounded RAM acceleration tier for several profiles while the + // SSD session bank retains older snapshots; inheriting OpenCode's + // auto budget allowed one long Hermes tool session to pin six + // multi-GiB KV frontiers and slow unrelated short generations. + env["MTPLX_SESSION_BANK_MAX_ENTRIES"] = "6" + env["MTPLX_SESSION_BANK_MAX_BYTES"] = "8G" + env["MTPLX_SESSION_BANK_PER_SESSION_BYTES"] = "3G" return TargetPreset( schedulerMode: "serial", batchingPreset: "latency", diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift index 1e8728bc7..3a5218e8f 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift @@ -1606,7 +1606,9 @@ final class MTPLXAppCoreTests: XCTestCase { XCTAssertTrue(command.arguments.containsInOrder(["--app-launch-id", "hermes-launch"])) XCTAssertEqual(command.environment["MTPLX_CLIENT"], "hermes") XCTAssertEqual(command.environment["MTPLX_VLLM_METAL_PAGED_GQA_SDPA_ROUTE"], "async_per_head") - XCTAssertEqual(command.environment["MTPLX_SESSION_BANK_MAX_ENTRIES"], "32") + XCTAssertEqual(command.environment["MTPLX_SESSION_BANK_MAX_ENTRIES"], "6") + XCTAssertEqual(command.environment["MTPLX_SESSION_BANK_MAX_BYTES"], "8G") + XCTAssertEqual(command.environment["MTPLX_SESSION_BANK_PER_SESSION_BYTES"], "3G") } func testCommandBuilderBenchmarkPresetStartsSoloBenchmarkDaemon() throws { diff --git a/mtplx/server/openai.py b/mtplx/server/openai.py index 80b2223c5..439a1d294 100644 --- a/mtplx/server/openai.py +++ b/mtplx/server/openai.py @@ -12969,11 +12969,20 @@ def _session_keep_live_refs_for_request( session_source: str | None, session_id: str | None, tool_names: list[str] | tuple[str, ...] | None = None, + client_hint: str | None = None, ) -> bool: if os.environ.get( "MTPLX_SESSIONBANK_LIVE_REFS_FOR_IMPLICIT_SESSIONS", "" ).strip().lower() in {"1", "true", "yes", "on"}: return True + # Hermes owns the durable conversation transcript and MTPLX's SSD tier + # already preserves cloneable prompt snapshots. Retaining the live KV + # containers as well lets one long tool session fill every RAM-bank slot; + # live-ref entries are intentionally ineligible for prefix superseding, so + # the footprint then survives into unrelated short requests. OpenCode's + # explicit live-frontier policy below remains unchanged. + if "hermes" in str(client_hint or "").strip().lower(): + return False source = str(session_source or "") if source.startswith("header.") or source.startswith("metadata."): return True @@ -21890,6 +21899,7 @@ async def chat_completions( session_source=session_source, session_id=session_id, tool_names=_tool_names(tool_specs) if tools_active else None, + client_hint=request_observability.get("request_client_hint"), ) live_frontier_policy = "none" if agent_transcript_tools_active: @@ -21914,6 +21924,14 @@ async def chat_completions( request_observability["request_session_keep_live_ref_reason"] = ( "opencode_tool_snapshot_only" ) + elif ( + _is_hermes_client(headers=headers, metadata=metadata) + and agent_transcript_tools_active + ): + live_frontier_policy = "hermes_snapshot_only" + request_observability["request_session_keep_live_ref_reason"] = ( + "hermes_tool_snapshot_only" + ) request_observability["request_session_keep_live_ref"] = bool( session_keep_live_ref ) diff --git a/tests/test_server_openai.py b/tests/test_server_openai.py index 17bd6abe7..d0f3285b7 100644 --- a/tests/test_server_openai.py +++ b/tests/test_server_openai.py @@ -718,11 +718,29 @@ def test_anonymous_coding_agent_tool_sessions_keep_live_refs(monkeypatch): session_source="new", session_id="anon-opencode", tool_names=["bash", "read", "write"], + client_hint="opencode", ) is True ) +def test_anonymous_hermes_tool_sessions_use_snapshots_not_live_refs(monkeypatch): + monkeypatch.delenv( + "MTPLX_SESSIONBANK_LIVE_REFS_FOR_IMPLICIT_SESSIONS", + raising=False, + ) + + assert ( + openai._session_keep_live_refs_for_request( + session_source="new", + session_id="anon-hermes", + tool_names=["bash", "read", "write"], + client_hint="hermes", + ) + is False + ) + + def test_anonymous_non_tool_benchmark_sessions_stay_cold(monkeypatch): monkeypatch.delenv( "MTPLX_SESSIONBANK_LIVE_REFS_FOR_IMPLICIT_SESSIONS", From 2eba067e95265e6b88ccbf9209d75d781eb7dead Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:25:45 +0200 Subject: [PATCH 39/40] fix(hermes): force TUI provider override for embedded launches Hermes gives a profile's persisted model.provider precedence over HERMES_INFERENCE_PROVIDER, so an embedded launch could inherit an unrelated provider (for example openai-codex). Set HERMES_TUI_PROVIDER=custom in the launch env dict, the processEnvironment lane, the dotenv profile, and the shell export block, and pin the override in the env and dotenv tests. --- .../Sources/MTPLXAppCore/Services/HermesIntegration.swift | 7 +++++++ .../Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift | 2 ++ 2 files changed, 9 insertions(+) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift index 643624638..adf4fa4f1 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift @@ -393,6 +393,10 @@ public struct HermesIntegration: Sendable { env["OPENAI_API_KEY"] = apiKey env["HERMES_MODEL"] = modelID env["HERMES_INFERENCE_MODEL"] = modelID + // Hermes gives a profile's persisted model.provider precedence over + // HERMES_INFERENCE_PROVIDER. MTPLX must use the explicit TUI override + // so an embedded launch never inherits (for example) openai-codex. + env["HERMES_TUI_PROVIDER"] = "custom" env["HERMES_INFERENCE_PROVIDER"] = "custom" env["HERMES_MTPLX_REASONING"] = reasoning env["HERMES_MTPLX_SHOW_REASONING"] = reasoning == "off" ? "0" : "1" @@ -461,6 +465,7 @@ public struct HermesIntegration: Sendable { processEnvironment["OPENAI_API_KEY"] = apiKey processEnvironment["HERMES_MODEL"] = modelID processEnvironment["HERMES_INFERENCE_MODEL"] = modelID + processEnvironment["HERMES_TUI_PROVIDER"] = "custom" processEnvironment["HERMES_INFERENCE_PROVIDER"] = "custom" processEnvironment["HERMES_DASHBOARD_SESSION_TOKEN"] = token processEnvironment["HERMES_SESSION_PLATFORM"] = "mtplx-app" @@ -1489,6 +1494,7 @@ public struct HermesIntegration: Sendable { OPENAI_API_KEY=\(dotenvQuote(apiKey)) HERMES_MODEL=\(dotenvQuote(modelID)) HERMES_INFERENCE_MODEL=\(dotenvQuote(modelID)) + HERMES_TUI_PROVIDER=custom HERMES_INFERENCE_PROVIDER=custom HERMES_MTPLX_REASONING=\(dotenvQuote(reasoning)) HERMES_MTPLX_SHOW_REASONING=\(reasoning == "off" ? "0" : "1") @@ -1617,6 +1623,7 @@ public struct HermesIntegration: Sendable { export OPENAI_API_KEY=\(Self.shellQuote(env["OPENAI_API_KEY"] ?? "")) export HERMES_MODEL=\(Self.shellQuote(env["HERMES_MODEL"] ?? "")) export HERMES_INFERENCE_MODEL=\(Self.shellQuote(env["HERMES_INFERENCE_MODEL"] ?? "")) + export HERMES_TUI_PROVIDER=custom export HERMES_INFERENCE_PROVIDER=custom export HERMES_YOLO_MODE=\(Self.shellQuote(env["HERMES_YOLO_MODE"] ?? "1")) export HERMES_MTPLX_TOOLSETS=\(Self.shellQuote(env["HERMES_MTPLX_TOOLSETS"] ?? Self.codingToolsets)) diff --git a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift index 3a5218e8f..c03a57ccc 100644 --- a/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift +++ b/apps/MTPLXApp/Tests/MTPLXAppCoreTests/MTPLXAppCoreTests.swift @@ -2556,6 +2556,7 @@ final class MTPLXAppCoreTests: XCTestCase { XCTAssertEqual(environment["OPENAI_API_KEY"], PiIntegration.localAPIKey) XCTAssertEqual(environment["HERMES_MODEL"], "mtplx-qwen36-27b-optimized-speed") XCTAssertEqual(environment["HERMES_INFERENCE_MODEL"], "mtplx-qwen36-27b-optimized-speed") + XCTAssertEqual(environment["HERMES_TUI_PROVIDER"], "custom") XCTAssertEqual(environment["HERMES_INFERENCE_PROVIDER"], "custom") XCTAssertEqual(environment["HERMES_YOLO_MODE"], "1") XCTAssertEqual(environment["HERMES_MTPLX_TOOLSETS"], "terminal,file,web,browser,messaging") @@ -2636,6 +2637,7 @@ final class MTPLXAppCoreTests: XCTestCase { XCTAssertTrue(configText.contains("tool_use_enforcement: auto")) XCTAssertTrue(configText.contains("cwd: '\(workspace.path)'")) XCTAssertTrue(configText.contains("show_reasoning: true")) + XCTAssertTrue(envText.contains("HERMES_TUI_PROVIDER=custom")) XCTAssertTrue(envText.contains("HERMES_INFERENCE_PROVIDER=custom")) XCTAssertTrue(envText.contains("HERMES_MTPLX_REASONING=\"auto\"")) XCTAssertTrue(envText.contains("HERMES_MTPLX_SHOW_REASONING=1")) From 8b5f61253c0e322b6dee0ec8cc4a6ec0b3e71cb3 Mon Sep 17 00:00:00 2001 From: Cyb3rb1ade <84099452+Cyb3rb1ade@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:41:37 +0200 Subject: [PATCH 40/40] fix(hermes): tolerate explicitly empty base_url in profile routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hermes CLI writes base_url: '' for provider profiles without a custom endpoint (for example openai-codex). The routing parser rejected the quoted empty scalar, failed the whole model block, and marked the profile unavailable — disabling it in the picker and hiding its sessions. A quoted empty string is now a valid empty scalar and an empty base_url is treated the same as an omitted one. Regression test pins the openai-codex profile shape. --- .../Services/HermesIntegration.swift | 13 +++++- .../MTPLXAppCoreTests/MTPLXAppCoreTests.swift | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift index adf4fa4f1..ce9707deb 100644 --- a/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift +++ b/apps/MTPLXApp/Sources/MTPLXAppCore/Services/HermesIntegration.swift @@ -1226,7 +1226,10 @@ public struct HermesIntegration: Sendable { let envText = try? String(contentsOf: envURL, encoding: .utf8) let customBaseURLs = envText.map { dotenvValues("CUSTOM_BASE_URL", in: $0) } ?? [] guard customBaseURLs.count <= 1 else { return nil } - let baseURL = values["base_url"] + // An explicitly empty `base_url: ''` means "no custom endpoint", the + // same as omitting the key — it must not fail the whole profile. + let configuredBaseURL = values["base_url"].flatMap { $0.isEmpty ? nil : $0 } + let baseURL = configuredBaseURL ?? customBaseURLs.first if let baseURL, baseURL.isEmpty { return nil } return HermesEffectiveProfileConfiguration( @@ -1241,13 +1244,19 @@ public struct HermesIntegration: Sendable { var value = String(line[line.index(after: colon)...]) .trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty else { return nil } + var wasQuoted = false if value.hasPrefix("\"") || value.hasPrefix("'") { guard value.count >= 2, value.first == value.last else { return nil } value = String(value.dropFirst().dropLast()) + wasQuoted = true } else if let commentStart = value.firstIndex(of: "#") { value = String(value[..