# Remote LAN and Tailnet Access Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Harden Alas Remote for direct LAN and tailnet access while preserving automatic LAN IP pairing and connection.

**Architecture:** Keep the existing in-process `RemoteServer`, static PWA bundle, pairing endpoint, and WebSocket gateway. Add pure address discovery and host-policy units, wire the policy into `RemoteConnection` before route handling, expose safe diagnostics, and update Settings to show selectable LAN/tailnet addresses.

**Tech Stack:** Swift 5.9, SwiftUI, Network.framework, Foundation, Swift Testing, XcodeGen, static JavaScript service worker.

---

## File Structure

Create:

- `Alas/Sources/Remote/Settings/RemoteNetwork.swift`
  - Owns `RemoteNetworkInterface`, `RemoteAdvertisedAddress`, and pure address discovery/classification helpers.
- `Alas/Sources/Remote/Server/RemoteAccessPolicy.swift`
  - Owns host-header normalization and allow/reject decisions.
- `AlasTests/Remote/RemoteNetworkTests.swift`
  - Covers address ordering, LAN/tailnet preservation, preferred host selection, and allowed-host candidates.
- `AlasTests/Remote/RemoteAccessPolicyTests.swift`
  - Covers loopback, LAN IP, tailnet IP, configured hostname, port stripping, IPv6 bracket syntax, and reject cases.

Modify:

- `Alas/Sources/Persistence/AppConfig.swift`
  - Add `remote.allowedHosts` and `remote.preferredAdvertisedHost` with backwards-compatible decoding.
- `Alas/Sources/Remote/Server/RemoteHTTPResponder.swift`
  - Add `RemoteDiagnosticsSnapshot`, `/health`, and `/remote-info`.
- `Alas/Sources/Remote/Server/RemoteConnection.swift`
  - Run `RemoteAccessPolicy` before HTTP response or WebSocket upgrade handling.
- `Alas/Sources/Remote/Server/RemoteServer.swift`
  - Store policy, diagnostics provider, and connected-device snapshots; pass policy into each connection.
- `Alas/Sources/App/AppState.swift`
  - Compute advertised addresses, build access policy, expose observable address/device state, and add revoke-all.
- `Alas/Sources/Remote/Settings/RemoteServerPane.swift`
  - Replace the single address row with selectable address rows, QR source selection, connected-now status, and revoke-all.
- `Alas/Resources/RemoteWeb/sw.js`
  - Keep diagnostics and future API routes network-only.
- `AlasTests/Remote/RemoteConfigTests.swift`
  - Cover new config fields and old config decoding.
- `AlasTests/Remote/RemoteServerIntegrationTests.swift`
  - Cover rejected hosts for `/pair`, rejected hosts for `/ws`, diagnostics routes, and connected-device snapshots.
- `AlasTests/Remote/RemoteWebAssetTests.swift`
  - Cover service worker network-only exclusions.
- `Alas.xcodeproj/project.pbxproj`
  - Regenerated by `xcodegen` after adding new Swift files.

No `project.yml` edit is needed because both targets include folder source roots. Run `xcodegen` after creating new Swift files so the checked-in Xcode project includes them.

---

### Task 1: Remote Config Fields

**Files:**
- Modify: `Alas/Sources/Persistence/AppConfig.swift`
- Modify: `AlasTests/Remote/RemoteConfigTests.swift`

- [ ] **Step 1: Write failing config tests**

Add these tests to `RemoteConfigTests`:

```swift
@Test func remoteConfigDefaultsHostFieldsEmpty() {
    let cfg = AppConfig.defaults
    #expect(cfg.remote.allowedHosts == [])
    #expect(cfg.remote.preferredAdvertisedHost == nil)
}

@Test func remoteConfigHostFieldsRoundTripJSON() throws {
    var cfg = AppConfig.defaults
    cfg.remote.enabled = true
    cfg.remote.port = 8765
    cfg.remote.allowedHosts = ["nacho-mbp.local", "100.88.1.20"]
    cfg.remote.preferredAdvertisedHost = "100.88.1.20"

    let data = try JSONEncoder().encode(cfg)
    let back = try JSONDecoder().decode(AppConfig.self, from: data)

    #expect(back.remote.enabled == true)
    #expect(back.remote.port == 8765)
    #expect(back.remote.allowedHosts == ["nacho-mbp.local", "100.88.1.20"])
    #expect(back.remote.preferredAdvertisedHost == "100.88.1.20")
}

@Test func oldRemoteConfigWithoutHostFieldsDecodesDefaults() throws {
    let data = try JSONEncoder().encode(AppConfig.defaults)
    var json = try #require(
        try JSONSerialization.jsonObject(with: data) as? [String: Any]
    )
    var remote = try #require(json["remote"] as? [String: Any])
    remote.removeValue(forKey: "allowedHosts")
    remote.removeValue(forKey: "preferredAdvertisedHost")
    json["remote"] = remote

    let stripped = try JSONSerialization.data(withJSONObject: json)
    let back = try JSONDecoder().decode(AppConfig.self, from: stripped)

    #expect(back.remote.allowedHosts == [])
    #expect(back.remote.preferredAdvertisedHost == nil)
}
```

- [ ] **Step 2: Run config tests to verify failure**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteConfigTests
```

Expected: build fails because `AppConfig.Remote` has no `allowedHosts` or `preferredAdvertisedHost`.

- [ ] **Step 3: Add config fields and decoding**

Replace `AppConfig.Remote` with this shape, preserving the existing public initializer:

```swift
struct Remote: Codable, Equatable {
    var enabled: Bool = false
    var port: UInt16 = 0          // 0 = OS-assigned
    var allowedHosts: [String] = []
    var preferredAdvertisedHost: String? = nil

    init(
        enabled: Bool = false,
        port: UInt16 = 0,
        allowedHosts: [String] = [],
        preferredAdvertisedHost: String? = nil
    ) {
        self.enabled = enabled
        self.port = port
        self.allowedHosts = allowedHosts
        self.preferredAdvertisedHost = preferredAdvertisedHost
    }

    enum CodingKeys: String, CodingKey {
        case enabled, port, allowedHosts, preferredAdvertisedHost
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        enabled = (try? c.decode(Bool.self, forKey: .enabled)) ?? false
        port = (try? c.decode(UInt16.self, forKey: .port)) ?? 0
        allowedHosts = (try? c.decode([String].self, forKey: .allowedHosts)) ?? []
        preferredAdvertisedHost = try? c.decodeIfPresent(String.self, forKey: .preferredAdvertisedHost)
    }
}
```

- [ ] **Step 4: Run config tests to verify pass**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteConfigTests
```

