Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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://<switchboard>.<tailnet>.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
Expand Down
1 change: 1 addition & 0 deletions Sources/InstanceMCPCore/AttachManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
115 changes: 102 additions & 13 deletions Sources/InstanceMCPCore/ReverseAttachClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
81 changes: 81 additions & 0 deletions Sources/InstanceMCPCore/Switchboard.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading