From e95fdbbc3782bd48ffa0a5e81018b66931467ed9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 02:48:06 +0000 Subject: [PATCH] fix(ios): prettify bonjour endpoint labels --- .../Sources/Bridge/BridgeDiscoveryModel.swift | 35 ++++++- docs/gateway/pairing.md | 95 +++++++++++++++++++ docs/ios/spec.md | 32 +++++-- 3 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 docs/gateway/pairing.md diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift index 970146bdf..933ec31fb 100644 --- a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift +++ b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift @@ -53,7 +53,7 @@ final class BridgeDiscoveryModel: ObservableObject { return DiscoveredBridge( name: name, endpoint: result.endpoint, - debugID: String(describing: result.endpoint)) + debugID: Self.prettyEndpointDebugID(result.endpoint)) default: return nil } @@ -72,4 +72,37 @@ final class BridgeDiscoveryModel: ObservableObject { self.bridges = [] self.statusText = "Stopped" } + + private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String { + self.decodeBonjourEscapes(String(describing: endpoint)) + } + + private static func decodeBonjourEscapes(_ input: String) -> String { + // mDNS / DNS-SD commonly escapes spaces as `\\032` (decimal byte value 32). Make this human-friendly for UI. + var out = "" + var i = input.startIndex + while i < input.endIndex { + if input[i] == "\\", + let d0 = input.index(i, offsetBy: 1, limitedBy: input.index(before: input.endIndex)), + let d1 = input.index(i, offsetBy: 2, limitedBy: input.index(before: input.endIndex)), + let d2 = input.index(i, offsetBy: 3, limitedBy: input.index(before: input.endIndex)), + input[d0].isNumber, + input[d1].isNumber, + input[d2].isNumber + { + let digits = String(input[d0...d2]) + if let value = Int(digits), + let scalar = UnicodeScalar(value) + { + out.append(Character(scalar)) + i = input.index(i, offsetBy: 4) + continue + } + } + + out.append(input[i]) + i = input.index(after: i) + } + return out + } } diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md new file mode 100644 index 000000000..43662c523 --- /dev/null +++ b/docs/gateway/pairing.md @@ -0,0 +1,95 @@ +--- +summary: "Gateway-owned node pairing (Option B) for iOS and other remote nodes" +read_when: + - Implementing node pairing approvals without macOS UI + - Adding CLI flows for approving remote nodes + - Extending gateway protocol with node management +--- +# Gateway-owned pairing (Option B) + +Goal: The Gateway (`clawd`) is the **source of truth** for which nodes are allowed to join the network. + +This enables: +- Headless approval via terminal/CLI (no Swift UI required). +- Optional macOS UI approval (Swift app is just a frontend). +- One consistent membership store for iOS, mac nodes, future hardware nodes. + +## Concepts +- **Pending request**: a node asked to join; requires explicit approve/reject. +- **Paired node**: node is allowed; gateway returns an auth token for subsequent connects. +- **Bridge**: LAN transport that forwards between node ↔ gateway. The bridge does not decide membership. + +## API surface (gateway protocol) +These are conceptual method names; wire them into `src/gateway/protocol/schema.ts` and regenerate Swift types. + +### Events +- `node.pair.requested` + - Emitted whenever a new pending pairing request is created. + - Payload: + - `requestId` (string) + - `nodeId` (string) + - `displayName?` (string) + - `platform?` (string) + - `version?` (string) + - `remoteIp?` (string) + - `ts` (ms since epoch) +- `node.pair.resolved` + - Emitted when a pending request is approved/rejected. + - Payload: + - `requestId` (string) + - `nodeId` (string) + - `decision` ("approved" | "rejected" | "expired") + - `ts` (ms since epoch) + +### Methods +- `node.pair.request` + - Creates (or returns) a pending request. + - Params: node metadata (same shape as `node.pair.requested` payload, minus `requestId`/`ts`). + - Result: + - `requestId` + - `status` ("pending" | "alreadyPaired") + - If already paired: may include `token` directly to allow fast path. +- `node.pair.list` + - Returns: + - `pending[]` (pending requests) + - `paired[]` (paired node records) +- `node.pair.approve` + - Params: `{ requestId }` + - Result: `{ nodeId, token }` + - Must be idempotent (first decision wins). +- `node.pair.reject` + - Params: `{ requestId }` + - Result: `{ nodeId }` + +## CLI flows +CLI must be able to fully operate without any GUI: +- `clawdis nodes pending` +- `clawdis nodes approve ` +- `clawdis nodes reject ` + +Optional interactive helper: +- `clawdis nodes watch` (subscribe to `node.pair.requested` and prompt in-place) + +## Storage (private, local) +Gateway stores the authoritative state under `~/.clawdis/`: +- `~/.clawdis/nodes/paired.json` +- `~/.clawdis/nodes/pending.json` (or `~/.clawdis/nodes/pending/*.json`) + +Notes: +- Tokens are secrets. Treat `paired.json` as sensitive. +- Pending entries should have a TTL (e.g. 5 minutes) and expire automatically. + +## Bridge integration +The macOS Bridge is responsible for: +- Surfacing the pairing request to the gateway (`node.pair.request`). +- Waiting for the decision (`node.pair.approve`/`reject`) and completing the on-wire pairing handshake to the node. +- Enforcing ACLs on what the node can call, even after paired. + +The macOS UI (Swift) can: +- Subscribe to `node.pair.requested`, show an alert, and call `node.pair.approve` or `node.pair.reject`. +- Or ignore/dismiss (“Later”) and let CLI handle it. + +## Implementation note +If the bridge is only provided by the macOS app, then “no Swift app running” cannot work end-to-end. +To support headless pairing, also add a `clawdis bridge` CLI mode that provides the Bonjour bridge service and forwards to the local gateway. + diff --git a/docs/ios/spec.md b/docs/ios/spec.md index c9a871cc5..e2b38210b 100644 --- a/docs/ios/spec.md +++ b/docs/ios/spec.md @@ -51,11 +51,26 @@ Why: - First connection: 1) iOS generates a keypair (Secure Enclave if available). 2) iOS connects to the bridge and requests pairing. - 3) macOS app shows “Approve node” with node name + device metadata. - 4) On approve, mac stores the node public key + permissions; iOS stores bridge identity + trust anchor in Keychain. + 3) The bridge forwards the pairing request to the **Gateway** as a *pending request*. + 4) Approval can happen via: + - **macOS UI** (Swift app shows “Approve node”), or + - **Terminal/CLI** (headless flows). + 5) Once approved, the bridge returns a token to iOS; iOS stores it in Keychain. - Subsequent connections: - The bridge requires the paired identity. Unpaired clients get a structured “not paired” error and no access. +#### Gateway-owned pairing (Option B details) +Pairing decisions must be owned by the Gateway (`clawd` / Node) so nodes can be approved without the macOS app running. + +Key idea: +- The Swift app may still show an alert, but it is only a **frontend** for pending requests stored in the Gateway. + +Desired behavior: +- If the Swift UI is present: show alert with Approve/Reject/Later. +- If the Swift UI is not present: `clawdis` CLI can list pending requests and approve/reject. + +See `docs/gateway/pairing.md` for the API/events and storage. + ### Authorization / scope control (bridge-side ACL) The bridge must not be a raw proxy to every gateway method. @@ -135,7 +150,7 @@ When iOS is backgrounded: Create/expand SwiftPM targets so both apps share: - `ClawdisProtocol` (generated models; platform-neutral) - `ClawdisGatewayClient` (shared WS framing + connect/req/res + seq-gap handling) -- `ClawdisNodeKit` (node.invoke command types + error codes) +- `ClawdisKit` (node/screen command types + deep links + shared utilities) macOS continues to own: - local Canvas implementation details (custom scheme handler serving on-disk HTML, window/panel presentation) @@ -171,11 +186,16 @@ open ClawdisNode.xcodeproj - `~/Library/Application Support/Clawdis/bridge/paired-nodes.json` - `~/Library/Application Support/Clawdis/bridge/keys/...` +### Gateway (node) +- Pairing (source of truth): + - `~/.clawdis/nodes/paired.json` + - `~/.clawdis/nodes/pending.json` (or `pending/*.json` for auditability) + ## Rollout plan (phased) 1) **Bridge discovery + pairing (mac + iOS)** - - Bonjour browse + resolve - - Approve prompt on mac - - Persist pairing in Keychain/App Support + - Bonjour browse + resolve + - Approve prompt on mac + - Persist pairing in Keychain/App Support 2) **Voice-only node** - iOS voice wake toggle - Forward transcript to Gateway `agent` via bridge