Expected: all `RemoteConfigTests` pass.

- [ ] **Step 5: Commit config change**

Run:

```bash
rtk git add Alas/Sources/Persistence/AppConfig.swift AlasTests/Remote/RemoteConfigTests.swift
rtk git commit -m "feat(remote): persist allowed remote hosts"
```

---

### Task 2: Address Discovery and Advertised Address Ordering

**Files:**
- Create: `Alas/Sources/Remote/Settings/RemoteNetwork.swift`
- Create: `AlasTests/Remote/RemoteNetworkTests.swift`
- Modify: `Alas.xcodeproj/project.pbxproj` via `xcodegen`

- [ ] **Step 1: Write failing remote network tests**

Create `AlasTests/Remote/RemoteNetworkTests.swift`:

```swift
import Testing
@testable import Alas

struct RemoteNetworkTests {
    @Test func advertisedAddressesPreferTailnetThenLanThenLocalhost() {
        let interfaces = [
            RemoteNetworkInterface(name: "lo0", host: "127.0.0.1", isLoopback: true),
            RemoteNetworkInterface(name: "en0", host: "192.168.1.23", isLoopback: false),
            RemoteNetworkInterface(name: "utun4", host: "100.88.1.20", isLoopback: false),
        ]

        let addresses = RemoteNetwork.advertisedAddresses(
            port: 8765,
            interfaces: interfaces,
            allowedHosts: [],
            preferredHost: nil,
            machineHostName: "nacho-mbp"
        )

        #expect(addresses.map(\.host) == ["100.88.1.20", "192.168.1.23", "localhost"])
        #expect(addresses.map(\.kind) == [.tailnet, .lan, .localhost])
        #expect(addresses.first?.isRecommended == true)
        #expect(addresses.first?.url == "http://100.88.1.20:8765")
    }

    @Test func lanIpContinuesToBeAdvertisedWithoutManualAllowedHost() {
        let interfaces = [
            RemoteNetworkInterface(name: "en0", host: "10.0.0.42", isLoopback: false),
        ]

        let addresses = RemoteNetwork.advertisedAddresses(
            port: 8765,
            interfaces: interfaces,
            allowedHosts: [],
            preferredHost: nil,
            machineHostName: nil
        )

        #expect(addresses.contains { $0.host == "10.0.0.42" && $0.kind == .lan })
        #expect(addresses.contains { $0.host == "localhost" && $0.kind == .localhost })
    }

    @Test func preferredHostMarksMatchingAddressRecommended() {
        let interfaces = [
            RemoteNetworkInterface(name: "en0", host: "192.168.1.23", isLoopback: false),
            RemoteNetworkInterface(name: "utun4", host: "100.88.1.20", isLoopback: false),
        ]

        let addresses = RemoteNetwork.advertisedAddresses(
            port: 8765,
            interfaces: interfaces,
            allowedHosts: [],
            preferredHost: "192.168.1.23",
            machineHostName: nil
        )

        #expect(addresses.first?.host == "192.168.1.23")
        #expect(addresses.first?.isRecommended == true)
    }

    @Test func customAllowedHostsAreAdvertisedAsCustom() {
        let addresses = RemoteNetwork.advertisedAddresses(
            port: 8765,
            interfaces: [],
            allowedHosts: ["alas.tailnet.example"],
            preferredHost: nil,
            machineHostName: nil
        )

        #expect(addresses.contains {
            $0.host == "alas.tailnet.example"
                && $0.kind == .custom
                && $0.url == "http://alas.tailnet.example:8765"
        })
    }

    @Test func allowedHostCandidatesIncludeLocalhostDetectedIpsAndMachineNames() {
        let interfaces = [
            RemoteNetworkInterface(name: "en0", host: "192.168.1.23", isLoopback: false),
            RemoteNetworkInterface(name: "utun4", host: "100.88.1.20", isLoopback: false),
        ]

        let hosts = RemoteNetwork.allowedHostCandidates(
            interfaces: interfaces,
            allowedHosts: ["custom.example"],
            machineHostName: "Nacho-MBP.local"
        )

        #expect(hosts.contains("localhost"))
        #expect(hosts.contains("127.0.0.1"))
        #expect(hosts.contains("::1"))
        #expect(hosts.contains("192.168.1.23"))
        #expect(hosts.contains("100.88.1.20"))
        #expect(hosts.contains("custom.example"))
        #expect(hosts.contains("nacho-mbp.local"))
        #expect(hosts.contains("nacho-mbp"))
    }
}
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteNetworkTests
```

Expected: build fails because `RemoteNetworkInterface`, `RemoteAdvertisedAddress`, and `RemoteNetwork` do not exist.

- [ ] **Step 3: Add remote network implementation**

Create `Alas/Sources/Remote/Settings/RemoteNetwork.swift`:

