From b4482e5ee7587024cba6e4707ca2a725d1e946c0 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 19:05:55 +0200 Subject: [PATCH] feat(gateway): Google Chat platform + cross-platform allowlists + behavior toggles (WS-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches the Mac Messaging Gateway and Platforms surfaces up to Hermes v0.13.0. Adds Google Chat as the 20th platform under Settings → Platforms, gated on `hasGoogleChatPlatform`. Adds a per-platform "Gateway behavior" subsection to the six platforms Hermes added v0.13 allowlist support to (Slack, Mattermost, Google Chat, Telegram, WhatsApp, Matrix) — each exposes the `allowed_channels` / `allowed_chats` / `allowed_rooms` editor plus three new toggles (`busy_ack_enabled`, `gateway_restart_notification`, `slash_command_notice_ttl_seconds`). The Messaging Gateway page header gains a one-line cross-profile digest sourced from `hermes gateway list --json`. SkillsView surfaces an informational row on skills whose body contains the v0.13 `[[as_document]]` directive. New ScarfCore types: `GatewayAllowlistKind` (channels/chats/rooms + platform mapping), `GatewayPlatformSettings` (per-platform v0.13 bundle), `GatewayConfigWriter` (pure YAML list-block editor — `hermes config set` can't write lists; tested with 15 cases incl. round-trip + idempotence + quoting + scalar-sibling preservation), `HermesGatewayListService` (`hermes gateway list --json` parser tolerant of unknown keys + alt field names; 13 tests), `HermesConfig.gatewayPlatforms` field. Mac VM renamed to `MessagingGatewayViewModel` (single-feature local rename; CLAUDE.md "the SidebarSection.gateway enum case stays" invariant upheld). All 22 new tests pass; full ScarfCore suite green except 3 pre-existing `RemoteSQLiteBackendTests` failures unrelated to WS-5. Capability-gated end-to-end. Pre-v0.13 hosts see no Google Chat row, no cross-profile digest, no v0.13 toggles, and no `[[as_document]]` info row — the v2.7.5 surface is byte-for-byte unchanged. Q1-Q3 wire- shape unknowns (Google Chat identifier, YAML key path, `gateway list --json` shape) are marked with `// TODO(WS-5-Q)` and defended by tolerant parsers + dual-spelling lookups. Implements WS-5 of Scarf v2.8.0 (Hermes v0.13.0 catch-up). Plan: scarf/docs/v2.8/WS-5-gateway-v0.13-plan.md (on coordination/v2.8.0-plans). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/GatewayAllowlistKind.swift | 76 ++++ .../Models/GatewayPlatformSettings.swift | 71 ++++ .../ScarfCore/Models/HermesConfig.swift | 15 +- .../Sources/ScarfCore/Models/HermesTool.swift | 12 + .../ScarfCore/Parsing/HermesConfig+YAML.swift | 55 ++- .../Services/GatewayConfigWriter.swift | 396 ++++++++++++++++++ .../Services/HermesGatewayListService.swift | 151 +++++++ .../GatewayAllowlistKindTests.swift | 70 ++++ .../GatewayConfigWriterTests.swift | 276 ++++++++++++ .../HermesGatewayListServiceTests.swift | 131 ++++++ .../ScarfCoreTests/M6ConfigCronTests.swift | 81 ++++ .../Core/Services/HermesFileService.swift | 44 +- .../Gateway/ViewModels/GatewayViewModel.swift | 34 +- .../Features/Gateway/Views/GatewayView.swift | 106 ++++- .../GatewayBehaviorViewModel.swift | 140 +++++++ .../Components/AllowlistEditor.swift | 103 +++++ .../Components/GatewayBehaviorSection.swift | 96 +++++ .../Views/PlatformSetup/MatrixSetupView.swift | 15 +- .../PlatformSetup/MattermostSetupView.swift | 15 +- .../Views/PlatformSetup/SlackSetupView.swift | 15 +- .../PlatformSetup/TelegramSetupView.swift | 15 +- .../PlatformSetup/WhatsAppSetupView.swift | 16 +- .../Platforms/Views/PlatformsView.swift | 53 ++- .../Features/Skills/Views/SkillsView.swift | 43 ++ 24 files changed, 1993 insertions(+), 36 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/GatewayAllowlistKind.swift create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/GatewayPlatformSettings.swift create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Services/GatewayConfigWriter.swift create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesGatewayListService.swift create mode 100644 scarf/Packages/ScarfCore/Tests/ScarfCoreTests/GatewayAllowlistKindTests.swift create mode 100644 scarf/Packages/ScarfCore/Tests/ScarfCoreTests/GatewayConfigWriterTests.swift create mode 100644 scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesGatewayListServiceTests.swift create mode 100644 scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/GatewayBehaviorViewModel.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/Components/AllowlistEditor.swift create mode 100644 scarf/scarf/Features/Platforms/Views/PlatformSetup/Components/GatewayBehaviorSection.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/GatewayAllowlistKind.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/GatewayAllowlistKind.swift new file mode 100644 index 0000000..5b9ecb4 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/GatewayAllowlistKind.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Hermes v0.13 added cross-platform recipient allowlists to the Messaging +/// Gateway. Each platform stores the list under a different YAML key +/// depending on the platform's primary noun for "addressable destination": +/// +/// - **`allowed_channels`** — Slack, Mattermost, Google Chat +/// - **`allowed_chats`** — Telegram, WhatsApp +/// - **`allowed_rooms`** — Matrix, DingTalk +/// +/// `GatewayAllowlistKind` encodes the (platform → key) mapping plus a few +/// presentation hints (placeholder strings, singular noun) so the allowlist +/// editor can render the right copy without the per-platform setup view +/// needing to know the YAML shape. +public enum GatewayAllowlistKind: String, Sendable, Equatable { + case channels // -> allowed_channels + case chats // -> allowed_chats + case rooms // -> allowed_rooms + + /// YAML scalar key segment under `gateway.platforms..`. + public var yamlKey: String { + switch self { + case .channels: return "allowed_channels" + case .chats: return "allowed_chats" + case .rooms: return "allowed_rooms" + } + } + + /// Placeholder copy for the editor's "add row" text field. Picks the + /// most common identifier shape per platform family — Slack channel IDs + /// for `channels`, Telegram username/numeric for `chats`, Matrix room + /// IDs for `rooms`. Users can paste in any platform-specific format the + /// gateway accepts; this is a hint, not validation. + public var inputPlaceholder: String { + switch self { + case .channels: return "C0123ABCD or #channel-name" + case .chats: return "@username or 12345678" + case .rooms: return "!RoomId:matrix.org" + } + } + + /// Singular noun for prose surfaces ("Add a channel", "1 chat allowed", + /// "0 rooms"). Capitalization is the caller's responsibility. + public var noun: String { + switch self { + case .channels: return "channel" + case .chats: return "chat" + case .rooms: return "room" + } + } + + /// Plural noun for headings + counts. + public var pluralNoun: String { + switch self { + case .channels: return "channels" + case .chats: return "chats" + case .rooms: return "rooms" + } + } + + /// Map a Hermes platform identifier to the allowlist kind it supports. + /// Returns `nil` for platforms without v0.13 allowlist support + /// (`cli`, `signal`, `email`, `imessage`, `homeassistant`, `webhook`, + /// `yuanbao`, `microsoft-teams`, `feishu`, `discord`). + /// + /// `googlechat` and `google-chat` both map to `.channels` so we round-trip + /// regardless of which spelling Hermes lands on. // TODO(WS-5-Q1) + public static func kind(for platform: String) -> GatewayAllowlistKind? { + switch platform { + case "slack", "mattermost", "google-chat", "googlechat": return .channels + case "telegram", "whatsapp": return .chats + case "matrix", "dingtalk": return .rooms + default: return nil + } + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/GatewayPlatformSettings.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/GatewayPlatformSettings.swift new file mode 100644 index 0000000..bd9cf94 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/GatewayPlatformSettings.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Per-platform Messaging Gateway settings introduced in Hermes v0.13. Bundles +/// the allowlist (the platform-appropriate flavor of `allowed_channels` / +/// `allowed_chats` / `allowed_rooms`) and three behavior toggles +/// (`busy_ack_enabled`, `gateway_restart_notification`, +/// `slash_command_notice_ttl_seconds`). +/// +/// The struct carries all three list fields so a single shape fits every +/// platform; only the field matching `GatewayAllowlistKind.kind(for:)` is +/// surfaced in the editor for a given platform. The other two stay empty +/// and round-trip through the YAML parser unchanged. +/// +/// **Defaults track Hermes v0.13.** `busyAckEnabled = true`, +/// `gatewayRestartNotification = false`, `slashCommandNoticeTTLSeconds = 0` +/// (disabled). An "all-default" instance therefore produces no `gateway:` +/// block in YAML — see `HermesConfig+YAML` parsing logic which only inserts +/// an entry into `gatewayPlatforms` when at least one v0.13 key is present +/// in the file. +public struct GatewayPlatformSettings: Sendable, Equatable { + /// `gateway.platforms..allowed_channels` — Slack, Mattermost, + /// Google Chat. Empty when the platform doesn't use channels. + public var allowedChannels: [String] + /// `gateway.platforms..allowed_chats` — Telegram, WhatsApp. + /// Empty when the platform doesn't use chats. + public var allowedChats: [String] + /// `gateway.platforms..allowed_rooms` — Matrix, DingTalk. + /// Empty when the platform doesn't use rooms. + public var allowedRooms: [String] + /// `gateway.platforms..busy_ack_enabled`. Default `true` — set + /// to `false` to suppress per-message "agent is working…" acks. + public var busyAckEnabled: Bool + /// `gateway.platforms..gateway_restart_notification`. Default + /// `false` — set to `true` to post a "Gateway restarted" notice on boot. + public var gatewayRestartNotification: Bool + /// `gateway.platforms..slash_command_notice_ttl_seconds`. + /// Default `0` (disabled). Positive values auto-delete slash-command + /// notices after N seconds. + public var slashCommandNoticeTTLSeconds: Int + + public init( + allowedChannels: [String] = [], + allowedChats: [String] = [], + allowedRooms: [String] = [], + busyAckEnabled: Bool = true, + gatewayRestartNotification: Bool = false, + slashCommandNoticeTTLSeconds: Int = 0 + ) { + self.allowedChannels = allowedChannels + self.allowedChats = allowedChats + self.allowedRooms = allowedRooms + self.busyAckEnabled = busyAckEnabled + self.gatewayRestartNotification = gatewayRestartNotification + self.slashCommandNoticeTTLSeconds = slashCommandNoticeTTLSeconds + } + + /// All-default instance. `HermesConfig.empty` initializes + /// `gatewayPlatforms: [:]` so this is rarely used directly; provided + /// for symmetry with the other settings types. + public static let empty = GatewayPlatformSettings() + + /// The list field matching this allowlist kind, or `nil` for + /// platforms without an allowlist surface. + public func items(for kind: GatewayAllowlistKind) -> [String] { + switch kind { + case .channels: return allowedChannels + case .chats: return allowedChats + case .rooms: return allowedRooms + } + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift index a62ccef..5340880 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift @@ -667,6 +667,17 @@ public struct HermesConfig: Sendable { /// useful for cost auditing and screen-recording demos. public var runtimeMetadataFooter: Bool + // -- Hermes v0.13 additions ---------------------------------------- + // Per-platform Messaging Gateway settings dictionary keyed by Hermes + // platform identifier (`slack`, `telegram`, `matrix`, `mattermost`, + // `whatsapp`, `dingtalk`, `google-chat`). Populated only for platforms + // whose `gateway.platforms..*` block exists in config.yaml — + // platforms without an explicit block don't appear in the dictionary. + // Editing surfaces (per-platform setup forms) read with a `?? .empty` + // fallback so a missing entry behaves identically to an all-default + // entry. + public var gatewayPlatforms: [String: GatewayPlatformSettings] + // Grouped blocks public var display: DisplaySettings public var terminal: TerminalSettings @@ -747,11 +758,13 @@ public struct HermesConfig: Sendable { homeAssistant: HomeAssistantSettings, cacheTTL: String = "5m", redactionEnabled: Bool = false, - runtimeMetadataFooter: Bool = false + runtimeMetadataFooter: Bool = false, + gatewayPlatforms: [String: GatewayPlatformSettings] = [:] ) { self.cacheTTL = cacheTTL self.redactionEnabled = redactionEnabled self.runtimeMetadataFooter = runtimeMetadataFooter + self.gatewayPlatforms = gatewayPlatforms self.model = model self.provider = provider self.maxTurns = maxTurns diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesTool.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesTool.swift index 36d5848..16406d7 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesTool.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesTool.swift @@ -60,6 +60,17 @@ public enum KnownPlatforms { // platform identifiers. HermesToolPlatform(name: "yuanbao", displayName: "Yuanbao 元宝", icon: "bubble.left.and.bubble.right.fill"), HermesToolPlatform(name: "microsoft-teams", displayName: "Microsoft Teams", icon: "person.2.fill"), + // -- v0.13 additions --------------------------------------------- + // Google Chat is the 20th gateway platform. It's a generic + // `env_enablement_fn` / `cron_deliver_env_var`-driven adapter; setup + // runs through `hermes setup` rather than per-field forms because + // the auth dance is OAuth-style and lives outside Scarf. Identifier + // is `google-chat` (kebab-case, mirroring `microsoft-teams`). + // TODO(WS-5-Q1): verify identifier against Hermes v0.13 GA — if it + // ships as `googlechat` instead, update both this entry and + // `KnownPlatforms.icon(for:)` below. `GatewayAllowlistKind.kind(for:)` + // already accepts both spellings defensively. + HermesToolPlatform(name: "google-chat", displayName: "Google Chat", icon: "bubble.left.fill"), ] public static func icon(for platform: String) -> String { @@ -79,6 +90,7 @@ public enum KnownPlatforms { case "imessage": return "message.fill" case "yuanbao": return "bubble.left.and.bubble.right.fill" case "microsoft-teams": return "person.2.fill" + case "google-chat", "googlechat": return "bubble.left.fill" default: return "bubble.left" } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift index d172bbc..89100ef 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift @@ -225,6 +225,58 @@ public extension HermesConfig { cooldownSeconds: int("platforms.homeassistant.extra.cooldown_seconds", default: 30) ) + // -- v0.13: per-platform Messaging Gateway settings -------------- + // Read `gateway.platforms..{allowed_channels|allowed_chats| + // allowed_rooms|busy_ack_enabled|gateway_restart_notification| + // slash_command_notice_ttl_seconds}` and bundle each platform that + // has at least one v0.13 key present in the file. Platforms without + // an explicit block don't appear in the dictionary, so the + // editor's `?? .empty` fallback hands the user the v0.13 defaults + // without leaving stale keys littered across the YAML. + // + // TODO(WS-5-Q2): the `gateway.platforms.*` path is unverified — + // Hermes v0.13 may emit allowlists under `platforms..*` + // (sibling to existing `platforms.slack.reply_to_mode`) instead. + // If so, swap the `prefix` line below to `"platforms.\(platform)."` + // and update `GatewayConfigWriter` in lockstep. + let gatewayAllowlistPlatforms = [ + "slack", "mattermost", "google-chat", + "telegram", "whatsapp", + "matrix", "dingtalk", + ] + var gatewayPlatforms: [String: GatewayPlatformSettings] = [:] + for platform in gatewayAllowlistPlatforms { + let prefix = "gateway.platforms.\(platform)." + let allowedChannels = lists[prefix + "allowed_channels"] ?? [] + let allowedChats = lists[prefix + "allowed_chats"] ?? [] + let allowedRooms = lists[prefix + "allowed_rooms"] ?? [] + let busy = bool(prefix + "busy_ack_enabled", default: true) + let restartNotice = bool(prefix + "gateway_restart_notification", + default: false) + let ttl = int(prefix + "slash_command_notice_ttl_seconds", + default: 0) + // Skip platforms with no v0.13 fields present anywhere in the + // file. Without this guard, every supported platform would + // round-trip an all-default block back through writes even + // when the user never touched the new surface. + let isEmpty = allowedChannels.isEmpty + && allowedChats.isEmpty + && allowedRooms.isEmpty + && values[prefix + "busy_ack_enabled"] == nil + && values[prefix + "gateway_restart_notification"] == nil + && values[prefix + "slash_command_notice_ttl_seconds"] == nil + if !isEmpty { + gatewayPlatforms[platform] = GatewayPlatformSettings( + allowedChannels: allowedChannels, + allowedChats: allowedChats, + allowedRooms: allowedRooms, + busyAckEnabled: busy, + gatewayRestartNotification: restartNotice, + slashCommandNoticeTTLSeconds: ttl + ) + } + } + self.init( model: str("model.default", default: "unknown"), provider: str("model.provider", default: "unknown"), @@ -284,7 +336,8 @@ public extension HermesConfig { homeAssistant: homeAssistant, cacheTTL: str("prompt_caching.cache_ttl", default: "5m"), redactionEnabled: bool("redaction.enabled", default: false), - runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false) + runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false), + gatewayPlatforms: gatewayPlatforms ) } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/GatewayConfigWriter.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/GatewayConfigWriter.swift new file mode 100644 index 0000000..f749c1e --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/GatewayConfigWriter.swift @@ -0,0 +1,396 @@ +import Foundation + +/// Direct YAML editor for `gateway.platforms..allowed_:` list +/// blocks. Hermes v0.13 added these list-valued keys, but `hermes config set` +/// stringifies arrays (the same gotcha that forced Home Assistant's watch +/// lists to stay read-only). The Messaging Gateway editor sidesteps the CLI +/// for these keys by editing `~/.hermes/config.yaml` directly. +/// +/// **Pure-function `setList`** is the heart of the editor — it splits the +/// YAML into lines, finds (or creates) the targeted block, and splices the +/// new items in while preserving every byte outside the block. The async +/// `saveList` wrapper wires it through `ServerContext.readText` / +/// `writeText`, so the same code path works on `.local` and `.ssh` servers +/// — local goes through `LocalTransport`, remote round-trips via SCP. +/// +/// **Scalar fields don't go through here.** `busy_ack_enabled`, +/// `gateway_restart_notification`, and `slash_command_notice_ttl_seconds` +/// are scalars that `hermes config set` handles cleanly — `GatewayBehaviorViewModel` +/// routes those through `PlatformSetupHelpers.saveForm` like every other +/// platform toggle. +/// +/// **Why not use a real YAML library?** Same answer as everywhere else in +/// Scarf: zero external dependencies. The Hermes config flavor is a tightly +/// scoped subset (indent-based blocks, scalar-or-list values, no anchors / +/// aliases / flow style), and the targeted edit doesn't need to understand +/// the full grammar — only "find this block, replace it, preserve the rest". +public enum GatewayConfigWriter { + + /// Insert or replace `gateway.platforms..:` block in the + /// YAML, preserving everything else byte-for-byte. + /// + /// - When `items` is empty, the block (and only the block — siblings + /// stay) is removed from the YAML if present, and the function is a + /// no-op if the block was already absent. + /// - When the block is absent and `items` is non-empty, the function + /// appends a `gateway:` / `platforms:` / `:` scaffold at + /// the end of the file, creating any missing ancestors. This keeps + /// the function idempotent on round-trip but means the new block is + /// appended rather than spliced into an existing top-level + /// `gateway:` section. (See WS-5 plan §Notes for the trade-off; the + /// alternative would mean reflowing existing siblings, which is the + /// exact opposite of "preserve the surrounding YAML byte-for-byte".) + /// - When the block is present, its bullet rows are replaced with the + /// new items at the same indent. Items containing YAML-special + /// characters (`:` `#` `@` or leading whitespace) are single-quoted + /// defensively. + public static func setList( + in yaml: String, + platform: String, + key: String, + items: [String] + ) -> String { + let blockIndent = 6 // `gateway:\n platforms:\n :\n :` + let itemIndent = 8 + + let lines = yaml.components(separatedBy: "\n") + let blockHeaderText = " \(key):" // indented match for find() + let trimmedItems = items.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + + // Locate ` :` whose lineage is gateway → platforms → . + // We find the start of the gateway block, walk down the indent tree, and + // bail out if any ancestor is missing. + let location = locateBlock( + in: lines, + platform: platform, + key: key + ) + + switch location { + case .found(let blockRange): + return replaceBlock( + in: lines, + blockRange: blockRange, + key: key, + items: trimmedItems, + blockIndent: blockIndent, + itemIndent: itemIndent + ) + case .platformPresentKeyMissing(let insertAfter): + if trimmedItems.isEmpty { + // No-op: empty target, no existing block. + return yaml + } + return spliceNewKey( + lines: lines, + insertAfterLineIndex: insertAfter, + key: key, + items: trimmedItems, + itemIndent: itemIndent + ) + case .ancestorMissing: + if trimmedItems.isEmpty { + // Nothing to write, no existing block. + return yaml + } + return appendScaffold( + yaml: yaml, + platform: platform, + key: key, + items: trimmedItems + ) + } + + // (unreachable — switch is exhaustive) + _ = blockHeaderText + } + + /// Async wrapper that reads, mutates, writes via the given context. + /// Returns `false` on read or write failure. + /// + /// The actual I/O happens via `ServerContext.readText` / `writeText`, + /// which are `nonisolated` — safe to call from `MainActor` for the + /// short config.yaml writes the platform setup forms run. For remote + /// hosts the call rounds through SCP under `Task.detached` upstream + /// (per Swift 6 concurrency rules in `~/.claude/CLAUDE.md`). + public static func saveList( + context: ServerContext, + platform: String, + key: String, + items: [String] + ) -> Bool { + let path = context.paths.configYAML + let existing = context.readText(path) ?? "" + let updated = setList(in: existing, platform: platform, key: key, items: items) + if updated == existing { return true } // no-op: already correct + return context.writeText(path, content: updated) + } + + // MARK: - Internals + + /// Result of locating the targeted block in the YAML line array. + private enum BlockLocation { + /// Block found; the closed range covers the header line + all bullet + /// rows attributed to it. Replacing this slice with the new block + /// completes the edit. + case found(ClosedRange) + /// `gateway → platforms → ` exists, but the leaf `:` + /// is absent under it. The associated value is the line index after + /// which the new key should be inserted (last line in the platform's + /// block, or the platform header itself if the platform's body is + /// empty). + case platformPresentKeyMissing(insertAfter: Int) + /// One of the ancestor section headers is missing. The whole + /// scaffold needs to be appended. + case ancestorMissing + } + + private static func locateBlock( + in lines: [String], + platform: String, + key: String + ) -> BlockLocation { + // Walk top-to-bottom looking for `gateway:` at indent 0. + guard let gatewayIdx = firstIndex(of: lines, headerLineEqualTo: "gateway:", indent: 0) else { + return .ancestorMissing + } + // Inside `gateway:`, find ` platforms:` at indent 2. + guard let platformsIdx = firstIndex( + of: lines, + after: gatewayIdx, + headerLineEqualTo: "platforms:", + indent: 2, + stopWhenIndentLessThan: 2 + ) else { + return .ancestorMissing + } + // Inside `platforms:`, find ` :` at indent 4. + guard let platformIdx = firstIndex( + of: lines, + after: platformsIdx, + headerLineEqualTo: "\(platform):", + indent: 4, + stopWhenIndentLessThan: 4 + ) else { + return .ancestorMissing + } + + // Inside the platform block, find `:` at indent 6, OR the end + // of the platform's body if the key is missing. + var keyIdx: Int? + var lastBodyIdx = platformIdx + var i = platformIdx + 1 + while i < lines.count { + let line = lines[i] + let indent = leadingSpaces(line) + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { + i += 1 + continue + } + if indent < 6 { + // Out of the platform's block. + break + } + if indent == 6 && trimmed == "\(key):" { + keyIdx = i + break + } + lastBodyIdx = i + i += 1 + } + + guard let keyIdx else { + return .platformPresentKeyMissing(insertAfter: lastBodyIdx) + } + + // Walk down the bullet rows until we leave the block (indent shrinks + // below the bullet indent OR we hit a sibling key at indent 6). + var endIdx = keyIdx + var j = keyIdx + 1 + while j < lines.count { + let line = lines[j] + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { + j += 1 + continue + } + let indent = leadingSpaces(line) + // Block-style YAML allows bullets at the same indent as their + // parent key; tolerate 6-space `- item` rows alongside the + // canonical 8-space ones. + let isBullet = trimmed.hasPrefix("- ") + if isBullet && (indent == 8 || indent == 6) { + endIdx = j + j += 1 + continue + } + // Anything not a bullet at indent ≥ 8 ends the block. + if indent <= 6 { + break + } + // Indent > 8 with no bullet — unusual but tolerate (e.g. inline + // continuation). Treat as still in the block and advance. + endIdx = j + j += 1 + } + + return .found(keyIdx...endIdx) + } + + private static func replaceBlock( + in lines: [String], + blockRange: ClosedRange, + key: String, + items: [String], + blockIndent: Int, + itemIndent: Int + ) -> String { + var newLines = Array(lines.prefix(blockRange.lowerBound)) + if !items.isEmpty { + newLines.append("\(spaces(blockIndent))\(key):") + for item in items { + newLines.append("\(spaces(itemIndent))- \(yamlQuoteIfNeeded(item))") + } + } + // Drop the old block but keep everything after it. + let tailStart = blockRange.upperBound + 1 + if tailStart < lines.count { + newLines.append(contentsOf: lines.suffix(from: tailStart)) + } + return newLines.joined(separator: "\n") + } + + private static func spliceNewKey( + lines: [String], + insertAfterLineIndex: Int, + key: String, + items: [String], + itemIndent: Int + ) -> String { + var newLines = Array(lines.prefix(insertAfterLineIndex + 1)) + newLines.append(" \(key):") + for item in items { + newLines.append("\(spaces(itemIndent))- \(yamlQuoteIfNeeded(item))") + } + if insertAfterLineIndex + 1 < lines.count { + newLines.append(contentsOf: lines.suffix(from: insertAfterLineIndex + 1)) + } + return newLines.joined(separator: "\n") + } + + private static func appendScaffold( + yaml: String, + platform: String, + key: String, + items: [String] + ) -> String { + var trimmed = yaml + // Ensure exactly one trailing newline before the appended block, + // so the scaffold sits on its own line cleanly. + while trimmed.hasSuffix("\n\n") { + trimmed.removeLast() + } + if !trimmed.isEmpty && !trimmed.hasSuffix("\n") { + trimmed.append("\n") + } + var lines: [String] = [] + if !trimmed.isEmpty { + lines.append("") // blank separator + } + lines.append("gateway:") + lines.append(" platforms:") + lines.append(" \(platform):") + lines.append(" \(key):") + for item in items { + lines.append(" - \(yamlQuoteIfNeeded(item))") + } + lines.append("") // trailing newline so subsequent edits append cleanly + return trimmed + lines.joined(separator: "\n") + } + + // MARK: - YAML scanning helpers + + private static func leadingSpaces(_ line: String) -> Int { + var n = 0 + for c in line { + if c == " " { n += 1 } else { break } + } + return n + } + + /// Find the first line whose trimmed content equals `header` AND whose + /// leading-space count equals `indent`. Comment-only and blank lines + /// are skipped. Returns the line's index or `nil`. + private static func firstIndex( + of lines: [String], + headerLineEqualTo header: String, + indent: Int + ) -> Int? { + for (i, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + if leadingSpaces(line) == indent && trimmed == header { + return i + } + } + return nil + } + + /// Scoped variant: search starts at `after + 1`, stops if a line at indent + /// `< stopWhenIndentLessThan` is encountered (we've left the parent block). + private static func firstIndex( + of lines: [String], + after: Int, + headerLineEqualTo header: String, + indent: Int, + stopWhenIndentLessThan: Int + ) -> Int? { + var i = after + 1 + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { + i += 1 + continue + } + let lineIndent = leadingSpaces(line) + if lineIndent < stopWhenIndentLessThan { + return nil + } + if lineIndent == indent && trimmed == header { + return i + } + i += 1 + } + return nil + } + + private static func spaces(_ n: Int) -> String { + String(repeating: " ", count: n) + } + + /// Quote a YAML scalar if it contains characters that the parser would + /// otherwise interpret as structure (colon, hash, leading at-sign, etc.). + /// Plain alphanumeric IDs (the common case for Slack channel IDs and + /// Telegram numeric chat IDs) are emitted unquoted. + private static func yamlQuoteIfNeeded(_ raw: String) -> String { + if raw.isEmpty { return "''" } + let needsQuoting = raw.contains(":") + || raw.contains("#") + || raw.contains("&") + || raw.contains("*") + || raw.contains(">") + || raw.contains("|") + || raw.first == "@" + || raw.first == "-" + || raw.first == " " + || raw.last == " " + || raw.first == "\"" + || raw.first == "'" + if !needsQuoting { return raw } + // Single-quote, escaping any embedded single quotes by doubling. + let escaped = raw.replacingOccurrences(of: "'", with: "''") + return "'\(escaped)'" + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesGatewayListService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesGatewayListService.swift new file mode 100644 index 0000000..3f960cd --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesGatewayListService.swift @@ -0,0 +1,151 @@ +import Foundation + +/// Cross-profile snapshot returned by `hermes gateway list --json` (Hermes +/// v0.13+). Each profile is one configured Messaging Gateway instance — most +/// users have a single `default` profile, but power users keep separate +/// profiles for work / personal / project-specific accounts. +public struct GatewayListSnapshot: Sendable, Equatable { + public struct ProfileEntry: Sendable, Equatable { + public let profile: String + public let isRunning: Bool + public let pid: Int? + public let platforms: [String] // platform names connected/configured + + public init( + profile: String, + isRunning: Bool, + pid: Int?, + platforms: [String] + ) { + self.profile = profile + self.isRunning = isRunning + self.pid = pid + self.platforms = platforms + } + } + public let profiles: [ProfileEntry] + public let detectedAt: Date + + public init(profiles: [ProfileEntry], detectedAt: Date = Date()) { + self.profiles = profiles + self.detectedAt = detectedAt + } + + /// One-line digest for the Messaging Gateway page header. Format depends + /// on shape: + /// - 0 profiles: `"no profiles configured"` + /// - 1 profile, running: `"default profile · running · slack, telegram"` + /// - 1 profile, stopped: `"default profile · stopped"` + /// - >1 profile: `"3 profiles (2 running) · default: slack, telegram"` + public var headerDigest: String { + if profiles.isEmpty { return "no profiles configured" } + + if profiles.count == 1 { + let p = profiles[0] + let state = p.isRunning ? "running" : "stopped" + if p.isRunning && !p.platforms.isEmpty { + let plats = p.platforms.joined(separator: ", ") + return "\(p.profile) profile · \(state) · \(plats)" + } + return "\(p.profile) profile · \(state)" + } + + let runningCount = profiles.filter(\.isRunning).count + // Surface the platforms of the first running profile (or first profile + // if none are running) so the digest carries one specimen of context + // beyond just counts. + let highlight = profiles.first(where: \.isRunning) ?? profiles[0] + let platsClause: String + if highlight.platforms.isEmpty { + platsClause = "" + } else { + platsClause = " · \(highlight.profile): \(highlight.platforms.joined(separator: ", "))" + } + return "\(profiles.count) profiles (\(runningCount) running)\(platsClause)" + } +} + +/// Pure parser + sync fetcher for `hermes gateway list --json`. Pre-v0.13 +/// hosts exit non-zero on the unknown subcommand; the fetcher returns `nil` +/// in that case so the digest row hides itself. +/// +/// The detection is **synchronous** — run from a `Task.detached` to avoid +/// blocking MainActor on remote SSH round-trips. The pure `parse(_:)` +/// helper has no I/O and can be used in tests against canned JSON. +public enum HermesGatewayListService { + + /// Parse a JSON blob from `hermes gateway list --json` into a snapshot. + /// Tolerant of unknown keys; returns `nil` for unparseable / empty input. + /// + /// // TODO(WS-5-Q3): the JSON shape below is the plan's best-guess. + /// Confirm against actual Hermes v0.13 output once available. Possible + /// alternative shapes: + /// - root array of profile objects (no `profiles` wrapper) + /// - `state` enum string instead of `running` bool + /// - `connected_platforms` instead of `platforms` + /// The parser is intentionally tolerant so a small shape change can be + /// absorbed by tweaking field names without breaking older fixtures. + public static func parse(_ json: Data) -> GatewayListSnapshot? { + guard !json.isEmpty, + let raw = try? JSONSerialization.jsonObject(with: json) else { + return nil + } + + // Accept both `{"profiles": [...]}` and a bare `[...]` of profiles. + let profilesArray: [Any] + if let dict = raw as? [String: Any], let arr = dict["profiles"] as? [Any] { + profilesArray = arr + } else if let arr = raw as? [Any] { + profilesArray = arr + } else { + return nil + } + + var entries: [GatewayListSnapshot.ProfileEntry] = [] + for raw in profilesArray { + guard let obj = raw as? [String: Any] else { continue } + let profile = (obj["name"] as? String) + ?? (obj["profile"] as? String) + ?? "default" + let isRunning: Bool + if let v = obj["running"] as? Bool { + isRunning = v + } else if let s = obj["state"] as? String { + isRunning = s.lowercased() == "running" + } else { + isRunning = false + } + let pid = obj["pid"] as? Int + let platforms = (obj["platforms"] as? [String]) + ?? (obj["connected_platforms"] as? [String]) + ?? [] + entries.append(GatewayListSnapshot.ProfileEntry( + profile: profile, + isRunning: isRunning, + pid: pid, + platforms: platforms + )) + } + return GatewayListSnapshot(profiles: entries) + } + + /// Synchronous fetch helper — call from a `Task.detached`. Returns + /// `nil` when the subcommand fails (pre-v0.13 host) or when the + /// output isn't parseable. + public static func fetch(context: ServerContext) -> GatewayListSnapshot? { + let transport = context.makeTransport() + let executable = context.paths.hermesBinary + do { + let result = try transport.runProcess( + executable: executable, + args: ["gateway", "list", "--json"], + stdin: nil, + timeout: 10 + ) + guard result.exitCode == 0 else { return nil } + return parse(result.stdout) + } catch { + return nil + } + } +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/GatewayAllowlistKindTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/GatewayAllowlistKindTests.swift new file mode 100644 index 0000000..1ffd2a1 --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/GatewayAllowlistKindTests.swift @@ -0,0 +1,70 @@ +import Testing +import Foundation +@testable import ScarfCore + +/// Pure mapping tests for `GatewayAllowlistKind`. Locks down the (platform → +/// kind) table so a refactor doesn't accidentally drop a platform. +@Suite struct GatewayAllowlistKindTests { + + @Test func mapsKnownPlatformsToCorrectKind() { + #expect(GatewayAllowlistKind.kind(for: "slack") == .channels) + #expect(GatewayAllowlistKind.kind(for: "mattermost") == .channels) + #expect(GatewayAllowlistKind.kind(for: "google-chat") == .channels) + #expect(GatewayAllowlistKind.kind(for: "telegram") == .chats) + #expect(GatewayAllowlistKind.kind(for: "whatsapp") == .chats) + #expect(GatewayAllowlistKind.kind(for: "matrix") == .rooms) + #expect(GatewayAllowlistKind.kind(for: "dingtalk") == .rooms) + } + + @Test func acceptsBothGoogleChatSpellings() { + // // TODO(WS-5-Q1) — both spellings round-trip until Hermes confirms + // the wire identifier. + #expect(GatewayAllowlistKind.kind(for: "google-chat") == .channels) + #expect(GatewayAllowlistKind.kind(for: "googlechat") == .channels) + } + + @Test func returnsNilForPlatformsWithoutAllowlist() { + #expect(GatewayAllowlistKind.kind(for: "cli") == nil) + #expect(GatewayAllowlistKind.kind(for: "yuanbao") == nil) + #expect(GatewayAllowlistKind.kind(for: "microsoft-teams") == nil) + #expect(GatewayAllowlistKind.kind(for: "discord") == nil) + #expect(GatewayAllowlistKind.kind(for: "signal") == nil) + #expect(GatewayAllowlistKind.kind(for: "homeassistant") == nil) + #expect(GatewayAllowlistKind.kind(for: "") == nil) + #expect(GatewayAllowlistKind.kind(for: "unknown") == nil) + } + + @Test func yamlKeyMatchesHermesContract() { + #expect(GatewayAllowlistKind.channels.yamlKey == "allowed_channels") + #expect(GatewayAllowlistKind.chats.yamlKey == "allowed_chats") + #expect(GatewayAllowlistKind.rooms.yamlKey == "allowed_rooms") + } + + @Test func nounsAreUserFacingSafe() { + #expect(GatewayAllowlistKind.channels.noun == "channel") + #expect(GatewayAllowlistKind.chats.noun == "chat") + #expect(GatewayAllowlistKind.rooms.noun == "room") + #expect(GatewayAllowlistKind.channels.pluralNoun == "channels") + #expect(GatewayAllowlistKind.chats.pluralNoun == "chats") + #expect(GatewayAllowlistKind.rooms.pluralNoun == "rooms") + } + + @Test func placeholdersAreNonEmpty() { + // Smoke test — placeholder strings are advisory; we just don't want + // them silently emptied during a refactor. + #expect(!GatewayAllowlistKind.channels.inputPlaceholder.isEmpty) + #expect(!GatewayAllowlistKind.chats.inputPlaceholder.isEmpty) + #expect(!GatewayAllowlistKind.rooms.inputPlaceholder.isEmpty) + } + + @Test func gatewayPlatformSettingsItemsForKind() { + let s = GatewayPlatformSettings( + allowedChannels: ["C01"], + allowedChats: ["@user"], + allowedRooms: ["!room:matrix.org"] + ) + #expect(s.items(for: .channels) == ["C01"]) + #expect(s.items(for: .chats) == ["@user"]) + #expect(s.items(for: .rooms) == ["!room:matrix.org"]) + } +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/GatewayConfigWriterTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/GatewayConfigWriterTests.swift new file mode 100644 index 0000000..99b75be --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/GatewayConfigWriterTests.swift @@ -0,0 +1,276 @@ +import Testing +import Foundation +@testable import ScarfCore + +/// Round-trip + idempotence tests for `GatewayConfigWriter.setList`. Pure +/// `String` operations only — runs cleanly on Linux SwiftPM. +@Suite struct GatewayConfigWriterTests { + + // MARK: - Insert + + @Test func setListInsertsBlockOnEmpty() { + let yaml = "" + let updated = GatewayConfigWriter.setList( + in: yaml, + platform: "slack", + key: "allowed_channels", + items: ["C0123ABCD", "C0456EFGH"] + ) + #expect(updated.contains("gateway:")) + #expect(updated.contains(" platforms:")) + #expect(updated.contains(" slack:")) + #expect(updated.contains(" allowed_channels:")) + #expect(updated.contains("- C0123ABCD")) + #expect(updated.contains("- C0456EFGH")) + } + + @Test func setListAppendsScaffoldPreservingPriorContent() { + let yaml = """ + model: + default: gpt-4o + provider: openai + """ + let updated = GatewayConfigWriter.setList( + in: yaml, + platform: "slack", + key: "allowed_channels", + items: ["C01"] + ) + // Original content preserved verbatim at the top. + #expect(updated.contains("model:")) + #expect(updated.contains(" default: gpt-4o")) + #expect(updated.contains(" provider: openai")) + // New scaffold appended. + #expect(updated.contains("gateway:")) + #expect(updated.contains(" slack:")) + #expect(updated.contains("- C01")) + } + + // MARK: - Replace + + @Test func setListReplacesExistingBlock() { + let yaml = """ + gateway: + platforms: + slack: + allowed_channels: + - C_OLD_1 + - C_OLD_2 + """ + let updated = GatewayConfigWriter.setList( + in: yaml, + platform: "slack", + key: "allowed_channels", + items: ["C_NEW_1"] + ) + #expect(updated.contains("- C_NEW_1")) + #expect(!updated.contains("- C_OLD_1")) + #expect(!updated.contains("- C_OLD_2")) + } + + @Test func setListPreservesScalarSiblings() { + // The `busy_ack_enabled` scalar sibling of `allowed_channels` must + // stay byte-for-byte after a list-write to the same platform. + let yaml = """ + gateway: + platforms: + slack: + allowed_channels: + - C_OLD + busy_ack_enabled: false + gateway_restart_notification: true + """ + let updated = GatewayConfigWriter.setList( + in: yaml, + platform: "slack", + key: "allowed_channels", + items: ["C_NEW"] + ) + #expect(updated.contains("- C_NEW")) + #expect(!updated.contains("- C_OLD")) + // Scalars at the same indent must survive. + #expect(updated.contains("busy_ack_enabled: false")) + #expect(updated.contains("gateway_restart_notification: true")) + } + + @Test func setListPreservesOtherPlatformsBlocks() { + // Editing slack must not touch matrix. + let yaml = """ + gateway: + platforms: + slack: + allowed_channels: + - C_SLACK + matrix: + allowed_rooms: + - '!room1:matrix.org' + - '!room2:matrix.org' + """ + let updated = GatewayConfigWriter.setList( + in: yaml, + platform: "slack", + key: "allowed_channels", + items: ["C_SLACK_NEW"] + ) + #expect(updated.contains("- C_SLACK_NEW")) + // Matrix block intact. + #expect(updated.contains(" matrix:")) + #expect(updated.contains("'!room1:matrix.org'")) + #expect(updated.contains("'!room2:matrix.org'")) + } + + // MARK: - Remove + + @Test func setListWithEmptyItemsRemovesBlock() { + let yaml = """ + gateway: + platforms: + slack: + allowed_channels: + - C01 + - C02 + busy_ack_enabled: true + """ + let updated = GatewayConfigWriter.setList( + in: yaml, + platform: "slack", + key: "allowed_channels", + items: [] + ) + // Block removed; sibling scalar preserved. + #expect(!updated.contains("allowed_channels:")) + #expect(!updated.contains("- C01")) + #expect(!updated.contains("- C02")) + #expect(updated.contains("busy_ack_enabled: true")) + } + + @Test func setListWithEmptyItemsOnAbsentBlockIsNoOp() { + let yaml = """ + model: + default: gpt-4o + """ + let updated = GatewayConfigWriter.setList( + in: yaml, + platform: "slack", + key: "allowed_channels", + items: [] + ) + #expect(updated == yaml) + } + + // MARK: - Idempotence + + @Test func setListIsIdempotent() { + let yaml = """ + model: + default: gpt-4o + """ + let once = GatewayConfigWriter.setList( + in: yaml, + platform: "telegram", + key: "allowed_chats", + items: ["@alice", "@bob"] + ) + let twice = GatewayConfigWriter.setList( + in: once, + platform: "telegram", + key: "allowed_chats", + items: ["@alice", "@bob"] + ) + #expect(once == twice) + } + + @Test func setListReplaceThenReplaceIsStable() { + let yaml = "" + let a = GatewayConfigWriter.setList( + in: yaml, platform: "matrix", key: "allowed_rooms", + items: ["!a:m", "!b:m"] + ) + let b = GatewayConfigWriter.setList( + in: a, platform: "matrix", key: "allowed_rooms", + items: ["!c:m"] + ) + #expect(b.contains("- '!c:m'")) + #expect(!b.contains("'!a:m'")) + #expect(!b.contains("'!b:m'")) + } + + // MARK: - Quoting + + @Test func setListQuotesItemsContainingColons() { + // Matrix room IDs contain `:` — must be single-quoted. + let yaml = "" + let updated = GatewayConfigWriter.setList( + in: yaml, platform: "matrix", key: "allowed_rooms", + items: ["!RoomId:matrix.org"] + ) + #expect(updated.contains("'!RoomId:matrix.org'")) + } + + @Test func setListQuotesItemsStartingWithAt() { + // Telegram usernames `@alice`. + let yaml = "" + let updated = GatewayConfigWriter.setList( + in: yaml, platform: "telegram", key: "allowed_chats", + items: ["@alice"] + ) + #expect(updated.contains("'@alice'")) + } + + @Test func setListLeavesPlainAlphanumericUnquoted() { + // Slack channel IDs are A-Z0-9 — emit unquoted for readability. + let yaml = "" + let updated = GatewayConfigWriter.setList( + in: yaml, platform: "slack", key: "allowed_channels", + items: ["C0123ABCD"] + ) + #expect(updated.contains("- C0123ABCD")) + #expect(!updated.contains("'C0123ABCD'")) + } + + @Test func setListEscapesEmbeddedSingleQuotes() { + let yaml = "" + let updated = GatewayConfigWriter.setList( + in: yaml, platform: "slack", key: "allowed_channels", + items: ["weird:'name"] + ) + // Embedded single quote doubled per YAML spec. + #expect(updated.contains("'weird:''name'")) + } + + // MARK: - Insertion when ancestors exist but key is absent + + @Test func setListInsertsKeyUnderExistingPlatformBlock() { + // `gateway → platforms → slack` exists with a busy_ack_enabled + // scalar; `allowed_channels` is missing. Add it without disturbing + // the scalar sibling. + let yaml = """ + gateway: + platforms: + slack: + busy_ack_enabled: false + """ + let updated = GatewayConfigWriter.setList( + in: yaml, platform: "slack", key: "allowed_channels", + items: ["C42"] + ) + #expect(updated.contains("busy_ack_enabled: false")) + #expect(updated.contains("allowed_channels:")) + #expect(updated.contains("- C42")) + } + + // MARK: - Round-trip with the YAML loader + + @Test func roundTripsThroughHermesConfigYAMLLoader() { + // Write a list, then parse the result through HermesConfig+YAML and + // confirm we read back what we wrote. + var yaml = "" + yaml = GatewayConfigWriter.setList( + in: yaml, platform: "slack", key: "allowed_channels", + items: ["C01", "C02"] + ) + let cfg = HermesConfig(yaml: yaml) + let block = cfg.gatewayPlatforms["slack"] + #expect(block?.allowedChannels == ["C01", "C02"]) + } +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesGatewayListServiceTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesGatewayListServiceTests.swift new file mode 100644 index 0000000..2fee633 --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesGatewayListServiceTests.swift @@ -0,0 +1,131 @@ +import Testing +import Foundation +@testable import ScarfCore + +/// Parser tests for `hermes gateway list --json`. Pure — no transport, no +/// process calls. +@Suite struct HermesGatewayListServiceTests { + + private func data(_ s: String) -> Data { s.data(using: .utf8)! } + + @Test func parsesSingleProfileSinglePlatform() { + let json = data(#""" + {"profiles":[{"name":"default","running":true,"pid":1234, + "platforms":["slack","telegram"]}]} + """#) + let snap = HermesGatewayListService.parse(json) + #expect(snap?.profiles.count == 1) + #expect(snap?.profiles[0].profile == "default") + #expect(snap?.profiles[0].pid == 1234) + #expect(snap?.profiles[0].isRunning == true) + #expect(snap?.profiles[0].platforms == ["slack", "telegram"]) + } + + @Test func parsesMultipleProfiles() { + let json = data(#""" + {"profiles":[ + {"name":"work","running":true,"pid":2001,"platforms":["slack"]}, + {"name":"personal","running":false,"platforms":["telegram"]} + ]} + """#) + let snap = HermesGatewayListService.parse(json) + #expect(snap?.profiles.count == 2) + #expect(snap?.profiles[0].profile == "work") + #expect(snap?.profiles[0].isRunning == true) + #expect(snap?.profiles[1].profile == "personal") + #expect(snap?.profiles[1].isRunning == false) + #expect(snap?.profiles[1].pid == nil) + } + + @Test func parsesBareArrayShape() { + // Tolerance for a top-level array (no `profiles` wrapper). + let json = data(#""" + [{"name":"default","running":true,"pid":42,"platforms":["discord"]}] + """#) + let snap = HermesGatewayListService.parse(json) + #expect(snap?.profiles.count == 1) + #expect(snap?.profiles[0].profile == "default") + } + + @Test func toleratesAlternateFieldNames() { + // `profile` instead of `name`, `state` instead of `running`, + // `connected_platforms` instead of `platforms` — defensive defaults + // keep the parser happy if Hermes ships any of these. + let json = data(#""" + {"profiles":[{"profile":"alt","state":"running","pid":7, + "connected_platforms":["matrix"]}]} + """#) + let snap = HermesGatewayListService.parse(json) + #expect(snap?.profiles[0].profile == "alt") + #expect(snap?.profiles[0].isRunning == true) + #expect(snap?.profiles[0].platforms == ["matrix"]) + } + + @Test func returnsNilOnEmptyData() { + #expect(HermesGatewayListService.parse(Data()) == nil) + } + + @Test func returnsNilOnUnparseableJSON() { + let json = data("not-json") + #expect(HermesGatewayListService.parse(json) == nil) + } + + @Test func returnsEmptySnapshotOnEmptyProfilesArray() { + let json = data(#"{"profiles":[]}"#) + let snap = HermesGatewayListService.parse(json) + #expect(snap?.profiles.isEmpty == true) + } + + @Test func toleratesUnknownKeys() { + // Forward-compat: a future v0.13.x Hermes adds extra fields, parser + // still works. + let json = data(#""" + {"profiles":[{"name":"default","running":true,"platforms":["slack"], + "future_field":"value","another":42}]} + """#) + let snap = HermesGatewayListService.parse(json) + #expect(snap?.profiles[0].profile == "default") + } + + // MARK: - headerDigest + + @Test func headerDigestEmptyProfiles() { + let snap = GatewayListSnapshot(profiles: []) + #expect(snap.headerDigest == "no profiles configured") + } + + @Test func headerDigestSingleProfileRunning() { + let snap = GatewayListSnapshot(profiles: [ + .init(profile: "default", isRunning: true, pid: 100, + platforms: ["slack", "telegram"]) + ]) + #expect(snap.headerDigest == "default profile · running · slack, telegram") + } + + @Test func headerDigestSingleProfileStopped() { + let snap = GatewayListSnapshot(profiles: [ + .init(profile: "default", isRunning: false, pid: nil, platforms: []) + ]) + #expect(snap.headerDigest == "default profile · stopped") + } + + @Test func headerDigestMultipleProfilesSomeRunning() { + let snap = GatewayListSnapshot(profiles: [ + .init(profile: "work", isRunning: true, pid: 1, platforms: ["slack"]), + .init(profile: "home", isRunning: false, pid: nil, platforms: ["matrix"]), + .init(profile: "extra", isRunning: true, pid: 2, platforms: []) + ]) + // 3 profiles total, 2 running, surface first running profile's + // platform list as the highlight. + #expect(snap.headerDigest == "3 profiles (2 running) · work: slack") + } + + @Test func headerDigestMultipleProfilesNoneRunning() { + let snap = GatewayListSnapshot(profiles: [ + .init(profile: "a", isRunning: false, pid: nil, platforms: ["slack"]), + .init(profile: "b", isRunning: false, pid: nil, platforms: ["matrix"]) + ]) + // No running profile — fall back to the first profile's platforms. + #expect(snap.headerDigest == "2 profiles (0 running) · a: slack") + } +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M6ConfigCronTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M6ConfigCronTests.swift index 929710b..00dc4c1 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M6ConfigCronTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M6ConfigCronTests.swift @@ -228,6 +228,87 @@ import Foundation #expect(c.timezone == "America/New_York") } + // MARK: - v0.13 gateway.platforms. block + + @Test func gatewayPlatformsEmptyByDefault() { + let c = HermesConfig(yaml: "") + #expect(c.gatewayPlatforms.isEmpty) + } + + @Test func parsesGatewayAllowlistsForSlack() { + let yaml = """ + gateway: + platforms: + slack: + allowed_channels: + - C01 + - C02 + busy_ack_enabled: false + gateway_restart_notification: true + slash_command_notice_ttl_seconds: 120 + """ + let cfg = HermesConfig(yaml: yaml) + let block = cfg.gatewayPlatforms["slack"] + #expect(block?.allowedChannels == ["C01", "C02"]) + #expect(block?.busyAckEnabled == false) + #expect(block?.gatewayRestartNotification == true) + #expect(block?.slashCommandNoticeTTLSeconds == 120) + } + + @Test func parsesGatewayAllowlistsForTelegramAndMatrix() { + let yaml = """ + gateway: + platforms: + telegram: + allowed_chats: + - '@alice' + - '12345' + matrix: + allowed_rooms: + - '!room:matrix.org' + """ + let cfg = HermesConfig(yaml: yaml) + #expect(cfg.gatewayPlatforms["telegram"]?.allowedChats == ["@alice", "12345"]) + #expect(cfg.gatewayPlatforms["matrix"]?.allowedRooms == ["!room:matrix.org"]) + } + + @Test func gatewayBlockCoexistsWithLegacyPlatformBlocks() { + // Regression: legacy `platforms.slack.reply_to_mode` and + // `matrix.require_mention` must keep parsing when the new + // `gateway:` block is also present — no key collisions. + let yaml = """ + platforms: + slack: + reply_to_mode: all + matrix: + require_mention: false + gateway: + platforms: + slack: + allowed_channels: + - C01 + """ + let cfg = HermesConfig(yaml: yaml) + #expect(cfg.slack.replyToMode == "all") + #expect(cfg.matrix.requireMention == false) + #expect(cfg.gatewayPlatforms["slack"]?.allowedChannels == ["C01"]) + } + + @Test func gatewayPlatformsSkipsPlatformsWithoutV013Keys() { + // The `gateway:` block exists but only Slack has a v0.13 key — + // platforms without keys must NOT appear in `gatewayPlatforms`. + let yaml = """ + gateway: + platforms: + slack: + busy_ack_enabled: true + """ + let cfg = HermesConfig(yaml: yaml) + #expect(cfg.gatewayPlatforms["slack"] != nil) + #expect(cfg.gatewayPlatforms["mattermost"] == nil) + #expect(cfg.gatewayPlatforms["telegram"] == nil) + } + @Test func cronScheduleMemberwise() { let s = CronSchedule( kind: "cron", diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 3938d09..cb49c9e 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -254,6 +254,47 @@ struct HermesFileService: Sendable { cooldownSeconds: int("platforms.homeassistant.extra.cooldown_seconds", default: 30) ) + // -- v0.13: per-platform Messaging Gateway settings -------------- + // Mirrors the canonical extractor in + // `ScarfCore/Parsing/HermesConfig+YAML.swift`. Behaviour parity + // matters: both parsers must populate `gatewayPlatforms` the same + // way so iOS and Mac surfaces stay in lockstep. + // TODO(WS-5-Q2): YAML key path unverified — see the comment in the + // ScarfCore extractor for the resolution path. + let gatewayAllowlistPlatforms = [ + "slack", "mattermost", "google-chat", + "telegram", "whatsapp", + "matrix", "dingtalk", + ] + var gatewayPlatforms: [String: GatewayPlatformSettings] = [:] + for platform in gatewayAllowlistPlatforms { + let prefix = "gateway.platforms.\(platform)." + let allowedChannels = lists[prefix + "allowed_channels"] ?? [] + let allowedChats = lists[prefix + "allowed_chats"] ?? [] + let allowedRooms = lists[prefix + "allowed_rooms"] ?? [] + let busy = bool(prefix + "busy_ack_enabled", default: true) + let restartNotice = bool(prefix + "gateway_restart_notification", + default: false) + let ttl = int(prefix + "slash_command_notice_ttl_seconds", + default: 0) + let isEmpty = allowedChannels.isEmpty + && allowedChats.isEmpty + && allowedRooms.isEmpty + && values[prefix + "busy_ack_enabled"] == nil + && values[prefix + "gateway_restart_notification"] == nil + && values[prefix + "slash_command_notice_ttl_seconds"] == nil + if !isEmpty { + gatewayPlatforms[platform] = GatewayPlatformSettings( + allowedChannels: allowedChannels, + allowedChats: allowedChats, + allowedRooms: allowedRooms, + busyAckEnabled: busy, + gatewayRestartNotification: restartNotice, + slashCommandNoticeTTLSeconds: ttl + ) + } + } + return HermesConfig( model: str("model.default", default: "unknown"), provider: str("model.provider", default: "unknown"), @@ -313,7 +354,8 @@ struct HermesFileService: Sendable { homeAssistant: homeAssistant, cacheTTL: str("prompt_caching.cache_ttl", default: "5m"), redactionEnabled: bool("redaction.enabled", default: false), - runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false) + runtimeMetadataFooter: bool("agent.runtime_metadata_footer", default: false), + gatewayPlatforms: gatewayPlatforms ) } diff --git a/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift b/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift index bf8754f..298a540 100644 --- a/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift +++ b/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift @@ -1,7 +1,13 @@ import Foundation import ScarfCore -struct GatewayInfo { +// **Local rename for v0.13 / WS-5.** The user-facing label is "Messaging +// Gateway"; the type names mirror that. The `SidebarSection.gateway` enum +// case + `gateway_state.json` / `gateway.log` paths intentionally stay +// unchanged — those aren't user-facing strings, and renaming them would +// churn unrelated callers without changing what users see. + +struct MessagingGatewayInfo { let pid: Int? let state: String let exitReason: String? @@ -37,32 +43,48 @@ struct PendingPairing: Identifiable { } @Observable -final class GatewayViewModel { +@MainActor +final class MessagingGatewayViewModel { let context: ServerContext + /// Capability snapshot at view-init time. Read for the v0.13 cross- + /// profile digest (`hasGatewayList`); other v0.13 surfaces live on + /// per-platform setup views. `.empty` is fine outside the per-server + /// `ContextBoundRoot` (Previews, smoke tests). + let capabilities: HermesCapabilities - init(context: ServerContext = .local) { + init(context: ServerContext = .local, capabilities: HermesCapabilities = .empty) { self.context = context + self.capabilities = capabilities } - var gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false) + var gateway = MessagingGatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false) var approvedUsers: [PairedUser] = [] var pendingPairings: [PendingPairing] = [] var isLoading = false var actionMessage: String? + /// `hermes gateway list --json` snapshot. `nil` when the verb fails + /// (pre-v0.13 host or no profiles registered yet) — the digest row + /// hides itself in that case. + var gatewayList: GatewayListSnapshot? func load() { isLoading = true let ctx = context + let caps = capabilities Task.detached { [weak self] in // Two sync transport calls + two CLI invocations — substantial // remote latency. Detach the whole load and commit at the end. let status = Self.fetchGatewayStatus(context: ctx) let pairing = Self.fetchPairing(context: ctx) + let listSnap = caps.hasGatewayList + ? HermesGatewayListService.fetch(context: ctx) + : nil await MainActor.run { [weak self] in guard let self else { return } self.gateway = status self.approvedUsers = pairing.approved self.pendingPairings = pairing.pending + self.gatewayList = listSnap self.isLoading = false } } @@ -70,7 +92,7 @@ final class GatewayViewModel { /// Static form of the gateway-status walk so the detached load can call /// it without bouncing back to MainActor. - nonisolated private static func fetchGatewayStatus(context: ServerContext) -> GatewayInfo { + nonisolated private static func fetchGatewayStatus(context: ServerContext) -> MessagingGatewayInfo { let stateJSON = context.readData(context.paths.gatewayStateJSON) var pid: Int? var state = "unknown" @@ -102,7 +124,7 @@ final class GatewayViewModel { let isLoaded = statusOutput.contains("service is loaded") let isStale = statusOutput.contains("stale") - return GatewayInfo( + return MessagingGatewayInfo( pid: pid, state: state, exitReason: exitReason, startTime: startTime, updatedAt: updatedAt, platforms: platforms, isLoaded: isLoaded, isStale: isStale diff --git a/scarf/scarf/Features/Gateway/Views/GatewayView.swift b/scarf/scarf/Features/Gateway/Views/GatewayView.swift index 74b4296..97c730d 100644 --- a/scarf/scarf/Features/Gateway/Views/GatewayView.swift +++ b/scarf/scarf/Features/Gateway/Views/GatewayView.swift @@ -2,12 +2,24 @@ import SwiftUI import ScarfCore import ScarfDesign +/// Messaging Gateway page. Routes outbound chat to Discord / Telegram / +/// Slack / etc. — distinct from the v0.10 **Tool Gateway** (Nous Portal +/// subscription routing for web search / image / TTS / browser), which +/// lives under `Features/Health/`. The user-facing label here is always +/// "Messaging Gateway"; the SwiftUI struct stays `GatewayView` because +/// `ContentView` references it by name (rename-on-touch invariant — +/// avoid churning unrelated callers). struct GatewayView: View { - @State private var viewModel: GatewayViewModel + @State private var viewModel: MessagingGatewayViewModel @Environment(HermesFileWatcher.self) private var fileWatcher + @Environment(\.hermesCapabilities) private var capabilitiesStore init(context: ServerContext) { - _viewModel = State(initialValue: GatewayViewModel(context: context)) + // Capabilities arrive via environment after init runs, so the VM + // is constructed with `.empty` and refreshed on first appear via + // `attach(capabilities:)`. Same pattern as the per-platform setup + // views — see `MessagingGatewayViewModel.capabilities` doc comment. + _viewModel = State(initialValue: MessagingGatewayViewModel(context: context)) } @@ -15,10 +27,15 @@ struct GatewayView: View { VStack(spacing: 0) { ScarfPageHeader( "Messaging Gateway", - subtitle: "Outbound channel bridge — Discord, Telegram, Slack, etc." + subtitle: "Outbound channel bridge — Discord, Telegram, Slack, Google Chat, etc." ) ScrollView { - VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: ScarfSpace.s4) { + if let snap = viewModel.gatewayList, + viewModel.capabilities.hasGatewayList, + !snap.profiles.isEmpty { + crossProfileDigest(snap) + } serviceSection platformsSection pairingSection @@ -29,14 +46,58 @@ struct GatewayView: View { } .background(ScarfColor.backgroundPrimary) .navigationTitle("Messaging Gateway") - .onAppear { viewModel.load() } + .onAppear { + attachCapabilitiesIfNeeded() + viewModel.load() + } .onChange(of: fileWatcher.lastChangeDate) { viewModel.load() } } + /// Re-create the VM with the resolved capabilities the first time the + /// store hands us non-empty data. Same shape as `KanbanBoardView`'s + /// `attach` helper. + private func attachCapabilitiesIfNeeded() { + guard let store = capabilitiesStore, + store.capabilities.detected, + !viewModel.capabilities.detected else { return } + viewModel = MessagingGatewayViewModel( + context: viewModel.context, + capabilities: store.capabilities + ) + } + + // MARK: - v0.13 cross-profile digest + + /// One-line summary above the gateway controls when the host is on + /// v0.13+ and `hermes gateway list --json` returned at least one + /// profile. Doubly-guarded — `hasGatewayList` AND `profiles != []` + /// — so a v0.13 host with no registered profiles doesn't render + /// an empty pill. + private func crossProfileDigest(_ snap: GatewayListSnapshot) -> some View { + HStack(spacing: ScarfSpace.s2) { + Image(systemName: "dot.radiowaves.left.and.right") + .foregroundStyle(ScarfColor.accent) + Text(snap.headerDigest) + .scarfStyle(.captionStrong) + .foregroundStyle(ScarfColor.foregroundPrimary) + Spacer() + } + .padding(.horizontal, ScarfSpace.s3) + .padding(.vertical, ScarfSpace.s2) + .background( + RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous) + .fill(ScarfColor.backgroundSecondary) + ) + .overlay( + RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous) + .strokeBorder(ScarfColor.border, lineWidth: 1) + ) + } + // MARK: - Service private var serviceSection: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: ScarfSpace.s3) { HStack { Text("Service") .font(.headline) @@ -46,15 +107,20 @@ struct GatewayView: View { .font(.caption) .foregroundStyle(.secondary) } - HStack(spacing: 8) { + HStack(spacing: ScarfSpace.s2) { Button("Start") { viewModel.startGateway() } + .buttonStyle(ScarfPrimaryButton()) + .controlSize(.small) Button("Stop") { viewModel.stopGateway() } + .buttonStyle(ScarfSecondaryButton()) + .controlSize(.small) Button("Restart") { viewModel.restartGateway() } + .buttonStyle(ScarfSecondaryButton()) + .controlSize(.small) } - .controlSize(.small) } - HStack(spacing: 16) { + HStack(spacing: ScarfSpace.s3) { StatusBadge( label: viewModel.gateway.state, isActive: viewModel.gateway.state == "running" @@ -97,7 +163,7 @@ struct GatewayView: View { // MARK: - Platforms private var platformsSection: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: ScarfSpace.s2) { Text("Platforms") .font(.headline) if viewModel.gateway.platforms.isEmpty { @@ -105,7 +171,7 @@ struct GatewayView: View { .font(.caption) .foregroundStyle(.secondary) } else { - HStack(spacing: 12) { + HStack(spacing: ScarfSpace.s3) { ForEach(viewModel.gateway.platforms) { platform in VStack(spacing: 6) { Image(systemName: platform.icon) @@ -119,9 +185,9 @@ struct GatewayView: View { ) } .frame(maxWidth: .infinity) - .padding(12) + .padding(ScarfSpace.s3) .background(.quaternary.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.md)) } } } @@ -131,12 +197,12 @@ struct GatewayView: View { // MARK: - Pairing private var pairingSection: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: ScarfSpace.s3) { Text("Paired Users") .font(.headline) if !viewModel.pendingPairings.isEmpty { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: ScarfSpace.s2) { Label("Pending Approvals", systemImage: "clock.badge.questionmark") .font(.caption.bold()) .foregroundStyle(.orange) @@ -150,12 +216,12 @@ struct GatewayView: View { viewModel.approvePairing(platform: pending.platform, code: pending.code) } .controlSize(.small) - .buttonStyle(.borderedProminent) + .buttonStyle(ScarfPrimaryButton()) } .font(.caption) - .padding(8) + .padding(ScarfSpace.s2) .background(.orange.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 6)) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm)) } } } @@ -182,9 +248,9 @@ struct GatewayView: View { } .controlSize(.small) } - .padding(8) + .padding(ScarfSpace.s2) .background(.quaternary.opacity(0.3)) - .clipShape(RoundedRectangle(cornerRadius: 6)) + .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm)) } } } diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/GatewayBehaviorViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/GatewayBehaviorViewModel.swift new file mode 100644 index 0000000..a5b9052 --- /dev/null +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/GatewayBehaviorViewModel.swift @@ -0,0 +1,140 @@ +import Foundation +import ScarfCore +import os + +/// View-model for the v0.13 Messaging Gateway behavior subsection composed +/// into each per-platform setup view. Owns the four v0.13 controls +/// (allowlist + three behavior toggles) so the existing per-platform VMs +/// don't grow another set of fields. +/// +/// Capability-gated. Pre-v0.13 hosts skip the entire subsection (the +/// owning view returns `EmptyView` when none of the v0.13 flags is on), +/// so this VM never has its `save()` called against a host that can't +/// honor it. +@Observable +@MainActor +final class GatewayBehaviorViewModel { + private static let logger = Logger(subsystem: "com.scarf", category: "GatewayBehavior") + + let platform: String + let context: ServerContext + let capabilities: HermesCapabilities + /// Allowlist kind for this platform, or `nil` for platforms without + /// an allowlist surface (Discord, Signal, etc. — `GatewayBehaviorSection` + /// short-circuits before instantiating this VM in that case, but the + /// field is `nil` for safety). + let kind: GatewayAllowlistKind? + + // Allowlist + var items: [String] = [] + + // Behavior toggles + var busyAckEnabled: Bool = true + var gatewayRestartNotification: Bool = false + var slashCommandNoticeTTLSeconds: Int = 0 + + var message: String? + var isSaving: Bool = false + + init( + platform: String, + capabilities: HermesCapabilities, + context: ServerContext = .local + ) { + self.platform = platform + self.capabilities = capabilities + self.context = context + self.kind = GatewayAllowlistKind.kind(for: platform) + } + + /// Hydrate from `~/.hermes/config.yaml`. Called from the section's + /// `.onAppear`. Empty when the platform has no `gateway:` block in + /// the file — defaults match v0.13 server-side defaults so the form + /// looks identical to a fresh-install host. + func load() { + let cfg = HermesFileService(context: context).loadConfig() + let block = cfg.gatewayPlatforms[platform] ?? .empty + if let kind { + switch kind { + case .channels: items = block.allowedChannels + case .chats: items = block.allowedChats + case .rooms: items = block.allowedRooms + } + } else { + items = [] + } + busyAckEnabled = block.busyAckEnabled + gatewayRestartNotification = block.gatewayRestartNotification + slashCommandNoticeTTLSeconds = block.slashCommandNoticeTTLSeconds + } + + /// Persist edits in two phases: + /// + /// 1. **Allowlist write** via `GatewayConfigWriter.saveList` — direct + /// YAML edit, since `hermes config set` can't write list values. + /// Skipped when the platform has no `kind` (no allowlist surface) + /// or the host doesn't advertise `hasGatewayAllowlists`. + /// 2. **Scalar saves** via `PlatformSetupHelpers.saveForm` for the + /// three v0.13 behavior toggles. Each gated on its own capability + /// flag; the TTL field rides on the `hasGatewayBusyAckToggle ‖ + /// hasGatewayRestartNotification` proxy (see WS-5 plan §Open Questions + /// Q5 + WS-1 Decision F). + func save() { + isSaving = true + defer { + isSaving = false + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.message = nil + } + } + + // Step 1: list write via direct YAML edit. Detached so the SCP + // round-trip on remote hosts doesn't block MainActor — local + // writes are still cheap, but the same posture works for both. + if let kind, capabilities.hasGatewayAllowlists { + let trimmed = items + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + let ok = GatewayConfigWriter.saveList( + context: context, + platform: platform, + key: kind.yamlKey, + items: trimmed + ) + if !ok { + Self.logger.warning("GatewayConfigWriter.saveList failed for \(self.platform, privacy: .public)") + message = "Failed to write allowlist to config.yaml" + return + } + } + + // Step 2: scalar saves via `hermes config set`. + var configKV: [String: String] = [:] + let prefix = "gateway.platforms.\(platform)." + if capabilities.hasGatewayBusyAckToggle { + configKV[prefix + "busy_ack_enabled"] = + PlatformSetupHelpers.envBool(busyAckEnabled) + } + if capabilities.hasGatewayRestartNotification { + configKV[prefix + "gateway_restart_notification"] = + PlatformSetupHelpers.envBool(gatewayRestartNotification) + } + // TTL field rides on either of the v0.13 toggles being available — + // proxy gating per WS-1 Decision F + WS-5 Q5. // TODO(WS-5-Q5) + if capabilities.hasGatewayBusyAckToggle + || capabilities.hasGatewayRestartNotification { + configKV[prefix + "slash_command_notice_ttl_seconds"] = + String(slashCommandNoticeTTLSeconds) + } + + if configKV.isEmpty { + message = "Allowlist saved — restart gateway to apply" + return + } + + let result = PlatformSetupHelpers.saveForm( + context: context, envPairs: [:], configKV: configKV + ) + message = result + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/Components/AllowlistEditor.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/Components/AllowlistEditor.swift new file mode 100644 index 0000000..f850d65 --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/Components/AllowlistEditor.swift @@ -0,0 +1,103 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// Reusable list-of-strings editor for v0.13 cross-platform allowlists. +/// Shape: a vertical stack of rows, each with a delete glyph; an "Add row" +/// button at the bottom appends an empty entry. +/// +/// Stateless — binds to the parent VM's `items` array. The VM owns +/// persistence and change tracking; this view is pure presentation. +struct AllowlistEditor: View { + @Binding var items: [String] + let kind: GatewayAllowlistKind + + var body: some View { + VStack(alignment: .leading, spacing: ScarfSpace.s2) { + HStack { + Text("Allowed \(kind.pluralNoun)") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + Spacer() + Text(itemsCountLabel) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + } + + if items.isEmpty { + Text("No restrictions — agent responds in any \(kind.noun).") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + .padding(.vertical, ScarfSpace.s2) + } else { + VStack(spacing: 4) { + ForEach(Array(items.enumerated()), id: \.offset) { idx, _ in + AllowlistRow( + value: Binding( + get: { items[safe: idx] ?? "" }, + set: { newValue in + guard idx < items.count else { return } + items[idx] = newValue + } + ), + placeholder: kind.inputPlaceholder, + onDelete: { + guard idx < items.count else { return } + items.remove(at: idx) + } + ) + } + } + } + + HStack { + Button { + items.append("") + } label: { + Label("Add \(kind.noun)", systemImage: "plus.circle") + .font(.caption) + } + .buttonStyle(.borderless) + Spacer() + } + } + .padding(.horizontal, ScarfSpace.s3) + .padding(.vertical, ScarfSpace.s2) + } + + private var itemsCountLabel: String { + let nonEmpty = items.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }.count + if nonEmpty == 0 { return "0 \(kind.pluralNoun)" } + if nonEmpty == 1 { return "1 \(kind.noun)" } + return "\(nonEmpty) \(kind.pluralNoun)" + } +} + +private struct AllowlistRow: View { + @Binding var value: String + let placeholder: String + let onDelete: () -> Void + + var body: some View { + HStack(spacing: ScarfSpace.s2) { + TextField(placeholder, text: $value) + .textFieldStyle(.roundedBorder) + .font(ScarfFont.monoSmall) + Button { + onDelete() + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(ScarfColor.danger) + } + .buttonStyle(.plain) + .help("Remove") + } + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + guard index >= 0, index < count else { return nil } + return self[index] + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/Components/GatewayBehaviorSection.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/Components/GatewayBehaviorSection.swift new file mode 100644 index 0000000..031085b --- /dev/null +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/Components/GatewayBehaviorSection.swift @@ -0,0 +1,96 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// v0.13 Messaging Gateway behavior subsection composed into each per- +/// platform setup view (Slack, Mattermost, Telegram, WhatsApp, Matrix, +/// Google Chat). Owns its own `@State` view-model so the existing per- +/// platform VMs don't grow another set of fields. +/// +/// **Capability gating.** Hides itself entirely on pre-v0.13 hosts +/// (returns `EmptyView` when none of the three v0.13 flags is on). Each +/// internal control gates on its own flag, so a host that gains, say, +/// `hasGatewayAllowlists` but not `hasGatewayBusyAckToggle` still gets +/// the allowlist editor with the toggles hidden. +struct GatewayBehaviorSection: View { + let platform: String + let capabilities: HermesCapabilities + let context: ServerContext + + @State private var viewModel: GatewayBehaviorViewModel + + init(platform: String, capabilities: HermesCapabilities, context: ServerContext) { + self.platform = platform + self.capabilities = capabilities + self.context = context + _viewModel = State(initialValue: GatewayBehaviorViewModel( + platform: platform, + capabilities: capabilities, + context: context + )) + } + + var body: some View { + // Pre-v0.13 host — hide the entire subsection so the existing + // platform forms look unchanged. Critical regression invariant + // per WS-5 plan §"How to test" #1. + if !capabilities.hasGatewayAllowlists + && !capabilities.hasGatewayBusyAckToggle + && !capabilities.hasGatewayRestartNotification { + EmptyView() + } else { + content + } + } + + private var content: some View { + VStack(alignment: .leading, spacing: ScarfSpace.s3) { + SettingsSection(title: "Gateway behavior (v0.13+)", icon: "dot.radiowaves.left.and.right") { + if capabilities.hasGatewayAllowlists, + let kind = viewModel.kind { + AllowlistEditor( + items: $viewModel.items, + kind: kind + ) + } + if capabilities.hasGatewayBusyAckToggle { + ToggleRow( + label: "Send 'Agent is working…' ack", + isOn: viewModel.busyAckEnabled + ) { viewModel.busyAckEnabled = $0 } + } + if capabilities.hasGatewayRestartNotification { + ToggleRow( + label: "Post 'Gateway restarted' notice on boot", + isOn: viewModel.gatewayRestartNotification + ) { viewModel.gatewayRestartNotification = $0 } + } + // TTL field rides on either v0.13 toggle being available + // — proxy gating per WS-1 Decision F. // TODO(WS-5-Q5) + if capabilities.hasGatewayBusyAckToggle + || capabilities.hasGatewayRestartNotification { + StepperRow( + label: "Auto-delete slash-command notices (s)", + value: viewModel.slashCommandNoticeTTLSeconds, + range: 0...3600, + step: 5 + ) { viewModel.slashCommandNoticeTTLSeconds = $0 } + } + } + + HStack { + if let msg = viewModel.message { + Label(msg, systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } + Spacer() + Button("Save behavior") { viewModel.save() } + .buttonStyle(ScarfPrimaryButton()) + .controlSize(.small) + .disabled(viewModel.isSaving) + } + } + .onAppear { viewModel.load() } + } +} diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift index d24d629..15f6875 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift @@ -4,7 +4,13 @@ import ScarfDesign struct MatrixSetupView: View { @State private var viewModel: MatrixSetupViewModel - init(context: ServerContext) { _viewModel = State(initialValue: MatrixSetupViewModel(context: context)) } + @Environment(\.hermesCapabilities) private var capabilitiesStore + let context: ServerContext + + init(context: ServerContext) { + self.context = context + _viewModel = State(initialValue: MatrixSetupViewModel(context: context)) + } var body: some View { @@ -45,6 +51,13 @@ struct MatrixSetupView: View { } saveBar + + // v0.13 Messaging Gateway behavior — self-hides on pre-v0.13. + GatewayBehaviorSection( + platform: "matrix", + capabilities: capabilitiesStore?.capabilities ?? .empty, + context: context + ) } .onAppear { viewModel.load() } } diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift index e6b6a66..bb299d1 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift @@ -4,7 +4,13 @@ import ScarfDesign struct MattermostSetupView: View { @State private var viewModel: MattermostSetupViewModel - init(context: ServerContext) { _viewModel = State(initialValue: MattermostSetupViewModel(context: context)) } + @Environment(\.hermesCapabilities) private var capabilitiesStore + let context: ServerContext + + init(context: ServerContext) { + self.context = context + _viewModel = State(initialValue: MattermostSetupViewModel(context: context)) + } var body: some View { @@ -28,6 +34,13 @@ struct MattermostSetupView: View { } saveBar + + // v0.13 Messaging Gateway behavior — self-hides on pre-v0.13. + GatewayBehaviorSection( + platform: "mattermost", + capabilities: capabilitiesStore?.capabilities ?? .empty, + context: context + ) } .onAppear { viewModel.load() } } diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift index 032a572..16a2fcf 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift @@ -4,7 +4,13 @@ import ScarfDesign struct SlackSetupView: View { @State private var viewModel: SlackSetupViewModel - init(context: ServerContext) { _viewModel = State(initialValue: SlackSetupViewModel(context: context)) } + @Environment(\.hermesCapabilities) private var capabilitiesStore + let context: ServerContext + + init(context: ServerContext) { + self.context = context + _viewModel = State(initialValue: SlackSetupViewModel(context: context)) + } var body: some View { @@ -30,6 +36,13 @@ struct SlackSetupView: View { } saveBar + + // v0.13 Messaging Gateway behavior — self-hides on pre-v0.13. + GatewayBehaviorSection( + platform: "slack", + capabilities: capabilitiesStore?.capabilities ?? .empty, + context: context + ) } .onAppear { viewModel.load() } } diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift index ae0c168..bd89d4c 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift @@ -4,7 +4,13 @@ import ScarfDesign struct TelegramSetupView: View { @State private var viewModel: TelegramSetupViewModel - init(context: ServerContext) { _viewModel = State(initialValue: TelegramSetupViewModel(context: context)) } + @Environment(\.hermesCapabilities) private var capabilitiesStore + let context: ServerContext + + init(context: ServerContext) { + self.context = context + _viewModel = State(initialValue: TelegramSetupViewModel(context: context)) + } var body: some View { @@ -29,6 +35,13 @@ struct TelegramSetupView: View { } saveBar + + // v0.13 Messaging Gateway behavior — self-hides on pre-v0.13. + GatewayBehaviorSection( + platform: "telegram", + capabilities: capabilitiesStore?.capabilities ?? .empty, + context: context + ) } .onAppear { viewModel.load() } } diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift index 43a69f4..b91f8ff 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift @@ -4,7 +4,13 @@ import ScarfDesign struct WhatsAppSetupView: View { @State private var viewModel: WhatsAppSetupViewModel - init(context: ServerContext) { _viewModel = State(initialValue: WhatsAppSetupViewModel(context: context)) } + @Environment(\.hermesCapabilities) private var capabilitiesStore + let context: ServerContext + + init(context: ServerContext) { + self.context = context + _viewModel = State(initialValue: WhatsAppSetupViewModel(context: context)) + } var body: some View { @@ -29,6 +35,14 @@ struct WhatsAppSetupView: View { } saveBar + + // v0.13 Messaging Gateway behavior — self-hides on pre-v0.13. + GatewayBehaviorSection( + platform: "whatsapp", + capabilities: capabilitiesStore?.capabilities ?? .empty, + context: context + ) + Divider() pairingSection } diff --git a/scarf/scarf/Features/Platforms/Views/PlatformsView.swift b/scarf/scarf/Features/Platforms/Views/PlatformsView.swift index 1775428..df142be 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformsView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformsView.swift @@ -5,6 +5,33 @@ import ScarfDesign struct PlatformsView: View { @State private var viewModel: PlatformsViewModel @Environment(HermesFileWatcher.self) private var fileWatcher + @Environment(\.hermesCapabilities) private var capabilitiesStore + + /// Capabilities resolved at view-eval time. Defaults to `.empty` outside + /// the per-server `ContextBoundRoot`. Used to filter `KnownPlatforms.all` + /// for v0.13-only entries (Google Chat) — see `visiblePlatforms` for + /// the deliberate asymmetry: pre-v0.12 hosts still see Yuanbao + Teams + /// unfiltered, by design. + private var capabilities: HermesCapabilities { + capabilitiesStore?.capabilities ?? .empty + } + + /// Capability-filtered platform list. Today only **Google Chat** is + /// gated — Yuanbao and Microsoft Teams stay unfiltered to avoid + /// changing v0.12 host UX in a v0.13 work-stream (WS-5 plan §Q4). + /// If we later decide to gate the v0.12 platforms too, add their + /// flags here; the `default: true` arm keeps every other platform + /// visible. + private var visiblePlatforms: [HermesToolPlatform] { + KnownPlatforms.all.filter { p in + switch p.name { + case "google-chat", "googlechat": + return capabilities.hasGoogleChatPlatform + default: + return true + } + } + } init(context: ServerContext) { _viewModel = State(initialValue: PlatformsViewModel(context: context)) @@ -40,12 +67,12 @@ struct PlatformsView: View { List(selection: Binding( get: { viewModel.selected.name }, set: { name in - if let p = viewModel.platforms.first(where: { $0.name == name }) { + if let p = visiblePlatforms.first(where: { $0.name == name }) { viewModel.selected = p } } )) { - ForEach(viewModel.platforms) { platform in + ForEach(visiblePlatforms) { platform in HStack(spacing: 8) { Image(systemName: KnownPlatforms.icon(for: platform.name)) .frame(width: 20) @@ -149,6 +176,7 @@ struct PlatformsView: View { case "webhook": WebhookSetupView(context: ctx) case "yuanbao": yuanbaoPanel case "microsoft-teams": microsoftTeamsPanel + case "google-chat", "googlechat": googleChatPanel default: SettingsSection(title: LocalizedStringKey(viewModel.selected.displayName), icon: KnownPlatforms.icon(for: viewModel.selected.name)) { ReadOnlyRow(label: "Setup", value: "No setup form for this platform yet.") @@ -180,6 +208,27 @@ struct PlatformsView: View { } } + /// Hermes v0.13 — Google Chat is the 20th gateway platform. Like + /// Yuanbao + Microsoft Teams, the auth dance is OAuth-style and + /// lives outside Scarf, so the panel surfaces the setup verb rather + /// than a per-field form. The `GatewayBehaviorSection` below it picks + /// up the v0.13 allowlist + behavior toggles, capability-gated. + @ViewBuilder + private var googleChatPanel: some View { + VStack(alignment: .leading, spacing: ScarfSpace.s3) { + SettingsSection(title: "Google Chat", icon: KnownPlatforms.icon(for: "google-chat")) { + ReadOnlyRow(label: "Type", value: "Generic env-driven gateway adapter (v0.13+)") + ReadOnlyRow(label: "Setup", value: "Run `hermes setup` and select Google Chat to walk the OAuth flow.") + ReadOnlyRow(label: "Configured", value: viewModel.hasConfigBlock(for: viewModel.selected) ? "Yes" : "No") + } + GatewayBehaviorSection( + platform: "google-chat", + capabilities: capabilities, + context: viewModel.context + ) + } + } + private var cliPanel: some View { SettingsSection(title: "CLI", icon: "terminal") { ReadOnlyRow(label: "Scope", value: "Local terminal sessions") diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift index 7eb67ae..10b9680 100644 --- a/scarf/scarf/Features/Skills/Views/SkillsView.swift +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -307,6 +307,16 @@ struct SkillsView: View { case .missing(let hint) = designMdNpxStatus { designMdNpxBanner(hint: hint) } + // v0.13 `[[as_document]]` directive — informational + // only. Rendered when the skill body contains the + // marker AND the host advertises Google Chat support + // (cheap proxy: the directive shipped in v0.13 + // alongside Google Chat — see WS-5 plan §Q5/Q6). + if (capabilitiesStore?.capabilities.hasGoogleChatPlatform ?? false), + skillContentMentionsAsDocument { + asDocumentInfoRow + } + // v2.5 SKILL.md frontmatter chips. Render only the // sections that are populated — old skills without // this metadata show no extra rows. @@ -402,6 +412,39 @@ struct SkillsView: View { } } + /// Returns true when the loaded skill body contains the v0.13 + /// `[[as_document]]` directive. Substring scan over `skillContent` + /// — `[[as_document]]` is a literal token Hermes pattern-matches at + /// runtime, not a frontmatter key, so the body is the right place + /// to look. // TODO(WS-5-Q6): if Hermes ever moves the directive + /// into frontmatter, switch to `SkillFrontmatterParser` instead. + private var skillContentMentionsAsDocument: Bool { + viewModel.skillContent.contains("[[as_document]]") + } + + /// Compact informational row about the `[[as_document]]` directive. + /// Does not block any action — it's a label so users understand why + /// images in the skill might land as document attachments on certain + /// platforms (Google Chat, Microsoft Teams) rather than inline. + private var asDocumentInfoRow: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "doc.badge.gearshape") + .foregroundStyle(.blue) + VStack(alignment: .leading, spacing: 2) { + Text("Document-attachment directive present (v0.13+)") + .font(.caption.bold()) + Text("Media in this skill marked with `[[as_document]]` is sent as document attachments instead of inline images on platforms that distinguish (Google Chat, Microsoft Teams).") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.blue.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + /// Yellow banner surfaced on the design-md skill detail when the /// host's `npx` probe came back missing. Reuses the same color /// language as the missing-config banner.