From c3607e5c7c1afcaa352bb9040835cdaf82448c18 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:07:26 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20switchboard=20mode=20=E2=80=94=20dial?= =?UTF-8?q?=20openab-sb=20/vm/attach=20and=20serve=20this=20computer's=20t?= =?UTF-8?q?ools=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 ++- Sources/InstanceMCPCore/AttachManager.swift | 1 + .../InstanceMCPCore/ReverseAttachClient.swift | 115 ++++++++++-- Sources/InstanceMCPCore/Switchboard.swift | 81 +++++++++ Sources/oab-instance-mcp/main.swift | 50 ++++++ .../SwitchboardTests.swift | 165 ++++++++++++++++++ 6 files changed, 427 insertions(+), 14 deletions(-) create mode 100644 Sources/InstanceMCPCore/Switchboard.swift create mode 100644 Tests/InstanceMCPCoreTests/SwitchboardTests.swift diff --git a/README.md b/README.md index a31f0fb..923195a 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ this exists for what SSH cannot reach. - [Quick start](#quick-start) · [Platforms](#platforms) - [Tools](#tools) · [Auth](#auth) -- [Reverse attach](#lending-this-mac-to-a-sandboxed-agent-reverse-attach) · [Browser tools](#browser-tools-for-lent-sessions---upstream) +- [Reverse attach](#lending-this-mac-to-a-sandboxed-agent-reverse-attach) · [Switchboard](#serving-a-switchboard---switchboard) · [Browser tools](#browser-tools-for-lent-sessions---upstream) - Deploy & operate: [Download and install](#download-and-install) · [Build & test](#build--test-on-macmini-the-laptop-never-compiles-swift) · [Deploy](#deploy-run-on-the-target) · [Menu bar](#menu-bar) · [Operate](#operate) - Operator notes: [TCC & signing](#tcc-grants-survive-re-deploys-only-if-the-signature-does) · [Gotchas](#gotchas-each-one-cost-a-cycle) @@ -200,6 +200,33 @@ curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/ grant is the identity; `Tailscale-User-Login` there would be pod-supplied. - `--no-attach` disables the plane (`/attach` → 404). +### Serving a switchboard (`--switchboard`) + +An [openab-sb](https://github.com/openabdev/openab-sb) switchboard lets callers (Connect, +agents) reach a computer that can only dial out. This daemon can be that computer: it dials the +switchboard's `GET /vm/attach` and serves its tools there, under one profile, for as long as it +runs. + +```sh +openab-sb gen-secret # on the switchboard: verifier → [vm].secret_sha256 +oab-instance-mcp --token-file ~/.config/oab-instance-mcp/token \ + --switchboard wss://..ts.net/vm/attach \ + --switchboard-secret-file ~/.config/oab-instance-mcp/switchboard.secret \ + --switchboard-profile observe # observe (default) | desktop | owner +``` + +- **Profile:** the profile here is this computer's ceiling. The switchboard's per-caller + allowlist applies on top, so a caller gets the intersection. +- **Transport:** `ws://` is accepted for loopback only. Anything else needs `wss://`. +- **Redial policy** (openab-sb `SOUTHBOUND-CONTRACT.md`): + - It stops on `4002` (replaced by another daemon) and `4003` (secret revoked). + - It redials with backoff (1 s → 60 s, ±20 %) on `4005`, `1001`, drops and proxy errors. + - On `401`/`403` it retries every ~5 minutes rather than stopping. +- **Secret rotation:** the secret file is re-read on every dial, so rotating the secret means + updating the file; the next retry picks it up. +- **Exiting:** the HTTP endpoint keeps serving after the switchboard attach stops. Restart the + process (or the LaunchAgent) to dial again after a `4002` or `4003`. + ### Browser tools for lent sessions (`--upstream`) The sandbox has no path to any browser, so browser control is served **from this Mac**: with diff --git a/Sources/InstanceMCPCore/AttachManager.swift b/Sources/InstanceMCPCore/AttachManager.swift index 9ba7a5b..5390913 100644 --- a/Sources/InstanceMCPCore/AttachManager.swift +++ b/Sources/InstanceMCPCore/AttachManager.swift @@ -318,6 +318,7 @@ extension AttachManager.Grant { case .replaced: return "replaced" case .sessionEnded: return "session_ended" case .revoked: return "revoked" + case .secretRevoked: return "secret_revoked" case .handshakeRejected(let s): return "handshake_rejected_\(s)" case .cancelled: return "cancelled" case .deadline: return "deadline" diff --git a/Sources/InstanceMCPCore/ReverseAttachClient.swift b/Sources/InstanceMCPCore/ReverseAttachClient.swift index 42abbf4..6e54d6e 100644 --- a/Sources/InstanceMCPCore/ReverseAttachClient.swift +++ b/Sources/InstanceMCPCore/ReverseAttachClient.swift @@ -23,12 +23,35 @@ import Foundation /// /// A `401` on the handshake means the verifier is gone (expired, revoked, or the /// pod was replaced): stop and report it, there is nothing to wait for. +/// +/// The same client also dials an openab-sb switchboard's `GET /vm/attach` +/// (`Kind.switchboard`, openab-sb `docs/SOUTHBOUND-CONTRACT.md`). The socket +/// carries the same plain MCP; only the URL, the lifetime and the close codes +/// differ: +/// +/// | code | meaning (southbound §5) | we | +/// |---|---|---| +/// | 4002 | replaced by another daemon with the same secret | stop | +/// | 4003 | the operator revoked or rotated the secret | stop | +/// | 4005 / 1001 / 1000 / error | handshake failed, restart, drop | redial with backoff | +/// | `401`/`403` on upgrade | wrong secret | retry at most every 5 minutes | +/// +/// A switchboard secret is long-lived, so there is no deadline; the secret file +/// is re-read on every dial so a rotated secret heals at the next retry. public actor ReverseAttachClient { + public enum Kind: Equatable, Sendable { + /// openab-pty `GET /tools/attach/{session}`, CLIENT-CONTRACT §9.2. + case openabPty + /// openab-sb `GET /vm/attach`, SOUTHBOUND-CONTRACT. + case switchboard + } + public enum Terminal: Equatable, Sendable { case grantExpired // 4001 case replaced // 4002 case sessionEnded // 4004 case revoked // 4010 + case secretRevoked // 4003 from a switchboard case handshakeRejected(Int) // HTTP status on upgrade, typically 401 case cancelled case deadline // our own grant deadline passed while redialling @@ -52,13 +75,33 @@ public actor ReverseAttachClient { public var deadline: Date public var initialBackoff: TimeInterval = 1 public var maxBackoff: TimeInterval = 30 + public var kind: Kind = .openabPty + /// When set, the secret is re-read from here on every dial (falling back + /// to `secret` if the file cannot be read). + public var secretFile: URL? = nil + /// How long to wait after a credential refusal before trying again. + public var credentialRetry: TimeInterval = 300 + /// A connection that stayed up this long resets the backoff (switchboard). + public var stableAfter: TimeInterval = 60 public init(runtime: URL, session: String, secret: String, profile: ToolProfile, deadline: Date) { self.runtime = runtime; self.session = session; self.secret = secret self.profile = profile; self.deadline = deadline } + /// A switchboard dial: `url` is the full `…/vm/attach` URL, used as-is. + public static func switchboard(url: URL, secret: String, secretFile: URL? = nil, + profile: ToolProfile) -> Config { + var c = Config(runtime: url, session: "switchboard", secret: secret, profile: profile, + deadline: .distantFuture) + c.kind = .switchboard + c.secretFile = secretFile + c.maxBackoff = 60 + return c + } + public var attachURL: URL { + if kind == .switchboard { return runtime } var c = URLComponents(url: runtime, resolvingAgainstBaseURL: false)! let base = c.path.hasSuffix("/") ? String(c.path.dropLast()) : c.path c.path = base + "/tools/attach/" + session @@ -71,9 +114,18 @@ public actor ReverseAttachClient { public enum Disposition: Equatable, Sendable { case stop(Terminal) case redial + /// The credential was refused; only an operator can fix it. Retry slowly. + case waitForCredentials } - public static func disposition(closeCode: Int) -> Disposition { + public static func disposition(closeCode: Int, kind: Kind = .openabPty) -> Disposition { + if kind == .switchboard { + switch closeCode { + case 4002: return .stop(.replaced) + case 4003: return .stop(.secretRevoked) + default: return .redial // 4005, 1001, 1000, 1006, anything else + } + } switch closeCode { case 4001: return .stop(.grantExpired) case 4002: return .stop(.replaced) @@ -83,7 +135,13 @@ public actor ReverseAttachClient { } } - public static func disposition(handshakeStatus: Int) -> Disposition { + public static func disposition(handshakeStatus: Int, kind: Kind = .openabPty) -> Disposition { + if kind == .switchboard { + switch handshakeStatus { + case 401, 403: return .waitForCredentials + default: return .redial // switchboard down, restarting, or behind a proxy error + } + } switch handshakeStatus { case 200..<300, 101: return .redial // not a rejection; caller should not be here case 429, 500..<600: return .redial // throttled or the runtime is unwell; wait @@ -136,29 +194,60 @@ public actor ReverseAttachClient { while !Task.isCancelled { if Date() >= config.deadline { set(.ended(.deadline)); return } set(.dialing) + let started = Date() let outcome = await dialOnce() if Task.isCancelled { return } + if config.kind == .switchboard, Date().timeIntervalSince(started) >= config.stableAfter { + backoff = config.initialBackoff + } + let base: TimeInterval switch outcome { case .stop(let t): log("attach \(config.session): stopping (\(t))") set(.ended(t)); return + case .waitForCredentials: + base = config.credentialRetry case .redial: - let remaining = config.deadline.timeIntervalSinceNow - guard remaining > 0 else { set(.ended(.deadline)); return } - let wait = min(backoff, remaining) - set(.waitingToRedial(seconds: wait)) - log("attach \(config.session): redial in \(Int(wait))s") - try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000)) + base = backoff backoff = min(backoff * 2, config.maxBackoff) } + let remaining = config.deadline.timeIntervalSinceNow + guard remaining > 0 else { set(.ended(.deadline)); return } + let wait = min(config.kind == .switchboard ? Self.jitter(base) : base, remaining) + set(.waitingToRedial(seconds: wait)) + log("attach \(config.session): redial in \(Int(wait))s") + try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000)) } } - /// One connection lifetime. Returns what to do next. - private func dialOnce() async -> Disposition { + /// ±20 %, so many daemons restarted together do not redial in lockstep. + static func jitter(_ base: TimeInterval) -> TimeInterval { + base * Double.random(in: 0.8...1.2) + } + + /// The secret for the next dial: the file's current contents when one is + /// configured and readable, else the secret given at start. + func currentSecret() -> String { + if let file = config.secretFile, + let text = try? String(contentsOf: file, encoding: .utf8) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + log("attach \(config.session): \(file.path) is empty; using the previous secret") + } + return config.secret + } + + /// The upgrade request for the next dial, with the current secret. + func attachRequest() -> URLRequest { var req = URLRequest(url: config.attachURL) - req.setValue("Bearer \(config.secret)", forHTTPHeaderField: "Authorization") + req.setValue("Bearer \(currentSecret())", forHTTPHeaderField: "Authorization") req.timeoutInterval = 15 + return req + } + + /// One connection lifetime. Returns what to do next. + private func dialOnce() async -> Disposition { + let req = attachRequest() let session = URLSession(configuration: .ephemeral) defer { session.finishTasksAndInvalidate() } let ws = session.webSocketTask(with: req) @@ -178,13 +267,13 @@ public actor ReverseAttachClient { if Task.isCancelled { return .stop(.cancelled) } if let http = ws.response as? HTTPURLResponse, http.statusCode != 101, firstFrame { log("attach \(config.session): handshake rejected \(http.statusCode)") - return Self.disposition(handshakeStatus: http.statusCode) + return Self.disposition(handshakeStatus: http.statusCode, kind: config.kind) } let code = ws.closeCode.rawValue if code != 0 { log("attach \(config.session): closed \(code) after \(served) calls") finishAttached() - return Self.disposition(closeCode: code) + return Self.disposition(closeCode: code, kind: config.kind) } log("attach \(config.session): socket error \(error.localizedDescription)") finishAttached() diff --git a/Sources/InstanceMCPCore/Switchboard.swift b/Sources/InstanceMCPCore/Switchboard.swift new file mode 100644 index 0000000..9453762 --- /dev/null +++ b/Sources/InstanceMCPCore/Switchboard.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Switchboard mode: this computer dials an openab-sb switchboard's +/// `GET /vm/attach` and serves its tools there, under one tool profile, for as +/// long as the process runs. Contract: openab-sb `docs/SOUTHBOUND-CONTRACT.md`. +/// +/// ```text +/// callers ─► openab-sb /mcp ─► Hub ◄── WS /vm/attach ── this Mac (dials out) +/// ``` +/// +/// The switchboard applies its own per-caller allowlist on top; the profile here +/// is this computer's own ceiling and the one that matters if the switchboard +/// is misconfigured. +public enum Switchboard { + public enum ConfigError: Error, Equatable, CustomStringConvertible { + case badURL(String) + case plaintextToRemote(String) + + public var description: String { + switch self { + case .badURL(let u): + return "--switchboard wants a ws:// or wss:// URL ending in /vm/attach, got \(u)" + case .plaintextToRemote(let h): + return "--switchboard: ws:// is only allowed to loopback; use wss:// for \(h) (the secret would cross the network in clear)" + } + } + } + + /// Accept `wss://…/vm/attach`, or `ws://` to a loopback host only. + public static func validate(_ text: String) throws -> URL { + guard let url = URL(string: text), let scheme = url.scheme?.lowercased(), + let host = url.host, !host.isEmpty, + scheme == "ws" || scheme == "wss", + url.path.hasSuffix("/vm/attach") else { + throw ConfigError.badURL(text) + } + if scheme == "ws" && !isLoopback(host) { throw ConfigError.plaintextToRemote(host) } + return url + } + + static func isLoopback(_ host: String) -> Bool { + let h = host.trimmingCharacters(in: CharacterSet(charactersIn: "[]")).lowercased() + return h == "localhost" || h == "::1" || h.hasPrefix("127.") + } + + /// Instructions for the scoped server: say how the caller got here, which a + /// Connect-lent session's instructions would get wrong. + public static func instructions(profile: ToolProfile, base: String?) -> String? { + let head = base.map { $0 + "\n\n" } ?? "" + switch profile { + case .observe: + return head + """ + You reached this computer through OpenAB Switchboard under the `observe` profile: you may \ + look, not act. Only `sys_info` and `screenshot` are available — you cannot click, type or run \ + anything here. If the task needs input, ask the operator to change the profile. + """ + case .desktop: + return head + """ + You reached this computer through OpenAB Switchboard under the `desktop` profile. Calls are \ + relayed over the network, so expect a second or more per call. There is no `exec` tool; drive \ + the computer through `screenshot`, `mouse`, `key` and `osascript`, and — when `browser_*` tools \ + are listed — through the browser directly (`browser_navigate`, then `browser_snapshot`). A \ + human may be watching the screen. + """ + case .owner: + return base + } + } + + /// A client that dials `url` with the secret in `secretFile` (re-read on + /// every dial) and serves `server` scoped to `profile`. + public static func client(url: URL, secretFile: URL, secret: String, profile: ToolProfile, + server: MCPServer, + onStateChange: @escaping @Sendable (ReverseAttachClient.State) -> Void = { _ in }, + log: @escaping @Sendable (String) -> Void = { _ in }) -> ReverseAttachClient { + let scoped = server.scoped(to: profile, instructions: instructions(profile: profile, base: server.instructions)) + let config = ReverseAttachClient.Config.switchboard(url: url, secret: secret, secretFile: secretFile, + profile: profile) + return ReverseAttachClient(config: config, server: scoped, onStateChange: onStateChange, log: log) + } +} diff --git a/Sources/oab-instance-mcp/main.swift b/Sources/oab-instance-mcp/main.swift index d050e26..010a035 100644 --- a/Sources/oab-instance-mcp/main.swift +++ b/Sources/oab-instance-mcp/main.swift @@ -21,6 +21,10 @@ struct Options { var persistGrants = true /// name=url pairs; each is a loopback MCP server whose tools are re-served. var upstreams: [(String, URL)] = [] + /// openab-sb `…/vm/attach` to dial, its secret file, and the profile served there. + var switchboard: String? = nil + var switchboardSecretFile: String? = nil + var switchboardProfile: ToolProfile = .observe } func usage() -> Never { @@ -49,6 +53,16 @@ func usage() -> Never { Keep reverse-attach grants in memory only. By default live grants are saved (record: ~/Library/Application Support/oab-instance-mcp/grants.json, mode 600; secret: login Keychain) and re-dialled after a restart. + --switchboard + Dial an openab-sb switchboard and serve this computer's tools on that socket, so + the switchboard's callers can reach it (this Mac dials out; nothing listens). + Redials on its own for as long as the process runs. ws:// is loopback-only. + --switchboard-secret-file + The VM secret from `openab-sb gen-secret` (required with --switchboard). Re-read + on every dial, so rotating it is: update the file; the next retry picks it up. + --switchboard-profile observe|desktop|owner + Tool profile served to the switchboard (default observe: sys_info + screenshot). + The switchboard's own per-caller allowlist applies on top. --menu-bar Show a status item in the menu bar (permissions, activity, restart/quit). --public-url The URL clients use (shown/copied from the menu); defaults to the local one. @@ -81,6 +95,14 @@ while !args.isEmpty { case "--no-grant-persistence": opts.persistGrants = false case "--public-url": opts.publicURL = next(a) case "--no-attach": opts.attach = false + case "--switchboard": opts.switchboard = next(a) + case "--switchboard-secret-file": opts.switchboardSecretFile = next(a) + case "--switchboard-profile": + let v = next(a) + guard let p = ToolProfile(rawValue: v) else { + fputs("--switchboard-profile wants observe, desktop or owner\n", stderr); usage() + } + opts.switchboardProfile = p case "--upstream": let v = next(a) guard let eq = v.firstIndex(of: "="), let u = URL(string: String(v[v.index(after: eq)...])), @@ -101,6 +123,24 @@ if let f = opts.tokenFile { opts.token = t } +// Validated before anything starts, so a typo fails the launch instead of a dial. +var switchboardTarget: (url: URL, file: URL, secret: String)? = nil +if let raw = opts.switchboard { + let url: URL + do { url = try Switchboard.validate(raw) } catch { fputs("\(error)\n", stderr); exit(64) } + guard let f = opts.switchboardSecretFile else { + fputs("--switchboard needs --switchboard-secret-file\n", stderr); exit(64) + } + let file = URL(fileURLWithPath: (f as NSString).expandingTildeInPath) + guard let s = try? String(contentsOf: file, encoding: .utf8), + !s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + fputs("cannot read --switchboard-secret-file \(f), or it is empty\n", stderr); exit(66) + } + switchboardTarget = (url, file, s.trimmingCharacters(in: .whitespacesAndNewlines)) +} else if opts.switchboardSecretFile != nil { + fputs("--switchboard-secret-file needs --switchboard\n", stderr); exit(64) +} + let auth = AuthPolicy(allowedLogins: opts.allowLogins, bearerToken: opts.token, allowLocalUnauthenticated: opts.insecureLocal) do { try auth.validate() } catch { fputs("\(error)\n", stderr); exit(64) } @@ -170,6 +210,16 @@ if let attachManager { } } +// Global for the same reason as `http`: the client must outlive this scope. +let switchboardClient: ReverseAttachClient? = switchboardTarget.map { t in + Switchboard.client(url: t.url, secretFile: t.file, secret: t.secret, profile: opts.switchboardProfile, + server: server, log: log) +} +if let switchboardClient, let t = switchboardTarget { + log("switchboard: dialling \(t.url.absoluteString) as \(opts.switchboardProfile.rawValue)") + Task { await switchboardClient.start() } +} + signal(SIGPIPE, SIG_IGN) let stop = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) signal(SIGTERM, SIG_IGN) diff --git a/Tests/InstanceMCPCoreTests/SwitchboardTests.swift b/Tests/InstanceMCPCoreTests/SwitchboardTests.swift new file mode 100644 index 0000000..814f61c --- /dev/null +++ b/Tests/InstanceMCPCoreTests/SwitchboardTests.swift @@ -0,0 +1,165 @@ +import Foundation +import Network +import XCTest +@testable import InstanceMCPCore + +// MARK: - Switchboard redial policy (openab-sb SOUTHBOUND-CONTRACT §2, §5) + +final class SwitchboardPolicyTests: XCTestCase { + func testStopsOnReplacedAndOnARevokedSecret() { + XCTAssertEqual(ReverseAttachClient.disposition(closeCode: 4002, kind: .switchboard), .stop(.replaced)) + XCTAssertEqual(ReverseAttachClient.disposition(closeCode: 4003, kind: .switchboard), .stop(.secretRevoked)) + } + + func testRedialsOnHandshakeFailureRestartAndDrops() { + // 4005 = our initialize reply failed; the openab-pty stop codes mean nothing here. + for code in [4005, 1001, 1000, 1006, 1011, 4001, 4004, 4010] { + XCTAssertEqual(ReverseAttachClient.disposition(closeCode: code, kind: .switchboard), .redial, "code \(code)") + } + } + + func testOpenabPtyPolicyIsUnchanged() { + // 4003 is not an openab-pty tools-attach code: it still redials there. + XCTAssertEqual(ReverseAttachClient.disposition(closeCode: 4003), .redial) + XCTAssertEqual(ReverseAttachClient.disposition(closeCode: 4010), .stop(.revoked)) + XCTAssertEqual(ReverseAttachClient.disposition(handshakeStatus: 401), .stop(.handshakeRejected(401))) + } + + func testCredentialRefusalWaitsAndEverythingElseRedials() { + // §2: a wrong secret needs an operator, so never stop a long-lived daemon on it. + XCTAssertEqual(ReverseAttachClient.disposition(handshakeStatus: 401, kind: .switchboard), .waitForCredentials) + XCTAssertEqual(ReverseAttachClient.disposition(handshakeStatus: 403, kind: .switchboard), .waitForCredentials) + for status in [404, 429, 500, 502, 503] { + XCTAssertEqual(ReverseAttachClient.disposition(handshakeStatus: status, kind: .switchboard), .redial, "\(status)") + } + } + + func testSwitchboardConfigUsesTheURLAsIsAndNeverExpires() { + let url = URL(string: "wss://macmini.tail.ts.net/vm/attach")! + let c = ReverseAttachClient.Config.switchboard(url: url, secret: "s", profile: .observe) + XCTAssertEqual(c.attachURL, url) + XCTAssertEqual(c.kind, .switchboard) + XCTAssertEqual(c.deadline, .distantFuture) + XCTAssertEqual(c.maxBackoff, 60) + XCTAssertEqual(c.credentialRetry, 300) + } + + func testJitterStaysWithinTwentyPercent() { + for _ in 0..<200 { + let j = ReverseAttachClient.jitter(10) + XCTAssertGreaterThanOrEqual(j, 8); XCTAssertLessThanOrEqual(j, 12) + } + } +} + +// MARK: - URL validation and instructions + +final class SwitchboardConfigTests: XCTestCase { + func testAcceptsWssAndLoopbackWs() throws { + XCTAssertEqual(try Switchboard.validate("wss://sb.tail.ts.net/vm/attach").host, "sb.tail.ts.net") + XCTAssertEqual(try Switchboard.validate("ws://127.0.0.1:8790/vm/attach").port, 8790) + XCTAssertNoThrow(try Switchboard.validate("ws://localhost:8790/vm/attach")) + XCTAssertNoThrow(try Switchboard.validate("ws://[::1]:8790/vm/attach")) + XCTAssertNoThrow(try Switchboard.validate("wss://sb.example/prefix/vm/attach")) + } + + func testRefusesPlaintextAcrossTheNetwork() { + XCTAssertThrowsError(try Switchboard.validate("ws://100.74.35.49:8790/vm/attach")) { + XCTAssertEqual($0 as? Switchboard.ConfigError, .plaintextToRemote("100.74.35.49")) + } + } + + func testRefusesOtherSchemesAndPaths() { + for bad in ["https://sb/vm/attach", "wss://sb/mcp", "wss:///vm/attach", "vm/attach", ""] { + XCTAssertThrowsError(try Switchboard.validate(bad), bad) + } + } + + func testInstructionsSayHowTheCallerArrived() { + for p in [ToolProfile.observe, .desktop] { + let text = Switchboard.instructions(profile: p, base: "base") ?? "" + XCTAssertTrue(text.hasPrefix("base\n\n"), p.rawValue) + XCTAssertTrue(text.contains("OpenAB Switchboard"), p.rawValue) + XCTAssertTrue(text.contains("`\(p.rawValue)` profile"), p.rawValue) + XCTAssertFalse(text.contains("Connect"), "not a Connect-lent session: \(p.rawValue)") + } + XCTAssertEqual(Switchboard.instructions(profile: .owner, base: "base"), "base") + } + + func testSecretFileIsReReadOnEveryDial() async throws { + let file = FileManager.default.temporaryDirectory + .appendingPathComponent("sb-secret-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: file) } + try "first\n".write(to: file, atomically: true, encoding: .utf8) + let client = Switchboard.client(url: URL(string: "ws://127.0.0.1:1/vm/attach")!, secretFile: file, + secret: "first", profile: .observe, server: fullServer()) + func bearer() async -> String? { + await client.attachRequest().value(forHTTPHeaderField: "Authorization") + } + let a = await bearer() + XCTAssertEqual(a, "Bearer first") + try "rotated\n".write(to: file, atomically: true, encoding: .utf8) + let b = await bearer() + XCTAssertEqual(b, "Bearer rotated", "the dial must present the file's current secret") + // An emptied file keeps the start-up secret rather than sending none. + try "".write(to: file, atomically: true, encoding: .utf8) + let c = await bearer() + XCTAssertEqual(c, "Bearer first") + let url = await client.attachRequest().url + XCTAssertEqual(url?.absoluteString, "ws://127.0.0.1:1/vm/attach") + } +} + +// MARK: - End to end against a fake switchboard + +final class SwitchboardEndToEndTests: XCTestCase { + /// FakeRuntime plays the switchboard here: same wire (plain MCP, it sends + /// `initialize` first), different close codes. + private func run(closeWith code: UInt16, for seconds: Double) async throws + -> (FakeRuntime, ReverseAttachClient, StateSink) { + let sb = try FakeRuntime(secret: "s", closeWith: .privateCode(code)) + let states = StateSink() + var cfg = ReverseAttachClient.Config.switchboard( + url: URL(string: "ws://127.0.0.1:\(sb.port)/vm/attach")!, secret: "s", profile: .desktop) + cfg.initialBackoff = 0.2 + let server = fullServer() + let client = ReverseAttachClient( + config: cfg, + server: server.scoped(to: .desktop, + instructions: Switchboard.instructions(profile: .desktop, base: server.instructions)), + onStateChange: { states.push($0) }) + await client.start() + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return (sb, client, states) + } + + func testServesTheSwitchboardThenStopsOnARevokedSecret() async throws { + let (sb, client, states) = try await run(closeWith: 4003, for: 2) + defer { sb.stop() } + XCTAssertEqual(sb.received.count, 4, "\(sb.received)") + XCTAssertTrue(sb.received[0]["result"]?["instructions"]?.stringValue?.contains("OpenAB Switchboard") == true) + let names = sb.received[1]["result"]?["tools"]?.arrayValue?.compactMap { $0["name"]?.stringValue } + XCTAssertEqual(names, ["echo", "screenshot"], "desktop profile: no exec*") + XCTAssertNotNil(sb.received[2]["error"], "exec under desktop is refused") + let final_ = await client.state + XCTAssertEqual(final_, .ended(.secretRevoked), "\(states.all)") + XCTAssertEqual(sb.upgradeAttempts, 1, "4003 must not redial") + } + + func testStopsWhenReplacedByAnotherDaemon() async throws { + let (sb, client, states) = try await run(closeWith: 4002, for: 2) + defer { sb.stop() } + let final_ = await client.state + XCTAssertEqual(final_, .ended(.replaced), "\(states.all)") + XCTAssertEqual(sb.upgradeAttempts, 1) + } + + func testRedialsAfterAFailedHandshakeAndNeverHitsADeadline() async throws { + let (sb, client, states) = try await run(closeWith: 4005, for: 2.5) + defer { sb.stop() } + XCTAssertGreaterThanOrEqual(sb.upgradeAttempts, 2, "4005 must redial: \(states.all)") + let now = await client.state + if case .ended = now { XCTFail("a switchboard attach has no deadline: \(states.all)") } + await client.cancel() + } +}