```swift
import Foundation

struct RemoteNetworkInterface: Equatable, Sendable {
    let name: String
    let host: String
    let isLoopback: Bool
}

struct RemoteAdvertisedAddress: Codable, Equatable, Identifiable, Sendable {
    enum Kind: String, Codable, Sendable {
        case localhost
        case lan
        case tailnet
        case custom
    }

    let id: String
    let kind: Kind
    let interfaceName: String?
    let host: String
    let port: UInt16
    let url: String
    let isRecommended: Bool

    init(kind: Kind, interfaceName: String?, host: String, port: UInt16, isRecommended: Bool) {
        self.kind = kind
        self.interfaceName = interfaceName
        self.host = host
        self.port = port
        self.url = "http://\(Self.urlHost(host)):\(port)"
        self.id = "\(kind.rawValue):\(host):\(port)"
        self.isRecommended = isRecommended
    }

    private static func urlHost(_ host: String) -> String {
        host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host
    }
}

enum RemoteNetwork {
    static func interfaces() -> [RemoteNetworkInterface] {
        var head: UnsafeMutablePointer<ifaddrs>?
        guard getifaddrs(&head) == 0, let first = head else { return [] }
        defer { freeifaddrs(head) }

        var output: [RemoteNetworkInterface] = []
        var cursor: UnsafeMutablePointer<ifaddrs>? = first
        while let ptr = cursor {
            defer { cursor = ptr.pointee.ifa_next }
            let flags = Int32(ptr.pointee.ifa_flags)
            guard (flags & IFF_UP) == IFF_UP else { continue }
            guard let addr = ptr.pointee.ifa_addr else { continue }
            guard addr.pointee.sa_family == sa_family_t(AF_INET)
                    || addr.pointee.sa_family == sa_family_t(AF_INET6) else { continue }

            let name = String(cString: ptr.pointee.ifa_name)
            var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
            let result = getnameinfo(
                addr,
                socklen_t(addr.pointee.sa_len),
                &host,
                socklen_t(host.count),
                nil,
                0,
                NI_NUMERICHOST
            )
            guard result == 0 else { continue }

            let value = String(cString: host)
            guard !value.isEmpty, !value.hasPrefix("fe80:") else { continue }
            output.append(RemoteNetworkInterface(
                name: name,
                host: value,
                isLoopback: (flags & IFF_LOOPBACK) == IFF_LOOPBACK
            ))
        }
        return output
    }

    static func machineHostName() -> String? {
        let raw = ProcessInfo.processInfo.hostName.trimmingCharacters(in: .whitespacesAndNewlines)
        return raw.isEmpty ? nil : raw
    }

    static func advertisedAddresses(
        port: UInt16,
        interfaces: [RemoteNetworkInterface] = interfaces(),
        allowedHosts: [String],
        preferredHost: String?,
        machineHostName: String? = machineHostName()
    ) -> [RemoteAdvertisedAddress] {
        var candidates: [(RemoteAdvertisedAddress.Kind, String?, String)] = []

        for iface in interfaces where !iface.isLoopback {
            if isTailnetInterface(iface) {
                candidates.append((.tailnet, iface.name, iface.host))
            } else if isPrivateIPv4(iface.host) {
                candidates.append((.lan, iface.name, iface.host))
            }
        }

        candidates.append((.localhost, nil, "localhost"))

        for host in normalizedHosts(allowedHosts) {
            if !candidates.contains(where: { normalizedHost($0.2) == host }) {
                candidates.append((.custom, nil, host))
            }
        }

        var unique: [(RemoteAdvertisedAddress.Kind, String?, String)] = []
        var seen = Set<String>()
        for item in candidates {
            let key = normalizedHost(item.2)
            guard !seen.contains(key) else { continue }
            seen.insert(key)
            unique.append(item)
        }

        if let preferred = preferredHost.map(normalizedHost),
           let index = unique.firstIndex(where: { normalizedHost($0.2) == preferred }) {
            let preferredItem = unique.remove(at: index)
            unique.insert(preferredItem, at: 0)
        }

        return unique.enumerated().map { index, item in
            RemoteAdvertisedAddress(
                kind: item.0,
                interfaceName: item.1,
                host: item.2,
                port: port,
                isRecommended: index == 0
            )
        }
    }

    static func allowedHostCandidates(
        interfaces: [RemoteNetworkInterface] = interfaces(),
        allowedHosts: [String],
        machineHostName: String? = machineHostName()
    ) -> Set<String> {
        var hosts: Set<String> = ["localhost", "127.0.0.1", "::1"]
        for iface in interfaces {
            hosts.insert(normalizedHost(iface.host))
        }
        for host in normalizedHosts(allowedHosts) {
            hosts.insert(host)
        }
        if let machine = machineHostName {
            let normalized = normalizedHost(machine)
            hosts.insert(normalized)
            if normalized.hasSuffix(".local") {
                hosts.insert(String(normalized.dropLast(".local".count)))
            } else {
                hosts.insert("\(normalized).local")
            }
        }
        return hosts
    }

    static func normalizedHost(_ host: String) -> String {
        host.trimmingCharacters(in: .whitespacesAndNewlines)
            .trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
            .lowercased()
    }

    private static func normalizedHosts(_ hosts: [String]) -> [String] {
        hosts.map(normalizedHost).filter { !$0.isEmpty }
    }

    private static func isTailnetInterface(_ iface: RemoteNetworkInterface) -> Bool {
        iface.name.hasPrefix("utun") && (iface.host.hasPrefix("100.") || isPrivateIPv4(iface.host))
    }

    private static func isPrivateIPv4(_ host: String) -> Bool {
        let parts = host.split(separator: ".").compactMap { Int($0) }
        guard parts.count == 4 else { return false }
        if parts[0] == 10 { return true }
        if parts[0] == 192 && parts[1] == 168 { return true }
        if parts[0] == 172 && (16...31).contains(parts[1]) { return true }
        if parts[0] == 100 { return true }
        return false
    }
}
```

- [ ] **Step 4: Regenerate the Xcode project**

Run:

```bash
rtk xcodegen
```

Expected: `Alas.xcodeproj/project.pbxproj` changes to include the new source and test files.

- [ ] **Step 5: Run remote network tests to verify pass**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteNetworkTests
```

Expected: all `RemoteNetworkTests` pass.

- [ ] **Step 6: Commit network helpers**

Run:

```bash
rtk git add Alas/Sources/Remote/Settings/RemoteNetwork.swift AlasTests/Remote/RemoteNetworkTests.swift Alas.xcodeproj/project.pbxproj
rtk git commit -m "feat(remote): classify advertised network addresses"
```

---

### Task 3: Host Access Policy

**Files:**
- Create: `Alas/Sources/Remote/Server/RemoteAccessPolicy.swift`
- Create: `AlasTests/Remote/RemoteAccessPolicyTests.swift`
- Modify: `Alas.xcodeproj/project.pbxproj` via `xcodegen`

- [ ] **Step 1: Write failing host policy tests**

Create `AlasTests/Remote/RemoteAccessPolicyTests.swift`:

```swift
import Testing
@testable import Alas

struct RemoteAccessPolicyTests {
    @Test func allowsLoopbackHosts() {
        let policy = RemoteAccessPolicy(allowedHosts: ["localhost", "127.0.0.1", "::1"])

        #expect(policy.allows(hostHeader: "localhost"))
        #expect(policy.allows(hostHeader: "localhost:8765"))
        #expect(policy.allows(hostHeader: "127.0.0.1:8765"))
        #expect(policy.allows(hostHeader: "[::1]:8765"))
    }

    @Test func allowsDetectedLanAndTailnetIps() {
        let policy = RemoteAccessPolicy(allowedHosts: ["192.168.1.23", "100.88.1.20"])

        #expect(policy.allows(hostHeader: "192.168.1.23:8765"))
        #expect(policy.allows(hostHeader: "100.88.1.20:8765"))
    }

    @Test func allowsConfiguredHostnameCaseInsensitively() {
        let policy = RemoteAccessPolicy(allowedHosts: ["Nacho-MBP.local"])

        #expect(policy.allows(hostHeader: "nacho-mbp.local:8765"))
        #expect(policy.allows(hostHeader: "NACHO-MBP.LOCAL"))
    }

    @Test func rejectsMissingOrUnrecognizedHost() {
        let policy = RemoteAccessPolicy(allowedHosts: ["localhost"])

        #expect(!policy.allows(hostHeader: nil))
        #expect(!policy.allows(hostHeader: ""))
        #expect(!policy.allows(hostHeader: "evil.example"))
        #expect(!policy.allows(hostHeader: "evil.example:8765"))
    }

    @Test func normalizesIpv6HostWithAndWithoutPort() {
        #expect(RemoteAccessPolicy.normalizedHost(from: "[::1]:8765") == "::1")
        #expect(RemoteAccessPolicy.normalizedHost(from: "::1") == "::1")
        #expect(RemoteAccessPolicy.normalizedHost(from: "192.168.1.23:8765") == "192.168.1.23")
        #expect(RemoteAccessPolicy.normalizedHost(from: "nacho-mbp.local:8765") == "nacho-mbp.local")
    }
}
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteAccessPolicyTests
```

Expected: build fails because `RemoteAccessPolicy` does not exist.

- [ ] **Step 3: Add access policy implementation**

Create `Alas/Sources/Remote/Server/RemoteAccessPolicy.swift`:

```swift
import Foundation

struct RemoteAccessPolicy: Equatable, Sendable {
    private let allowedHosts: Set<String>

    init(allowedHosts: Set<String>) {
        self.allowedHosts = Set(allowedHosts.map(RemoteNetwork.normalizedHost).filter { !$0.isEmpty })
    }

    init(allowedHosts: [String]) {
        self.init(allowedHosts: Set(allowedHosts))
    }

    static let loopback = RemoteAccessPolicy(allowedHosts: ["localhost", "127.0.0.1", "::1"])

    func allows(hostHeader: String?) -> Bool {
        guard let normalized = Self.normalizedHost(from: hostHeader) else { return false }
        return allowedHosts.contains(normalized)
    }

    static func normalizedHost(from hostHeader: String?) -> String? {
        guard var raw = hostHeader?.trimmingCharacters(in: .whitespacesAndNewlines),
              !raw.isEmpty else { return nil }
        raw = raw.lowercased()

        if raw.hasPrefix("[") {
            guard let close = raw.firstIndex(of: "]") else { return nil }
            let inside = raw[raw.index(after: raw.startIndex)..<close]
            return String(inside)
        }

        let colonCount = raw.filter { $0 == ":" }.count
        if colonCount == 1, let colon = raw.lastIndex(of: ":") {
            return String(raw[..<colon])
        }
        return raw.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
    }
}
```

- [ ] **Step 4: Regenerate the Xcode project**

Run:

```bash
rtk xcodegen
```

Expected: project file includes `RemoteAccessPolicy.swift` and `RemoteAccessPolicyTests.swift`.

- [ ] **Step 5: Run host policy tests to verify pass**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteAccessPolicyTests
```

Expected: all `RemoteAccessPolicyTests` pass.

- [ ] **Step 6: Commit host policy**

Run:

```bash
rtk git add Alas/Sources/Remote/Server/RemoteAccessPolicy.swift AlasTests/Remote/RemoteAccessPolicyTests.swift Alas.xcodeproj/project.pbxproj
rtk git commit -m "feat(remote): add host access policy"
```

---

### Task 4: Safe Diagnostics Routes

**Files:**
- Modify: `Alas/Sources/Remote/Server/RemoteHTTPResponder.swift`
- Modify: `AlasTests/Remote/RemoteServerIntegrationTests.swift`

- [ ] **Step 1: Write failing diagnostics tests**

Add this test to `RemoteServerIntegrationTests`:

```swift
@Test func remoteDiagnosticsRoutesReturnSafeJSON() async throws {
    let pairing = RemotePairingService(store: InMemoryDeviceStore())
    _ = try pairing.redeem(code: pairing.beginPairing(), deviceName: "iPhone")

    let diagnostics = RemoteDiagnosticsSnapshot(
        appName: "Alas",
        port: 8765,
        addresses: [
            RemoteAdvertisedAddress(
                kind: .lan,
                interfaceName: "en0",
                host: "192.168.1.23",
                port: 8765,
                isRecommended: true
            )
        ],
        usesPlainHTTP: true,
        pairedDeviceCount: 1
    )

    let root = FileManager.default.temporaryDirectory
        .appendingPathComponent("remote-diag-\(UUID().uuidString)", isDirectory: true)
    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
    defer { try? FileManager.default.removeItem(at: root) }

    let server = RemoteServer(
        pairing: pairing,
        assets: RemoteWebAssets(root: root),
        provider: FakeSessionsProvider(),
        accessPolicy: RemoteAccessPolicy(allowedHosts: ["127.0.0.1"]),
        diagnostics: { _ in diagnostics }
    )
    try server.start(port: 0)
    defer { server.stop() }

    for _ in 0..<50 where server.port == nil {
        try await Task.sleep(nanoseconds: 20_000_000)
    }
    let port = try #require(server.port)

    let (healthData, healthResponse) = try await URLSession.shared.data(
        from: URL(string: "http://127.0.0.1:\(port)/health")!
    )
    #expect((healthResponse as? HTTPURLResponse)?.statusCode == 200)
    #expect(String(data: healthData, encoding: .utf8)?.contains(#""ok":true"#) == true)

    let (infoData, infoResponse) = try await URLSession.shared.data(
        from: URL(string: "http://127.0.0.1:\(port)/remote-info")!
    )
    #expect((infoResponse as? HTTPURLResponse)?.statusCode == 200)
    let text = try #require(String(data: infoData, encoding: .utf8))
    #expect(text.contains(#""appName":"Alas""#))
    #expect(text.contains(#""pairedDeviceCount":1"#))
    #expect(text.contains("192.168.1.23"))
    #expect(!text.contains("token"))
    #expect(!text.contains("code"))
    #expect(!text.contains("session"))
    #expect(!text.contains("/Users/"))
}
```

- [ ] **Step 2: Run diagnostics test to verify failure**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteServerIntegrationTests/remoteDiagnosticsRoutesReturnSafeJSON
```

Expected: build fails because `RemoteDiagnosticsSnapshot` and the new `RemoteServer` initializer parameters do not exist.

- [ ] **Step 3: Add diagnostics snapshot and responder routes**

Add this type near `RemoteHTTPResponder`:

```swift
struct RemoteDiagnosticsSnapshot: Codable, Equatable, Sendable {
    let appName: String
    let port: UInt16?
    let addresses: [RemoteAdvertisedAddress]
    let usesPlainHTTP: Bool
    let pairedDeviceCount: Int
}
```

Update `RemoteHTTPResponder` to accept diagnostics and handle routes:

```swift
@MainActor
struct RemoteHTTPResponder {
    let pairing: RemotePairingService
    let assets: RemoteWebAssets
    let diagnostics: () -> RemoteDiagnosticsSnapshot

    func response(for req: HTTPRequest, body: Data) -> Data {
        if req.method == "GET", req.path == "/health" {
            return Self.json(["ok": true])
        }
        if req.method == "GET", req.path == "/remote-info" {
            let data = (try? JSONEncoder().encode(diagnostics())) ?? Data(#"{"error":"encode"}"#.utf8)
            return Self.http(status: "200 OK", contentType: "application/json; charset=utf-8", body: data)
        }
        if req.method == "POST", req.path == "/pair" {
            return pairResponse(body: body)
        }
        if req.method == "GET" {
            let path = req.path == "/" ? "/index.html" : req.path
            if let asset = assets.asset(forPath: path) {
                return Self.http(status: "200 OK", contentType: asset.contentType, body: asset.data)
            }
        }
        return Self.http(status: "404 Not Found", contentType: "text/plain", body: Data("not found".utf8))
    }

    private static func json(_ object: [String: Bool]) -> Data {
        let data = (try? JSONSerialization.data(withJSONObject: object, options: [])) ?? Data(#"{"ok":false}"#.utf8)
        return http(status: "200 OK", contentType: "application/json; charset=utf-8", body: data)
    }
}
```

Keep the existing `pairResponse`, `http`, `RemoteWebAssets`, and content-type behavior.

- [ ] **Step 4: Add default diagnostics wiring to `RemoteServer`**

Update the `RemoteServer` initializer shape:

```swift
private let accessPolicy: RemoteAccessPolicy
private let diagnosticsProvider: @MainActor (UInt16?) -> RemoteDiagnosticsSnapshot

init(
    pairing: RemotePairingService,
    assets: RemoteWebAssets,
    provider: RemoteSessionsProvider,
    accessPolicy: RemoteAccessPolicy = .loopback,
    diagnostics: @escaping @MainActor (UInt16?) -> RemoteDiagnosticsSnapshot = { port in
        RemoteDiagnosticsSnapshot(
            appName: "Alas",
            port: port,
            addresses: [],
            usesPlainHTTP: true,
            pairedDeviceCount: 0
        )
    }
) {
    self.pairing = pairing
    self.assets = assets
    self.provider = provider
    self.accessPolicy = accessPolicy
    self.diagnosticsProvider = diagnostics
}
```

When building the responder in `accept(_:)`, use:

```swift
let responder = RemoteHTTPResponder(
    pairing: pairing,
    assets: assets,
    diagnostics: { diagnosticsProvider(self.port) }
)
```

- [ ] **Step 5: Run diagnostics test to verify pass**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteServerIntegrationTests/remoteDiagnosticsRoutesReturnSafeJSON
```

Expected: diagnostics test passes.

- [ ] **Step 6: Commit diagnostics routes**

Run:

```bash
rtk git add Alas/Sources/Remote/Server/RemoteHTTPResponder.swift Alas/Sources/Remote/Server/RemoteServer.swift AlasTests/Remote/RemoteServerIntegrationTests.swift
rtk git commit -m "feat(remote): add safe diagnostics endpoints"
```

---

### Task 5: Enforce Host Policy Before Pairing and WebSocket

**Files:**
- Modify: `Alas/Sources/Remote/Server/RemoteConnection.swift`
- Modify: `Alas/Sources/Remote/Server/RemoteServer.swift`
- Modify: `AlasTests/Remote/RemoteServerIntegrationTests.swift`

- [ ] **Step 1: Write failing host rejection tests**

Add these tests to `RemoteServerIntegrationTests`:

```swift
@Test func pairWithRejectedHostReturns403AndDoesNotRedeemCode() async throws {
    let pairing = RemotePairingService(store: InMemoryDeviceStore())
    let code = pairing.beginPairing()
    let (server, port) = try await startServer(pairing: pairing)
    defer { server.stop() }

    var req = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/pair")!)
    req.httpMethod = "POST"
    req.setValue("evil.example", forHTTPHeaderField: "Host")
    req.httpBody = Data(#"{"code":"\#(code)","deviceName":"x"}"#.utf8)

    let (_, resp) = try await URLSession.shared.data(for: req)
    #expect((resp as? HTTPURLResponse)?.statusCode == 403)

    let token = try pairing.redeem(code: code, deviceName: "after-403")
    #expect(pairing.validate(token: token) != nil)
}

@Test func webSocketWithRejectedHostIsRejectedBeforeTokenValidation() async throws {
    let pairing = RemotePairingService(store: InMemoryDeviceStore())
    let token = try pairing.redeem(code: pairing.beginPairing(), deviceName: "phone")
    let (server, port) = try await startServer(pairing: pairing)
    defer { server.stop() }

    var req = URLRequest(url: URL(string: "ws://127.0.0.1:\(port)/ws")!)
    req.setValue("evil.example", forHTTPHeaderField: "Host")
    let task = URLSession.shared.webSocketTask(with: req, protocols: [token])
    task.resume()

    do {
        _ = try await task.receive()
        Issue.record("expected rejected host to fail the WebSocket upgrade")
    } catch {
        #expect(pairing.devices.first?.lastSeenAt == nil)
    }
    task.cancel(with: .goingAway, reason: nil)
}
```

- [ ] **Step 2: Run host rejection tests to verify failure**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteServerIntegrationTests/pairWithRejectedHostReturns403AndDoesNotRedeemCode -only-testing:AlasTests/RemoteServerIntegrationTests/webSocketWithRejectedHostIsRejectedBeforeTokenValidation
```

Expected: tests fail because rejected hosts are not blocked.

- [ ] **Step 3: Pass access policy into `RemoteConnection`**

Add a stored property and initializer parameter in `RemoteConnection`:

```swift
private let accessPolicy: RemoteAccessPolicy

init(conn: NWConnection,
     queue: DispatchQueue,
     responder: @escaping @MainActor (HTTPRequest, Data) -> Data,
     authorize: @escaping @MainActor (String) -> String?,
     accessPolicy: RemoteAccessPolicy,
     makeGateway: @escaping @MainActor (@escaping (RemoteServerMessage) -> Void) -> RemoteSessionGateway,
     onAuthenticated: ((RemoteConnection, String) -> Void)? = nil,
     onClose: @escaping (RemoteConnection) -> Void = { _ in }) {
    self.conn = conn
    self.queue = queue
    self.responder = responder
    self.authorize = authorize
    self.accessPolicy = accessPolicy
    self.makeGateway = makeGateway
    self.onAuthenticated = onAuthenticated
    self.onClose = onClose
}
```

In the HTTP parsing path, immediately after `req` is available and before upgrade/body handling, add:

```swift
guard accessPolicy.allows(hostHeader: req.headers["host"]) else {
    send(RemoteHTTPResponder.http(
        status: "403 Forbidden",
        contentType: "text/plain",
        body: Data("forbidden".utf8)
    )) { self.teardown() }
    return
}
```

- [ ] **Step 4: Wire policy from `RemoteServer.accept(_:)`**

When creating `RemoteConnection`, add:

```swift
accessPolicy: accessPolicy,
```

between `authorize:` and `makeGateway:`.

- [ ] **Step 5: Run host rejection tests to verify pass**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteServerIntegrationTests/pairWithRejectedHostReturns403AndDoesNotRedeemCode -only-testing:AlasTests/RemoteServerIntegrationTests/webSocketWithRejectedHostIsRejectedBeforeTokenValidation
```

Expected: both tests pass.

- [ ] **Step 6: Run the full remote server integration test file**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteServerIntegrationTests
```

Expected: all `RemoteServerIntegrationTests` pass.

- [ ] **Step 7: Commit host enforcement**

Run:

```bash
rtk git add Alas/Sources/Remote/Server/RemoteConnection.swift Alas/Sources/Remote/Server/RemoteServer.swift AlasTests/Remote/RemoteServerIntegrationTests.swift
rtk git commit -m "feat(remote): reject untrusted remote hosts"
```

---

### Task 6: Server State, Advertised Addresses, and Revoke All

**Files:**
- Modify: `Alas/Sources/Remote/Pairing/RemotePairingService.swift`
- Modify: `Alas/Sources/Remote/Server/RemoteServer.swift`
- Modify: `Alas/Sources/App/AppState.swift`
- Modify: `AlasTests/Remote/RemotePairingServiceTests.swift`
- Modify: `AlasTests/Remote/RemoteServerIntegrationTests.swift`

- [ ] **Step 1: Write failing pairing and connected-device tests**

Add to `RemotePairingServiceTests`:

```swift
@Test func revokeAllClearsEveryDevice() throws {
    let svc = make()
    _ = try svc.redeem(code: svc.beginPairing(), deviceName: "A")
    _ = try svc.redeem(code: svc.beginPairing(), deviceName: "B")
    #expect(svc.devices.count == 2)

    svc.revokeAll()

    #expect(svc.devices.isEmpty)
}
```

Add to `RemoteServerIntegrationTests`:

```swift
@Test func connectedDeviceCountsReflectAuthenticatedWebSockets() async throws {
    let provider = FakeSessionsProvider()
    let mgr = try makeManager()
    let s = mgr.createSession(agentId: "claude")
    provider.sessions[s.id] = s

    let pairing = RemotePairingService(store: InMemoryDeviceStore())
    let server = RemoteServer(
        pairing: pairing,
        assets: RemoteWebAssets(root: URL(fileURLWithPath: NSTemporaryDirectory())),
        provider: provider
    )
    try server.start(port: 0)
    defer { server.stop() }

    for _ in 0..<50 where server.port == nil {
        try await Task.sleep(nanoseconds: 20_000_000)
    }
    let port = try #require(server.port)

    let code = pairing.beginPairing()
    var pairReq = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/pair")!)
    pairReq.httpMethod = "POST"
    pairReq.httpBody = Data(#"{"code":"\#(code)","deviceName":"phone"}"#.utf8)
    let (pairData, _) = try await URLSession.shared.data(for: pairReq)
    struct PairResp: Decodable { let token: String }
    let token = try JSONDecoder().decode(PairResp.self, from: pairData).token
    let deviceId = try #require(pairing.devices.first?.id)

    let task = URLSession.shared.webSocketTask(
        with: URL(string: "ws://127.0.0.1:\(port)/ws")!,
        protocols: [token]
    )
    task.resume()
    try await task.send(.data(JSONEncoder().encode(RemoteClientMessage.subscribe(sessionId: s.id))))
    _ = try await task.receive()

    #expect(server.connectedDeviceCounts()[deviceId] == 1)

    task.cancel(with: .goingAway, reason: nil)
}
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemotePairingServiceTests/revokeAllClearsEveryDevice -only-testing:AlasTests/RemoteServerIntegrationTests/connectedDeviceCountsReflectAuthenticatedWebSockets
```

Expected: build fails because `revokeAll()` and `connectedDeviceCounts()` do not exist.

- [ ] **Step 3: Add `revokeAll()`**

Add to `RemotePairingService`:

```swift
func revokeAll() {
    devices.removeAll()
    store.save(devices)
}
```

- [ ] **Step 4: Add connected-device snapshot to `RemoteServer`**

Add to `RemoteServer`:

```swift
func connectedDeviceCounts() -> [String: Int] {
    var counts: [String: Int] = [:]
    for deviceId in connectionDevice.values {
        counts[deviceId, default: 0] += 1
    }
    return counts
}

func updateAccessPolicy(_ policy: RemoteAccessPolicy) {
    accessPolicy = policy
}
```

Change `accessPolicy` from `let` to `var`:

```swift
private var accessPolicy: RemoteAccessPolicy
```

- [ ] **Step 5: Expose remote access state from `AppState`**

Add observable state near the existing remote fields:

```swift
private(set) var remoteAdvertisedAddresses: [RemoteAdvertisedAddress] = []
```

Add helpers in `AppState`:

```swift
private func makeRemoteInterfaces() -> [RemoteNetworkInterface] {
    RemoteNetwork.interfaces()
}

private func makeRemoteAdvertisedAddresses(port: UInt16?) -> [RemoteAdvertisedAddress] {
    guard let port else { return [] }
    return RemoteNetwork.advertisedAddresses(
        port: port,
        interfaces: makeRemoteInterfaces(),
        allowedHosts: config.remote.allowedHosts,
        preferredHost: config.remote.preferredAdvertisedHost
    )
}

private func makeRemoteAccessPolicy() -> RemoteAccessPolicy {
    let hosts = RemoteNetwork.allowedHostCandidates(
        interfaces: makeRemoteInterfaces(),
        allowedHosts: config.remote.allowedHosts
    )
    return RemoteAccessPolicy(allowedHosts: hosts)
}

func refreshRemoteAccessState() {
    remoteAdvertisedAddresses = makeRemoteAdvertisedAddresses(port: remotePort)
    remoteServer?.updateAccessPolicy(makeRemoteAccessPolicy())
}

func remoteConnectedDeviceCounts() -> [String: Int] {
    remoteServer?.connectedDeviceCounts() ?? [:]
}

func revokeAllRemoteDevices() {
    let ids = remotePairing.devices.map(\.id)
    remotePairing.revokeAll()
    for id in ids {
        remoteServer?.disconnectDevice(id)
    }
}
```

When creating `RemoteServer`, pass policy and diagnostics:

```swift
let server = RemoteServer(
    pairing: remotePairing,
    assets: assets,
    provider: self,
    accessPolicy: makeRemoteAccessPolicy(),
    diagnostics: { [weak self] port in
        RemoteDiagnosticsSnapshot(
            appName: "Alas",
            port: port,
            addresses: self?.makeRemoteAdvertisedAddresses(port: port) ?? [],
            usesPlainHTTP: true,
            pairedDeviceCount: self?.remotePairing.devices.count ?? 0
        )
    }
)
```

Update `onPortChange`:

```swift
server.onPortChange = { [weak self] p in
    self?.remotePort = p
    self?.refreshRemoteAccessState()
}
```

When stopping the server, clear addresses:

```swift
remoteAdvertisedAddresses = []
```

- [ ] **Step 6: Run focused tests to verify pass**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemotePairingServiceTests/revokeAllClearsEveryDevice -only-testing:AlasTests/RemoteServerIntegrationTests/connectedDeviceCountsReflectAuthenticatedWebSockets
```

Expected: both tests pass.

- [ ] **Step 7: Run remote test group**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemotePairingServiceTests -only-testing:AlasTests/RemoteServerIntegrationTests -only-testing:AlasTests/RemoteNetworkTests -only-testing:AlasTests/RemoteAccessPolicyTests
```

Expected: all selected tests pass.

- [ ] **Step 8: Commit server state wiring**

Run:

```bash
rtk git add Alas/Sources/Remote/Pairing/RemotePairingService.swift Alas/Sources/Remote/Server/RemoteServer.swift Alas/Sources/App/AppState.swift AlasTests/Remote/RemotePairingServiceTests.swift AlasTests/Remote/RemoteServerIntegrationTests.swift
rtk git commit -m "feat(remote): expose private access server state"
```

---

### Task 7: Settings UI for Address Selection and Device Controls

**Files:**
- Modify: `Alas/Sources/Remote/Settings/RemoteServerPane.swift`
- Optional test if pure helpers are extracted: `AlasTests/Remote/RemoteServerPaneTests.swift`

- [ ] **Step 1: Add a pure selected-address helper test if extracting helper**

If the selection logic is extracted into a pure helper, add this test file:

```swift
import Testing
@testable import Alas

struct RemoteServerPaneTests {
    @Test func selectedAddressFallsBackToRecommendedAddress() {
        let lan = RemoteAdvertisedAddress(kind: .lan, interfaceName: "en0", host: "192.168.1.23", port: 8765, isRecommended: false)
        let tailnet = RemoteAdvertisedAddress(kind: .tailnet, interfaceName: "utun4", host: "100.88.1.20", port: 8765, isRecommended: true)

        #expect(RemoteAddressSelection.selectedAddress(in: [lan, tailnet], preferredHost: nil) == tailnet)
        #expect(RemoteAddressSelection.selectedAddress(in: [lan, tailnet], preferredHost: "192.168.1.23") == lan)
    }
}
```

If no helper is extracted, skip this test and cover selection manually in Step 6.

- [ ] **Step 2: Remove the nested `LocalNetwork` enum**

Delete the `LocalNetwork` enum at the bottom of `RemoteServerPane.swift`. Address discovery now lives in `RemoteNetwork`.

- [ ] **Step 3: Add address row rendering**

Add this helper inside `RemoteServerPane`:

```swift
private func addressLabel(_ address: RemoteAdvertisedAddress) -> String {
    switch address.kind {
    case .tailnet: return "Tailnet"
    case .lan: return "LAN"
    case .localhost: return "Localhost"
    case .custom: return "Custom"
    }
}

private func addressDescription(_ address: RemoteAdvertisedAddress) -> String {
    if let interface = address.interfaceName {
        return "\(address.url) on \(interface)"
    }
    return address.url
}

private func chooseAddress(_ address: RemoteAdvertisedAddress) {
    state.config.remote.preferredAdvertisedHost = address.host
    state.saveConfig()
    state.refreshRemoteAccessState()
}

private func pairingURL(port: UInt16) -> String {
    let selected = selectedAddress(port: port)
    return selected?.url ?? "http://localhost:\(port)"
}

private func selectedAddress(port: UInt16) -> RemoteAdvertisedAddress? {
    let addresses = state.remoteAdvertisedAddresses
    if let preferred = state.config.remote.preferredAdvertisedHost,
       let match = addresses.first(where: { RemoteNetwork.normalizedHost($0.host) == RemoteNetwork.normalizedHost(preferred) }) {
        return match
    }
    return addresses.first(where: \.isRecommended) ?? addresses.first
}
```

- [ ] **Step 4: Replace the single Address row**

Replace the current `SettingsRow(name: "Address", ...)` block with rows over `state.remoteAdvertisedAddresses`:

```swift
ForEach(state.remoteAdvertisedAddresses) { address in
    SettingsRow(
        name: addressLabel(address),
        desc: addressDescription(address)
    ) {
        HStack(spacing: 8) {
            if selectedAddress(port: address.port)?.id == address.id {
                Icon(name: "checkmark", size: 13, color: theme.color("accent"))
            }
            Button {
                let pasteboard = NSPasteboard.general
                pasteboard.clearContents()
                pasteboard.setString(address.url, forType: .string)
            } label: {
                Text("Copy")
                    .font(.system(size: 12, weight: .medium))
            }
            .buttonStyle(.plain)

            Button {
                chooseAddress(address)
            } label: {
                Text("Use for QR")
                    .font(.system(size: 12, weight: .medium))
            }
            .buttonStyle(.plain)
        }
    }
}
```

If `Icon(name: "checkmark")` is not available, use `Image(systemName: "checkmark")` with the same color and size.

- [ ] **Step 5: Update QR generation**

Change QR URL generation from:

```swift
QRView(text: "\(Self.reachableURL(port: port))/?code=\(code)")
```

to:

```swift
QRView(text: "\(pairingURL(port: port))/?code=\(code)")
```

Remove `reachableURL(port:)` after all call sites are gone.

- [ ] **Step 6: Add device connection and revoke-all controls**

Inside the paired devices group, compute:

```swift
let connected = state.remoteConnectedDeviceCounts()
```

Use the count in each device description:

```swift
let liveCount = connected[device.id] ?? 0
let seen = device.lastSeenAt.map { "Last seen \($0.formatted())" } ?? "Never connected"
let desc = liveCount > 0 ? "Connected now (\(liveCount)); \(seen)" : seen
```

Add a revoke-all row when devices are not empty:

```swift
SettingsRow(
    name: "All devices",
    desc: "Revoke every paired browser and close active remote sockets."
) {
    Button {
        pairingCode = nil
        state.revokeAllRemoteDevices()
    } label: {
        Text("Revoke All")
            .font(.system(size: 12, weight: .medium))
            .foregroundColor(theme.color("warn"))
            .padding(.horizontal, 12)
            .frame(height: 28)
            .overlay(
                RoundedRectangle(cornerRadius: 6)
                    .strokeBorder(theme.color("warn").opacity(0.4), lineWidth: 0.5)
            )
    }
    .buttonStyle(.plain)
}
```

- [ ] **Step 7: Add plain HTTP caveat**

Under address rows, add a Settings row:

```swift
SettingsRow(
    name: "PWA install",
    desc: "Live remote access works over a trusted LAN or tailnet. Full browser install and offline shell behavior can require HTTPS, depending on the phone browser."
) {
    Icon(name: "info", size: 14, color: theme.color("fg-dim"))
}
```

- [ ] **Step 8: Build to verify Settings compiles**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' -quiet build
```

Expected: build succeeds.

- [ ] **Step 9: Manual UI check**

Run the app from Xcode or a local build, then verify:

```text
Settings -> Remote shows Tailnet, LAN, and Localhost rows when those addresses are available.
Selecting the LAN row changes the QR payload to the LAN URL.
Copy on each address places that URL on the pasteboard.
Revoke All clears paired devices.
```

- [ ] **Step 10: Commit Settings UI**

Run:

```bash
rtk git add Alas/Sources/Remote/Settings/RemoteServerPane.swift Alas/Sources/App/AppState.swift
rtk git commit -m "feat(remote): show selectable private addresses"
```

---

### Task 8: Service Worker Network-Only Exclusions

**Files:**
- Modify: `Alas/Resources/RemoteWeb/sw.js`
- Modify: `AlasTests/Remote/RemoteWebAssetTests.swift`

- [ ] **Step 1: Write failing service worker asset test**

Add to `RemoteWebAssetTests`:

```swift
@Test func serviceWorkerKeepsControlAndDiagnosticRoutesNetworkOnly() throws {
    let sw = try asset("sw.js")

    #expect(sw.contains(#"url.pathname === "/pair""#))
    #expect(sw.contains(#"url.pathname === "/ws""#))
    #expect(sw.contains(#"url.pathname === "/health""#))
    #expect(sw.contains(#"url.pathname === "/remote-info""#))
    #expect(sw.contains(#"url.pathname.startsWith("/api/")"#))
    #expect(!sw.contains(#""/health","#))
    #expect(!sw.contains(#""/remote-info","#))
}
```

- [ ] **Step 2: Run test to verify failure**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteWebAssetTests/serviceWorkerKeepsControlAndDiagnosticRoutesNetworkOnly
```

Expected: test fails because `/health`, `/remote-info`, and `/api/*` are not listed as network-only.

- [ ] **Step 3: Update service worker**

Change the cache name:

```javascript
const CACHE_NAME = "alas-remote-shell-v3";
```

Replace the route exclusion block with:

```javascript
  if (
    url.pathname === "/pair" ||
    url.pathname === "/ws" ||
    url.pathname === "/health" ||
    url.pathname === "/remote-info" ||
    url.pathname.startsWith("/api/")
  ) return;
```

Keep diagnostics routes out of `SHELL_ASSETS`.

- [ ] **Step 4: Run test to verify pass**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteWebAssetTests/serviceWorkerKeepsControlAndDiagnosticRoutesNetworkOnly
```

Expected: service worker test passes.

- [ ] **Step 5: Commit service worker update**

Run:

```bash
rtk git add Alas/Resources/RemoteWeb/sw.js AlasTests/Remote/RemoteWebAssetTests.swift
rtk git commit -m "fix(remote): keep diagnostics network-only in pwa"
```

---

### Task 9: Full Verification

**Files:**
- All files touched by prior tasks.

- [ ] **Step 1: Regenerate Xcode project**

Run:

```bash
rtk xcodegen
```

Expected: command succeeds and `Alas.xcodeproj/project.pbxproj` is up to date.

- [ ] **Step 2: Run remote-focused tests**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test -only-testing:AlasTests/RemoteConfigTests -only-testing:AlasTests/RemoteNetworkTests -only-testing:AlasTests/RemoteAccessPolicyTests -only-testing:AlasTests/RemotePairingServiceTests -only-testing:AlasTests/RemoteServerIntegrationTests -only-testing:AlasTests/RemoteWebAssetTests
```

Expected: all selected tests pass.

- [ ] **Step 3: Run required build**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' -quiet build
```

Expected: build succeeds.

- [ ] **Step 4: Run required full test suite**

Run:

```bash
rtk xcodebuild -project Alas.xcodeproj -scheme Alas -destination 'platform=macOS' test
```

Expected: full suite passes.

- [ ] **Step 5: Inspect final diff**

Run:

```bash
rtk git status --short
rtk git diff --stat HEAD
```

Expected: only intended remote access files are modified after the last task commit. If verification changed `Alas.xcodeproj/project.pbxproj`, commit it:

```bash
rtk git add Alas.xcodeproj/project.pbxproj
rtk git commit -m "chore: regenerate xcode project"
```

- [ ] **Step 6: Manual LAN/tailnet verification**

Use the built app:

```text
1. Enable Settings -> Remote.
2. Select the LAN address row.
3. Scan the QR code from a phone on the same LAN.
4. Confirm pair, session list, transcript stream, prompt send, stop, and permission response work.
5. Select the tailnet address row.
6. Scan from a phone on the same tailnet.
7. Confirm the same remote controls work.
8. Send a request with Host: evil.example and confirm 403.
9. Revoke all devices and confirm the phone reconnects to the pairing gate.
```

- [ ] **Step 7: Final commit if needed**

If uncommitted intended changes remain:

```bash
rtk git add Alas/Sources AlasTests Alas/Resources/RemoteWeb Alas.xcodeproj/project.pbxproj
rtk git commit -m "feat(remote): harden LAN tailnet access"
```

Expected: working tree is clean after the final commit.
