From 50b122eca5878a7736d237165dbaddda524bf724 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 14:28:36 +0100 Subject: [PATCH 01/29] feat(macapp): pin each Docker workroom to its Docker context (#309) Docker Desktop, OrbStack and Colima are each a context behind the one `docker` CLI, and the app followed whichever was current. A workroom made on OrbStack seemed to vanish once the user switched to Docker Desktop. A new base is now pinned to the context the CLI uses at create time, and its workrooms go where it is. The context is recorded in the host's container record and named with `--context` on every command, which outranks DOCKER_HOST and DOCKER_CONTEXT (measured, Docker 29). `default` is never pinned, since it is whatever DOCKER_HOST says. RemoteHosts keeps one ContainerHostDriver per context and adopts each recorded host into its own. Every driver's sweep keeps every recorded host: two contexts can name one daemon, and a sweep that kept only its own driver's hosts would remove the other's. Records written before this have no context, read as nil, and run with no `--context`, exactly as before. Co-Authored-By: Claude Opus 5.5 --- macapp/WorkroomApp/Core/AppStore.swift | 4 +- .../Core/ContainerHostDriver.swift | 43 ++++- macapp/WorkroomApp/Core/Models.swift | 2 +- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 137 ++++++++++----- .../WorkroomApp/Core/TerminalSessions.swift | 2 +- .../WorkroomAppTests/RemoteHostsTests.swift | 165 ++++++++++++++++++ 6 files changed, 307 insertions(+), 46 deletions(-) diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 8b05f4ef9..06edb71ca 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -3523,7 +3523,9 @@ final class AppStore: ObservableObject { beginBusy(project.path) defer { endBusy(project.path) } do { - let (driver, environment) = try RemoteHosts.shared.environment() + // A new base is pinned to the Docker context in use now; a workroom goes where its base is. + let context = try await RemoteHosts.shared.context(forBase: project.host) + let (driver, environment) = try RemoteHosts.shared.environment(context: context) let resolution = await WorkroomStatusResolver().resolveRepository(in: project.path) guard case .found(let repository) = resolution else { throw RemoteWorkrooms.Failure.notOnGitHub("Couldn't find its GitHub repository with gh.") diff --git a/macapp/WorkroomApp/Core/ContainerHostDriver.swift b/macapp/WorkroomApp/Core/ContainerHostDriver.swift index 13058572d..6c8d2b6e5 100644 --- a/macapp/WorkroomApp/Core/ContainerHostDriver.swift +++ b/macapp/WorkroomApp/Core/ContainerHostDriver.swift @@ -45,6 +45,11 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { /// `key=value` labels on every container and image made, so whoever started the fixture can /// sweep up what a crashed test left (run.sh does). let labels: [String] + /// The Docker context every command names (`--context`, #309), or nil for whichever daemon + /// the environment and the CLI's current context pick. Docker Desktop, OrbStack and Colima + /// are each a context behind the one `docker`, so without it a host made on one is looked for + /// on whichever the user switched to since. + var context: String? = nil } /// A host this driver made: its container, and for a derived one the image it was run from, @@ -65,9 +70,12 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { let hostKey: String /// The image a derived instance was run from, which only it uses; nil for a base. let image: String? + /// The Docker context it runs in (`Provisioning.context`). nil, as every record made before + /// #309 has it, follows the environment and the CLI's current context, as those always did. + var context: String? = nil enum CodingKeys: String, CodingKey { - case address, port, user, image + case address, port, user, image, context case hostKey = "host_key" } } @@ -194,7 +202,7 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { guard let target = hosts[id], let made = provisioned[id] else { return nil } return Record( address: target.address, port: target.port, user: target.user, hostKey: target.hostKey, - image: made.image) + image: made.image, context: provisioning?.context) } } @@ -208,6 +216,12 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { if let image = record.image, !Self.isImageID(image) { throw HostDriverError.invalidConfiguration("image \(image) is not an image ID") } + // Its container is on that context's daemon, which this driver's commands don't reach. + guard record.context == provisioning.context else { + throw HostDriverError.invalidConfiguration( + "host is in Docker context \(record.context ?? "(current)"), not " + + (provisioning.context ?? "(current)")) + } lock.withLock { hosts[id] = Host( address: record.address, port: record.port, user: record.user, @@ -383,17 +397,38 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { ["HOME", "PATH", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG"].contains($0.key) } let (status, output) = try await HostStream.spawn( - provisioning.runtime, arguments, environment: environment, handshakeTimeout: 20, - purpose: .exchange + provisioning.runtime, Self.runtimeArguments(arguments, context: provisioning.context), + environment: environment, handshakeTimeout: 20, purpose: .exchange ).communicate(nil, timeout: timeout) let said = output.trimmingCharacters(in: .whitespacesAndNewlines) guard status == 0 else { + if let context = provisioning.context, said.contains("context not found") { + throw HostDriverError.provisioning( + "the Docker context \(context) this host was made in no longer exists. Create it " + + "again (`docker context create \(context)`), or delete the workroom.") + } throw HostDriverError.provisioning( "\(provisioning.runtime.lastPathComponent) \(arguments[0]) exited \(status): \(said)") } return allLines ? said : said.split(separator: "\n").first.map(String.init) ?? "" } + /// `arguments` for the runtime, naming `context` when there is one. `--context` is global, so it + /// goes before the command (after it, Docker refuses it), and it outranks both `DOCKER_HOST` and + /// `DOCKER_CONTEXT` (Docker 29, measured). + static func runtimeArguments(_ arguments: [String], context: String?) -> [String] { + guard let context else { return arguments } + return ["--context", context] + arguments + } + + /// The Docker context the CLI would use now, to pin a new host to (#309), or nil for `default`: + /// that one is whatever `DOCKER_HOST` says, so naming it pins nothing. Asks a driver whose own + /// context is nil, so the answer is the environment's and the CLI's. + func currentContext() async throws -> String? { + let name = try await runtime(["context", "show"]) + return name.isEmpty || name == "default" ? nil : name + } + func openStream(to host: HostID) async throws -> HostStream { let (id, target) = try target(host) return try Self.exec( diff --git a/macapp/WorkroomApp/Core/Models.swift b/macapp/WorkroomApp/Core/Models.swift index 74a0900df..5a63b5603 100644 --- a/macapp/WorkroomApp/Core/Models.swift +++ b/macapp/WorkroomApp/Core/Models.swift @@ -35,7 +35,7 @@ struct HostDescriptor: Codable, Hashable { var repository: String? = nil var cloneURL: String? = nil var path: String? = nil - /// How `ContainerHostDriver` finds the host again. + /// How `ContainerHostDriver` finds the host again, including the Docker context it runs in. var container: ContainerHostDriver.Record? = nil var isDestroyed: Bool { state == "destroyed" } diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index a1b464edd..44a9f08d4 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -309,13 +309,15 @@ enum RemoteWorkrooms { } } -/// The app's remote hosts (#253): one `ContainerHostDriver` on this Mac's Docker, the hosts config -/// records adopted into it at each reload, and one sweep per launch for what no record names. +/// The app's remote hosts (#253): a `ContainerHostDriver` per Docker context on this Mac (#309), +/// the hosts config records adopted into theirs at each reload, and one sweep per launch for what +/// no record names. final class RemoteHosts: @unchecked Sendable { static let shared = RemoteHosts() private let lock = NSLock() - private var made: ContainerHostDriver? + /// The drivers made so far, by the Docker context they name; nil is the one that names none. + private var made: [String?: ContainerHostDriver] = [:] private var swept = false /// A call reached the sweep while a delete was in flight and left it for later (#296). private var held = false @@ -327,15 +329,22 @@ final class RemoteHosts: @unchecked Sendable { private let connectHost: (@Sendable (HostID) async throws -> Void)? private let isConnected: (@Sendable (HostID) async -> Bool)? private let now: @Sendable () -> ContinuousClock.Instant + /// `adopt`'s seams, nil in the app: making a context's driver, and sweeping one. + private let makeDriver: (@Sendable (String?) throws -> ContainerHostDriver)? + private let sweepDriver: (@Sendable (ContainerHostDriver, Set) async -> [String])? init( connectHost: (@Sendable (HostID) async throws -> Void)? = nil, isConnected: (@Sendable (HostID) async -> Bool)? = nil, - now: @escaping @Sendable () -> ContinuousClock.Instant = { .now } + now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }, + makeDriver: (@Sendable (String?) throws -> ContainerHostDriver)? = nil, + sweepDriver: (@Sendable (ContainerHostDriver, Set) async -> [String])? = nil ) { self.connectHost = connectHost self.isConnected = isConnected self.now = now + self.makeDriver = makeDriver + self.sweepDriver = sweepDriver } /// How long a failed connect answers for its host before another is tried. The status sweep and @@ -346,14 +355,18 @@ final class RemoteHosts: @unchecked Sendable { private static let logger = Logger( subsystem: "com.developwithstyle.workroom", category: "RemoteWorkrooms") - /// The driver, made on first use: it needs Docker and this Mac's ssh key. - func driver() throws -> ContainerHostDriver { + /// The driver for Docker `context`, made on first use: it needs Docker and this Mac's ssh key. + /// nil names no context, as every driver did before #309. Each driver writes its hosts' ssh + /// files under their own IDs, so they share one directory. + func driver(context: String? = nil) throws -> ContainerHostDriver { try lock.withLock { - if let made { return made } - let driver = ContainerHostDriver( - hosts: [:], directory: Self.directory.appendingPathComponent("hosts", isDirectory: true), - provisioning: try Self.provisioning()) - made = driver + if let made = made[context] { return made } + let driver = + try makeDriver?(context) + ?? ContainerHostDriver( + hosts: [:], directory: Self.directory.appendingPathComponent("hosts", isDirectory: true), + provisioning: try Self.provisioning(context: context)) + made[context] = driver return driver } } @@ -375,14 +388,17 @@ final class RemoteHosts: @unchecked Sendable { /// so a launch with nothing recorded never reads config again for one. var sweepHeld: Bool { lock.withLock { held && !swept } } - /// The driver if something has already made it, for a pane, which must not probe Docker. - var existingDriver: ContainerHostDriver? { lock.withLock { made } } + /// The driver that holds host `id`, for a pane, which must not probe Docker: nil when no driver + /// made so far has it. + func existingDriver(holding id: UUID) -> ContainerHostDriver? { + lock.withLock { made.values.first { $0.record(of: .remote(id)) != nil } } + } - /// Takes on every host `projects` record, so their panes and services reach them after a - /// relaunch, then sweeps once per launch what carries this app's labels and no record names. - /// Does nothing, and never touches Docker, when nothing is recorded and nothing has been made. - /// `sweep: false` holds the sweep for a later call: a list with a delete in flight leaves out - /// hosts config still records (#296). + /// Takes on every host `projects` record, each into the driver for its Docker context, so their + /// panes and services reach them after a relaunch, then sweeps once per launch what carries this + /// app's labels and no record names. Does nothing, and never touches Docker, when nothing is + /// recorded and nothing has been made. `sweep: false` holds the sweep for a later call: a list + /// with a delete in flight leaves out hosts config still records (#296). func adopt(_ projects: [Project], sweep: Bool = true) { let descriptors = projects.compactMap(\.host) + projects.flatMap { $0.workrooms.compactMap(\.host) } @@ -391,36 +407,62 @@ final class RemoteHosts: @unchecked Sendable { $0.driver == RemoteWorkrooms.containerDriver && $0.provisioner == RemoteWorkrooms.provisioner } - guard !recorded.isEmpty || existingDriver != nil else { return } - let driver: ContainerHostDriver - do { driver = try self.driver() } catch { - Self.logger.error("remote hosts: \(error.localizedDescription, privacy: .public)") - return - } - for descriptor in recorded where !descriptor.isDestroyed { - guard let id = descriptor.id, let record = descriptor.container, - driver.record(of: .remote(id)) == nil - else { continue } - do { try driver.adopt(id, record) } catch { - Self.logger.error("adopting host \(id, privacy: .public): \(error, privacy: .public)") + let already = lock.withLock { Set(made.keys) } + guard !recorded.isEmpty || !already.isEmpty else { return } + // A descriptor with no container record yet goes to the driver that names no context, where the + // one driver before #309 would have had it. + let contexts = already.union(recorded.map { $0.container?.context }) + var drivers: [ContainerHostDriver] = [] + for context in contexts { + let driver: ContainerHostDriver + do { driver = try self.driver(context: context) } catch { + Self.logger.error("remote hosts: \(error.localizedDescription, privacy: .public)") + return + } + drivers.append(driver) + for descriptor in recorded + where !descriptor.isDestroyed && descriptor.container?.context == context { + guard let id = descriptor.id, let record = descriptor.container, + driver.record(of: .remote(id)) == nil + else { continue } + do { try driver.adopt(id, record) } catch { + Self.logger.error("adopting host \(id, privacy: .public): \(error, privacy: .public)") + } } } guard claimSweep(allowed: sweep) else { return } + // Every driver keeps every recorded host, not only its own: two contexts can name one daemon + // (the nil driver's current context and that context by name), and a sweep that kept only its + // own hosts would remove the other's, labelled as this build's and old enough. let known = Set(recorded.compactMap(\.id)) + let sweep = sweepDriver ?? { await $0.sweep(keeping: $1) } Task.detached(priority: .utility) { - for failure in await driver.sweep(keeping: known) { - Self.logger.error("remote host sweep: \(failure, privacy: .public)") + for driver in drivers { + for failure in await sweep(driver, known) { + Self.logger.error("remote host sweep: \(failure, privacy: .public)") + } } } } - /// The sequence's environment over the driver, signed in as this Mac. + /// The Docker context a new workroom of a project with `base` goes in (#309): its base's, since + /// it is derived from the base on that daemon, or for a project with no base yet the context the + /// CLI would use now, which the new base is then pinned to. nil follows the environment, as + /// every host made before #309 does. + func context(forBase base: HostDescriptor?) async throws -> String? { + if let base, base.id != nil { return base.container?.context } + return try await driver(context: nil).currentContext() + } + + /// The sequence's environment over Docker `context`'s driver, signed in as this Mac. @MainActor - func environment() throws -> (ContainerHostDriver, RemoteProvisioning.Environment) { + func environment(context: String?) throws -> ( + ContainerHostDriver, RemoteProvisioning.Environment + ) { guard let client = BrokerSession.shared.client() else { throw RemoteWorkrooms.Failure.signedOut } - let driver = try driver() + let driver = try driver(context: context) var environment = RemoteProvisioning.Environment( driver: driver, agentSocket: RemoteWorkrooms.agentSocket, client: client) #if DEBUG @@ -441,7 +483,16 @@ final class RemoteHosts: @unchecked Sendable { @MainActor func environment(toDelete hosts: [HostDescriptor]) throws -> RemoteProvisioning.Environment? { guard try RemoteWorkrooms.checkDeletable(hosts) else { return nil } - let (driver, environment) = try environment() + // One environment has one driver, so one context. A project's workrooms are derived from its + // base, on its daemon, so the hosts of one delete share it. + let contexts = Set( + hosts.filter(RemoteWorkrooms.isLive).compactMap(\.container).map(\.context)) + guard contexts.count <= 1 else { + throw HostDriverError.invalidConfiguration( + "these hosts are in different Docker contexts: " + + contexts.map { $0 ?? "(current)" }.sorted().joined(separator: ", ")) + } + let (driver, environment) = try environment(context: contexts.first ?? nil) // Taking a box down needs it on the driver, whatever a reload adopted: with previews off it // adopted nothing, and the box would read as unknown. for host in hosts where RemoteWorkrooms.isLive(host) { @@ -485,7 +536,7 @@ final class RemoteHosts: @unchecked Sendable { } do { let connect = - try connectHost ?? { [driver = try driver()] in + try connectHost ?? { [driver = try heldDriver(host)] in try await self.connect($0, driver: driver) } let task = lock.withLock { () -> Task in @@ -507,6 +558,14 @@ final class RemoteHosts: @unchecked Sendable { } } + /// The driver holding `host`, which a reload adopted it into (`adopt`) or which made it. + private func heldDriver(_ host: HostID) throws -> ContainerHostDriver { + guard case .remote(let id) = host, let driver = existingDriver(holding: id) else { + throw HostDriverError.unknownHost(host) + } + return driver + } + /// `Application Support/Workroom//remote`. static var directory: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] @@ -523,7 +582,7 @@ final class RemoteHosts: @unchecked Sendable { "/Applications/Docker.app/Contents/Resources/bin/docker", ] - private static func provisioning() throws -> ContainerHostDriver.Provisioning { + private static func provisioning(context: String?) throws -> ContainerHostDriver.Provisioning { guard let runtime = runtimeCandidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) @@ -537,7 +596,7 @@ final class RemoteHosts: @unchecked Sendable { .trimmingCharacters(in: .whitespacesAndNewlines), agentSocket: RemoteWorkrooms.agentSocket, // Per build, so a Dev app's sweep never takes a Nightly app's hosts, nor the other way. - labels: ["workroom.provisioner=\(RemoteWorkrooms.provisioner)"]) + labels: ["workroom.provisioner=\(RemoteWorkrooms.provisioner)"], context: context) } /// This Mac's ssh key for its remote hosts, in `directory`, made the first time: ed25519, no diff --git a/macapp/WorkroomApp/Core/TerminalSessions.swift b/macapp/WorkroomApp/Core/TerminalSessions.swift index eeb068218..c83ad9a0c 100644 --- a/macapp/WorkroomApp/Core/TerminalSessions.swift +++ b/macapp/WorkroomApp/Core/TerminalSessions.swift @@ -2263,7 +2263,7 @@ final class TerminalSessions: ObservableObject { /// rather than opening that shell (`PersistentSessionService.attachCommand`). private func registerRemote(_ session: UUID, on host: UUID, workingDirectory: String) { let driver = - RemoteHosts.shared.existingDriver + RemoteHosts.shared.existingDriver(holding: host) ?? ContainerHostDriver(hosts: [:], directory: RemoteHosts.directory) sessionService.registerRemoteSession( session, on: .remote(host), via: driver, workingDirectory: workingDirectory) diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index 478993f3d..150c15099 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -224,6 +224,171 @@ final class RemoteHostsTests: XCTestCase { XCTAssertEqual(files.state, .idle, "Files listed the remote path on this Mac") } + // MARK: Docker contexts (#309) + + /// A stand-in runtime CLI: a script that appends its arguments to `log`, one call per line, and + /// prints `output`. + private func stubRuntime(output: String = "") throws -> (runtime: URL, log: URL) { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "wr-runtime-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + let runtime = directory.appendingPathComponent("docker") + let log = directory.appendingPathComponent("calls") + try """ + #!/bin/sh + printf '%s\\n' "$*" >> \(ContainerHostDriver.shellQuoted(log.path)) + printf '%s' \(ContainerHostDriver.shellQuoted(output)) + """.write(to: runtime, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: runtime.path) + return (runtime, log) + } + + private static func driver(runtime: URL, context: String?) -> ContainerHostDriver { + ContainerHostDriver( + hosts: [:], directory: FileManager.default.temporaryDirectory, + provisioning: ContainerHostDriver.Provisioning( + runtime: runtime, image: "workroom-host", user: RemoteWorkrooms.user, + identityFile: "/dev/null", publicKey: "ssh-ed25519 AAAA", + agentSocket: RemoteWorkrooms.agentSocket, labels: ["workroom.provisioner=test"], + context: context)) + } + + private static func record(context: String?) -> ContainerHostDriver.Record { + ContainerHostDriver.Record( + address: "127.0.0.1", port: 2222, user: RemoteWorkrooms.user, hostKey: "ssh-ed25519 AAAA", + image: nil, context: context) + } + + /// A record written before #309 has no context: it reads as nil, which names no context on any + /// command, as every command did then. A pinned one keeps its context through config. + func testARecordWithoutAContextReadsAsTheUnpinnedOne() throws { + let old = Data( + #"{"address":"127.0.0.1","port":2222,"user":"workroom","host_key":"ssh-ed25519 AAAA"}"#.utf8) + let decoded = try JSONDecoder().decode(ContainerHostDriver.Record.self, from: old) + XCTAssertNil(decoded.context) + XCTAssertEqual(decoded, Self.record(context: nil)) + + let pinned = Self.record(context: "orbstack") + XCTAssertEqual( + try JSONDecoder().decode( + ContainerHostDriver.Record.self, from: try JSONEncoder().encode(pinned)), pinned) + } + + /// Every runtime command names a pinned context, ahead of the command (Docker refuses it after), + /// and an unpinned driver's commands are exactly what they were before #309. + func testRuntimeCommandsNameTheContextAheadOfTheCommand() async throws { + for context in [nil, "orbstack"] as [String?] { + let (runtime, log) = try stubRuntime() + _ = await Self.driver(runtime: runtime, context: context).sweep(keeping: []) + let calls = try String(contentsOf: log, encoding: .utf8).split(separator: "\n") + let prefix = context.map { "--context \($0) " } ?? "" + XCTAssertEqual( + calls, + [ + "\(prefix)ps -a --filter label=workroom.provisioner=test --format " + + "{{.Names}}\t{{.Label \"workroom.created\"}}", + "\(prefix)images -aq --filter label=workroom.provisioner=test", + ], "context \(context ?? "nil")") + } + } + + /// A new base is pinned to the context the CLI uses now, except `default`, which is whatever + /// `DOCKER_HOST` says and so pins nothing. A project with a base stays on the base's context. + func testANewWorkroomGoesInItsBasesContextOrTheCurrentOne() async throws { + for (current, expected) in [("orbstack", "orbstack"), ("default", nil)] as [(String, String?)] { + let (runtime, log) = try stubRuntime(output: current + "\n") + let remote = RemoteHosts(makeDriver: { Self.driver(runtime: runtime, context: $0) }) + let context = try await remote.context(forBase: nil) + XCTAssertEqual(context, expected, current) + XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "context show\n") + } + + let remote = RemoteHosts(makeDriver: { _ in + XCTFail("a project with a base asked Docker") + throw RemoteWorkrooms.Failure.noDocker + }) + for context in [nil, "desktop-linux"] as [String?] { + let base = HostDescriptor( + provisioner: RemoteWorkrooms.provisioner, id: UUID(), + container: Self.record(context: context)) + let found = try await remote.context(forBase: base) + XCTAssertEqual(found, context) + } + } + + /// Each recorded host is adopted into its own context's driver, and every driver's sweep keeps + /// every recorded host: two contexts can name one daemon, and a sweep that kept only its own + /// driver's hosts would remove the other's. + func testHostsAreAdoptedByContextAndEverySweepKeepsThemAll() async throws { + let (runtime, _) = try stubRuntime() + let swept = Swept() + let remote = RemoteHosts( + makeDriver: { Self.driver(runtime: runtime, context: $0) }, + sweepDriver: { driver, known in + swept.add(driver.provisioning?.context, known) + return [] + }) + let (base, pinned) = (UUID(), UUID()) + let descriptor = { (id: UUID, context: String?) in + HostDescriptor( + driver: RemoteWorkrooms.containerDriver, provisioner: RemoteWorkrooms.provisioner, id: id, + container: Self.record(context: context)) + } + let project = Project( + path: "/proj", vcs: "git", + workrooms: [ + Workroom( + name: "w", path: "/home/workroom/r", vcsName: "workroom/w", warnings: [], + host: descriptor(pinned, "orbstack")) + ], host: descriptor(base, nil)) + + remote.adopt([project]) + + XCTAssertNil(try XCTUnwrap(remote.existingDriver(holding: base)).provisioning?.context) + XCTAssertEqual(remote.existingDriver(holding: pinned)?.provisioning?.context, "orbstack") + for _ in 0..<500 where swept.calls.count < 2 { try await Task.sleep(for: .milliseconds(2)) } + XCTAssertEqual(Set(swept.calls.map(\.context)), [nil, "orbstack"]) + for call in swept.calls { XCTAssertEqual(call.known, [base, pinned], call.context ?? "nil") } + } + + private final class Swept: @unchecked Sendable { + private let lock = NSLock() + private var made: [(context: String?, known: Set)] = [] + var calls: [(context: String?, known: Set)] { lock.withLock { made } } + func add(_ context: String?, _ known: Set) { + lock.withLock { made.append((context, known)) } + } + } + + /// A driver takes on only a host of its own context: its commands would not reach another's. + func testADriverRefusesAHostOfAnotherContext() throws { + let (runtime, _) = try stubRuntime() + XCTAssertThrowsError( + try Self.driver(runtime: runtime, context: "orbstack").adopt( + UUID(), Self.record(context: nil))) + XCTAssertNoThrow( + try Self.driver(runtime: runtime, context: "orbstack").adopt( + UUID(), Self.record(context: "orbstack"))) + } + + /// One delete runs one driver, so hosts in two contexts are refused before anything is removed. + @MainActor + func testADeleteAcrossContextsIsRefused() throws { + let live = { (context: String?) in + HostDescriptor( + driver: RemoteWorkrooms.containerDriver, provisioner: RemoteWorkrooms.provisioner, + id: UUID(), container: Self.record(context: context)) + } + XCTAssertThrowsError( + try RemoteHosts().environment(toDelete: [live(nil), live("orbstack")]) + ) { error in + guard case HostDriverError.invalidConfiguration = error else { + return XCTFail("\(error)") + } + } + } + /// A base record that names a host but not enough to derive from is refused: building another /// would leave the recorded one running with nothing pointing at it. func testAnIncompleteBaseIsNotReplaced() async throws { From 3d1b3eeba970ba14b3ade8afa40c87f84e387ae8 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 14:35:38 +0100 Subject: [PATCH 02/29] feat(ci,macapp): publish the workroom-host image and pin it into the app (#309) Nightly and Release now build the FIXTURE=0 host image once per architecture, smoke-test that exact image (host-image-test.sh takes it as WR_HOST_IMAGE), push it by digest, and join the two into one multi-arch manifest on ghcr. The manifest digest goes into the app build as WorkroomHostImage, so an app version always runs the image it was tested with. The hidden remoteHostImage setting still overrides it, and a local build with neither runs `workroom-host` as before. A base's image is now pulled explicitly when the runtime doesn't have it, and `run` passes --pull=never. Left to `run`, a missing image was looked for on Docker Hub as library/workroom-host, and the error said nothing about why. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- .github/workflows/host-image.yml | 101 ++++++++++++++++++ .github/workflows/nightly.yml | 16 ++- .github/workflows/release.yml | 13 +++ macapp/Scripts/release.sh | 7 ++ .../Core/ContainerHostDriver.swift | 19 +++- macapp/WorkroomApp/Core/DefaultsKeys.swift | 8 +- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 18 +++- macapp/WorkroomApp/Info.plist | 2 + .../WorkroomAppTests/RemoteHostsTests.swift | 53 +++++++++ macapp/project.yml | 4 + vcs/scripts/ssh-fixture/host-image-test.sh | 10 +- 11 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/host-image.yml diff --git a/.github/workflows/host-image.yml b/.github/workflows/host-image.yml new file mode 100644 index 000000000..db4d8ef7b --- /dev/null +++ b/.github/workflows/host-image.yml @@ -0,0 +1,101 @@ +name: Host image + +# Publishes the `workroom-host` image a local container workroom runs (#309), for the Nightly and +# Release workflows, which pin the app they build to the digest this outputs. +# +# Each architecture is built once, smoke-tested as that exact image (host-image-test.sh with +# WR_HOST_IMAGE), and only then pushed by digest: a second build for the push could resolve other +# Debian packages than the ones tested. The two digests are then joined into one multi-arch manifest +# under `tag`, and the manifest's digest is the output. +# +# The package must be public for an app to pull it without signing in to ghcr: set once, by hand, +# in the package's settings after its first push. + +on: + workflow_call: + inputs: + tag: + description: Tag the manifest is published under (nightly, or the release version). + required: true + type: string + outputs: + image: + description: The published manifest, by digest (ghcr.io//workroom-host@sha256:…). + value: ${{ jobs.manifest.outputs.image }} + +jobs: + build: + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + permissions: + contents: read + packages: write + outputs: + # One output per leg; a matrix job's outputs are last-writer-wins otherwise. + digest-amd64: ${{ steps.push.outputs.digest-amd64 }} + digest-arm64: ${{ steps.push.outputs.digest-arm64 }} + steps: + - uses: actions/checkout@v5 + + - name: Build + run: docker build --tag workroom-host-candidate vcs/scripts/ssh-fixture + + - name: Smoke-test that image + env: + WR_HOST_IMAGE: workroom-host-candidate + run: vcs/scripts/ssh-fixture/host-image-test.sh + + - name: Log in to ghcr + env: + GH_TOKEN: ${{ github.token }} + ACTOR: ${{ github.actor }} + run: echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin + + # Pushed under a per-run, per-arch tag only so `docker push` has something to push; the + # manifest below references it by digest, which is what the app pins. + - name: Push + id: push + run: | + repo="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/workroom-host" + tag="$repo:build-${{ github.run_id }}-${{ matrix.arch }}" + docker tag workroom-host-candidate "$tag" + digest="$(docker push "$tag" | sed -n 's/.*digest: \(sha256:[0-9a-f]\{64\}\).*/\1/p')" + test -n "$digest" || { echo "::error::no digest from docker push"; exit 1; } + echo "digest-${{ matrix.arch }}=$digest" >> "$GITHUB_OUTPUT" + + manifest: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.create.outputs.image }} + steps: + - name: Log in to ghcr + env: + GH_TOKEN: ${{ github.token }} + ACTOR: ${{ github.actor }} + run: echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin + + - name: Create the multi-arch manifest + id: create + env: + AMD64: ${{ needs.build.outputs.digest-amd64 }} + ARM64: ${{ needs.build.outputs.digest-arm64 }} + TAG: ${{ inputs.tag }} + run: | + repo="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/workroom-host" + docker buildx imagetools create --tag "$repo:$TAG" "$repo@$AMD64" "$repo@$ARM64" + digest="$(docker buildx imagetools inspect "$repo:$TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')" + case "$digest" in sha256:*) ;; *) echo "::error::no manifest digest: $digest"; exit 1 ;; esac + echo "image=$repo@$digest" >> "$GITHUB_OUTPUT" + echo "Published $repo@$digest ($TAG)" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 11f0e40d8..d2fbda7be 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -121,9 +121,21 @@ jobs: # Build + sign + notarize + staple the DMG alongside the CLI. The publish job below # uploads it, updates the appcast, then stamps the successful version as the skip marker. - macapp: + # The `workroom-host` image tonight's local container workrooms run (#309), built, tested and + # published per architecture; its digest is pinned into the app below. + host-image: needs: check if: needs.check.outputs.should_build == 'true' + uses: ./.github/workflows/host-image.yml + permissions: + contents: read + packages: write + with: + tag: nightly + + macapp: + needs: [check, host-image] + if: needs.check.outputs.should_build == 'true' runs-on: macos-15 env: VERSION: ${{ needs.check.outputs.version }} @@ -240,6 +252,8 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + # The host image this app runs, by digest (release.sh → WorkroomHostImage). + WORKROOM_HOST_IMAGE: ${{ needs.host-image.outputs.image }} # release.sh derives MARKETING_VERSION = ${VERSION#v} and CURRENT_PROJECT_VERSION = commit # count; the nightly version has no leading v, so it flows through unchanged. run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62b776ffc..6b8fc161d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,18 @@ jobs: # NOTARY_KEY / NOTARY_KEY_ID / NOTARY_ISSUER_ID. # Optional: SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT enable Sentry dSYM upload so release # crashes symbolicate; release.sh skips that step when any of them (or sentry-cli) is absent. + # The `workroom-host` image this release's local container workrooms run (#309), built, tested + # and published per architecture; its digest is pinned into the app below. + host-image: + uses: ./.github/workflows/host-image.yml + permissions: + contents: read + packages: write + with: + tag: ${{ github.ref_name }} + macapp: + needs: host-image runs-on: macos-15 steps: - uses: actions/checkout@v5 @@ -124,6 +135,8 @@ jobs: # Empty until the secret is added — release.sh then skips signing (DMG still ships). SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} VERSION: ${{ github.ref_name }} + # The host image this app runs, by digest (release.sh → WorkroomHostImage). + WORKROOM_HOST_IMAGE: ${{ needs.host-image.outputs.image }} # Sentry dSYM upload (optional — release.sh skips when any is empty). ORG/PROJECT are # slugs, not secrets, but kept as secrets here to match this job's convention. SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/macapp/Scripts/release.sh b/macapp/Scripts/release.sh index 5786ac79b..935631466 100755 --- a/macapp/Scripts/release.sh +++ b/macapp/Scripts/release.sh @@ -95,6 +95,12 @@ BUILD_NUMBER="$(git -C "$MACAPP_DIR/.." rev-list --count HEAD 2>/dev/null || ech # Go helper — with our Developer ID, hardened runtime, and a secure timestamp. A plain `build` # leaves nested helpers without a timestamp (and injects get-task-allow), which notarization # rejects. Archiving also produces a distribution build with get-task-allow omitted. +# The `workroom-host` image the app's local container workrooms run, by digest (#309): CI's +# host-image workflow publishes it and passes it here. Without it the app falls back to a locally +# built `workroom-host` (`make remote-host-image`), which a user won't have. +if [ -z "${WORKROOM_HOST_IMAGE:-}" ]; then + echo "warning: WORKROOM_HOST_IMAGE is unset; this build runs a local workroom-host image" >&2 +fi echo "==> Archiving $CONFIGURATION ($APP_NAME) $SHORT_VERSION ($BUILD_NUMBER)" rm -rf "$ARCHIVE" "$EXPORT_DIR" xcodebuild -project "$PROJ" -scheme WorkroomApp -configuration "$CONFIGURATION" \ @@ -102,6 +108,7 @@ xcodebuild -project "$PROJ" -scheme WorkroomApp -configuration "$CONFIGURATION" -clonedSourcePackagesDirPath "$BUILD/SourcePackages" \ -archivePath "$ARCHIVE" \ MARKETING_VERSION="$SHORT_VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ + WORKROOM_HOST_IMAGE="${WORKROOM_HOST_IMAGE:-}" \ archive # Upload dSYMs to Sentry so release crashes symbolicate. The archive bundles them in diff --git a/macapp/WorkroomApp/Core/ContainerHostDriver.swift b/macapp/WorkroomApp/Core/ContainerHostDriver.swift index 6c8d2b6e5..f2d5d7dbc 100644 --- a/macapp/WorkroomApp/Core/ContainerHostDriver.swift +++ b/macapp/WorkroomApp/Core/ContainerHostDriver.swift @@ -117,9 +117,25 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { func create() async throws -> HostID { guard let provisioning else { throw HostDriverError.notImplemented("Creating a base") } + try await ensureImage(provisioning.image) return try await run(provisioning.image, image: nil) } + /// Pulls `image` unless the runtime already has it (#309). `run` itself never pulls + /// (`--pull=never`): left to it, a missing `workroom-host` was looked for on Docker Hub as + /// `library/workroom-host`, and failed with a registry error that said nothing about why. + private func ensureImage(_ image: String) async throws { + if (try? await runtime(["image", "inspect", "--format", "{{.Id}}", image])) != nil { return } + do { + // Silence-bounded, and a pull reports its progress as it goes. + _ = try await runtime(["pull", image], timeout: 900) + } catch { + throw HostDriverError.provisioning( + "couldn't download the workroom host image \(image). Check your network connection, " + + "then try again. (\(error.localizedDescription))") + } + } + /// A snapshot of `base`'s disk, run as a container of its own. The base keeps running: `commit` /// pauses it only while it copies. The new container mints its own identity at its first boot /// (`entrypoint.sh`), before sshd or the agent starts, so it serves nothing as its base; its @@ -326,7 +342,8 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { _ = try await runtime( // `unless-stopped`: a Docker or Mac restart brings it back, on the port its record names. [ - "run", "--detach", "--init", "--restart", "unless-stopped", "--name", container, + "run", "--pull=never", "--detach", "--init", "--restart", "unless-stopped", "--name", + container, "--publish", "127.0.0.1:\(port):22", ] + (provisioning.labels + [Self.created()]).flatMap { ["--label", $0] } diff --git a/macapp/WorkroomApp/Core/DefaultsKeys.swift b/macapp/WorkroomApp/Core/DefaultsKeys.swift index 1546206cd..6ba81ff42 100644 --- a/macapp/WorkroomApp/Core/DefaultsKeys.swift +++ b/macapp/WorkroomApp/Core/DefaultsKeys.swift @@ -92,9 +92,11 @@ extension Defaults.Keys { /// `defaults write remoteWorkroomsPreview -bool YES`. static let remoteWorkroomsPreview = Key( "remoteWorkroomsPreview", default: false, suite: .app) - /// The Docker image a remote workroom's host runs (#253): `make remote-host-image` builds it. - /// Hidden; override with `defaults write remoteHostImage `. - static let remoteHostImage = Key("remoteHostImage", default: "workroom-host", suite: .app) + /// Overrides the image a container workroom's host runs (#253, #309), which is otherwise the one + /// this build pins (`RemoteWorkrooms.hostImage`). Hidden; set it with + /// `defaults write remoteHostImage `, e.g. `workroom-host` for the image + /// `make remote-host-image` builds. + static let remoteHostImage = Key("remoteHostImage", default: nil, suite: .app) /// The Workroom credential broker (Codaset). Empty means the build's own: codaset.dev, or /// `https://codaset.localhost` in a Debug build, which only ever reaches this Mac /// (`BrokerEndpoint`). Hidden; override with `defaults write brokerURL `. diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index 44a9f08d4..d685acac9 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -31,6 +31,22 @@ enum RemoteWorkrooms { static let agentSocket = "/run/workroom/agent.sock" static let user = "workroom" + /// The image a new base runs (#309): the hidden `remoteHostImage` override, else the published + /// image this build pins by digest (`WorkroomHostImage`, from CI), else a local `workroom-host`, + /// as `make remote-host-image` builds it for a Dev build. + static var hostImage: String { + hostImage( + override: Defaults[.remoteHostImage], + pinned: Bundle.main.object(forInfoDictionaryKey: "WorkroomHostImage") as? String) + } + + static func hostImage(override: String?, pinned: String?) -> String { + for image in [override, pinned] { + if let image = image?.trimmingCharacters(in: .whitespaces), !image.isEmpty { return image } + } + return "workroom-host" + } + /// The branch a remote workroom checks out, as a local workroom's is named. static func branch(for name: String) -> String { "workroom/\(name)" } @@ -590,7 +606,7 @@ final class RemoteHosts: @unchecked Sendable { else { throw RemoteWorkrooms.Failure.noDocker } let key = try clientKey() return ContainerHostDriver.Provisioning( - runtime: URL(fileURLWithPath: runtime), image: Defaults[.remoteHostImage], + runtime: URL(fileURLWithPath: runtime), image: RemoteWorkrooms.hostImage, user: RemoteWorkrooms.user, identityFile: key.path, publicKey: try String(contentsOf: key.appendingPathExtension("pub"), encoding: .utf8) .trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/macapp/WorkroomApp/Info.plist b/macapp/WorkroomApp/Info.plist index 06a3292b1..c697a3094 100644 --- a/macapp/WorkroomApp/Info.plist +++ b/macapp/WorkroomApp/Info.plist @@ -38,6 +38,8 @@ JUdX5urBRS1a/heb5qKRuxdxQtHXS3WLGYd0+qI9P6k= SUScheduledCheckInterval 86400 + WorkroomHostImage + $(WORKROOM_HOST_IMAGE) WorkroomReleaseChannel $(WORKROOM_RELEASE_CHANNEL) diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index 150c15099..9fcb426e1 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -244,6 +244,59 @@ final class RemoteHostsTests: XCTestCase { return (runtime, log) } + /// A stand-in runtime CLI that logs every call and fails `image inspect` (the image is missing), + /// `run`, and `pull` when `pullFails`, so `create` stops at its first container. + private func failingRuntime(pullFails: Bool) throws -> (runtime: URL, log: URL) { + let (runtime, log) = try stubRuntime() + let script = try String(contentsOf: runtime, encoding: .utf8) + try + (script + """ + + case "$1 $2" in "image inspect") exit 1 ;; esac + case "$1" in run) exit 1 ;; pull) \(pullFails ? "echo 'denied' >&2; exit 1" : "exit 0") ;; esac + """).write(to: runtime, atomically: true, encoding: .utf8) + return (runtime, log) + } + + /// A missing host image is pulled, by itself, before the base's container runs, and `run` never + /// pulls: left to it, a missing image was looked for on Docker Hub (#309). + func testAMissingHostImageIsPulledBeforeRunAndRunNeverPulls() async throws { + let (runtime, log) = try failingRuntime(pullFails: false) + do { + _ = try await Self.driver(runtime: runtime, context: nil).create() + XCTFail("run was meant to fail") + } catch {} + let calls = try String(contentsOf: log, encoding: .utf8).split(separator: "\n").map { + $0.split(separator: " ").prefix(2).joined(separator: " ") + } + XCTAssertEqual( + Array(calls.prefix(3)), ["image inspect", "pull workroom-host", "run --pull=never"]) + } + + /// A pull that fails says the host image couldn't be downloaded, and runs nothing. + func testAFailedPullSaysTheImageCouldNotBeDownloaded() async throws { + let (runtime, log) = try failingRuntime(pullFails: true) + do { + _ = try await Self.driver(runtime: runtime, context: nil).create() + XCTFail("a failed pull created a host") + } catch { + XCTAssertTrue( + error.localizedDescription.contains("couldn't download the workroom host image"), + error.localizedDescription) + } + XCTAssertFalse(try String(contentsOf: log, encoding: .utf8).contains("run ")) + } + + /// The image a new base runs: the hidden override, else the build's pinned digest, else a local + /// `workroom-host`. Empty values, as an unset build setting leaves `WorkroomHostImage`, don't count. + func testTheHostImageIsTheOverrideThenThePinThenLocal() { + let pinned = "ghcr.io/joelmoss/workroom-host@sha256:" + String(repeating: "a", count: 64) + XCTAssertEqual(RemoteWorkrooms.hostImage(override: "mine", pinned: pinned), "mine") + XCTAssertEqual(RemoteWorkrooms.hostImage(override: nil, pinned: pinned), pinned) + XCTAssertEqual(RemoteWorkrooms.hostImage(override: " ", pinned: ""), "workroom-host") + XCTAssertEqual(RemoteWorkrooms.hostImage(override: nil, pinned: nil), "workroom-host") + } + private static func driver(runtime: URL, context: String?) -> ContainerHostDriver { ContainerHostDriver( hosts: [:], directory: FileManager.default.temporaryDirectory, diff --git a/macapp/project.yml b/macapp/project.yml index 448aad7fc..b66fae33b 100644 --- a/macapp/project.yml +++ b/macapp/project.yml @@ -274,6 +274,9 @@ targets: # selectable at runtime), "nightly" for the side-by-side Workroom Nightly build. Read by # ReleaseChannel.current → Updater.allowedChannels + CommandLineInstaller's binary name. WorkroomReleaseChannel: "$(WORKROOM_RELEASE_CHANNEL)" + # The `workroom-host` image local container workrooms run, by digest (#309): CI publishes + # it and release.sh passes it in. Empty for a local build, which runs `workroom-host`. + WorkroomHostImage: "$(WORKROOM_HOST_IMAGE)" LSMinimumSystemVersion: "15.0" NSHighResolutionCapable: true LSApplicationCategoryType: public.app-category.developer-tools @@ -328,6 +331,7 @@ targets: ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # Base identity is the main/release channel (empty marker). Debug + Nightly override below. WORKROOM_RELEASE_CHANNEL: "" + WORKROOM_HOST_IMAGE: "" # Sparkle feed filename, substituted into SUFeedURL above. Nightly overrides it; Debug # inherits the main feed (Dev builds can't self-update, so it is never fetched). WORKROOM_APPCAST: appcast.xml diff --git a/vcs/scripts/ssh-fixture/host-image-test.sh b/vcs/scripts/ssh-fixture/host-image-test.sh index 8104ae69f..e0d48ccf3 100755 --- a/vcs/scripts/ssh-fixture/host-image-test.sh +++ b/vcs/scripts/ssh-fixture/host-image-test.sh @@ -3,6 +3,7 @@ # Smoke-tests the `workroom-host` image (FIXTURE=0, `make remote-host-image`, #253, #288). # # vcs/scripts/ssh-fixture/host-image-test.sh +# WR_HOST_IMAGE= vcs/scripts/ssh-fixture/host-image-test.sh # test a built image (#309) # # run.sh only ever builds FIXTURE=1, so nothing else exercises the paths only this variant takes: # the `[ "$FIXTURE" = 1 ] || exit 0` steps, the `wr-agen[t]` COPY with no agent in the context, and @@ -50,7 +51,14 @@ cp "$HERE/Dockerfile" "$HERE/entrypoint.sh" "$HERE/identity.sh" "$HERE/fake-gith printf '#!/bin/sh\nexit 0\n' > "$STAGE/control/wr-agent" "$RUNTIME" build --quiet --tag "$CONTROL" --build-arg FIXTURE=1 \ ${CONTROL_BUILD_FLAGS[@]+"${CONTROL_BUILD_FLAGS[@]}"} "$STAGE/control" >/dev/null -"$RUNTIME" build --quiet --tag "$NAME" ${BUILD_FLAGS[@]+"${BUILD_FLAGS[@]}"} "$HERE" >/dev/null +# WR_HOST_IMAGE names an image already built, to test it rather than build one (#309): CI tests the +# exact image it then publishes, since a second build could resolve other packages. Tagged with this +# run's name, so cleanup's `rmi` only takes that tag off and the image stays for the push. +if [ -n "${WR_HOST_IMAGE:-}" ]; then + "$RUNTIME" tag "$WR_HOST_IMAGE" "$NAME" +else + "$RUNTIME" build --quiet --tag "$NAME" ${BUILD_FLAGS[@]+"${BUILD_FLAGS[@]}"} "$HERE" >/dev/null +fi ssh-keygen -q -t ed25519 -N '' -C wr-host-image-test -f "$STAGE/id_ed25519" From 12aeeec741a311c1f57c3038337c126e3c269c26 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 14:37:27 +0100 Subject: [PATCH 03/29] fix(macapp): address the review of Docker context pinning (#309) - Every driver's sweep keeps every recorded host's image, not only the images it adopted: another context's driver can reach the same daemon, and only Docker's in-use refusal was keeping those images, with a logged failure each launch. - Sweep the contexts at once, so a slow daemon holds up no other sweep. - Check the Codaset sign-in before asking Docker for the current context, so a signed-out user is told to sign in, as before. - Test that a record with no context is written back with no context key, so an old record's bytes stay the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/AppStore.swift | 2 ++ .../Core/ContainerHostDriver.swift | 9 +++++-- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 18 ++++++++----- .../WorkroomAppTests/RemoteHostsTests.swift | 26 ++++++++++++++++++- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 06edb71ca..8d02a3e20 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -3523,6 +3523,8 @@ final class AppStore: ObservableObject { beginBusy(project.path) defer { endBusy(project.path) } do { + // Signed out says so before anything asks Docker (`environment` checks it again). + guard BrokerSession.shared.client() != nil else { throw RemoteWorkrooms.Failure.signedOut } // A new base is pinned to the Docker context in use now; a workroom goes where its base is. let context = try await RemoteHosts.shared.context(forBase: project.host) let (driver, environment) = try RemoteHosts.shared.environment(context: context) diff --git a/macapp/WorkroomApp/Core/ContainerHostDriver.swift b/macapp/WorkroomApp/Core/ContainerHostDriver.swift index f2d5d7dbc..ee6b57ed3 100644 --- a/macapp/WorkroomApp/Core/ContainerHostDriver.swift +++ b/macapp/WorkroomApp/Core/ContainerHostDriver.swift @@ -254,7 +254,11 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { /// since it may belong to a create or derive still under way, in this app or another sharing /// the daemon. Returns what it could not remove. /// ponytail: a resource made before `createdLabel` existed has no age and counts as old. - func sweep(keeping known: Set, grace: TimeInterval = 20 * 60) async -> [String] { + /// `images` are images other drivers' hosts were run from, kept too: a driver for another Docker + /// context can reach the same daemon (#309). + func sweep( + keeping known: Set, images: Set = [], grace: TimeInterval = 20 * 60 + ) async -> [String] { guard let provisioning, !provisioning.labels.isEmpty else { return [] } let filters = provisioning.labels.flatMap { ["--filter", "label=\($0)"] } let cutoff = Date().timeIntervalSince1970 - grace @@ -263,7 +267,8 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { let keptContainers = Set(known.map(Self.containerName)) let keptImages = Set( - lock.withLock { known.compactMap { provisioned[$0]?.image } }.map(Self.shortImageID)) + (lock.withLock { known.compactMap { provisioned[$0]?.image } } + images).map( + Self.shortImageID)) do { for line in try await runtime( ["ps", "-a"] + filters + ["--format", "{{.Names}}\t{{.Label \"\(Self.createdLabel)\"}}"], diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index d685acac9..e9057ceb8 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -347,14 +347,15 @@ final class RemoteHosts: @unchecked Sendable { private let now: @Sendable () -> ContinuousClock.Instant /// `adopt`'s seams, nil in the app: making a context's driver, and sweeping one. private let makeDriver: (@Sendable (String?) throws -> ContainerHostDriver)? - private let sweepDriver: (@Sendable (ContainerHostDriver, Set) async -> [String])? + private let sweepDriver: + (@Sendable (ContainerHostDriver, Set, Set) async -> [String])? init( connectHost: (@Sendable (HostID) async throws -> Void)? = nil, isConnected: (@Sendable (HostID) async -> Bool)? = nil, now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }, makeDriver: (@Sendable (String?) throws -> ContainerHostDriver)? = nil, - sweepDriver: (@Sendable (ContainerHostDriver, Set) async -> [String])? = nil + sweepDriver: (@Sendable (ContainerHostDriver, Set, Set) async -> [String])? = nil ) { self.connectHost = connectHost self.isConnected = isConnected @@ -451,11 +452,16 @@ final class RemoteHosts: @unchecked Sendable { // (the nil driver's current context and that context by name), and a sweep that kept only its // own hosts would remove the other's, labelled as this build's and old enough. let known = Set(recorded.compactMap(\.id)) - let sweep = sweepDriver ?? { await $0.sweep(keeping: $1) } + let images = Set(recorded.compactMap { $0.container?.image }) + let sweep = sweepDriver ?? { await $0.sweep(keeping: $1, images: $2) } + // At once: a context whose daemon is slow to answer holds up no other context's sweep. Task.detached(priority: .utility) { - for driver in drivers { - for failure in await sweep(driver, known) { - Self.logger.error("remote host sweep: \(failure, privacy: .public)") + await withTaskGroup(of: [String].self) { group in + for driver in drivers { group.addTask { await sweep(driver, known, images) } } + for await failures in group { + for failure in failures { + Self.logger.error("remote host sweep: \(failure, privacy: .public)") + } } } } diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index 9fcb426e1..f289cd97e 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -322,6 +322,11 @@ final class RemoteHostsTests: XCTestCase { XCTAssertNil(decoded.context) XCTAssertEqual(decoded, Self.record(context: nil)) + // Written back with no `context` key at all, so an old record's bytes don't change. + let written = try JSONSerialization.jsonObject( + with: try JSONEncoder().encode(Self.record(context: nil))) + XCTAssertNil((written as? [String: Any])?["context"]) + let pinned = Self.record(context: "orbstack") XCTAssertEqual( try JSONDecoder().decode( @@ -378,7 +383,7 @@ final class RemoteHostsTests: XCTestCase { let swept = Swept() let remote = RemoteHosts( makeDriver: { Self.driver(runtime: runtime, context: $0) }, - sweepDriver: { driver, known in + sweepDriver: { driver, known, _ in swept.add(driver.provisioning?.context, known) return [] }) @@ -414,6 +419,25 @@ final class RemoteHostsTests: XCTestCase { } } + /// A sweep keeps an image another context's host was run from: both contexts can name one daemon, + /// where that image is labelled as this build's and old enough to go. + func testASweepKeepsImagesOtherContextsHostsRunFrom() async throws { + let image = "sha256:" + String(repeating: "b", count: 64) + for kept in [true, false] { + let (runtime, log) = try stubRuntime() + let script = try String(contentsOf: runtime, encoding: .utf8) + try + (script + """ + + case "$1 $2" in "images -aq") echo \(image.dropFirst(7).prefix(12)) ;; "image inspect") echo 0 ;; esac + """).write(to: runtime, atomically: true, encoding: .utf8) + _ = await Self.driver(runtime: runtime, context: nil).sweep( + keeping: [], images: kept ? [image] : []) + let removed = try String(contentsOf: log, encoding: .utf8).contains("rmi ") + XCTAssertEqual(removed, !kept, kept ? "a recorded image was removed" : "the control kept it") + } + } + /// A driver takes on only a host of its own context: its commands would not reach another's. func testADriverRefusesAHostOfAnotherContext() throws { let (runtime, _) = try stubRuntime() From 27018c72f91aec0ecb120503c4f361104576c07a Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 14:45:13 +0100 Subject: [PATCH 04/29] feat(macapp): start a stopped container when its workroom is opened (#309) A container someone stopped stays stopped, and Apple's container runtime has no restart policy, so after a reboot every one of its containers is down. Opening a workroom (selecting it, or a reload that finds it selected) now marks its host as opened. That host's connects start its container first if it is stopped, then wait for ssh. Background status probes never mark a host opened. They connect to every reachable remote workroom, so starting on any connect would have booted them all. Opening also lifts the host's retry window, since a probe of the stopped container is usually what failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/AppStore.swift | 4 ++ .../Core/ContainerHostDriver.swift | 14 +++++++ macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 28 ++++++++++++- ...ontainerProvisioningIntegrationTests.swift | 21 ++++++++++ .../WorkroomAppTests/RemoteHostsTests.swift | 40 ++++++++++++++++++- 5 files changed, 105 insertions(+), 2 deletions(-) diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 8d02a3e20..a0133db9c 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -208,6 +208,8 @@ final class AppStore: ObservableObject { // instead of lingering on the previous workroom's commits until the panel's `.task` catches // up. `focus` is idempotent, so the panel's later re-focus to the same root no-ops. if selectedTargetID != oldValue { + // Opening a container workroom starts its container if it was stopped (#309). + if let host = selectedWorkroom?.reachableHost { RemoteHosts.shared.activate(.remote(host)) } focusHistoryIfShown() focusRemoteStateIfShown() // A confirmation asking about the OLD workroom's dirty tree is meaningless once the selection @@ -3088,6 +3090,8 @@ final class AppStore: ObservableObject { }) RepositoryRouter.shared.replaceRemote(RemoteWorkrooms.registrations(fresh)) projects = fresh + // A selection restored before its projects were loaded opened nothing then (#309). + if let host = selectedWorkroom?.reachableHost { RemoteHosts.shared.activate(.remote(host)) } // Prune shared caches only when publishing an accepted snapshot. let liveIDs = Set(fresh.map(\.id)) rootRefs = rootRefs.filter { liveIDs.contains($0.key) } diff --git a/macapp/WorkroomApp/Core/ContainerHostDriver.swift b/macapp/WorkroomApp/Core/ContainerHostDriver.swift index ee6b57ed3..e11e2f656 100644 --- a/macapp/WorkroomApp/Core/ContainerHostDriver.swift +++ b/macapp/WorkroomApp/Core/ContainerHostDriver.swift @@ -210,6 +210,20 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { try? FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString)) } + /// Starts `host`'s container if it is stopped, and waits until it can be logged in to (#309): a + /// container someone stopped stays stopped, and a runtime with no restart policy (Apple's) leaves + /// every one stopped after a reboot. Only for a workroom the user opens (`RemoteHosts.activate`), + /// never for a background probe, which would start every container the app knows. + func startIfStopped(_ host: HostID) async throws { + guard case .remote(let id) = host, let made = lock.withLock({ provisioned[id] }) else { + throw HostDriverError.unknownHost(host) + } + let running = try await runtime(["inspect", "--format", "{{.State.Running}}", made.container]) + guard running == "false" else { return } + _ = try await runtime(["start", made.container]) + try await awaitLogin(host) + } + /// What a later launch's driver needs to `adopt` `host`, or nil for a host this driver did not /// make. func record(of host: HostID) -> Record? { diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index e9057ceb8..078fd9e32 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -341,9 +341,13 @@ final class RemoteHosts: @unchecked Sendable { private var connecting: [HostID: Task] = [:] /// When each host's last attempt failed, which answers for it for `retryAfter`. private var failedAt: [HostID: ContinuousClock.Instant] = [:] + /// Hosts of workrooms the user has opened this launch (`activate`), whose connect starts a + /// stopped container first. + private var activated: Set = [] /// `ensureConnected`'s seams, nil in the app: the connect itself, whether a host is up, the clock. private let connectHost: (@Sendable (HostID) async throws -> Void)? private let isConnected: (@Sendable (HostID) async -> Bool)? + private let startHost: (@Sendable (HostID) async throws -> Void)? private let now: @Sendable () -> ContinuousClock.Instant /// `adopt`'s seams, nil in the app: making a context's driver, and sweeping one. private let makeDriver: (@Sendable (String?) throws -> ContainerHostDriver)? @@ -353,12 +357,14 @@ final class RemoteHosts: @unchecked Sendable { init( connectHost: (@Sendable (HostID) async throws -> Void)? = nil, isConnected: (@Sendable (HostID) async -> Bool)? = nil, + startHost: (@Sendable (HostID) async throws -> Void)? = nil, now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }, makeDriver: (@Sendable (String?) throws -> ContainerHostDriver)? = nil, sweepDriver: (@Sendable (ContainerHostDriver, Set, Set) async -> [String])? = nil ) { self.connectHost = connectHost self.isConnected = isConnected + self.startHost = startHost self.now = now self.makeDriver = makeDriver self.sweepDriver = sweepDriver @@ -561,9 +567,19 @@ final class RemoteHosts: @unchecked Sendable { try connectHost ?? { [driver = try heldDriver(host)] in try await self.connect($0, driver: driver) } + // Only a workroom the user opened has its container started: the status sweep connects to + // every remote workroom, and would otherwise start them all. + let start = + lock.withLock({ activated.contains(host) }) + ? try startHost ?? { [driver = try heldDriver(host)] in try await driver.startIfStopped($0) + } + : nil let task = lock.withLock { () -> Task in if let running = connecting[host] { return running } - let task = Task { try await connect(host) } + let task = Task { + try await start?(host) + try await connect(host) + } connecting[host] = task return task } @@ -580,6 +596,16 @@ final class RemoteHosts: @unchecked Sendable { } } + /// The user opened a workroom on `host` (#309): its connects start its container if it is stopped, + /// and a failure from before, such as a status probe of the stopped container, no longer holds + /// the next attempt back. + func activate(_ host: HostID) { + lock.withLock { + activated.insert(host) + failedAt[host] = nil + } + } + /// The driver holding `host`, which a reload adopted it into (`adopt`) or which made it. private func heldDriver(_ host: HostID) throws -> ContainerHostDriver { guard case .remote(let id) = host, let driver = existingDriver(holding: id) else { diff --git a/macapp/WorkroomAppTests/ContainerProvisioningIntegrationTests.swift b/macapp/WorkroomAppTests/ContainerProvisioningIntegrationTests.swift index af86b70d0..cd4d38b33 100644 --- a/macapp/WorkroomAppTests/ContainerProvisioningIntegrationTests.swift +++ b/macapp/WorkroomAppTests/ContainerProvisioningIntegrationTests.swift @@ -124,6 +124,27 @@ final class ContainerProvisioningIntegrationTests: XCTestCase { } catch HostDriverError.unknownHost {} } + /// A stopped container is started again, and logged in to, when its workroom is opened (#309); a + /// running one is left as it is. + func testAStoppedHostIsStartedAndReachableAgain() async throws { + let provisioning = try provisioning() + let driver = ContainerHostDriver(hosts: [:], directory: directory, provisioning: provisioning) + let base = try await driver.create() + guard case .remote(let id) = base else { return XCTFail("\(base)") } + let container = ContainerHostDriver.containerName(id) + + try await driver.startIfStopped(base) + XCTAssertEqual(try docker(runtime, ["inspect", "-f", "{{.State.Running}}", container]), "true") + _ = try docker(runtime, ["stop", container]) + XCTAssertEqual( + try docker(runtime, ["inspect", "-f", "{{.State.Running}}", container]), "false") + + try await driver.startIfStopped(base) + let answer = try await onHost(driver, base, "echo up") + XCTAssertEqual(answer, "up") + try await driver.destroy(base) + } + func testInstancesDerivedFromOneBaseCarryItsDiskButMintTheirOwnIdentity() async throws { let provisioning = try provisioning() let driver = ContainerHostDriver(hosts: [:], directory: directory, provisioning: provisioning) diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index f289cd97e..75968488d 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -40,7 +40,7 @@ final class RemoteHostsTests: XCTestCase { connects.ask() return false }, - now: { connects.now }) + startHost: { _ in }, now: { connects.now }) } /// The inspector's panels ask together when a remote workroom is selected: one connect serves @@ -87,6 +87,44 @@ final class RemoteHostsTests: XCTestCase { XCTAssertEqual(connects.calls, 2) } + /// Only a host whose workroom the user opened has its container started before connecting: the + /// status sweep connects to every remote workroom, and must not start them all (#309). + func testOnlyAnOpenedHostIsStartedBeforeItsConnect() async throws { + let connects = Connects() + connects.hold(false) + let started = Swept() + let remote = RemoteHosts( + connectHost: { try await connects.connect($0) }, isConnected: { _ in false }, + startHost: { host in + guard case .remote(let id) = host else { return } + started.add(nil, [id]) + }) + let (probed, opened) = (UUID(), UUID()) + + try await remote.ensureConnected(.remote(probed)) + XCTAssertTrue(started.calls.isEmpty, "a probe of an unopened host started it") + + remote.activate(.remote(opened)) + try await remote.ensureConnected(.remote(opened)) + XCTAssertEqual(started.calls.map(\.known), [[opened]]) + XCTAssertEqual(connects.calls, 2) + } + + /// Opening a workroom whose host just failed a probe tries it again at once, starting it: the + /// probe's failure was the container being stopped, which opening is about to fix. + func testOpeningAHostLiftsItsRetryWindow() async throws { + let connects = Connects() + connects.hold(false) + connects.fail(true) + let remote = hosts(connects) + let host = HostID.remote(UUID()) + do { try await remote.ensureConnected(host) } catch {} + connects.fail(false) + remote.activate(host) + try await remote.ensureConnected(host) + XCTAssertEqual(connects.calls, 2) + } + /// `destroy` removes a host's image with `--force`, so a record's image must be a commit's ID. func testOnlyAWholeLowercaseSHA256IsAnImageID() { let hex = String(repeating: "a1", count: 32) From 9e272c1ee505255165995d343f2f49f783da3204 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 15:34:58 +0100 Subject: [PATCH 05/29] feat(macapp): run container workrooms on Apple's container runtime (#309) ContainerHostDriver now speaks Apple's `container` CLI as well as Docker's, picked by Provisioning.dialect. RemoteHosts keys its drivers by runtime and Docker context, and a descriptor's `driver` names the runtime: `container` stays Docker, so every existing record is unchanged, and `apple-container` is Apple's. Measured against container 1.5.0, which differs from its docs on main: - There is no `commit`. A derive stops the base, exports its disk, starts it again whatever happens, builds the disk back into an image from scratch with the host image's own entrypoint and environment, and runs it. The image is then removed: the container's disk outlives it, and it is the size of the base's whole disk. The builder VM (2 CPUs, 2 GB) is stopped again after each derive. - `build --quiet` never returns, so it is never passed. - Apple removes an image a container is still using, so its sweep keeps every image any container was run from, instead of relying on a refusal. It reads `--format json`, since there is no `--filter`. - No restart policy: an Apple container is started when its workroom is opened. Published ports survive a stop and start. - Images are pulled for arm64 only; digest references work throughout. A base is now marked as one explicitly, since an Apple instance records no image either, and deriving from an instance would copy its enrolment. A workroom asked for on another runtime than its project's base is refused until bases are kept per runtime. AppleContainerIntegrationTests run the whole lifecycle on the real runtime; they are opt-in (TEST_RUNNER_WR_APPLE_CONTAINER_TESTS=1), since CI's macOS runners can't run Apple's VMs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/AppStore.swift | 19 +- .../WorkroomApp/Core/AppleContainerCLI.swift | 114 ++++++++++ .../Core/ContainerHostDriver.swift | 196 ++++++++++++++-- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 182 ++++++++++----- .../Session/PersistentSessionService.swift | 19 +- .../AppleContainerIntegrationTests.swift | 137 ++++++++++++ .../WorkroomAppTests/RemoteHostsTests.swift | 210 +++++++++++++++++- 7 files changed, 769 insertions(+), 108 deletions(-) create mode 100644 macapp/WorkroomApp/Core/AppleContainerCLI.swift create mode 100644 macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index a0133db9c..78677a7a3 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -3520,18 +3520,21 @@ final class AppStore: ObservableObject { && !deletingProjects.contains(project.path) } - /// Creates a remote workroom for `project` (#253), derived from the project's base machine - /// (built first if it has none), then selects it, which opens its first pane on the far side. - func createRemoteWorkroom(in project: Project) async { + /// Creates a container workroom for `project` on `runtime` (#253, #309), derived from the + /// project's base machine (built first if it has none), then selects it, which opens its first + /// pane on the far side. + func createRemoteWorkroom( + in project: Project, runtime: RemoteWorkrooms.Runtime = .docker + ) async { guard canCreateRemoteWorkroom(in: project) else { return } beginBusy(project.path) defer { endBusy(project.path) } do { - // Signed out says so before anything asks Docker (`environment` checks it again). + // Signed out says so before anything asks the runtime (`environment` checks it again). guard BrokerSession.shared.client() != nil else { throw RemoteWorkrooms.Failure.signedOut } - // A new base is pinned to the Docker context in use now; a workroom goes where its base is. - let context = try await RemoteHosts.shared.context(forBase: project.host) - let (driver, environment) = try RemoteHosts.shared.environment(context: context) + // A new Docker base is pinned to the context in use now; a workroom goes where its base is. + let key = try await RemoteHosts.shared.key(for: runtime, base: project.host) + let (driver, environment) = try RemoteHosts.shared.environment(key) let resolution = await WorkroomStatusResolver().resolveRepository(in: project.path) guard case .found(let repository) = resolution else { throw RemoteWorkrooms.Failure.notOnGitHub("Couldn't find its GitHub repository with gh.") @@ -3541,7 +3544,7 @@ final class AppStore: ObservableObject { } let created = try await RemoteWorkrooms.create( repository: repository, cloneURL: RemoteWorkrooms.cloneURL(for: repository), - base: project.host, driver: driver, environment: environment, + base: project.host, runtime: runtime, driver: driver, environment: environment, recorder: remoteRecorder(project: project.path)) // Its panes attach through their own ssh; Changes and the rest connect for themselves. await created.instance.connection.close() diff --git a/macapp/WorkroomApp/Core/AppleContainerCLI.swift b/macapp/WorkroomApp/Core/AppleContainerCLI.swift new file mode 100644 index 000000000..3ecc5f2df --- /dev/null +++ b/macapp/WorkroomApp/Core/AppleContainerCLI.swift @@ -0,0 +1,114 @@ +import Foundation + +/// What `ContainerHostDriver` reads from, and writes for, Apple's `container` CLI (#309): its +/// `--format json` output, which is the only format it has besides tables (no Go templates, no +/// `--filter`), and the Dockerfile a derive builds its snapshot with, since 1.5.0 has no `commit`. +/// Measured against `container` 1.5.0. +enum AppleContainerCLI { + /// A JSON array of objects, as `list`, `inspect`, `image list` and `image inspect` print. + static func objects(_ text: String) throws -> [[String: Any]] { + guard let objects = try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [[String: Any]] + else { + throw HostDriverError.provisioning("container printed something other than a JSON list") + } + return objects + } + + /// A container's ID (its name, as the driver runs it with `--name`). + static func id(of container: [String: Any]) -> String? { container["id"] as? String } + + /// `running`, `stopped`, … + static func state(of container: [String: Any]) -> String? { + (container["status"] as? [String: Any])?["state"] as? String + } + + static func labels(of container: [String: Any]) -> [String: String] { + (configuration(container)["labels"] as? [String: String]) ?? [:] + } + + /// The image reference the container was run from. + static func imageReference(of container: [String: Any]) -> String? { + (configuration(container)["image"] as? [String: Any])?["reference"] as? String + } + + /// An image's reference, as `image list` names it. + static func name(ofImage image: [String: Any]) -> String? { + configuration(image)["name"] as? String + } + + /// An image's labels, from whichever of its platforms carries them. + static func labels(ofImage image: [String: Any]) -> [String: String] { + variants(image).reduce(into: [:]) { labels, variant in + labels.merge(config(variant)["Labels"] as? [String: String] ?? [:]) { first, _ in first } + } + } + + /// How an image's process starts, for its `architecture` (`arm64`: the runtime is Apple + /// silicon only). Read from the image rather than from a container run from it, whose + /// environment holds what `run --env` added, such as the client key. + struct ProcessConfig: Equatable { + var entrypoint: [String]? = nil + var cmd: [String]? = nil + var env: [String] = [] + var workingDir: String? = nil + var user: String? = nil + } + + static func processConfig(ofImage image: [String: Any], architecture: String) -> ProcessConfig? { + guard + let variant = variants(image).first(where: { + ($0["platform"] as? [String: Any])?["architecture"] as? String == architecture + }) + else { return nil } + let config = config(variant) + return ProcessConfig( + entrypoint: config["Entrypoint"] as? [String], cmd: config["Cmd"] as? [String], + env: config["Env"] as? [String] ?? [], workingDir: config["WorkingDir"] as? String, + user: config["User"] as? String) + } + + /// The Dockerfile that turns an exported container disk (`rootfs.tar`, beside it) back into an + /// image that starts as `process` did. `ADD` unpacks a local tar. Exec form throughout, so + /// nothing passes through a shell; a value with a newline is refused, since no Dockerfile line + /// can hold one. + static func dockerfile(_ process: ProcessConfig) throws -> String { + func json(_ words: [String]) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + return String(decoding: try encoder.encode(words), as: UTF8.self) + } + let values = + process.env + (process.entrypoint ?? []) + (process.cmd ?? []) + + [process.workingDir, process.user].compactMap { $0 } + guard !values.contains(where: { $0.contains(where: \.isNewline) }) else { + throw HostDriverError.invalidConfiguration("the host image's config has a newline in it") + } + var lines = ["FROM scratch", "ADD rootfs.tar /"] + for variable in process.env { + guard let equals = variable.firstIndex(of: "=") else { continue } + let value = variable[variable.index(after: equals)...] + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + lines.append("ENV \(variable[.. [String: Any] { + object["configuration"] as? [String: Any] ?? [:] + } + + private static func variants(_ image: [String: Any]) -> [[String: Any]] { + image["variants"] as? [[String: Any]] ?? [] + } + + private static func config(_ variant: [String: Any]) -> [String: Any] { + ((variant["config"] as? [String: Any])?["config"] as? [String: Any]) ?? [:] + } +} diff --git a/macapp/WorkroomApp/Core/ContainerHostDriver.swift b/macapp/WorkroomApp/Core/ContainerHostDriver.swift index e11e2f656..9dba36594 100644 --- a/macapp/WorkroomApp/Core/ContainerHostDriver.swift +++ b/macapp/WorkroomApp/Core/ContainerHostDriver.swift @@ -50,13 +50,25 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { /// are each a context behind the one `docker`, so without it a host made on one is looked for /// on whichever the user switched to since. var context: String? = nil + /// Which runtime's CLI `runtime` is: Docker's, or Apple's `container` (#309). + var dialect: Dialect = .docker } - /// A host this driver made: its container, and for a derived one the image it was run from, - /// which only it uses. + /// The two container CLIs a driver can speak (#309). Apple's `container` (1.5.0) runs each + /// container in a VM of its own and has no `commit`, no `--restart`, no `--filter` and no Go + /// templates (`AppleContainerCLI`). + enum Dialect: Sendable { + case docker + case apple + } + + /// A host this driver made: its container, whether it is a base (the one kind a workroom is + /// derived from), and for a derived one the image it was run from, which only it uses. An Apple + /// instance has no image: its own disk outlives the image it was run from, which is removed. private struct Provisioned { let container: String let image: String? + let isBase: Bool } /// What a host this driver made needs to be reached and destroyed again by a driver in a later @@ -118,17 +130,22 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { func create() async throws -> HostID { guard let provisioning else { throw HostDriverError.notImplemented("Creating a base") } try await ensureImage(provisioning.image) - return try await run(provisioning.image, image: nil) + return try await run(provisioning.image, image: nil, isBase: true) } /// Pulls `image` unless the runtime already has it (#309). `run` itself never pulls /// (`--pull=never`): left to it, a missing `workroom-host` was looked for on Docker Hub as /// `library/workroom-host`, and failed with a registry error that said nothing about why. private func ensureImage(_ image: String) async throws { - if (try? await runtime(["image", "inspect", "--format", "{{.Id}}", image])) != nil { return } + let apple = provisioning?.dialect == .apple + let inspect = + apple ? ["image", "inspect", image] : ["image", "inspect", "--format", "{{.Id}}", image] + if (try? await runtime(inspect)) != nil { return } do { - // Silence-bounded, and a pull reports its progress as it goes. - _ = try await runtime(["pull", image], timeout: 900) + // Silence-bounded, and a pull reports its progress as it goes. Apple's unpacks every + // platform of a multi-arch image unless told which, and it runs on Apple silicon only. + _ = try await runtime( + apple ? ["image", "pull", "--arch", "arm64", image] : ["pull", image], timeout: 900) } catch { throw HostDriverError.provisioning( "couldn't download the workroom host image \(image). Check your network connection, " @@ -149,9 +166,10 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { } // An instance has enrolled, and its key and credential helper are on its disk: a copy of it // would start with them. - guard source.image == nil else { + guard source.isBase else { throw HostDriverError.invalidConfiguration("a workroom instance cannot be derived from") } + if provisioning.dialect == .apple { return try await appleDerive(from: source) } // Labelled with an ID chosen here, because the runtime's own is known only from its output: // a commit that outlives its CLI (killed by the silence bound, or a cancelled derive) leaves // an image nothing would otherwise find. @@ -165,7 +183,7 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { + [source.container], // `commit` prints nothing until it is done, and copying a base's disk takes a while. timeout: 900) - return try await run(image, image: image) + return try await run(image, image: image, isBase: false) } catch { let removals = await Task { () -> [String] in do { @@ -190,6 +208,59 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { } } + /// Apple's derive (#309). `container` 1.5.0 has no `commit`, so the base's disk is exported (with + /// the base stopped, so the copy is consistent, and started again whatever happens), built back + /// into an image from scratch with the host image's own entrypoint and environment, and run. + /// The image is then removed: Apple's runtime gives each container a disk of its own, which + /// outlives the image it came from, and a derived image is the size of the base's whole disk. + /// Apple also removes an image a container still uses, so nothing would protect one later. + private func appleDerive(from source: Provisioned) async throws -> HostID { + guard let provisioning else { throw HostDriverError.notImplemented("Provisioning") } + let tag = "workroom-derive-\(UUID().uuidString.lowercased())" + let work = FileManager.default.temporaryDirectory.appendingPathComponent(tag) + try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: work) } + + let base = try AppleContainerCLI.objects( + try await runtime(["inspect", source.container], allLines: true)) + guard let reference = base.first.flatMap(AppleContainerCLI.imageReference), + let image = try AppleContainerCLI.objects( + try await runtime(["image", "inspect", reference], allLines: true) + ).first, + let process = AppleContainerCLI.processConfig(ofImage: image, architecture: "arm64") + else { + throw HostDriverError.provisioning("couldn't read the base's image to derive from") + } + try AppleContainerCLI.dockerfile(process).write( + to: work.appendingPathComponent("Dockerfile"), atomically: true, encoding: .utf8) + + let rootfs = work.appendingPathComponent("rootfs.tar") + _ = try await runtime(["stop", source.container]) + do { + // `export` prints nothing until the whole disk is written. + _ = try await runtime(["export", "--output", rootfs.path, source.container], timeout: 900) + } catch { + _ = try? await runtime(["start", source.container]) + throw error + } + _ = try await runtime(["start", source.container]) + + // Never `--quiet`: in 1.5.0 a quiet build never returns. The builder is a VM of its own (2 + // CPUs, 2 GB), stopped again afterwards; the next derive starts it again in seconds. + defer { Task { _ = try? await self.runtime(["builder", "stop"]) } } + do { + _ = try await runtime( + ["build"] + (provisioning.labels + [Self.created()]).flatMap { ["--label", $0] } + + ["--tag", tag, work.path], timeout: 900) + let host = try await run(tag, image: nil, isBase: false) + _ = try? await runtime(["image", "delete", tag]) + return host + } catch { + _ = try? await runtime(["image", "delete", "--force", tag]) + throw error + } + } + /// Removes the container and the image it alone was run from. A host handed in rather than /// made here is not this driver's to remove. func destroy(_ host: HostID) async throws { @@ -200,8 +271,12 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { } throw HostDriverError.unknownHost(host) } - _ = try await runtime(["rm", "--force", "--volumes", made.container]) - if let image = made.image { _ = try await runtime(["rmi", "--force", image]) } + if provisioning?.dialect == .apple { + _ = try await runtime(["delete", "--force", made.container]) + } else { + _ = try await runtime(["rm", "--force", "--volumes", made.container]) + if let image = made.image { _ = try await runtime(["rmi", "--force", image]) } + } lock.withLock { hosts[id] = nil provisioned[id] = nil @@ -218,8 +293,16 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { guard case .remote(let id) = host, let made = lock.withLock({ provisioned[id] }) else { throw HostDriverError.unknownHost(host) } - let running = try await runtime(["inspect", "--format", "{{.State.Running}}", made.container]) - guard running == "false" else { return } + let stopped: Bool + if provisioning?.dialect == .apple { + let containers = try AppleContainerCLI.objects( + try await runtime(["inspect", made.container], allLines: true)) + stopped = containers.first.flatMap(AppleContainerCLI.state) == "stopped" + } else { + stopped = + try await runtime(["inspect", "--format", "{{.State.Running}}", made.container]) == "false" + } + guard stopped else { return } _ = try await runtime(["start", made.container]) try await awaitLogin(host) } @@ -238,8 +321,10 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { /// Takes a host an earlier launch made back on (#253), as its `record` describes it, so it can /// be reached, derived from and destroyed as if this driver had made it. Whether its container - /// is still there is found out by using it. - func adopt(_ id: UUID, _ record: Record) throws { + /// is still there is found out by using it. `isBase` says whether it is a project's base; left + /// out, a Docker host with no image is one, as only a base has none. An Apple instance has none + /// either, so it is a base only when the caller says so. + func adopt(_ id: UUID, _ record: Record, isBase: Bool? = nil) throws { guard let provisioning else { throw HostDriverError.notImplemented("Adopting a host") } // `destroy` removes it by this, with `--force`: a config edited by hand must not name an image // that is not a commit's. @@ -257,7 +342,9 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { address: record.address, port: record.port, user: record.user, identityFile: provisioning.identityFile, hostKey: record.hostKey, agentSocket: provisioning.agentSocket) - provisioned[id] = Provisioned(container: Self.containerName(id), image: record.image) + provisioned[id] = Provisioned( + container: Self.containerName(id), image: record.image, + isBase: isBase ?? (provisioning.dialect == .docker && record.image == nil)) destroyed.remove(id) } } @@ -274,6 +361,9 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { keeping known: Set, images: Set = [], grace: TimeInterval = 20 * 60 ) async -> [String] { guard let provisioning, !provisioning.labels.isEmpty else { return [] } + if provisioning.dialect == .apple { + return await appleSweep(keeping: known, images: images, grace: grace) + } let filters = provisioning.labels.flatMap { ["--filter", "label=\($0)"] } let cutoff = Date().timeIntervalSince1970 - grace func old(_ created: Substring) -> Bool { (TimeInterval(created) ?? 0) <= cutoff } @@ -322,6 +412,59 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { return failed } + /// Apple's sweep (#309): the same rules as Docker's, read from JSON, since its CLI has no + /// `--filter` and no templates. An image is kept while any container at all was run from it: + /// Apple removes an image a container still uses, where Docker refuses. + private func appleSweep(keeping known: Set, images kept: Set, grace: TimeInterval) + async -> [String] + { + guard let provisioning else { return [] } + let cutoff = Date().timeIntervalSince1970 - grace + func ours(_ labels: [String: String]) -> Bool { + provisioning.labels.allSatisfy { label in + let parts = label.split(separator: "=", maxSplits: 1).map(String.init) + return parts.count == 2 && labels[parts[0]] == parts[1] + } + } + func old(_ labels: [String: String]) -> Bool { + (labels[Self.createdLabel].flatMap(TimeInterval.init) ?? 0) <= cutoff + } + var failed: [String] = [] + let keptContainers = Set(known.map(Self.containerName)) + var inUse = kept + do { + for container in try AppleContainerCLI.objects( + try await runtime(["list", "--all", "--format", "json"], allLines: true)) + { + if let image = AppleContainerCLI.imageReference(of: container) { inUse.insert(image) } + let labels = AppleContainerCLI.labels(of: container) + guard let id = AppleContainerCLI.id(of: container), ours(labels), old(labels), + !keptContainers.contains(id) + else { continue } + do { _ = try await runtime(["delete", "--force", id]) } catch { + failed.append("container \(id): \(error.localizedDescription)") + } + } + } catch { + // Without the list, no image is known to be unused. + return ["listing containers: \(error.localizedDescription)"] + } + do { + for image in try AppleContainerCLI.objects( + try await runtime(["image", "list", "--format", "json"], allLines: true)) + { + let labels = AppleContainerCLI.labels(ofImage: image) + guard let name = AppleContainerCLI.name(ofImage: image), ours(labels), old(labels), + !inUse.contains(name) + else { continue } + do { _ = try await runtime(["image", "delete", name]) } catch { + failed.append("image \(name): \(error.localizedDescription)") + } + } + } catch { failed.append("listing images: \(error.localizedDescription)") } + return failed + } + static func containerName(_ id: UUID) -> String { "workroom-\(id.uuidString.lowercased())" } private static func created() -> String { @@ -343,7 +486,7 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { /// Runs a container from `source` and waits until it can be reached: its identity minted, its /// host key pinned, and an ssh login through this driver's own configuration answering. A /// container that gets no further is removed. - private func run(_ source: String, image: String?) async throws -> HostID { + private func run(_ source: String, image: String?, isBase: Bool) async throws -> HostID { guard let provisioning else { throw HostDriverError.notImplemented("Provisioning") } // A port of its own rather than an ephemeral one (`127.0.0.1::22`): a restart keeps it, so the // host's address outlives a reboot, as a provider's box keeps its address. @@ -358,13 +501,15 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { // outlives its CLI still leaves a container this name removes. let container = Self.containerName(id) do { + // Docker's `unless-stopped`: a Docker or Mac restart brings it back, on the port its record + // names. Apple's runtime has no restart policy; opening the workroom starts it + // (`startIfStopped`), on the port it keeps. + let start = + provisioning.dialect == .apple + ? ["run", "--detach", "--init", "--arch", "arm64"] + : ["run", "--pull=never", "--detach", "--init", "--restart", "unless-stopped"] _ = try await runtime( - // `unless-stopped`: a Docker or Mac restart brings it back, on the port its record names. - [ - "run", "--pull=never", "--detach", "--init", "--restart", "unless-stopped", "--name", - container, - "--publish", "127.0.0.1:\(port):22", - ] + start + ["--name", container, "--publish", "127.0.0.1:\(port):22"] + (provisioning.labels + [Self.created()]).flatMap { ["--label", $0] } + ["--env", "AUTHORIZED_KEY=\(provisioning.publicKey)", source]) let hostKey = try await identity(of: container) @@ -373,7 +518,7 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { address: "127.0.0.1", port: Int(port), user: provisioning.user, identityFile: provisioning.identityFile, hostKey: hostKey, agentSocket: provisioning.agentSocket) - provisioned[id] = Provisioned(container: container, image: image) + provisioned[id] = Provisioned(container: container, image: image, isBase: isBase) } try await awaitLogin(.remote(id)) return .remote(id) @@ -382,9 +527,12 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { hosts[id] = nil provisioned[id] = nil } + let remove = + provisioning.dialect == .apple + ? ["delete", "--force", container] : ["rm", "--force", "--volumes", container] let removal = await Task { () -> String? in do { - _ = try await self.runtime(["rm", "--force", "--volumes", container]) + _ = try await self.runtime(remove) return nil } catch { return "container \(container): \(error.localizedDescription)" } }.value diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index 078fd9e32..9cf83f013 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -23,8 +23,18 @@ enum RemoteWorkrooms { /// defaults domain, and this is per process. nonisolated(unsafe) static var enabledForTesting: Bool? - /// The descriptor's `driver` for `ContainerHostDriver`, the only driver until boxd (#256). - static let containerDriver = "container" + /// Which container runtime makes a workroom's host (#309): Docker (Desktop, OrbStack, Colima) or + /// Apple's `container`. The raw value is the descriptor's `driver`; `container` predates Apple's + /// and keeps meaning Docker, so every record made before stays Docker's. + enum Runtime: String, CaseIterable, Sendable { + case docker = "container" + case apple = "apple-container" + + var displayName: String { self == .docker ? "Docker" : "Apple Container" } + } + + /// The descriptor's `driver` for a Docker host. + static let containerDriver = Runtime.docker.rawValue /// The descriptor's `provisioner`: this build, whose ssh key and Docker labels its hosts carry. static var provisioner: String { Bundle.main.bundleIdentifier ?? "com.developwithstyle.workroom" } /// Fixed by the host image's entrypoint (`vcs/scripts/ssh-fixture/entrypoint.sh`). @@ -64,6 +74,8 @@ enum RemoteWorkrooms { case signedOut case notOnGitHub(String) case noDocker + case noAppleContainer + case baseOnOtherRuntime(String) case anotherBuildsBase(String) case anotherBuildsHost(String) case baseRepositoryChanged(base: String, origin: String) @@ -93,8 +105,13 @@ enum RemoteWorkrooms { + "building another would leave it running unrecorded. Delete the project (its remote " + "workrooms and base go with it) and add it again." case .noDocker: - return "Remote workrooms run on Docker on this Mac for now, and no docker command was " - + "found. Install Docker Desktop, then run `make remote-host-image`." + return "No docker command was found. Install Docker Desktop, OrbStack or Colima." + case .noAppleContainer: + return "Apple's container command wasn't found. Install it from " + + "github.com/apple/container, then run `container system start`." + case .baseOnOtherRuntime(let runtime): + return "This project's base machine runs on \(runtime), so its workrooms are created there " + + "too." } } } @@ -149,13 +166,20 @@ enum RemoteWorkrooms { /// the entry keeps what is still live (host, grant) so deleting it can finish the job. static func create( repository: GitHubRepository, cloneURL: String, base existing: HostDescriptor?, - driver: ContainerHostDriver, environment: RemoteProvisioning.Environment, recorder: Recorder + runtime: Runtime = .docker, driver: ContainerHostDriver, + environment: RemoteProvisioning.Environment, recorder: Recorder ) async throws -> Created { let base: RemoteProvisioning.Base if let existing, existing.provisioner != provisioner { // Its key and labels are another build's, so this one can neither reach nor replace it. throw Failure.anotherBuildsBase(existing.provisioner ?? "an unknown build") } + // A workroom is derived from its base, on the base's runtime. + if let existing, existing.id != nil, + let other = Runtime(rawValue: existing.driver ?? Runtime.docker.rawValue), other != runtime + { + throw Failure.baseOnOtherRuntime(other.displayName) + } if let recorded = existing?.base { // Reused only for the repository it cloned: a changed origin would otherwise get workrooms of // the old one, with credentials for it. @@ -178,7 +202,7 @@ enum RemoteWorkrooms { try await recorder.record( nil, HostDescriptor( - driver: containerDriver, provisioner: provisioner, id: base.host, + driver: runtime.rawValue, provisioner: provisioner, id: base.host, repository: base.repository, cloneURL: base.cloneURL, path: base.path, container: driver.record(of: .remote(base.host)))) @@ -189,7 +213,7 @@ enum RemoteWorkrooms { let name = try await recorder.reserve( base.path, HostDescriptor( - state: "creating", driver: containerDriver, provisioner: provisioner, + state: "creating", driver: runtime.rawValue, provisioner: provisioner, workroomID: workroomID)) let instance: RemoteProvisioning.Instance do { @@ -200,7 +224,8 @@ enum RemoteWorkrooms { try await recorder.record( name, remaining( - state: "creating", workroomID: workroomID, host: host, grant: grant, driver: driver)) + state: "creating", workroomID: workroomID, host: host, grant: grant, runtime: runtime, + driver: driver)) } } catch RemoteProvisioning.Failure.rollbackIncomplete(let cause, let host, let grant, let left) { @@ -210,7 +235,7 @@ enum RemoteWorkrooms { name, remaining( state: host == nil && grant == nil ? "destroyed" : "failed", workroomID: workroomID, - host: host, grant: grant, driver: driver)) + host: host, grant: grant, runtime: runtime, driver: driver)) throw RemoteProvisioning.Failure.rollbackIncomplete( cause: cause, host: host, grantID: grant, cleanup: left) } catch { @@ -225,7 +250,7 @@ enum RemoteWorkrooms { try await recorder.record( name, HostDescriptor( - driver: containerDriver, provisioner: provisioner, id: id, grantID: instance.grantID, + driver: runtime.rawValue, provisioner: provisioner, id: id, grantID: instance.grantID, workroomID: workroomID, container: driver.record(of: instance.host))) } catch { // Unrecorded, the instance would be found by nothing but the sweep, and its grant by nothing. @@ -236,7 +261,8 @@ enum RemoteWorkrooms { try? await recorder.record( name, remaining( - state: "failed", workroomID: workroomID, host: host, grant: grant, driver: driver)) + state: "failed", workroomID: workroomID, host: host, grant: grant, runtime: runtime, + driver: driver)) throw error } catch { // `destroy` throws nothing else: everything it made is gone. @@ -249,10 +275,11 @@ enum RemoteWorkrooms { /// The descriptor of a workroom whose undoing left `host` and `grant` live. private static func remaining( - state: String, workroomID: UUID, host: HostID?, grant: String?, driver: ContainerHostDriver + state: String, workroomID: UUID, host: HostID?, grant: String?, runtime: Runtime, + driver: ContainerHostDriver ) -> HostDescriptor { var descriptor = HostDescriptor( - state: state, driver: containerDriver, provisioner: provisioner, grantID: grant, + state: state, driver: runtime.rawValue, provisioner: provisioner, grantID: grant, workroomID: workroomID) if case .remote(let id) = host { descriptor.id = id @@ -325,15 +352,35 @@ enum RemoteWorkrooms { } } -/// The app's remote hosts (#253): a `ContainerHostDriver` per Docker context on this Mac (#309), -/// the hosts config records adopted into theirs at each reload, and one sweep per launch for what -/// no record names. +/// The app's remote hosts (#253): a `ContainerHostDriver` per container runtime and Docker context +/// on this Mac (#309), the hosts config records adopted into theirs at each reload, and one sweep +/// per launch for what no record names. final class RemoteHosts: @unchecked Sendable { static let shared = RemoteHosts() private let lock = NSLock() - /// The drivers made so far, by the Docker context they name; nil is the one that names none. - private var made: [String?: ContainerHostDriver] = [:] + /// Which driver a host is in: its runtime, and for Docker the context it names (nil names none). + struct DriverKey: Hashable, Sendable { + var runtime: RemoteWorkrooms.Runtime = .docker + var context: String? = nil + + /// The driver a recorded host is in, or nil for a descriptor of no container runtime. + init?(_ descriptor: HostDescriptor) { + guard let runtime = RemoteWorkrooms.Runtime(rawValue: descriptor.driver ?? "") else { + return nil + } + self.init(runtime: runtime, context: descriptor.container?.context) + } + + init(runtime: RemoteWorkrooms.Runtime = .docker, context: String? = nil) { + self.runtime = runtime + // Apple's runtime has no contexts. + self.context = runtime == .docker ? context : nil + } + } + + /// The drivers made so far. + private var made: [DriverKey: ContainerHostDriver] = [:] private var swept = false /// A call reached the sweep while a delete was in flight and left it for later (#296). private var held = false @@ -349,8 +396,8 @@ final class RemoteHosts: @unchecked Sendable { private let isConnected: (@Sendable (HostID) async -> Bool)? private let startHost: (@Sendable (HostID) async throws -> Void)? private let now: @Sendable () -> ContinuousClock.Instant - /// `adopt`'s seams, nil in the app: making a context's driver, and sweeping one. - private let makeDriver: (@Sendable (String?) throws -> ContainerHostDriver)? + /// `adopt`'s seams, nil in the app: making a key's driver, and sweeping one. + private let makeDriver: (@Sendable (DriverKey) throws -> ContainerHostDriver)? private let sweepDriver: (@Sendable (ContainerHostDriver, Set, Set) async -> [String])? @@ -359,7 +406,7 @@ final class RemoteHosts: @unchecked Sendable { isConnected: (@Sendable (HostID) async -> Bool)? = nil, startHost: (@Sendable (HostID) async throws -> Void)? = nil, now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }, - makeDriver: (@Sendable (String?) throws -> ContainerHostDriver)? = nil, + makeDriver: (@Sendable (DriverKey) throws -> ContainerHostDriver)? = nil, sweepDriver: (@Sendable (ContainerHostDriver, Set, Set) async -> [String])? = nil ) { self.connectHost = connectHost @@ -378,18 +425,18 @@ final class RemoteHosts: @unchecked Sendable { private static let logger = Logger( subsystem: "com.developwithstyle.workroom", category: "RemoteWorkrooms") - /// The driver for Docker `context`, made on first use: it needs Docker and this Mac's ssh key. - /// nil names no context, as every driver did before #309. Each driver writes its hosts' ssh - /// files under their own IDs, so they share one directory. - func driver(context: String? = nil) throws -> ContainerHostDriver { + /// The driver for `key`, made on first use: it needs that runtime's CLI and this Mac's ssh key. + /// The default key is Docker naming no context, as every driver did before #309. Each driver + /// writes its hosts' ssh files under their own IDs, so they share one directory. + func driver(_ key: DriverKey = DriverKey()) throws -> ContainerHostDriver { try lock.withLock { - if let made = made[context] { return made } + if let made = made[key] { return made } let driver = - try makeDriver?(context) + try makeDriver?(key) ?? ContainerHostDriver( hosts: [:], directory: Self.directory.appendingPathComponent("hosts", isDirectory: true), - provisioning: try Self.provisioning(context: context)) - made[context] = driver + provisioning: try Self.provisioning(key)) + made[key] = driver return driver } } @@ -427,28 +474,29 @@ final class RemoteHosts: @unchecked Sendable { projects.compactMap(\.host) + projects.flatMap { $0.workrooms.compactMap(\.host) } // Another build's hosts take another key: adopted here, they would refuse every login. let recorded = descriptors.filter { - $0.driver == RemoteWorkrooms.containerDriver - && $0.provisioner == RemoteWorkrooms.provisioner + DriverKey($0) != nil && $0.provisioner == RemoteWorkrooms.provisioner } + // Only a base is derived from; on Apple nothing in its record says which a host is. + let bases = Set(projects.compactMap(\.host?.id)) let already = lock.withLock { Set(made.keys) } guard !recorded.isEmpty || !already.isEmpty else { return } - // A descriptor with no container record yet goes to the driver that names no context, where the - // one driver before #309 would have had it. - let contexts = already.union(recorded.map { $0.container?.context }) + // A descriptor with no container record yet goes to its runtime's driver that names no + // context, where the one driver before #309 would have had it. + let keys = already.union(recorded.compactMap(DriverKey.init)) var drivers: [ContainerHostDriver] = [] - for context in contexts { + for key in keys { let driver: ContainerHostDriver - do { driver = try self.driver(context: context) } catch { + do { driver = try self.driver(key) } catch { + // That runtime isn't installed; another's may be. Self.logger.error("remote hosts: \(error.localizedDescription, privacy: .public)") - return + continue } drivers.append(driver) - for descriptor in recorded - where !descriptor.isDestroyed && descriptor.container?.context == context { + for descriptor in recorded where !descriptor.isDestroyed && DriverKey(descriptor) == key { guard let id = descriptor.id, let record = descriptor.container, driver.record(of: .remote(id)) == nil else { continue } - do { try driver.adopt(id, record) } catch { + do { try driver.adopt(id, record, isBase: bases.contains(id)) } catch { Self.logger.error("adopting host \(id, privacy: .public): \(error, privacy: .public)") } } @@ -479,18 +527,25 @@ final class RemoteHosts: @unchecked Sendable { /// every host made before #309 does. func context(forBase base: HostDescriptor?) async throws -> String? { if let base, base.id != nil { return base.container?.context } - return try await driver(context: nil).currentContext() + return try await driver().currentContext() + } + + /// The driver key a new workroom on `runtime` goes in, for a project with `base`: Apple's + /// runtime has one; Docker's is pinned to a context (`context(forBase:)`). + func key(for runtime: RemoteWorkrooms.Runtime, base: HostDescriptor?) async throws -> DriverKey { + guard runtime == .docker else { return DriverKey(runtime: runtime) } + return DriverKey(runtime: .docker, context: try await context(forBase: base)) } - /// The sequence's environment over Docker `context`'s driver, signed in as this Mac. + /// The sequence's environment over `key`'s driver, signed in as this Mac. @MainActor - func environment(context: String?) throws -> ( + func environment(_ key: DriverKey = DriverKey()) throws -> ( ContainerHostDriver, RemoteProvisioning.Environment ) { guard let client = BrokerSession.shared.client() else { throw RemoteWorkrooms.Failure.signedOut } - let driver = try driver(context: context) + let driver = try driver(key) var environment = RemoteProvisioning.Environment( driver: driver, agentSocket: RemoteWorkrooms.agentSocket, client: client) #if DEBUG @@ -511,16 +566,16 @@ final class RemoteHosts: @unchecked Sendable { @MainActor func environment(toDelete hosts: [HostDescriptor]) throws -> RemoteProvisioning.Environment? { guard try RemoteWorkrooms.checkDeletable(hosts) else { return nil } - // One environment has one driver, so one context. A project's workrooms are derived from its - // base, on its daemon, so the hosts of one delete share it. - let contexts = Set( - hosts.filter(RemoteWorkrooms.isLive).compactMap(\.container).map(\.context)) - guard contexts.count <= 1 else { + // One environment has one driver. A project's workrooms are derived from its base, on its + // runtime and daemon, so the hosts of one delete share one. + let keys = Set(hosts.filter(RemoteWorkrooms.isLive).compactMap(DriverKey.init)) + guard keys.count <= 1 else { throw HostDriverError.invalidConfiguration( - "these hosts are in different Docker contexts: " - + contexts.map { $0 ?? "(current)" }.sorted().joined(separator: ", ")) + "these hosts are on different container runtimes or Docker contexts: " + + keys.map { "\($0.runtime.displayName) \($0.context ?? "(current)")" }.sorted() + .joined(separator: ", ")) } - let (driver, environment) = try environment(context: contexts.first ?? nil) + let (driver, environment) = try environment(keys.first ?? DriverKey()) // Taking a box down needs it on the driver, whatever a reload adopted: with previews off it // adopted nothing, and the box would read as unknown. for host in hosts where RemoteWorkrooms.isLive(host) { @@ -629,13 +684,21 @@ final class RemoteHosts: @unchecked Sendable { "/usr/local/bin/docker", "/opt/homebrew/bin/docker", "/Applications/Docker.app/Contents/Resources/bin/docker", ] + /// Where Apple's `container` is: its installer's, or Homebrew's. + static let appleCandidates = ["/usr/local/bin/container", "/opt/homebrew/bin/container"] - private static func provisioning(context: String?) throws -> ContainerHostDriver.Provisioning { - guard - let runtime = runtimeCandidates.first(where: { - FileManager.default.isExecutableFile(atPath: $0) - }) - else { throw RemoteWorkrooms.Failure.noDocker } + /// `runtime`'s CLI on this Mac, or nil when it isn't installed. + static func executable(for runtime: RemoteWorkrooms.Runtime) -> String? { + (runtime == .docker ? runtimeCandidates : appleCandidates).first { + FileManager.default.isExecutableFile(atPath: $0) + } + } + + private static func provisioning(_ target: DriverKey) throws -> ContainerHostDriver.Provisioning { + guard let runtime = executable(for: target.runtime) else { + throw target.runtime == .docker + ? RemoteWorkrooms.Failure.noDocker : RemoteWorkrooms.Failure.noAppleContainer + } let key = try clientKey() return ContainerHostDriver.Provisioning( runtime: URL(fileURLWithPath: runtime), image: RemoteWorkrooms.hostImage, @@ -644,7 +707,8 @@ final class RemoteHosts: @unchecked Sendable { .trimmingCharacters(in: .whitespacesAndNewlines), agentSocket: RemoteWorkrooms.agentSocket, // Per build, so a Dev app's sweep never takes a Nightly app's hosts, nor the other way. - labels: ["workroom.provisioner=\(RemoteWorkrooms.provisioner)"], context: context) + labels: ["workroom.provisioner=\(RemoteWorkrooms.provisioner)"], context: target.context, + dialect: target.runtime == .docker ? .docker : .apple) } /// This Mac's ssh key for its remote hosts, in `directory`, made the first time: ed25519, no diff --git a/macapp/WorkroomApp/Core/Session/PersistentSessionService.swift b/macapp/WorkroomApp/Core/Session/PersistentSessionService.swift index 182722a8e..d870ca0f9 100644 --- a/macapp/WorkroomApp/Core/Session/PersistentSessionService.swift +++ b/macapp/WorkroomApp/Core/Session/PersistentSessionService.swift @@ -129,20 +129,13 @@ final class PersistentSessionService { self.ownershipOverride = nil self.now = { ProcessInfo.processInfo.systemUptime } self.endRemote = { session, host in - // No container runtime on this Mac: the app's remote hosts run in it (#253), so there is no - // host for the session to be running on, and nothing to stop. Reported as ended, rather - // than as a close that left something running. "No runtime" is no runtime CLI where the - // app looks for one, which a daemon still running behind a moved CLI would defeat. Off the - // main actor: making the driver for the first time can generate its ssh key. + // No container runtime on this Mac, Docker's or Apple's: the app's remote hosts run in one + // (#253, #309), so there is no host for the session to be running on, and nothing to stop. + // Reported as ended, rather than as a close that left something running. "No runtime" is no + // runtime CLI where the app looks for one, which a daemon still running behind a moved CLI + // would defeat. Off the main actor: it looks at the file system. let noRuntime = await Task.detached { () -> Bool in - do { - _ = try RemoteHosts.shared.driver() - return false - } catch RemoteWorkrooms.Failure.noDocker { - return true - } catch { - return false - } + RemoteWorkrooms.Runtime.allCases.allSatisfy { RemoteHosts.executable(for: $0) == nil } }.value if noRuntime { return true } try await RemoteHosts.shared.ensureConnected(host) diff --git a/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift b/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift new file mode 100644 index 000000000..6e831fc09 --- /dev/null +++ b/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift @@ -0,0 +1,137 @@ +import XCTest + +@testable import Workroom + +/// `ContainerHostDriver` on Apple's real `container` runtime (#309): a base, a workroom derived +/// from it, a restart, the sweep, and teardown. Opt-in: Apple's runtime needs Apple silicon and a +/// VM per container, which CI's macOS runners can't give it, so these run on a Mac by hand before +/// a release. Skipped unless `TEST_RUNNER_WR_APPLE_CONTAINER_TESTS=1`, with +/// `TEST_RUNNER_WR_APPLE_HOST_IMAGE` naming a `workroom-host` image the runtime already has, e.g. +/// +/// docker build --platform linux/arm64 -t workroom-host vcs/scripts/ssh-fixture +/// docker save workroom-host -o /tmp/host.tar && container image load -i /tmp/host.tar +/// TEST_RUNNER_WR_APPLE_CONTAINER_TESTS=1 \ +/// TEST_RUNNER_WR_APPLE_HOST_IMAGE=docker.io/library/workroom-host:latest \ +/// make app-test APP_TEST_FLAGS=-only-testing:WorkroomAppTests/AppleContainerIntegrationTests +/// +/// with `container system start` run first. +final class AppleContainerIntegrationTests: XCTestCase { + private var directory: URL! + private var runtime: URL! + private var label: String! + + override func setUpWithError() throws { + let environment = ProcessInfo.processInfo.environment + guard environment["WR_APPLE_CONTAINER_TESTS"] == "1" else { + throw XCTSkip("set TEST_RUNNER_WR_APPLE_CONTAINER_TESTS=1 to run Apple container tests") + } + guard let found = RemoteHosts.executable(for: .apple) else { + throw XCTSkip("Apple's container CLI isn't installed") + } + runtime = URL(fileURLWithPath: found) + directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "wr-apple-\(UUID().uuidString)") + label = "workroom.provisioner=apple-test-\(UUID().uuidString.lowercased())" + } + + override func tearDown() async throws { + // Whatever a failed test left, by this run's label. + if runtime != nil { + let containers = (try? AppleContainerCLI.objects(cli(["list", "--all", "--format", "json"]))) + for container in containers ?? [] where Self.carries(AppleContainerCLI.labels(of: container)) + { + if let id = AppleContainerCLI.id(of: container) { _ = try? cli(["delete", "--force", id]) } + } + } + if let directory { try? FileManager.default.removeItem(at: directory) } + } + + private static func carries(_ labels: [String: String]) -> Bool { + labels["workroom.provisioner"]?.hasPrefix("apple-test-") == true + } + + /// The real CLI, run from here rather than through the driver, so a check is never the driver + /// checking itself. + @discardableResult + private func cli(_ arguments: [String]) throws -> String { + let process = Process() + process.executableURL = runtime + process.arguments = arguments + let out = Pipe() + process.standardOutput = out + process.standardError = FileHandle.nullDevice + try process.run() + let data = out.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func ours() throws -> [String] { + try AppleContainerCLI.objects(cli(["list", "--all", "--format", "json"])).filter { + AppleContainerCLI.labels(of: $0)["workroom.provisioner"] + == label.split(separator: "=")[1] + .description + }.compactMap(AppleContainerCLI.id) + } + + private func provisioning() throws -> ContainerHostDriver.Provisioning { + guard let image = ProcessInfo.processInfo.environment["WR_APPLE_HOST_IMAGE"] else { + throw XCTSkip("set TEST_RUNNER_WR_APPLE_HOST_IMAGE to a workroom-host image") + } + let key = try RemoteHosts.clientKey(in: directory) + return ContainerHostDriver.Provisioning( + runtime: runtime, image: image, user: RemoteWorkrooms.user, identityFile: key.path, + publicKey: try String(contentsOf: key.appendingPathExtension("pub"), encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), + agentSocket: RemoteWorkrooms.agentSocket, labels: [label], dialect: .apple) + } + + private func onHost(_ driver: ContainerHostDriver, _ host: HostID, _ command: String) + async throws -> String + { + let (status, output) = try await driver.exec(command, on: host).communicate(nil, timeout: 20) + XCTAssertEqual(status, 0, "on the host, \(command) failed: \(output)") + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func testABaseDerivesARestartableWorkroomAndTeardownLeavesNothing() async throws { + let driver = ContainerHostDriver( + hosts: [:], directory: directory, provisioning: try provisioning()) + + let base = try await driver.create() + _ = try await onHost(driver, base, "echo carried > ~/marker") + let baseKey = try await onHost(driver, base, "cut -d' ' -f2 /etc/ssh/ssh_host_ed25519_key.pub") + + // The disk comes across, the identity doesn't, and the derived image is gone again. + let instance = try await driver.deriveFromBase(base) + let marker = try await onHost(driver, instance, "cat ~/marker") + XCTAssertEqual(marker, "carried") + let instanceKey = try await onHost( + driver, instance, "cut -d' ' -f2 /etc/ssh/ssh_host_ed25519_key.pub") + XCTAssertNotEqual(instanceKey, baseKey) + let images = try AppleContainerCLI.objects(cli(["image", "list", "--format", "json"])) + XCTAssertFalse( + images.contains { AppleContainerCLI.name(ofImage: $0)?.contains("workroom-derive-") == true }, + "the derived image was left behind") + // The base was stopped for the export and started again. + let back = try await onHost(driver, base, "cat ~/marker") + XCTAssertEqual(back, "carried") + + // Apple keeps nothing running across a restart: opening starts it again, on its port. + guard case .remote(let id) = instance else { return XCTFail("\(instance)") } + try cli(["stop", ContainerHostDriver.containerName(id)]) + try await driver.startIfStopped(instance) + let up = try await onHost(driver, instance, "echo up") + XCTAssertEqual(up, "up") + + // Both are recorded hosts: the sweep takes neither, however old. + guard case .remote(let baseID) = base else { return XCTFail("\(base)") } + let failures = await driver.sweep(keeping: [baseID, id], grace: 0) + XCTAssertEqual(failures, []) + XCTAssertEqual(try ours().count, 2) + + try await driver.destroy(instance) + try await driver.destroy(base) + XCTAssertEqual(try ours(), []) + } +} diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index 75968488d..3f254c9e2 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -335,14 +335,16 @@ final class RemoteHostsTests: XCTestCase { XCTAssertEqual(RemoteWorkrooms.hostImage(override: nil, pinned: nil), "workroom-host") } - private static func driver(runtime: URL, context: String?) -> ContainerHostDriver { + private static func driver( + runtime: URL, context: String?, dialect: ContainerHostDriver.Dialect = .docker + ) -> ContainerHostDriver { ContainerHostDriver( hosts: [:], directory: FileManager.default.temporaryDirectory, provisioning: ContainerHostDriver.Provisioning( runtime: runtime, image: "workroom-host", user: RemoteWorkrooms.user, identityFile: "/dev/null", publicKey: "ssh-ed25519 AAAA", agentSocket: RemoteWorkrooms.agentSocket, labels: ["workroom.provisioner=test"], - context: context)) + context: context, dialect: dialect)) } private static func record(context: String?) -> ContainerHostDriver.Record { @@ -394,7 +396,7 @@ final class RemoteHostsTests: XCTestCase { func testANewWorkroomGoesInItsBasesContextOrTheCurrentOne() async throws { for (current, expected) in [("orbstack", "orbstack"), ("default", nil)] as [(String, String?)] { let (runtime, log) = try stubRuntime(output: current + "\n") - let remote = RemoteHosts(makeDriver: { Self.driver(runtime: runtime, context: $0) }) + let remote = RemoteHosts(makeDriver: { Self.driver(runtime: runtime, context: $0.context) }) let context = try await remote.context(forBase: nil) XCTAssertEqual(context, expected, current) XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "context show\n") @@ -420,7 +422,7 @@ final class RemoteHostsTests: XCTestCase { let (runtime, _) = try stubRuntime() let swept = Swept() let remote = RemoteHosts( - makeDriver: { Self.driver(runtime: runtime, context: $0) }, + makeDriver: { Self.driver(runtime: runtime, context: $0.context) }, sweepDriver: { driver, known, _ in swept.add(driver.provisioning?.context, known) return [] @@ -476,6 +478,206 @@ final class RemoteHostsTests: XCTestCase { } } + // MARK: Apple's container runtime (#309) + + /// A stand-in CLI that answers each command by the shell `cases` given (a `case "$*" in` body), + /// and logs every call. + private func scriptedRuntime(_ cases: String) throws -> (runtime: URL, log: URL) { + let (runtime, log) = try stubRuntime() + let script = try String(contentsOf: runtime, encoding: .utf8) + try (script + "\ncase \"$*\" in\n\(cases)\nesac\n").write( + to: runtime, atomically: true, encoding: .utf8) + return (runtime, log) + } + + private func calls(_ log: URL) throws -> [String] { + try String(contentsOf: log, encoding: .utf8).split(separator: "\n").map(String.init) + } + + /// Apple's runtime has no `--restart` or `--pull`, and pulls every platform of an image unless + /// told its architecture: a missing image is pulled for arm64, and run with neither flag. + func testAppleRunsAndPullsInItsOwnDialect() async throws { + let (runtime, log) = try scriptedRuntime( + """ + "image inspect workroom-host") exit 1 ;; + run*) exit 1 ;; + """) + do { + _ = try await Self.driver(runtime: runtime, context: nil, dialect: .apple).create() + XCTFail("run was meant to fail") + } catch {} + let made = try calls(log) + XCTAssertEqual( + Array(made.prefix(2)), + ["image inspect workroom-host", "image pull --arch arm64 workroom-host"]) + let run = try XCTUnwrap(made.first { $0.hasPrefix("run ") }) + XCTAssertTrue(run.hasPrefix("run --detach --init --arch arm64 --name workroom-"), run) + XCTAssertFalse(run.contains("--restart") || run.contains("--pull"), run) + XCTAssertTrue(made.contains { $0.hasPrefix("delete --force workroom-") }, "\(made)") + } + + /// Apple's sweep: containers and images labelled as this build's and old enough go, unless a + /// recorded host is theirs; an image any container was run from stays, since Apple removes an + /// image that is in use where Docker refuses. + func testAppleSweepsByJSONAndKeepsImagesInUse() async throws { + let (kept, swept) = (UUID(), UUID()) + func container(_ id: String, labels: [String: String], image: String) -> [String: Any] { + [ + "id": id, "status": ["state": "running"], + "configuration": ["labels": labels, "image": ["reference": image]], + ] + } + func image(_ name: String, labels: [String: String]) -> [String: Any] { + [ + "configuration": ["name": name], + "variants": [ + ["platform": ["architecture": "arm64"], "config": ["config": ["Labels": labels]]] + ], + ] + } + let ours = ["workroom.provisioner": "test", "workroom.created": "1"] + let list = [ + container(ContainerHostDriver.containerName(kept), labels: ours, image: "in-use:1"), + container(ContainerHostDriver.containerName(swept), labels: ours, image: "other:1"), + container( + "theirs", labels: ["workroom.provisioner": "other", "workroom.created": "1"], image: "x"), + container( + "fresh", + labels: [ + "workroom.provisioner": "test", + "workroom.created": "\(Int(Date().timeIntervalSince1970))", + ], image: "y"), + ] + let images = [ + image("in-use:1", labels: ours), image("leftover:1", labels: ours), + image("recorded:1", labels: ours), image("user:1", labels: [:]), + ] + let json = { (o: Any) in + String(decoding: try JSONSerialization.data(withJSONObject: o), as: UTF8.self) + } + let (runtime, log) = try scriptedRuntime( + """ + "list --all --format json") printf '%s' \(ContainerHostDriver.shellQuoted(try json(list))) ;; + "image list --format json") printf '%s' \(ContainerHostDriver.shellQuoted(try json(images))) ;; + """) + let failures = await Self.driver(runtime: runtime, context: nil, dialect: .apple).sweep( + keeping: [kept], images: ["recorded:1"]) + XCTAssertEqual(failures, []) + let removed = try calls(log).filter { $0.hasPrefix("delete ") || $0.hasPrefix("image delete ") } + XCTAssertEqual( + removed, + ["delete --force \(ContainerHostDriver.containerName(swept))", "image delete leftover:1"]) + } + + /// A derived image is rebuilt from the exported disk with the host image's own process: its + /// entrypoint, command and environment, not the run-time ones a container carries. + func testAppleDerivesWithTheImagesOwnProcess() throws { + let image: [String: Any] = [ + "variants": [ + [ + "platform": ["architecture": "amd64"], + "config": ["config": ["Entrypoint": ["/wrong"]]], + ], + [ + "platform": ["architecture": "arm64"], + "config": [ + "config": [ + "Entrypoint": ["/usr/local/bin/entrypoint.sh"], "Cmd": ["serve", "a b"], + "Env": ["PATH=/usr/bin:/bin", #"QUOTED=say "hi" \ bye"#], "WorkingDir": "/srv", + "User": "", + ] + ], + ], + ] + ] + let process = try XCTUnwrap( + AppleContainerCLI.processConfig(ofImage: image, architecture: "arm64")) + XCTAssertEqual( + try AppleContainerCLI.dockerfile(process), + """ + FROM scratch + ADD rootfs.tar / + ENV PATH="/usr/bin:/bin" + ENV QUOTED="say \\"hi\\" \\\\ bye" + WORKDIR /srv + ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] + CMD ["serve","a b"] + + """) + var broken = process + broken.env.append("BAD=line\nbreak") + XCTAssertThrowsError(try AppleContainerCLI.dockerfile(broken)) + } + + /// Only a base is derived from. An Apple instance records no image, as a base doesn't, so a + /// host is a base only when its adopter says so; deriving from an instance would copy its + /// enrolment. + func testAnAppleInstanceIsNeverDerivedFrom() async throws { + let (runtime, log) = try stubRuntime() + let driver = Self.driver(runtime: runtime, context: nil, dialect: .apple) + let instance = UUID() + try driver.adopt(instance, Self.record(context: nil), isBase: false) + do { + _ = try await driver.deriveFromBase(.remote(instance)) + XCTFail("derived from an instance") + } catch HostDriverError.invalidConfiguration {} + XCTAssertFalse(FileManager.default.fileExists(atPath: log.path), "the instance was touched") + } + + /// A recorded Apple host is adopted into Apple's driver, and a Docker one beside it into + /// Docker's, by the descriptor's `driver`. + func testHostsAreAdoptedIntoTheirRuntimesDrivers() throws { + let (runtime, _) = try stubRuntime() + let remote = RemoteHosts( + makeDriver: { key in + Self.driver( + runtime: runtime, context: key.context, dialect: key.runtime == .apple ? .apple : .docker) + }, sweepDriver: { _, _, _ in [] }) + let (docker, apple) = (UUID(), UUID()) + let host = { (id: UUID, runtime: RemoteWorkrooms.Runtime) in + HostDescriptor( + driver: runtime.rawValue, provisioner: RemoteWorkrooms.provisioner, id: id, + container: Self.record(context: nil)) + } + let project = Project( + path: "/proj", vcs: "git", + workrooms: [ + Workroom( + name: "w", path: "/home/workroom/r", vcsName: "workroom/w", warnings: [], + host: host(apple, .apple)) + ], host: host(docker, .docker)) + remote.adopt([project]) + XCTAssertEqual(remote.existingDriver(holding: docker)?.provisioning?.dialect, .docker) + XCTAssertEqual(remote.existingDriver(holding: apple)?.provisioning?.dialect, .apple) + } + + /// A project's workrooms are derived from its base, so they go on the base's runtime: asking + /// for another is refused before anything is made. + func testAWorkroomOnAnotherRuntimeThanItsBaseIsRefused() async throws { + let (runtime, _) = try stubRuntime() + let driver = Self.driver(runtime: runtime, context: nil, dialect: .apple) + let environment = RemoteProvisioning.Environment( + driver: driver, agentSocket: RemoteWorkrooms.agentSocket, + client: BrokerClient( + baseURL: BrokerEndpoint.development, key: .software(P256.Signing.PrivateKey()))) + do { + _ = try await RemoteWorkrooms.create( + repository: try XCTUnwrap(GitHubRepository(host: "github.com", owner: "o", name: "r")), + cloneURL: "https://github.com/o/r.git", + base: HostDescriptor( + driver: RemoteWorkrooms.Runtime.docker.rawValue, provisioner: RemoteWorkrooms.provisioner, + id: UUID(), repository: "o/r", cloneURL: "https://github.com/o/r.git", + path: "/home/workroom/r"), + runtime: .apple, driver: driver, environment: environment, + recorder: RemoteWorkrooms.Recorder( + reserve: { _, _ in "x" }, record: { _, _ in XCTFail("recorded") }, + forget: { _ in })) + XCTFail("a workroom went on another runtime than its base") + } catch RemoteWorkrooms.Failure.baseOnOtherRuntime(let runtime) { + XCTAssertEqual(runtime, "Docker") + } + } + /// A driver takes on only a host of its own context: its commands would not reach another's. func testADriverRefusesAHostOfAnotherContext() throws { let (runtime, _) = try stubRuntime() From d733499cf186655e6d0d7fbffe669436c7be2ce9 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 15:37:49 +0100 Subject: [PATCH 06/29] feat(macapp): New Workroom asks where: this Mac, Docker or Apple Container (#309) With the remote preview on, a project's New Workroom becomes a submenu: This Mac, then Docker and Apple Container under "Local containers". It replaces the separate New Remote Workroom item. Without the preview, as in every stable build, New Workroom stays a plain item and nothing changes. An entry that can't be used now is disabled and says why in its title and tooltip: Apple's needs Apple silicon and macOS 26, either runtime may not be installed, and creating one still needs a Codaset sign-in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/AppStore.swift | 2 +- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 28 +++++++++ macapp/WorkroomApp/Views/ProjectSidebar.swift | 63 +++++++++++++------ .../WorkroomAppTests/RemoteHostsTests.swift | 33 +++++++++- 4 files changed, 105 insertions(+), 21 deletions(-) diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 78677a7a3..96b3fe811 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -3513,7 +3513,7 @@ final class AppStore: ObservableObject { }) } - /// Whether New Remote Workroom is on for `project`: not while another create holds it busy, since + /// Whether creating a container workroom is on for `project`: not while another create holds it busy, since /// a second create would build a second base. func canCreateRemoteWorkroom(in project: Project) -> Bool { RemoteWorkrooms.isEnabled && !isBusyProject(project.path) diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index 9cf83f013..e1840fcf1 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -35,6 +35,34 @@ enum RemoteWorkrooms { /// The descriptor's `driver` for a Docker host. static let containerDriver = Runtime.docker.rawValue + + /// Why a workroom can't be created on `runtime` now, as the New Workroom menu shows it beside the + /// entry, or nil when it can (#309). + @MainActor + static func unavailability(of runtime: Runtime) -> String? { + var arm64: Int32 = 0 + var size = MemoryLayout.size + let appleSilicon = sysctlbyname("hw.optional.arm64", &arm64, &size, nil, 0) == 0 && arm64 == 1 + return unavailability( + of: runtime, installed: RemoteHosts.executable(for: runtime) != nil, + appleSilicon: appleSilicon, + macOS26: ProcessInfo.processInfo.isOperatingSystemAtLeast( + OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0)), + signedIn: BrokerSession.shared.client() != nil) + } + + static func unavailability( + of runtime: Runtime, installed: Bool, appleSilicon: Bool, macOS26: Bool, signedIn: Bool + ) -> String? { + if runtime == .apple { + // Apple's runtime runs each container in a VM of its own, on Apple silicon and macOS 26. + if !appleSilicon { return "needs Apple silicon" } + if !macOS26 { return "needs macOS 26" } + } + if !installed { return "not installed" } + if !signedIn { return "sign in to Codaset in Settings" } + return nil + } /// The descriptor's `provisioner`: this build, whose ssh key and Docker labels its hosts carry. static var provisioner: String { Bundle.main.bundleIdentifier ?? "com.developwithstyle.workroom" } /// Fixed by the host image's entrypoint (`vcs/scripts/ssh-fixture/entrypoint.sh`). diff --git a/macapp/WorkroomApp/Views/ProjectSidebar.swift b/macapp/WorkroomApp/Views/ProjectSidebar.swift index 71861b68b..a2405a021 100644 --- a/macapp/WorkroomApp/Views/ProjectSidebar.swift +++ b/macapp/WorkroomApp/Views/ProjectSidebar.swift @@ -188,6 +188,37 @@ struct ProjectSidebar: View { // MARK: Rows + /// New Workroom on this Mac. Disabled while a create is in flight, matching the row's own "+" + /// button (issue #167): ungated, the menu was one right-click away from N concurrent pre-name + /// creates, only one of which can own the loader slot. + private func newLocalWorkroomButton(_ label: some View, in project: Project) -> some View { + Button { + Task { await store.createWorkroom(in: project) } + } label: { + label + } + .help("A workroom of \(project.displayName) on this Mac") + .disabled(store.isBusyProject(project.path)) + } + + /// New Workroom in a local container on `runtime` (#309), cloned from the project's GitHub + /// origin. One that can't be used now says why in its title. + private func newContainerWorkroomButton( + on runtime: RemoteWorkrooms.Runtime, in project: Project + ) -> some View { + let reason = RemoteWorkrooms.unavailability(of: runtime) + return Button { + Task { await store.createRemoteWorkroom(in: project, runtime: runtime) } + } label: { + Text(reason.map { "\(runtime.displayName) — \($0)" } ?? runtime.displayName) + } + .help( + reason.map { "\(runtime.displayName) can't be used: \($0)" } + ?? "A workroom of \(project.displayName) in a \(runtime.displayName) container on this Mac" + ) + .disabled(reason != nil || !store.canCreateRemoteWorkroom(in: project)) + } + @ViewBuilder private func projectRow(_ project: Project) -> some View { let id = SidebarID.project(project.path) @@ -251,28 +282,22 @@ struct ProjectSidebar: View { if inside { hovered = id } else if hovered == id { hovered = nil } } .contextMenu { - Button { - Task { await store.createWorkroom(in: project) } - } label: { - Label("New Workroom", systemImage: "plus") - } - // Disabled while a create is in flight, matching the row's own "+" button above (issue #167) — - // ungated, this menu item was one right-click away from N concurrent pre-name creates, only one - // of which can own the loader slot. Re-read here rather than reusing the `busy` above: that one - // is scoped to the row's own `HStack` builder. - .disabled(store.isBusyProject(project.path)) - // Nightly and Dev, behind the preview setting (#253). The workroom belongs to this local - // project: its base machine is cloned from the project's GitHub origin. + // Nightly and Dev, behind the preview setting (#253, #309): New Workroom asks where, this Mac + // or a local container. Without the preview there is only this Mac, so no submenu. if RemoteWorkrooms.isEnabled { - Button { - Task { await store.createRemoteWorkroom(in: project) } + Menu { + newLocalWorkroomButton(Text("This Mac"), in: project) + Section("Local containers") { + ForEach(RemoteWorkrooms.Runtime.allCases, id: \.self) { runtime in + newContainerWorkroomButton(on: runtime, in: project) + } + } } label: { - Label("New Remote Workroom", systemImage: "network") + Label("New Workroom", systemImage: "plus") } - .help( - "A workroom of \(project.displayName) on a remote host, cloned from its GitHub origin" - ) - .disabled(!store.canCreateRemoteWorkroom(in: project)) + .help("Create a workroom of \(project.displayName), on this Mac or in a local container") + } else { + newLocalWorkroomButton(Label("New Workroom", systemImage: "plus"), in: project) } Divider() Button { diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index 3f254c9e2..75398d8b7 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -203,7 +203,7 @@ final class RemoteHostsTests: XCTestCase { XCTAssertFalse(remote.claimSweep(allowed: true)) } - /// New Remote Workroom is off while its project is busy: a second create would build a second + /// A container workroom's create is off while its project is busy: a second create would build a second /// base. @MainActor func testARemoteCreateIsOffWhileItsProjectIsBusy() { @@ -678,6 +678,37 @@ final class RemoteHostsTests: XCTestCase { } } + /// Why a container runtime's New Workroom entry is off, in the order a user has to fix it: + /// Apple's needs Apple silicon and macOS 26 before installing it means anything (#309). + func testARuntimeEntrySaysWhyItIsOff() { + let ready = { (runtime: RemoteWorkrooms.Runtime) in + RemoteWorkrooms.unavailability( + of: runtime, installed: true, appleSilicon: true, macOS26: true, signedIn: true) + } + XCTAssertNil(ready(.docker)) + XCTAssertNil(ready(.apple)) + XCTAssertEqual( + RemoteWorkrooms.unavailability( + of: .apple, installed: false, appleSilicon: false, macOS26: false, signedIn: false), + "needs Apple silicon") + XCTAssertEqual( + RemoteWorkrooms.unavailability( + of: .apple, installed: false, appleSilicon: true, macOS26: false, signedIn: true), + "needs macOS 26") + // Docker runs on an Intel Mac and on macOS 15. + XCTAssertNil( + RemoteWorkrooms.unavailability( + of: .docker, installed: true, appleSilicon: false, macOS26: false, signedIn: true)) + XCTAssertEqual( + RemoteWorkrooms.unavailability( + of: .docker, installed: false, appleSilicon: true, macOS26: true, signedIn: true), + "not installed") + XCTAssertEqual( + RemoteWorkrooms.unavailability( + of: .docker, installed: true, appleSilicon: true, macOS26: true, signedIn: false), + "sign in to Codaset in Settings") + } + /// A driver takes on only a host of its own context: its commands would not reach another's. func testADriverRefusesAHostOfAnotherContext() throws { let (runtime, _) = try stubRuntime() From 6eb56543e491ef86f11eb8bcfa0c0f1b92d72ab9 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 15:42:45 +0100 Subject: [PATCH 07/29] feat(macapp): keep a base per container runtime and Docker context (#309) A project's base record can now hold several bases, one per runtime and Docker context, in a `bases` list. A record without one is a single base, as every project made before has it, and a project with one base keeps that form. A new workroom derives from the base where it is asked for: Apple's, or Docker's on the context in use now. A base made before #309 has no context and still serves Docker. When there is none, a base is made there and recorded beside the others. Deleting builds an environment per runtime and context, so a project whose workrooms span Docker and Apple's runtime is taken down whole. Each base is cleared from config as it goes, so a failure part-way leaves only what is still live recorded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/AppStore.swift | 41 +++++-- macapp/WorkroomApp/Core/Models.swift | 14 ++- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 104 +++++++++++------- .../WorkroomAppTests/RemoteHostsTests.swift | 92 +++++++++++----- 4 files changed, 174 insertions(+), 77 deletions(-) diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 96b3fe811..9e541d3a5 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -3532,8 +3532,11 @@ final class AppStore: ObservableObject { do { // Signed out says so before anything asks the runtime (`environment` checks it again). guard BrokerSession.shared.client() != nil else { throw RemoteWorkrooms.Failure.signedOut } - // A new Docker base is pinned to the context in use now; a workroom goes where its base is. - let key = try await RemoteHosts.shared.key(for: runtime, base: project.host) + // A project keeps a base per runtime and Docker context (#309): the workroom derives from the + // one where it is asked for, made there first if there is none. + let wanted = try await RemoteHosts.shared.key(for: runtime) + let base = RemoteWorkrooms.base(in: project.host, for: wanted) + let key = base.flatMap(RemoteHosts.DriverKey.init) ?? wanted let (driver, environment) = try RemoteHosts.shared.environment(key) let resolution = await WorkroomStatusResolver().resolveRepository(in: project.path) guard case .found(let repository) = resolution else { @@ -3544,7 +3547,8 @@ final class AppStore: ObservableObject { } let created = try await RemoteWorkrooms.create( repository: repository, cloneURL: RemoteWorkrooms.cloneURL(for: repository), - base: project.host, runtime: runtime, driver: driver, environment: environment, + base: base, project: project.host, runtime: runtime, driver: driver, + environment: environment, recorder: remoteRecorder(project: project.path)) // Its panes attach through their own ssh; Changes and the rest connect for themselves. await created.instance.connection.close() @@ -3741,7 +3745,9 @@ final class AppStore: ObservableObject { // optimistic removal below forgets its label. var remote: RemoteProvisioning.Environment? if let host = workroom.host { - do { remote = try RemoteHosts.shared.environment(toDelete: [host]) } catch { + do { + remote = try RemoteHosts.shared.environment(toDelete: [host])?.environment(for: host) + } catch { present(error) errorTitle = "Can't delete \(workroom.displayName)" return @@ -4040,10 +4046,11 @@ final class AppStore: ObservableObject { // What that needs is checked here, before the local cleanup below kills this project's shells // and forgets its labels. let remoteWorkrooms = project.workrooms.filter(\.isRemote) - let remote: RemoteProvisioning.Environment? + let bases = project.host?.allBases ?? [] + let remote: RemoteHosts.Deletion? do { remote = try RemoteHosts.shared.environment( - toDelete: remoteWorkrooms.compactMap(\.host) + [project.host].compactMap { $0 }) + toDelete: remoteWorkrooms.compactMap(\.host) + bases) } catch { present(error) errorTitle = "Can't delete \(project.displayName)" @@ -4080,13 +4087,25 @@ final class AppStore: ObservableObject { for workroom in remoteWorkrooms { guard let host = workroom.host else { continue } try await RemoteWorkrooms.delete( - workroom.name, host: host, environment: remote, recorder: recorder) + workroom.name, host: host, environment: remote?.environment(for: host), + recorder: recorder) } - if let base = project.host { - let cli = self.cli - try await RemoteWorkrooms.deleteBase(base, environment: remote) { - try await cli.setHost(project: project.path, workroom: nil, descriptor: nil) + // Each base on its own runtime and context (#309). Config keeps the ones still to go, so + // a failure part-way leaves what is live recorded for the next delete. + let cli = self.cli + var left = project.host + for base in bases { + let rest = base.id.flatMap { RemoteWorkrooms.removing($0, from: left) } + try await RemoteWorkrooms.deleteBase(base, environment: remote?.environment(for: base)) + { + try await cli.setHost( + project: project.path, workroom: nil, + descriptor: try rest.map { try JSONEncoder().encode($0) }) } + left = rest + } + if left != nil { + try await cli.setHost(project: project.path, workroom: nil, descriptor: nil) } let trashPaths = try await self.cli.deleteProject( project.path, diff --git a/macapp/WorkroomApp/Core/Models.swift b/macapp/WorkroomApp/Core/Models.swift index 5a63b5603..1a5568bd5 100644 --- a/macapp/WorkroomApp/Core/Models.swift +++ b/macapp/WorkroomApp/Core/Models.swift @@ -37,11 +37,18 @@ struct HostDescriptor: Codable, Hashable { var path: String? = nil /// How `ContainerHostDriver` finds the host again, including the Docker context it runs in. var container: ContainerHostDriver.Record? = nil + /// A project's bases when it has more than one (#309): one per container runtime and Docker + /// context, each a whole base descriptor. nil for a project with one base, whose descriptor is + /// the base itself, as every project before #309 has it. + var bases: [HostDescriptor]? = nil + + /// The bases a project's descriptor records. + var allBases: [HostDescriptor] { bases ?? [self] } var isDestroyed: Bool { state == "destroyed" } enum CodingKeys: String, CodingKey { - case state, driver, provisioner, id, repository, path, container + case state, driver, provisioner, id, repository, path, container, bases case grantID = "grant_id" case workroomID = "workroom_id" case cloneURL = "clone_url" @@ -50,7 +57,8 @@ struct HostDescriptor: Codable, Hashable { init( state: String? = nil, driver: String? = nil, provisioner: String? = nil, id: UUID? = nil, grantID: String? = nil, workroomID: UUID? = nil, repository: String? = nil, - cloneURL: String? = nil, path: String? = nil, container: ContainerHostDriver.Record? = nil + cloneURL: String? = nil, path: String? = nil, container: ContainerHostDriver.Record? = nil, + bases: [HostDescriptor]? = nil ) { self.state = state self.driver = driver @@ -62,6 +70,7 @@ struct HostDescriptor: Codable, Hashable { self.cloneURL = cloneURL self.path = path self.container = container + self.bases = bases } init(from decoder: Decoder) throws { @@ -76,6 +85,7 @@ struct HostDescriptor: Codable, Hashable { cloneURL = try? fields?.decodeIfPresent(String.self, forKey: .cloneURL) path = try? fields?.decodeIfPresent(String.self, forKey: .path) container = try? fields?.decodeIfPresent(ContainerHostDriver.Record.self, forKey: .container) + bases = try? fields?.decodeIfPresent([HostDescriptor].self, forKey: .bases) } /// A project's base, when the descriptor records a whole one. diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index e1840fcf1..8b9e04f77 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -144,12 +144,36 @@ enum RemoteWorkrooms { } } + /// The base a workroom on `key` derives from, in a project whose descriptor is `host` (#309): the + /// one made on that runtime and Docker context, else for Docker one made before #309, which names + /// no context and follows the current one, as `key` does. + static func base(in host: HostDescriptor?, for key: RemoteHosts.DriverKey) -> HostDescriptor? { + let bases = host?.allBases ?? [] + return bases.first { RemoteHosts.DriverKey($0) == key } + ?? bases.first { RemoteHosts.DriverKey($0) == RemoteHosts.DriverKey(runtime: key.runtime) } + } + + /// A project's descriptor `host` with `base` recorded in it, in place of any base on the same + /// runtime and context (#309). A project with one base keeps the one-base form. + static func recording(_ base: HostDescriptor, in host: HostDescriptor?) -> HostDescriptor { + let key = RemoteHosts.DriverKey(base) + let others = (host?.allBases ?? []).filter { $0.id != nil && RemoteHosts.DriverKey($0) != key } + return others.isEmpty ? base : HostDescriptor(bases: others + [base]) + } + + /// A project's descriptor `host` without the base `id`, or nil when it had no other. + static func removing(_ id: UUID, from host: HostDescriptor?) -> HostDescriptor? { + let others = (host?.allBases ?? []).filter { $0.id != nil && $0.id != id } + return others.count > 1 ? HostDescriptor(bases: others) : others.first + } + /// The router registrations of `projects`' reachable remote workrooms (#253). Each is its own /// shared root on its host, as an independent clone is. Its GitHub identity is the base's, so its /// PR and CI status read through `gh` here. static func registrations(_ projects: [Project]) -> [RepositoryRouter.Registration] { projects.flatMap { project in - let github = project.host?.repository.flatMap(gitHubRepository) + let github = project.host?.allBases.lazy.compactMap(\.repository).first.flatMap( + gitHubRepository) return project.workrooms.compactMap { workroom -> RepositoryRouter.Registration? in guard let host = workroom.reachableHost, let location = try? RepositoryLocation.remote(host: host, path: workroom.path) @@ -194,7 +218,8 @@ enum RemoteWorkrooms { /// the entry keeps what is still live (host, grant) so deleting it can finish the job. static func create( repository: GitHubRepository, cloneURL: String, base existing: HostDescriptor?, - runtime: Runtime = .docker, driver: ContainerHostDriver, + project projectHost: HostDescriptor? = nil, runtime: Runtime = .docker, + driver: ContainerHostDriver, environment: RemoteProvisioning.Environment, recorder: Recorder ) async throws -> Created { let base: RemoteProvisioning.Base @@ -227,13 +252,16 @@ enum RemoteWorkrooms { cloneURL: cloneURL, path: clonePath(for: repository), in: environment ) { base in + // Beside the project's bases on other runtimes and contexts (#309). try await recorder.record( nil, - HostDescriptor( - driver: runtime.rawValue, provisioner: provisioner, id: base.host, - repository: base.repository, - cloneURL: base.cloneURL, path: base.path, - container: driver.record(of: .remote(base.host)))) + recording( + HostDescriptor( + driver: runtime.rawValue, provisioner: provisioner, id: base.host, + repository: base.repository, + cloneURL: base.cloneURL, path: base.path, + container: driver.record(of: .remote(base.host))), + in: projectHost)) } } @@ -499,13 +527,14 @@ final class RemoteHosts: @unchecked Sendable { /// with a delete in flight leaves out hosts config still records (#296). func adopt(_ projects: [Project], sweep: Bool = true) { let descriptors = - projects.compactMap(\.host) + projects.flatMap { $0.workrooms.compactMap(\.host) } + projects.flatMap { $0.host?.allBases ?? [] } + + projects.flatMap { $0.workrooms.compactMap(\.host) } // Another build's hosts take another key: adopted here, they would refuse every login. let recorded = descriptors.filter { DriverKey($0) != nil && $0.provisioner == RemoteWorkrooms.provisioner } // Only a base is derived from; on Apple nothing in its record says which a host is. - let bases = Set(projects.compactMap(\.host?.id)) + let bases = Set(projects.flatMap { $0.host?.allBases.compactMap(\.id) ?? [] }) let already = lock.withLock { Set(made.keys) } guard !recorded.isEmpty || !already.isEmpty else { return } // A descriptor with no container record yet goes to its runtime's driver that names no @@ -549,20 +578,12 @@ final class RemoteHosts: @unchecked Sendable { } } - /// The Docker context a new workroom of a project with `base` goes in (#309): its base's, since - /// it is derived from the base on that daemon, or for a project with no base yet the context the - /// CLI would use now, which the new base is then pinned to. nil follows the environment, as - /// every host made before #309 does. - func context(forBase base: HostDescriptor?) async throws -> String? { - if let base, base.id != nil { return base.container?.context } - return try await driver().currentContext() - } - - /// The driver key a new workroom on `runtime` goes in, for a project with `base`: Apple's - /// runtime has one; Docker's is pinned to a context (`context(forBase:)`). - func key(for runtime: RemoteWorkrooms.Runtime, base: HostDescriptor?) async throws -> DriverKey { + /// The driver key a new workroom on `runtime` wants (#309): Apple's runtime has one, and Docker's + /// is the context the CLI uses now, which a new base is pinned to. `RemoteWorkrooms.base(in:for:)` + /// then finds the project's base there, if it has one. + func key(for runtime: RemoteWorkrooms.Runtime) async throws -> DriverKey { guard runtime == .docker else { return DriverKey(runtime: runtime) } - return DriverKey(runtime: .docker, context: try await context(forBase: base)) + return DriverKey(runtime: .docker, context: try await driver().currentContext()) } /// The sequence's environment over `key`'s driver, signed in as this Mac. @@ -588,32 +609,37 @@ final class RemoteHosts: @unchecked Sendable { return (driver, environment) } - /// What deleting `hosts` needs, checked before anything is removed (#253): nil when none of them - /// is live, else the environment, which throws when signed out or without Docker. Refuses one - /// another build made (`RemoteWorkrooms.checkDeletable`). + /// What deleting `hosts` needs, checked before anything is removed (#253): an environment per + /// runtime and Docker context they are on (#309), or nil when none of them is live. Throws when + /// signed out or without a runtime they need. Refuses one another build made + /// (`RemoteWorkrooms.checkDeletable`). @MainActor - func environment(toDelete hosts: [HostDescriptor]) throws -> RemoteProvisioning.Environment? { + func environment(toDelete hosts: [HostDescriptor]) throws -> Deletion? { guard try RemoteWorkrooms.checkDeletable(hosts) else { return nil } - // One environment has one driver. A project's workrooms are derived from its base, on its - // runtime and daemon, so the hosts of one delete share one. - let keys = Set(hosts.filter(RemoteWorkrooms.isLive).compactMap(DriverKey.init)) - guard keys.count <= 1 else { - throw HostDriverError.invalidConfiguration( - "these hosts are on different container runtimes or Docker contexts: " - + keys.map { "\($0.runtime.displayName) \($0.context ?? "(current)")" }.sorted() - .joined(separator: ", ")) - } - let (driver, environment) = try environment(keys.first ?? DriverKey()) - // Taking a box down needs it on the driver, whatever a reload adopted: with previews off it - // adopted nothing, and the box would read as unknown. + var environments: [DriverKey: RemoteProvisioning.Environment] = [:] for host in hosts where RemoteWorkrooms.isLive(host) { + let key = DriverKey(host) ?? DriverKey() + if environments[key] == nil { environments[key] = try environment(key).1 } + // Taking a box down needs it on its driver, whatever a reload adopted: with previews off it + // adopted nothing, and the box would read as unknown. + let driver = try driver(key) guard let id = host.id, let record = host.container, driver.record(of: .remote(id)) == nil else { continue } do { try driver.adopt(id, record) } catch { Self.logger.error("adopting host \(id, privacy: .public): \(error, privacy: .public)") } } - return environment + return environments.isEmpty ? nil : Deletion(environments: environments) + } + + /// The environments a delete takes hosts down with, one per runtime and Docker context. + struct Deletion { + let environments: [DriverKey: RemoteProvisioning.Environment] + + /// The environment for `host`, or nil when it isn't live and needs none. + func environment(for host: HostDescriptor) -> RemoteProvisioning.Environment? { + environments[DriverKey(host) ?? DriverKey()] + } } /// The app's service connection to `host` (`HostConnectionManager`), with its agent bootstrapped diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index 75398d8b7..d24687709 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -391,28 +391,59 @@ final class RemoteHostsTests: XCTestCase { } } - /// A new base is pinned to the context the CLI uses now, except `default`, which is whatever - /// `DOCKER_HOST` says and so pins nothing. A project with a base stays on the base's context. - func testANewWorkroomGoesInItsBasesContextOrTheCurrentOne() async throws { + /// A new Docker workroom wants the context the CLI uses now, except `default`, which is whatever + /// `DOCKER_HOST` says and so pins nothing. Apple's runtime has no contexts and asks nothing. + func testANewWorkroomWantsTheCurrentDockerContext() async throws { for (current, expected) in [("orbstack", "orbstack"), ("default", nil)] as [(String, String?)] { let (runtime, log) = try stubRuntime(output: current + "\n") let remote = RemoteHosts(makeDriver: { Self.driver(runtime: runtime, context: $0.context) }) - let context = try await remote.context(forBase: nil) - XCTAssertEqual(context, expected, current) + let key = try await remote.key(for: .docker) + XCTAssertEqual(key, RemoteHosts.DriverKey(runtime: .docker, context: expected), current) XCTAssertEqual(try String(contentsOf: log, encoding: .utf8), "context show\n") } - let remote = RemoteHosts(makeDriver: { _ in - XCTFail("a project with a base asked Docker") + XCTFail("Apple's key asked a runtime") throw RemoteWorkrooms.Failure.noDocker }) - for context in [nil, "desktop-linux"] as [String?] { - let base = HostDescriptor( - provisioner: RemoteWorkrooms.provisioner, id: UUID(), + let apple = try await remote.key(for: .apple) + XCTAssertEqual(apple, RemoteHosts.DriverKey(runtime: .apple)) + } + + /// A project keeps a base per runtime and Docker context (#309): a workroom derives from the one + /// where it is asked for, a base made before #309 (no context) still serves Docker, and recording + /// or removing one keeps the others. + func testAProjectKeepsABasePerRuntimeAndContext() throws { + let base = { (runtime: RemoteWorkrooms.Runtime, context: String?) in + HostDescriptor( + driver: runtime.rawValue, provisioner: RemoteWorkrooms.provisioner, id: UUID(), container: Self.record(context: context)) - let found = try await remote.context(forBase: base) - XCTAssertEqual(found, context) } + let old = base(.docker, nil) + let key = { (runtime: RemoteWorkrooms.Runtime, context: String?) in + RemoteHosts.DriverKey(runtime: runtime, context: context) + } + // The one-base form a project before #309 has serves Docker on any context, and not Apple. + XCTAssertEqual(RemoteWorkrooms.base(in: old, for: key(.docker, "orbstack"))?.id, old.id) + XCTAssertNil(RemoteWorkrooms.base(in: old, for: key(.apple, nil))) + + let apple = base(.apple, nil) + let both = RemoteWorkrooms.recording(apple, in: old) + XCTAssertEqual(both.allBases.map(\.id), [old.id, apple.id]) + XCTAssertEqual(RemoteWorkrooms.base(in: both, for: key(.apple, nil))?.id, apple.id) + // An exact match wins over the context-less one. + let pinned = base(.docker, "orbstack") + let three = RemoteWorkrooms.recording(pinned, in: both) + XCTAssertEqual(RemoteWorkrooms.base(in: three, for: key(.docker, "orbstack"))?.id, pinned.id) + XCTAssertEqual(RemoteWorkrooms.base(in: three, for: key(.docker, "desktop-linux"))?.id, old.id) + // Round-trips through config, and comes apart again base by base. + let decoded = try JSONDecoder().decode( + HostDescriptor.self, from: try JSONEncoder().encode(three)) + XCTAssertEqual(decoded, three) + let fewer = RemoteWorkrooms.removing(try XCTUnwrap(old.id), from: three) + XCTAssertEqual(fewer?.allBases.map(\.id), [apple.id, pinned.id]) + let one = RemoteWorkrooms.removing(try XCTUnwrap(apple.id), from: fewer) + XCTAssertEqual(one, pinned, "one base left goes back to the one-base form") + XCTAssertNil(RemoteWorkrooms.removing(try XCTUnwrap(pinned.id), from: one)) } /// Each recorded host is adopted into its own context's driver, and every driver's sweep keeps @@ -720,20 +751,31 @@ final class RemoteHostsTests: XCTestCase { UUID(), Self.record(context: "orbstack"))) } - /// One delete runs one driver, so hosts in two contexts are refused before anything is removed. - @MainActor - func testADeleteAcrossContextsIsRefused() throws { - let live = { (context: String?) in - HostDescriptor( - driver: RemoteWorkrooms.containerDriver, provisioner: RemoteWorkrooms.provisioner, - id: UUID(), container: Self.record(context: context)) + /// A delete takes each host down with the environment of its own runtime and context (#309). + func testADeleteRoutesEachHostToItsRuntimesEnvironment() throws { + let (runtime, _) = try stubRuntime() + let environment = { (key: RemoteHosts.DriverKey) in + RemoteProvisioning.Environment( + driver: Self.driver( + runtime: runtime, context: key.context, dialect: key.runtime == .apple ? .apple : .docker), + agentSocket: RemoteWorkrooms.agentSocket, + client: BrokerClient( + baseURL: BrokerEndpoint.development, key: .software(P256.Signing.PrivateKey()))) } - XCTAssertThrowsError( - try RemoteHosts().environment(toDelete: [live(nil), live("orbstack")]) - ) { error in - guard case HostDriverError.invalidConfiguration = error else { - return XCTFail("\(error)") - } + let keys = [ + RemoteHosts.DriverKey(runtime: .docker), + RemoteHosts.DriverKey(runtime: .docker, context: "orbstack"), + RemoteHosts.DriverKey(runtime: .apple), + ] + let deletion = RemoteHosts.Deletion( + environments: Dictionary(uniqueKeysWithValues: keys.map { ($0, environment($0)) })) + for key in keys { + let host = HostDescriptor( + driver: key.runtime.rawValue, provisioner: RemoteWorkrooms.provisioner, id: UUID(), + container: Self.record(context: key.context)) + let driver = try XCTUnwrap(deletion.environment(for: host)?.driver as? ContainerHostDriver) + XCTAssertEqual(driver.provisioning?.context, key.context) + XCTAssertEqual(driver.provisioning?.dialect, key.runtime == .apple ? .apple : .docker) } } From e0cb1cd7eccb8ad1165d6634b4fe5b8cfeb2c733 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 15:47:46 +0100 Subject: [PATCH 08/29] fix(macapp): address the review of the Apple container dialect (#309) - Never stop Apple's builder after a derive: it is one VM for the whole Mac, and stopping it killed any other build in flight, another project's derive included. - A derive stops the base only if it was running, and starts it again in a task of its own, so a cancelled derive can't leave it down. `stop` and `start` are no-ops when already done (1.5.0, measured), so a base a reboot left stopped is still derived from. - Give each Apple container explicit CPUs and memory. The runtime's own default is a 1 GB VM, too little for a compiler or an agent. It now gets half the cores and a quarter of the memory (2-8 GB), and both can be overridden with the hidden containerCPUs/containerMemory settings. - Escape `$` in the derived image's ENV, WORKDIR and USER: a builder expands it, and the image holds the values already expanded. - Delete the exported disk as soon as the image is built, and have the sweep remove the staging folders of derives a crash cut short. - Take a derived image's process config from the host image when the base's own image is gone, since Apple removes images in use. - Mark bases as bases when a delete adopts them, and check the host's own runtime, not any runtime, before reporting a session ended. - The export's bound is an hour: it prints nothing until it is done, so its silence bound is a total one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/AppStore.swift | 2 +- .../WorkroomApp/Core/AppleContainerCLI.swift | 18 ++-- .../Core/ContainerHostDriver.swift | 101 +++++++++++++----- macapp/WorkroomApp/Core/DefaultsKeys.swift | 4 + macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 43 +++++++- .../Session/PersistentSessionService.swift | 8 +- .../AppleContainerIntegrationTests.swift | 14 +++ .../WorkroomAppTests/RemoteHostsTests.swift | 53 ++++++++- 8 files changed, 198 insertions(+), 45 deletions(-) diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 9e541d3a5..db1d9e65d 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -4050,7 +4050,7 @@ final class AppStore: ObservableObject { let remote: RemoteHosts.Deletion? do { remote = try RemoteHosts.shared.environment( - toDelete: remoteWorkrooms.compactMap(\.host) + bases) + toDelete: remoteWorkrooms.compactMap(\.host) + bases, bases: Set(bases.compactMap(\.id))) } catch { present(error) errorTitle = "Can't delete \(project.displayName)" diff --git a/macapp/WorkroomApp/Core/AppleContainerCLI.swift b/macapp/WorkroomApp/Core/AppleContainerCLI.swift index 3ecc5f2df..cc6f74866 100644 --- a/macapp/WorkroomApp/Core/AppleContainerCLI.swift +++ b/macapp/WorkroomApp/Core/AppleContainerCLI.swift @@ -83,18 +83,24 @@ enum AppleContainerCLI { guard !values.contains(where: { $0.contains(where: \.isNewline) }) else { throw HostDriverError.invalidConfiguration("the host image's config has a newline in it") } + // A builder expands `$` in ENV, WORKDIR and USER; the image holds the values already expanded. + // Quotes only matter inside ENV's quoted value. + func literal(_ text: some StringProtocol, quoted: Bool = false) -> String { + let escaped = text.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "$", with: "\\$") + return quoted ? escaped.replacingOccurrences(of: "\"", with: "\\\"") : escaped + } var lines = ["FROM scratch", "ADD rootfs.tar /"] for variable in process.env { guard let equals = variable.firstIndex(of: "=") else { continue } - let value = variable[variable.index(after: equals)...] - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - lines.append("ENV \(variable[.. HostID { guard let provisioning else { throw HostDriverError.notImplemented("Provisioning") } - let tag = "workroom-derive-\(UUID().uuidString.lowercased())" + let tag = Self.deriveDirectoryPrefix + UUID().uuidString.lowercased() let work = FileManager.default.temporaryDirectory.appendingPathComponent(tag) try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: work) } - let base = try AppleContainerCLI.objects( - try await runtime(["inspect", source.container], allLines: true)) - guard let reference = base.first.flatMap(AppleContainerCLI.imageReference), - let image = try AppleContainerCLI.objects( - try await runtime(["image", "inspect", reference], allLines: true) - ).first, - let process = AppleContainerCLI.processConfig(ofImage: image, architecture: "arm64") - else { - throw HostDriverError.provisioning("couldn't read the base's image to derive from") - } - try AppleContainerCLI.dockerfile(process).write( + try AppleContainerCLI.dockerfile(try await appleProcess(of: source, provisioning)).write( to: work.appendingPathComponent("Dockerfile"), atomically: true, encoding: .utf8) let rootfs = work.appendingPathComponent("rootfs.tar") - _ = try await runtime(["stop", source.container]) + // Stopped only if running, and started again whatever happens, even if this derive is + // cancelled: a cancelled `runtime` call ends its CLI, so the start runs in a task of its own. + // `stop` and `start` are both no-ops when already done (1.5.0, measured), so a base an + // earlier derive left stopped is exported and started here. + let wasRunning = + try AppleContainerCLI.objects( + try await runtime(["inspect", source.container], allLines: true) + ).first.flatMap(AppleContainerCLI.state) == "running" + if wasRunning { _ = try await runtime(["stop", source.container]) } + let exported: Error? do { - // `export` prints nothing until the whole disk is written. - _ = try await runtime(["export", "--output", rootfs.path, source.container], timeout: 900) - } catch { - _ = try? await runtime(["start", source.container]) - throw error - } - _ = try await runtime(["start", source.container]) - - // Never `--quiet`: in 1.5.0 a quiet build never returns. The builder is a VM of its own (2 - // CPUs, 2 GB), stopped again afterwards; the next derive starts it again in seconds. - defer { Task { _ = try? await self.runtime(["builder", "stop"]) } } + // ponytail: `export` prints nothing until the whole disk is written, so this silence bound + // is a total one, an hour; upgrade path: export to stdout and stream it to the file. + _ = try await runtime(["export", "--output", rootfs.path, source.container], timeout: 3600) + exported = nil + } catch { exported = error } + if wasRunning { + try await Task { _ = try await self.runtime(["start", source.container]) }.value + } + if let exported { throw exported } + + // Never `--quiet`: in 1.5.0 a quiet build never returns. The builder is one VM for the whole + // Mac, which other builds may be using, so it is left running. do { _ = try await runtime( ["build"] + (provisioning.labels + [Self.created()]).flatMap { ["--label", $0] } + ["--tag", tag, work.path], timeout: 900) + // The image holds the disk now; the tar is as big again. + try? FileManager.default.removeItem(at: rootfs) let host = try await run(tag, image: nil, isBase: false) _ = try? await runtime(["image", "delete", tag]) return host } catch { - _ = try? await runtime(["image", "delete", "--force", tag]) + _ = await Task { try? await self.runtime(["image", "delete", "--force", tag]) }.value throw error } } + /// How the image `source` was run from starts its process, for the derived image to start the + /// same way; the host image's own when that image is gone (Apple removes images still in use, so + /// a prune or a newer pinned image can take it). + private func appleProcess(of source: Provisioned, _ provisioning: Provisioning) async throws + -> AppleContainerCLI.ProcessConfig + { + let base = try AppleContainerCLI.objects( + try await runtime(["inspect", source.container], allLines: true)) + for reference in [base.first.flatMap(AppleContainerCLI.imageReference), provisioning.image] + .compactMap({ $0 }) + { + guard let text = try? await runtime(["image", "inspect", reference], allLines: true), + let image = try? AppleContainerCLI.objects(text).first, + let process = AppleContainerCLI.processConfig(ofImage: image, architecture: "arm64") + else { continue } + return process + } + throw HostDriverError.provisioning("couldn't read the base's image to derive from") + } + + /// Where an Apple derive stages a base's exported disk, and the derived image's name. + static let deriveDirectoryPrefix = "workroom-derive-" + /// Removes the container and the image it alone was run from. A host handed in rather than /// made here is not this driver's to remove. func destroy(_ host: HostID) async throws { @@ -430,6 +460,22 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { (labels[Self.createdLabel].flatMap(TimeInterval.init) ?? 0) <= cutoff } var failed: [String] = [] + // A derive a crash cut short leaves its staged disk behind, the size of a base's whole disk. One + // still under way is writing to it, which keeps its newest time recent. + let temp = FileManager.default.temporaryDirectory + for name in (try? FileManager.default.contentsOfDirectory(atPath: temp.path)) ?? [] + where name.hasPrefix(Self.deriveDirectoryPrefix) { + let directory = temp.appendingPathComponent(name) + let times = + ([directory] + + ((try? FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: [.contentModificationDateKey])) ?? [])) + .compactMap { try? $0.resourceValues(forKeys: [.contentModificationDateKey]) } + .compactMap(\.contentModificationDate) + if let newest = times.max(), newest.timeIntervalSince1970 <= cutoff { + try? FileManager.default.removeItem(at: directory) + } + } let keptContainers = Set(known.map(Self.containerName)) var inUse = kept do { @@ -508,8 +554,11 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { provisioning.dialect == .apple ? ["run", "--detach", "--init", "--arch", "arm64"] : ["run", "--pull=never", "--detach", "--init", "--restart", "unless-stopped"] + let resources = + (provisioning.cpus.map { ["--cpus", "\($0)"] } ?? []) + + (provisioning.memory.map { ["--memory", $0] } ?? []) _ = try await runtime( - start + ["--name", container, "--publish", "127.0.0.1:\(port):22"] + start + resources + ["--name", container, "--publish", "127.0.0.1:\(port):22"] + (provisioning.labels + [Self.created()]).flatMap { ["--label", $0] } + ["--env", "AUTHORIZED_KEY=\(provisioning.publicKey)", source]) let hostKey = try await identity(of: container) diff --git a/macapp/WorkroomApp/Core/DefaultsKeys.swift b/macapp/WorkroomApp/Core/DefaultsKeys.swift index 6ba81ff42..a2f193da3 100644 --- a/macapp/WorkroomApp/Core/DefaultsKeys.swift +++ b/macapp/WorkroomApp/Core/DefaultsKeys.swift @@ -97,6 +97,10 @@ extension Defaults.Keys { /// `defaults write remoteHostImage `, e.g. `workroom-host` for the image /// `make remote-host-image` builds. static let remoteHostImage = Key("remoteHostImage", default: nil, suite: .app) + /// CPUs and memory (`8G`) each container workroom gets (#309), over the defaults + /// `RemoteHosts.resources` picks. Hidden: `defaults write containerCPUs -int 6`. + static let containerCPUs = Key("containerCPUs", default: nil, suite: .app) + static let containerMemory = Key("containerMemory", default: nil, suite: .app) /// The Workroom credential broker (Codaset). Empty means the build's own: codaset.dev, or /// `https://codaset.localhost` in a Debug build, which only ever reaches this Mac /// (`BrokerEndpoint`). Hidden; override with `defaults write brokerURL `. diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index 8b9e04f77..a7dfff66e 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -437,6 +437,8 @@ final class RemoteHosts: @unchecked Sendable { /// The drivers made so far. private var made: [DriverKey: ContainerHostDriver] = [:] + /// The runtime each host config records is on, whether or not a driver holds it. + private var runtimes: [UUID: RemoteWorkrooms.Runtime] = [:] private var swept = false /// A call reached the sweep while a delete was in flight and left it for later (#296). private var held = false @@ -535,7 +537,12 @@ final class RemoteHosts: @unchecked Sendable { } // Only a base is derived from; on Apple nothing in its record says which a host is. let bases = Set(projects.flatMap { $0.host?.allBases.compactMap(\.id) ?? [] }) - let already = lock.withLock { Set(made.keys) } + let already = lock.withLock { + for descriptor in recorded { + if let id = descriptor.id, let key = DriverKey(descriptor) { runtimes[id] = key.runtime } + } + return Set(made.keys) + } guard !recorded.isEmpty || !already.isEmpty else { return } // A descriptor with no container record yet goes to its runtime's driver that names no // context, where the one driver before #309 would have had it. @@ -614,7 +621,7 @@ final class RemoteHosts: @unchecked Sendable { /// signed out or without a runtime they need. Refuses one another build made /// (`RemoteWorkrooms.checkDeletable`). @MainActor - func environment(toDelete hosts: [HostDescriptor]) throws -> Deletion? { + func environment(toDelete hosts: [HostDescriptor], bases: Set = []) throws -> Deletion? { guard try RemoteWorkrooms.checkDeletable(hosts) else { return nil } var environments: [DriverKey: RemoteProvisioning.Environment] = [:] for host in hosts where RemoteWorkrooms.isLive(host) { @@ -625,7 +632,8 @@ final class RemoteHosts: @unchecked Sendable { let driver = try driver(key) guard let id = host.id, let record = host.container, driver.record(of: .remote(id)) == nil else { continue } - do { try driver.adopt(id, record) } catch { + // Whether it is a base sticks for the launch: a later create derives from it. + do { try driver.adopt(id, record, isBase: bases.contains(id)) } catch { Self.logger.error("adopting host \(id, privacy: .public): \(error, privacy: .public)") } } @@ -715,6 +723,16 @@ final class RemoteHosts: @unchecked Sendable { } } + /// Whether `host`'s runtime is missing from this Mac, so nothing can be running on it: the + /// runtime config records it on, or with none recorded, every runtime. + func runtimeIsMissing(for host: HostID) -> Bool { + guard case .remote(let id) = host else { return false } + let runtime = lock.withLock { runtimes[id] } + return (runtime.map { [$0] } ?? RemoteWorkrooms.Runtime.allCases).allSatisfy { + Self.executable(for: $0) == nil + } + } + /// The driver holding `host`, which a reload adopted it into (`adopt`) or which made it. private func heldDriver(_ host: HostID) throws -> ContainerHostDriver { guard case .remote(let id) = host, let driver = existingDriver(holding: id) else { @@ -748,7 +766,23 @@ final class RemoteHosts: @unchecked Sendable { } } + /// What each container workroom on `runtime` gets (#309): the hidden settings, else for Apple's + /// runtime, whose own default is a 1 GB VM per container, half this Mac's cores and a quarter of + /// its memory, between 2 and 8 GB. Docker's containers share Docker's VM, which already bounds + /// them, so they get nothing unless set. + static func resources( + for runtime: RemoteWorkrooms.Runtime, cpus: Int? = Defaults[.containerCPUs], + memory: String? = Defaults[.containerMemory], + cores: Int = ProcessInfo.processInfo.activeProcessorCount, + physicalMemory: UInt64 = ProcessInfo.processInfo.physicalMemory + ) -> (cpus: Int?, memory: String?) { + guard runtime == .apple else { return (cpus, memory) } + let gigabytes = min(max(Int(physicalMemory / 4 / 1_073_741_824), 2), 8) + return (cpus ?? max(2, cores / 2), memory ?? "\(gigabytes)G") + } + private static func provisioning(_ target: DriverKey) throws -> ContainerHostDriver.Provisioning { + let resources = resources(for: target.runtime) guard let runtime = executable(for: target.runtime) else { throw target.runtime == .docker ? RemoteWorkrooms.Failure.noDocker : RemoteWorkrooms.Failure.noAppleContainer @@ -762,7 +796,8 @@ final class RemoteHosts: @unchecked Sendable { agentSocket: RemoteWorkrooms.agentSocket, // Per build, so a Dev app's sweep never takes a Nightly app's hosts, nor the other way. labels: ["workroom.provisioner=\(RemoteWorkrooms.provisioner)"], context: target.context, - dialect: target.runtime == .docker ? .docker : .apple) + dialect: target.runtime == .docker ? .docker : .apple, cpus: resources.cpus, + memory: resources.memory) } /// This Mac's ssh key for its remote hosts, in `directory`, made the first time: ed25519, no diff --git a/macapp/WorkroomApp/Core/Session/PersistentSessionService.swift b/macapp/WorkroomApp/Core/Session/PersistentSessionService.swift index d870ca0f9..5d4a4511a 100644 --- a/macapp/WorkroomApp/Core/Session/PersistentSessionService.swift +++ b/macapp/WorkroomApp/Core/Session/PersistentSessionService.swift @@ -129,14 +129,12 @@ final class PersistentSessionService { self.ownershipOverride = nil self.now = { ProcessInfo.processInfo.systemUptime } self.endRemote = { session, host in - // No container runtime on this Mac, Docker's or Apple's: the app's remote hosts run in one - // (#253, #309), so there is no host for the session to be running on, and nothing to stop. + // The container runtime the host is on, Docker's or Apple's, isn't on this Mac (#253, #309), + // so there is no host for the session to be running on, and nothing to stop. // Reported as ended, rather than as a close that left something running. "No runtime" is no // runtime CLI where the app looks for one, which a daemon still running behind a moved CLI // would defeat. Off the main actor: it looks at the file system. - let noRuntime = await Task.detached { () -> Bool in - RemoteWorkrooms.Runtime.allCases.allSatisfy { RemoteHosts.executable(for: $0) == nil } - }.value + let noRuntime = await Task.detached { RemoteHosts.shared.runtimeIsMissing(for: host) }.value if noRuntime { return true } try await RemoteHosts.shared.ensureConnected(host) return try await HostConnectionManager.shared.endSession(session, on: host) diff --git a/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift b/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift index 6e831fc09..0c247cc6b 100644 --- a/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift +++ b/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift @@ -124,6 +124,20 @@ final class AppleContainerIntegrationTests: XCTestCase { let up = try await onHost(driver, instance, "echo up") XCTAssertEqual(up, "up") + // A base left stopped (a reboot: Apple restarts nothing) is still derived from, and left + // stopped. + guard case .remote(let stoppedBase) = base else { return XCTFail("\(base)") } + try cli(["stop", ContainerHostDriver.containerName(stoppedBase)]) + let second = try await driver.deriveFromBase(base) + let carried = try await onHost(driver, second, "cat ~/marker") + XCTAssertEqual(carried, "carried") + let state = try AppleContainerCLI.objects( + cli(["inspect", ContainerHostDriver.containerName(stoppedBase)]) + ).first.flatMap(AppleContainerCLI.state) + XCTAssertEqual(state, "stopped") + try await driver.destroy(second) + try await driver.startIfStopped(base) + // Both are recorded hosts: the sweep takes neither, however old. guard case .remote(let baseID) = base else { return XCTFail("\(base)") } let failures = await driver.sweep(keeping: [baseID, id], grace: 0) diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index d24687709..2135689fe 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -614,7 +614,7 @@ final class RemoteHostsTests: XCTestCase { "config": [ "config": [ "Entrypoint": ["/usr/local/bin/entrypoint.sh"], "Cmd": ["serve", "a b"], - "Env": ["PATH=/usr/bin:/bin", #"QUOTED=say "hi" \ bye"#], "WorkingDir": "/srv", + "Env": ["PATH=/usr/bin:/bin", #"QUOTED=say "hi" \ $HOME"#], "WorkingDir": "/srv/$x", "User": "", ] ], @@ -629,8 +629,8 @@ final class RemoteHostsTests: XCTestCase { FROM scratch ADD rootfs.tar / ENV PATH="/usr/bin:/bin" - ENV QUOTED="say \\"hi\\" \\\\ bye" - WORKDIR /srv + ENV QUOTED="say \\"hi\\" \\\\ \\$HOME" + WORKDIR /srv/\\$x ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["serve","a b"] @@ -640,6 +640,53 @@ final class RemoteHostsTests: XCTestCase { XCTAssertThrowsError(try AppleContainerCLI.dockerfile(broken)) } + /// What each container gets: Apple's own default is a 1 GB VM, too small for a compiler or an + /// agent, so it gets half the cores and a quarter of the memory, between 2 and 8 GB, unless set. + /// Docker's containers share Docker's VM and get nothing unless set. + func testContainerResourcesDefaultForAppleOnly() { + let gb: UInt64 = 1_073_741_824 + let apple = { (cores: Int, memory: UInt64) in + RemoteHosts.resources( + for: .apple, cpus: nil, memory: nil, cores: cores, physicalMemory: memory) + } + XCTAssertTrue(apple(10, 32 * gb) == (5, "8G")) + XCTAssertTrue(apple(8, 16 * gb) == (4, "4G")) + XCTAssertTrue(apple(2, 4 * gb) == (2, "2G")) + XCTAssertTrue( + RemoteHosts.resources( + for: .apple, cpus: 6, memory: "12G", cores: 8, physicalMemory: 16 * gb) == (6, "12G")) + XCTAssertTrue( + RemoteHosts.resources(for: .docker, cpus: nil, memory: nil, cores: 8, physicalMemory: 16 * gb) + == (nil, nil)) + } + + /// A derive a crash cut short leaves its staged disk in the temporary folder; the sweep removes + /// one gone quiet, and leaves one still being written. + func testAppleSweepRemovesStaleDeriveFolders() async throws { + let temp = FileManager.default.temporaryDirectory + let stale = temp.appendingPathComponent( + ContainerHostDriver.deriveDirectoryPrefix + "stale-\(UUID())") + let live = temp.appendingPathComponent( + ContainerHostDriver.deriveDirectoryPrefix + "live-\(UUID())") + for directory in [stale, live] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("disk".utf8).write(to: directory.appendingPathComponent("rootfs.tar")) + } + defer { for d in [stale, live] { try? FileManager.default.removeItem(at: d) } } + let old = Date(timeIntervalSinceNow: -3600) + for url in [stale, stale.appendingPathComponent("rootfs.tar"), live] { + try FileManager.default.setAttributes([.modificationDate: old], ofItemAtPath: url.path) + } + let (runtime, _) = try scriptedRuntime( + """ + "list --all --format json") printf '[]' ;; + "image list --format json") printf '[]' ;; + """) + _ = await Self.driver(runtime: runtime, context: nil, dialect: .apple).sweep(keeping: []) + XCTAssertFalse(FileManager.default.fileExists(atPath: stale.path), "a stale folder stayed") + XCTAssertTrue(FileManager.default.fileExists(atPath: live.path), "a live derive's was taken") + } + /// Only a base is derived from. An Apple instance records no image, as a base doesn't, so a /// host is a base only when its adopter says so; deriving from an instance would copy its /// enrolment. From 502ed80edac1c7c66895e2684370657c941909d0 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 15:59:52 +0100 Subject: [PATCH 09/29] feat(wr-agent): relay git credentials from the Mac for a workroom that never enrolled (#309) `wr-agent credential relay --port `, with a secret on stdin, records where the Mac answers for this workroom and makes `wr-agent credential` git's helper, as enrolling does. It is idempotent, so the app can run it on every connect. `credential get` then answers a workroom that never enrolled by sending the secret and a github.com request to that port on the box's loopback, which the app carries back to itself. Only the answer's username and password reach git. An enrolled workroom always mints its own token and never asks the relay. When the app isn't connected, git is told to open the workroom in Workroom. Anything in the workroom can read the secret and ask, which is the accepted trade for a local container workroom: it gets the GitHub access the user's own `gh` has, as a workroom on the Mac itself does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- vcs/crates/wr-agent/src/broker.rs | 93 +++++++++++++++++- vcs/crates/wr-agent/src/main.rs | 43 +++++++- vcs/crates/wr-agent/tests/broker.rs | 146 ++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 2 deletions(-) diff --git a/vcs/crates/wr-agent/src/broker.rs b/vcs/crates/wr-agent/src/broker.rs index 7e6a18c6b..29f78c18c 100644 --- a/vcs/crates/wr-agent/src/broker.rs +++ b/vcs/crates/wr-agent/src/broker.rs @@ -41,6 +41,11 @@ use serde_json::{json, Value}; const STATE_FILE: &str = "broker.json"; const TOKEN_FILE: &str = "broker-token.json"; +/// Where the Mac relays a workroom that never enrolled (#309): a local container workroom whose +/// user is signed in to `gh` but not to Codaset. See `relay`. +const RELAY_FILE: &str = "relay.json"; +/// The most a relayed answer may be: git's credential protocol is a handful of short lines. +const RELAY_MAX: u64 = 16 * 1024; /// A cached token with less than this left is replaced, so git never starts a long push with a /// token about to die. If the broker cannot be reached it is still used until it expires. const REFRESH_MARGIN: i64 = 300; @@ -295,7 +300,13 @@ pub fn credential( if action != "get" { return Ok(()); } - let token = token(dir)?; + // An enrolled workroom always mints its own; only one that never enrolled is relayed. + let token = match token(dir) { + Err(BrokerError::NotEnrolled) if load::(dir, RELAY_FILE)?.is_some() => { + return relay(dir, &mut output); + } + result => result?, + }; // `x-access-token` is the username GitHub documents for installation tokens over HTTPS. // `password_expiry_utc` (git 2.41+) stops git reusing it past its expiry; older gits ignore it. write!( @@ -307,6 +318,86 @@ pub fn credential( Ok(()) } +/// Where a workroom that never enrolled asks the Mac for git's credentials (#309): a port on this +/// box's loopback that the app carries back to itself over its own connection (a reverse +/// forward), and the secret that tells the app which workroom is asking. Anything in the workroom +/// can read it and ask, which is the accepted trade: such a workroom gets the GitHub access the +/// user's own `gh` has, as a workroom on the Mac itself does. +#[derive(Serialize, Deserialize)] +struct Relay { + port: u16, + secret: String, +} + +/// Records the relay for `credential get` (`wr-agent credential relay`). +pub fn install_relay(dir: &Path, port: u16, secret: &str) -> Result<(), BrokerError> { + let secret = secret.trim(); + if port == 0 || secret.is_empty() || secret.contains(char::is_whitespace) { + return Err(BrokerError::Invalid( + "a relay needs a port and a one-word secret".into(), + )); + } + save( + dir, + RELAY_FILE, + &Relay { + port, + secret: secret.to_string(), + }, + ) +} + +/// `get` through the Mac: sends the secret, then the request (only ever github.com over HTTPS, +/// which `credential` checked), and copies back the answer's `username` and `password`. Nothing +/// else in the answer reaches git. +fn relay(dir: &Path, output: &mut impl Write) -> Result<(), BrokerError> { + use std::io::Read; + use std::net::{Ipv4Addr, SocketAddr, TcpStream}; + let Some(relay) = load::(dir, RELAY_FILE)? else { + return Err(BrokerError::NotEnrolled); + }; + let unreachable = |e: io::Error| { + BrokerError::Transport(format!( + "the Workroom app isn't connected to this workroom, so git has no GitHub credentials. \ + Open the workroom in Workroom. ({e})" + )) + }; + let address = SocketAddr::from((Ipv4Addr::LOCALHOST, relay.port)); + let mut stream = TcpStream::connect_timeout(&address, TIMEOUT).map_err(unreachable)?; + stream.set_read_timeout(Some(TIMEOUT))?; + stream.set_write_timeout(Some(TIMEOUT))?; + write!( + stream, + "{}\nprotocol=https\nhost=github.com\n\n", + relay.secret + ) + .map_err(unreachable)?; + let mut answer = String::new(); + stream + .take(RELAY_MAX) + .read_to_string(&mut answer) + .map_err(unreachable)?; + let mut fields = answer.lines().filter_map(|line| line.split_once('=')); + let mut username = None; + let mut password = None; + for (key, value) in fields.by_ref() { + match key { + "username" => username = Some(value), + "password" => password = Some(value), + "error" => return Err(BrokerError::Invalid(value.to_string())), + _ => {} + } + } + let (Some(username), Some(password)) = (username, password) else { + return Err(BrokerError::Invalid( + "the Workroom app had no GitHub credentials to give: sign in with `gh auth login`" + .into(), + )); + }; + write!(output, "username={username}\npassword={password}\n")?; + Ok(()) +} + /// Makes `helper` git's only credential helper for `https://github.com`, in global config. /// /// The empty entry first resets the list inherited from system config, where boxd installs its diff --git a/vcs/crates/wr-agent/src/main.rs b/vcs/crates/wr-agent/src/main.rs index 114f9539b..50cc8e3b0 100644 --- a/vcs/crates/wr-agent/src/main.rs +++ b/vcs/crates/wr-agent/src/main.rs @@ -60,7 +60,12 @@ fn usage() -> &'static str { enrol this remote workroom with the Workroom credential broker, reading the one-time code from stdin, and make `wr-agent credential` git's helper for https://github.com wr-agent credential - git's credential helper: answers github.com with an installation token from the broker + git's credential helper: answers github.com with an installation token from the broker, + or for a workroom that never enrolled, with what the Mac relays + wr-agent credential relay --port + have a workroom that never enrolled ask the Mac for git's credentials through on + this box's loopback, reading the relay's secret from stdin, and make `wr-agent credential` + git's helper for https://github.com wr-agent protocol print the protocol version this build speaks " @@ -1129,10 +1134,46 @@ fn run_enrol(args: &[String]) -> ExitCode { } } +/// `wr-agent credential relay --port `, the secret on stdin (#309): has a workroom that never +/// enrolled ask the Mac for git's GitHub credentials, through `port` on this box's loopback, and +/// points git at `wr-agent credential`. Run on every connect, so it is idempotent. +fn run_credential_relay(args: &[String]) -> ExitCode { + let Some(port) = flag(args, "--port").and_then(|p| p.parse::().ok()) else { + eprintln!("error: credential relay needs --port , and the secret on stdin"); + return ExitCode::FAILURE; + }; + let mut secret = String::new(); + if std::io::stdin().read_line(&mut secret).is_err() { + eprintln!("error: could not read the relay's secret from stdin"); + return ExitCode::FAILURE; + } + let result = wr_agent::broker::directory() + .map_err(wr_agent::broker::BrokerError::from) + .and_then(|dir| wr_agent::broker::install_relay(&dir, port, &secret)) + .and_then(|()| { + let binary = std::env::current_exe()?; + let helper = wr_agent::broker::helper_command(&binary); + Ok(wr_agent::broker::configure_git( + || std::process::Command::new("git"), + &helper, + )?) + }); + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} + /// git's credential helper. A failure is reported on stderr, which git shows beside its own /// "Authentication failed", and answers nothing, so git falls through as it would for no helper. fn run_credential(args: &[String]) -> ExitCode { let action = args.get(1).map(String::as_str).unwrap_or(""); + if action == "relay" { + return run_credential_relay(args); + } let result = wr_agent::broker::directory() .map_err(wr_agent::broker::BrokerError::from) .and_then(|dir| { diff --git a/vcs/crates/wr-agent/tests/broker.rs b/vcs/crates/wr-agent/tests/broker.rs index 6bdab93c1..a823875bf 100644 --- a/vcs/crates/wr-agent/tests/broker.rs +++ b/vcs/crates/wr-agent/tests/broker.rs @@ -738,3 +738,149 @@ fn an_unreadable_state_file_is_not_an_enrolment() { Err(BrokerError::NotEnrolled) )); } + +/// A stand-in for the Mac's end of a relay (#309): takes one connection, records what was sent, +/// and answers with `answer`. +fn relay_listener(answer: &'static str) -> (u16, std::thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let thread = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut sent = String::new(); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap() == 0 || line == "\n" { + break; + } + sent.push_str(&line); + } + let mut stream = stream; + stream.write_all(answer.as_bytes()).unwrap(); + sent + }); + (port, thread) +} + +#[test] +fn a_workroom_that_never_enrolled_is_answered_through_the_relay() { + let workspace = Workspace::new("broker-relay"); + let (port, mac) = relay_listener( + "protocol=https\nhost=github.com\nusername=joel\npassword=gho_mac\nextra=dropped\n", + ); + broker::install_relay(&workspace.dir, port, "s3cret\n").unwrap(); + + let mut output = Vec::new(); + broker::credential( + &workspace.dir, + "get", + &b"protocol=https\nhost=github.com\npath=joelmoss/workroom.git\n\n"[..], + &mut output, + ) + .unwrap(); + // Only the credential reaches git, and the Mac gets the secret and a github.com request only. + assert_eq!( + String::from_utf8(output).unwrap(), + "username=joel\npassword=gho_mac\n" + ); + assert_eq!( + mac.join().unwrap(), + "s3cret\nprotocol=https\nhost=github.com\n" + ); + + // Nothing but github.com over HTTPS is ever relayed, and `store`/`erase` ask nothing. + for (action, input) in [ + ("get", &b"protocol=https\nhost=gitlab.com\n\n"[..]), + ( + "store", + &b"protocol=https\nhost=github.com\npassword=x\n\n"[..], + ), + ("erase", &b"protocol=https\nhost=github.com\n\n"[..]), + ] { + let mut output = Vec::new(); + broker::credential(&workspace.dir, action, input, &mut output).unwrap(); + assert!(output.is_empty(), "{action}"); + } +} + +#[test] +fn an_enrolled_workroom_mints_its_own_and_never_asks_the_relay() { + let workspace = Workspace::new("broker-relay-enrolled"); + let broker = Broker::start(vec![ + enrolled_answer(), + ok(&format!( + r#"{{"token":"ghs_own","expires_at":"{}"}}"#, + iso8601(now() + 3600) + )), + ]); + enrolled(&workspace, &broker, "wr-1"); + // A port nothing listens on: a relay asked would fail the get. + let unused = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + broker::install_relay(&workspace.dir, unused, "s3cret").unwrap(); + + let mut output = Vec::new(); + broker::credential( + &workspace.dir, + "get", + &b"protocol=https\nhost=github.com\n\n"[..], + &mut output, + ) + .unwrap(); + assert!(String::from_utf8(output) + .unwrap() + .starts_with("username=x-access-token\npassword=ghs_own\n")); +} + +#[test] +fn a_relay_the_mac_is_not_listening_on_says_to_open_the_workroom() { + let workspace = Workspace::new("broker-relay-closed"); + let unused = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + broker::install_relay(&workspace.dir, unused, "s3cret").unwrap(); + let mut output = Vec::new(); + match broker::credential( + &workspace.dir, + "get", + &b"protocol=https\nhost=github.com\n\n"[..], + &mut output, + ) { + Err(BrokerError::Transport(message)) => { + assert!( + message.contains("isn't connected to this workroom"), + "{message}" + ) + } + other => panic!("expected a transport error, got {other:?}"), + } + assert!(output.is_empty()); + + // Neither is a workroom with no relay and no enrolment. + let bare = Workspace::new("broker-relay-none"); + assert!(matches!( + broker::credential( + &bare.dir, + "get", + &b"protocol=https\nhost=github.com\n\n"[..], + Vec::new() + ), + Err(BrokerError::NotEnrolled) + )); +} + +#[test] +fn a_relay_needs_a_port_and_a_one_word_secret() { + let workspace = Workspace::new("broker-relay-install"); + for (port, secret) in [(0, "s"), (1, ""), (1, "two words"), (1, " \n")] { + assert!( + broker::install_relay(&workspace.dir, port, secret).is_err(), + "{port} {secret:?}" + ); + } +} From 2896e95a36746e14aa53d5cbcca88e479c072fd8 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 16:18:24 +0100 Subject: [PATCH 10/29] feat(macapp): relay git credentials from the Mac's gh for local container workrooms (#309) A local container workroom no longer needs a Codaset sign-in. A base decides how its workrooms reach GitHub, once, and records it: the broker when signed in to Codaset, else, or when the broker refuses because the repository's owner hasn't installed the Codaset App, the Mac's own `gh`. A relayed base clones, and a relayed workroom fetches and branches, with `gh auth token` as git's header, the way a broker clone token goes. The workroom never enrols. Later git in it asks `wr-agent credential`, which the app points at a port on the box's loopback on every connect. The app carries that port back to CredentialRelay's listener on the Mac's loopback, which checks the workroom's per-launch secret and answers github.com over https only, from `gh auth git-credential get`. The reverse-forward registry is lifted out of the Debug-only broker route into ReverseForwardRegistry, which both now use. Accepted (D9 in #309): anything in the workroom can ask, so it has the user's gh access while the app is connected to it, as a workroom on the Mac itself does. When the app isn't connected, git is told to open the workroom. Remote workrooms always enrol. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/AppStore.swift | 2 - .../Core/Broker/BrokerReverseForwards.swift | 228 +--------------- macapp/WorkroomApp/Core/CredentialRelay.swift | 244 +++++++++++++++++ macapp/WorkroomApp/Core/Models.swift | 16 +- .../WorkroomApp/Core/RemoteProvisioning.swift | 117 +++++--- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 44 ++- .../Core/ReverseForwardRegistry.swift | 255 ++++++++++++++++++ .../CredentialRelayTests.swift | 89 ++++++ .../WorkroomAppTests/RemoteHostsTests.swift | 2 +- .../RemoteProvisioningIntegrationTests.swift | 80 ++++++ 10 files changed, 813 insertions(+), 264 deletions(-) create mode 100644 macapp/WorkroomApp/Core/CredentialRelay.swift create mode 100644 macapp/WorkroomApp/Core/ReverseForwardRegistry.swift create mode 100644 macapp/WorkroomAppTests/CredentialRelayTests.swift diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index db1d9e65d..b7055a805 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -3530,8 +3530,6 @@ final class AppStore: ObservableObject { beginBusy(project.path) defer { endBusy(project.path) } do { - // Signed out says so before anything asks the runtime (`environment` checks it again). - guard BrokerSession.shared.client() != nil else { throw RemoteWorkrooms.Failure.signedOut } // A project keeps a base per runtime and Docker context (#309): the workroom derives from the // one where it is asked for, made there first if there is none. let wanted = try await RemoteHosts.shared.key(for: runtime) diff --git a/macapp/WorkroomApp/Core/Broker/BrokerReverseForwards.swift b/macapp/WorkroomApp/Core/Broker/BrokerReverseForwards.swift index aac66b608..7f6eaf614 100644 --- a/macapp/WorkroomApp/Core/Broker/BrokerReverseForwards.swift +++ b/macapp/WorkroomApp/Core/Broker/BrokerReverseForwards.swift @@ -27,54 +27,20 @@ final class BrokerReverseForwards { static let shared = BrokerReverseForwards() - /// How the registry reaches a host's connection, so a test can hand it a real agent without - /// `HostConnectionManager`: the shape `PortForwardingModel.Transport` set. - struct Transport { - var forwarding: - @Sendable (HostID) async throws -> (HostConnectionManager.Lease, AgentForwardService) - var updates: @Sendable (HostID) async -> AsyncStream - - static let live = Transport( - forwarding: { try await HostConnectionManager.shared.forwarding(host: $0) }, - updates: { await HostConnectionManager.shared.updates(for: $0) }) - } - - /// How long the agent may take to answer a `listen`. - static let listenTimeout: Duration = .seconds(5) - /// A bind that lost the race with the old connection's teardown is retried this often, this - /// many times: the agent notices a departed connection within its 200 ms accept poll. - static let bindRetries = 10 - static let bindRetryDelay: Duration = .milliseconds(200) - /// How many times a connection's listener is tried, `reopenRetry` apart, before the registry - /// waits for the next connection: five minutes at the default. - static let reopenAttempts = 60 - - private let transport: Transport - /// The Puma port on this Mac each connection is carried to, asked again for each listener. - private let target: @Sendable () async -> UInt16 - private let reopenRetry: Duration - private let log = Logger(subsystem: "com.developwithstyle.workroom", category: "broker") - - private struct Entry { - let host: HostID - /// The connection the listener is open on, once it is. - var lease: HostConnectionManager.Lease? - var forward: ReverseForward? - var watch: Task? - /// An open in flight, which a second caller waits on rather than opening a second listener. - /// The id is how the task, finishing, clears only itself. - var opening: (id: UUID, task: Task)? - } - private var entries: [UUID: Entry] = [:] + typealias Transport = ReverseForwardRegistry.Transport + private let registry: ReverseForwardRegistry init( transport: Transport = .live, target: @escaping @Sendable () async -> UInt16 = { await DevelopmentCodaset.port() }, reopenRetry: Duration = .seconds(5) ) { - self.transport = transport - self.target = target - self.reopenRetry = reopenRetry + registry = ReverseForwardRegistry( + transport: transport, port: Self.port(for:), target: target, + failure: { + BrokerError.agent("The remote agent could not reach this Mac's Codaset: \($0)") + }, + reopenRetry: reopenRetry) } /// The agent's port for `workroom`: the same on every connection and every launch, so the URL in @@ -96,188 +62,16 @@ /// Open the listener for `workroom` on `host`, keep it open on every later connection, and return /// the URL to give the agent. Throws when the agent does not listen now. func open(workroom: UUID, host: HostID) async throws -> URL { - if entries[workroom] == nil { - entries[workroom] = Entry(host: host) - watch(workroom) - } - try await listen(workroom) + try await registry.open(workroom: workroom, host: host) return Self.agentURL(for: workroom) } /// Stop carrying `workroom`'s connections: its enrolment failed, or the workroom is gone. - func close(workroom: UUID) { - guard let entry = entries.removeValue(forKey: workroom) else { return } - entry.watch?.cancel() - entry.opening?.task.cancel() - entry.forward?.stop() - } + func close(workroom: UUID) { registry.close(workroom: workroom) } /// Whether `workroom`'s listener is open on `lease`, for the tests. func isOpen(_ workroom: UUID, on lease: HostConnectionManager.Lease) -> Bool { - entries[workroom]?.lease == lease && entries[workroom]?.forward != nil - } - - private func listen(_ workroom: UUID) async throws { - // An open in flight is waited out, then this caller runs its own: the one in flight may be - // for an earlier connection than the caller's, and `reopen` does nothing when the current - // connection already has a listener. - while let opening = entries[workroom]?.opening { _ = try? await opening.task.value } - guard entries[workroom] != nil else { throw CancellationError() } - let id = UUID() - // The task clears its own entry as its last step, on this actor, BEFORE it completes: a - // waiter above resumes only after that, so it never sees a finished open still recorded - // (awaiting a finished task does not suspend, and it would spin on this actor forever). - let task = Task { - defer { - if self.entries[workroom]?.opening?.id == id { self.entries[workroom]?.opening = nil } - } - try await self.reopen(workroom) - } - entries[workroom]?.opening = (id, task) - try await task.value - } - - /// A listener on the host's current connection, unless one is already open there. - private func reopen(_ workroom: UUID) async throws { - guard let host = entries[workroom]?.host else { return } - let (lease, service) = try await transport.forwarding(host) - guard var entry = entries[workroom] else { return } - if entry.lease == lease, entry.forward != nil { return } - entry.forward?.stop() - entry.forward = nil - entries[workroom] = entry - - // A bind can lose a race on a reconnect: the agent releases the old connection's port only - // once it notices that connection ended, which can be just after the new one asks. The - // port is this workroom's alone, so a bind failure is retried for a moment, not reported. - var attempt = 0 - var outcome = await listen(service, port: Self.port(for: workroom), workroom, lease) - while case .failure(let refusal) = outcome, refusal.detail.hasPrefix("bind:"), - attempt < Self.bindRetries - { - attempt += 1 - try await Task.sleep(for: Self.bindRetryDelay) - outcome = await listen(service, port: Self.port(for: workroom), workroom, lease) - } - let forward: ReverseForward - switch outcome { - case .success(let listening): forward = listening - case .failure(let refusal): - throw BrokerError.agent( - "The remote agent could not reach this Mac's Codaset: \(refusal.detail)") - } - // Closed meanwhile, or another connection took over: this listener is nobody's. - guard entries[workroom] != nil else { - forward.stop() - throw CancellationError() - } - entries[workroom]?.lease = lease - entries[workroom]?.forward = forward - } - - /// One `listen`, answered or timed out. A refused listener is stopped before it is returned. - private func listen( - _ service: AgentForwardService, port: UInt16, _ workroom: UUID, - _ lease: HostConnectionManager.Lease - ) async -> Result { - let ready = ListenOutcome() - let forward = service.reverse(remotePort: port, target: await target()) { - [weak self, log] event in - switch event { - case .listening: - ready.resolve(nil) - case .failed(let detail): - log.info("broker reverse forward: \(detail, privacy: .public)") - case .stopped(let detail): - ready.resolve(detail) - Task { @MainActor in self?.lost(workroom, on: lease) } - } - } - forward.start() - if let refusal = await ready.wait(Self.listenTimeout) { - forward.stop() - return .failure(.init(refusal)) - } - return .success(forward) - } - - /// The listener ended. With its connection, the next connection opens a new one; on its own - /// (the agent's listener broke while the connection lives), no new connection is coming, so it - /// is reopened here. - private func lost(_ workroom: UUID, on lease: HostConnectionManager.Lease) { - guard entries[workroom]?.lease == lease else { return } - entries[workroom]?.lease = nil - entries[workroom]?.forward = nil - Task { await keepTrying(workroom) } - } - - /// Listen until it works, the workroom is closed, or the attempts run out. A link lost without - /// a goodbye leaves the old connection's listener holding the port until the agent notices it - /// has gone, which can take as long as ssh's keepalives, so a bind that keeps failing is tried - /// again every `reopenRetry` rather than given up on until the next connection. - private func keepTrying(_ workroom: UUID) async { - for attempt in 1...Self.reopenAttempts { - guard entries[workroom] != nil, !Task.isCancelled else { return } - do { - try await listen(workroom) - return - } catch { - if attempt == 1 || attempt == Self.reopenAttempts { - log.error( - "broker reverse forward did not reopen (attempt \(attempt)): \(error.localizedDescription, privacy: .public)" - ) - } - } - try? await Task.sleep(for: reopenRetry) - } - } - - /// Reopen on every new connection to the host. - private func watch(_ workroom: UUID) { - guard let host = entries[workroom]?.host else { return } - entries[workroom]?.watch = Task { [weak self, transport] in - for await snapshot in await transport.updates(host) { - guard let self, !Task.isCancelled else { return } - guard snapshot.status == .connected, let lease = snapshot.lease, - self.entries[workroom]?.lease != lease - else { continue } - await self.keepTrying(workroom) - } - } - } - } - - /// The first answer to a `listen`, waited on with a deadline: nil once the agent listens, the - /// reason when it will not. - private final class ListenOutcome: @unchecked Sendable { - private let lock = NSLock() - private var outcome: String?? - private var waiter: CheckedContinuation? - - func resolve(_ refusal: String?) { - let waiter: CheckedContinuation? = lock.withLock { - guard outcome == nil else { return nil } - outcome = .some(refusal) - defer { self.waiter = nil } - return self.waiter - } - waiter?.resume(returning: refusal) - } - - func wait(_ timeout: Duration) async -> String? { - let timer = Task { [weak self] in - try? await Task.sleep(for: timeout) - self?.resolve("the agent did not answer the listen request") - } - defer { timer.cancel() } - return await withCheckedContinuation { continuation in - let settled: String?? = lock.withLock { - if let outcome { return outcome } - waiter = continuation - return nil - } - if let settled { continuation.resume(returning: settled) } - } + registry.isOpen(workroom, on: lease) } } diff --git a/macapp/WorkroomApp/Core/CredentialRelay.swift b/macapp/WorkroomApp/Core/CredentialRelay.swift new file mode 100644 index 000000000..9a8148e9b --- /dev/null +++ b/macapp/WorkroomApp/Core/CredentialRelay.swift @@ -0,0 +1,244 @@ +import Foundation +import os + +/// The Mac's end of the git credential relay for a local container workroom that never enrolled +/// with the Codaset broker (#309): the user is signed in to `gh`, not to Codaset, or the repository's +/// owner hasn't installed the Codaset App. +/// +/// Its agent's `wr-agent credential` dials a port on its own box's loopback (`port(for:)`), which +/// `ReverseForwardRegistry` carries over the app's connection to a listener here, on this Mac's +/// loopback. A connection sends the workroom's secret, then git's request; it is answered from +/// `gh auth git-credential get`, for `https://github.com` only. The secret is what keeps another +/// user of this Mac, who can also reach the listener, from being answered; it is made per launch +/// and installed on every connect (`install`). +/// +/// **Accepted exposure (D9, #309).** Anything in the workroom can read the secret and ask, so such +/// a workroom has the GitHub access the user's own `gh` has, while the app is connected to it, as a +/// workroom on the Mac itself does. A remote workroom never uses this: it always enrols. +final class CredentialRelay: @unchecked Sendable { + static let shared = CredentialRelay() + + private let lock = NSLock() + private var listener: (descriptor: Int32, port: UInt16)? + /// Each relayed host's secret. + private var secrets: [UUID: String] = [:] + private let answer: @Sendable (_ request: String) throws -> String + /// How the registry reaches a host's connection: the app's own, or a test's. + private let transport: ReverseForwardRegistry.Transport + /// The listeners on relayed hosts, made on the main actor the first time one is installed. + private var forwards: ReverseForwardRegistry? + private static let logger = Logger( + subsystem: "com.developwithstyle.workroom", category: "CredentialRelay") + /// The most a request may be: git's credential protocol is a handful of short lines. + static let maxRequest = 16 * 1024 + + init( + answer: @escaping @Sendable (_ request: String) throws -> String = CredentialRelay.gh, + transport: ReverseForwardRegistry.Transport = .live + ) { + self.answer = answer + self.transport = transport + } + + /// The agent's port for workroom host `id`: the same on every connection and every launch, so + /// the `relay.json` it was given stays right. 30000-31999, below Linux's ephemeral ports (32768 + /// up) and apart from the Debug broker route's (`BrokerReverseForwards`). + static func port(for id: UUID) -> UInt16 { + let bytes = id.uuid + return 30_000 + (UInt16(bytes.2) << 8 | UInt16(bytes.3)) % 2_000 + } + + /// The secret host `id`'s agent sends, made the first time it is asked for this launch. + func secret(for id: UUID) -> String { + lock.withLock { + if let secret = secrets[id] { return secret } + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let secret = bytes.map { String(format: "%02x", $0) }.joined() + secrets[id] = secret + return secret + } + } + + /// The listener on this Mac's loopback, started the first time it is needed. + func localPort() throws -> UInt16 { + try lock.withLock { + if let listener { return listener.port } + guard let made = LoopbackSocket.listen(backlog: 8) else { + throw HostDriverError.provisioning("couldn't listen for git credentials (errno \(errno))") + } + listener = made + Thread.detachNewThread { [weak self] in self?.accept(on: made.descriptor) } + return made.port + } + } + + private func accept(on descriptor: Int32) { + while true { + let connection = Darwin.accept(descriptor, nil, nil) + if connection < 0 { + if errno == EINTR || errno == ECONNABORTED { continue } + Self.logger.error("credential relay stopped accepting: errno \(errno)") + return + } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + defer { Darwin.close(connection) } + self?.serve(connection) + } + } + } + + /// One request: a known secret, then `protocol=https` and `host=github.com`, or nothing back. + private func serve(_ connection: Int32) { + var timeout = timeval(tv_sec: 15, tv_usec: 0) + setsockopt( + connection, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt( + connection, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + guard let request = Self.readRequest(connection) else { return } + let reply = respond(to: request) + _ = reply.withCString { send(connection, $0, strlen($0), 0) } + } + + /// Reads up to the blank line that ends git's request, or nil past `maxRequest` or a timeout. + static func readRequest(_ connection: Int32) -> String? { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while data.count < maxRequest { + let count = recv(connection, &buffer, buffer.count, 0) + guard count > 0 else { return nil } + data.append(buffer, count: count) + if data.range(of: Data("\n\n".utf8)) != nil { return String(data: data, encoding: .utf8) } + } + return nil + } + + /// The reply to `request` (the secret's line, then git's), as `username`/`password` lines, or an + /// `error=` line the agent passes on to git. + func respond(to request: String) -> String { + let lines = request.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard let secret = lines.first, + lock.withLock({ secrets.values.contains(secret) }) + else { return "error=this workroom's credential relay isn't known to the Workroom app\n" } + var fields: [String: String] = [:] + for line in lines.dropFirst() where !line.isEmpty { + guard let equals = line.firstIndex(of: "=") else { continue } + fields[String(line[.. String { + try runGH(["auth", "git-credential", "get"], input: request) + } + + /// The token `gh` holds for github.com, for git commands the app runs on a relayed workroom's + /// host while provisioning it (`RemoteProvisioning.cloneEnvironment`). + @Sendable static func gitHubToken() async throws -> String { + try await runBlocking { + let token = try runGH(["auth", "token", "--hostname", "github.com"], input: nil) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { throw RemoteWorkrooms.Failure.signedOut } + return token + } + } + + /// Whether `gh` lists a sign-in for github.com, read from its config without running it: for the + /// New Workroom menu, which can't wait. A create checks for real (`gitHubToken`). + static func hasGitHubSignIn( + hosts: URL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/gh/hosts.yml") + ) -> Bool { + guard let text = try? String(contentsOf: hosts, encoding: .utf8) else { return false } + return text.split(separator: "\n").contains { $0.hasPrefix("github.com:") } + } + + private static func runGH(_ arguments: [String], input: String?) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["gh"] + arguments + // A Finder-launched app's PATH has no Homebrew; gh is wherever the user's shell finds it. + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = ShellEnvironment.path() + environment["GH_PROMPT_DISABLED"] = "1" + process.environment = environment + let stdin = Pipe() + let stdout = Pipe() + process.standardInput = stdin + process.standardOutput = stdout + process.standardError = FileHandle.nullDevice + let exited = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in exited.signal() } + try process.run() + if let input { stdin.fileHandleForWriting.write(Data(input.utf8)) } + try? stdin.fileHandleForWriting.close() + let output = stdout.fileHandleForReading.readDataToEndOfFile() + guard exited.wait(timeout: .now() + 15) == .success else { + process.terminate() + throw HostDriverError.provisioning("gh didn't answer in time") + } + guard process.terminationStatus == 0 else { + throw HostDriverError.provisioning( + "gh has no GitHub sign-in on this Mac: run `gh auth login`") + } + return String(decoding: output, as: UTF8.self) + } + + // MARK: Hosts + + /// Has host `id`'s agent ask this Mac for git's credentials: its listener carried here, and + /// `wr-agent credential relay` run there with this launch's secret. Run on every connect: the + /// listener goes with the connection, and the secret with the launch. + @MainActor + func install(on host: HostID, driver: any HostDriver, agentBinary: String) async throws { + guard case .remote(let id) = host else { return } + let target = try localPort() + try await registry(target: target).open(workroom: id, host: host) + let command = + ContainerHostDriver.shellQuoted(agentBinary) + + " credential relay --port \(Self.port(for: id))" + let (status, output) = try await driver.exec(command, on: host).communicate( + Data((secret(for: id) + "\n").utf8), timeout: 30) + guard status == 0 else { + throw HostDriverError.provisioning("setting up git's credentials failed: \(output)") + } + } + + /// Stops carrying host `id`'s relay: its workroom is gone. + @MainActor + func close(_ id: UUID) { + lock.withLock { forwards }?.close(workroom: id) + _ = lock.withLock { secrets.removeValue(forKey: id) } + } + + @MainActor + private func registry(target: UInt16) -> ReverseForwardRegistry { + if let made = lock.withLock({ forwards }) { return made } + let made = ReverseForwardRegistry( + transport: transport, port: { Self.port(for: $0) }, target: { target }, + failure: { + HostDriverError.provisioning("the workroom's agent couldn't relay git's credentials: \($0)") + }) + lock.withLock { forwards = made } + return made + } +} diff --git a/macapp/WorkroomApp/Core/Models.swift b/macapp/WorkroomApp/Core/Models.swift index 1a5568bd5..4c88cc2e0 100644 --- a/macapp/WorkroomApp/Core/Models.swift +++ b/macapp/WorkroomApp/Core/Models.swift @@ -41,6 +41,12 @@ struct HostDescriptor: Codable, Hashable { /// context, each a whole base descriptor. nil for a project with one base, whose descriptor is /// the base itself, as every project before #309 has it. var bases: [HostDescriptor]? = nil + /// `relay` for a local container base or workroom whose git credentials come from the Mac's + /// relay instead of the broker (#309); nil for the broker. + var credentials: String? = nil + + /// Whether git's credentials on this host come through the Mac's relay (#309). + var isRelayed: Bool { credentials == "relay" } /// The bases a project's descriptor records. var allBases: [HostDescriptor] { bases ?? [self] } @@ -48,7 +54,7 @@ struct HostDescriptor: Codable, Hashable { var isDestroyed: Bool { state == "destroyed" } enum CodingKeys: String, CodingKey { - case state, driver, provisioner, id, repository, path, container, bases + case state, driver, provisioner, id, repository, path, container, bases, credentials case grantID = "grant_id" case workroomID = "workroom_id" case cloneURL = "clone_url" @@ -58,7 +64,7 @@ struct HostDescriptor: Codable, Hashable { state: String? = nil, driver: String? = nil, provisioner: String? = nil, id: UUID? = nil, grantID: String? = nil, workroomID: UUID? = nil, repository: String? = nil, cloneURL: String? = nil, path: String? = nil, container: ContainerHostDriver.Record? = nil, - bases: [HostDescriptor]? = nil + bases: [HostDescriptor]? = nil, credentials: String? = nil ) { self.state = state self.driver = driver @@ -71,6 +77,7 @@ struct HostDescriptor: Codable, Hashable { self.path = path self.container = container self.bases = bases + self.credentials = credentials } init(from decoder: Decoder) throws { @@ -86,12 +93,15 @@ struct HostDescriptor: Codable, Hashable { path = try? fields?.decodeIfPresent(String.self, forKey: .path) container = try? fields?.decodeIfPresent(ContainerHostDriver.Record.self, forKey: .container) bases = try? fields?.decodeIfPresent([HostDescriptor].self, forKey: .bases) + credentials = try? fields?.decodeIfPresent(String.self, forKey: .credentials) } /// A project's base, when the descriptor records a whole one. var base: RemoteProvisioning.Base? { guard let id, let repository, let cloneURL, let path else { return nil } - return RemoteProvisioning.Base(host: id, repository: repository, cloneURL: cloneURL, path: path) + return RemoteProvisioning.Base( + host: id, repository: repository, cloneURL: cloneURL, path: path, + relayed: isRelayed ? true : nil) } } diff --git a/macapp/WorkroomApp/Core/RemoteProvisioning.swift b/macapp/WorkroomApp/Core/RemoteProvisioning.swift index 1b627bc14..67c643cf2 100644 --- a/macapp/WorkroomApp/Core/RemoteProvisioning.swift +++ b/macapp/WorkroomApp/Core/RemoteProvisioning.swift @@ -18,7 +18,11 @@ enum RemoteProvisioning { let driver: any HostDriver /// The agent's socket on each host of `driver`, which puts the agent's binary beside it. let agentSocket: String - let client: BrokerClient + /// The broker, when signed in to Codaset. Without it, only a relayed base can be made (#309). + let client: BrokerClient? + /// The Mac's own GitHub token (`gh`), for a base whose repository's git goes through the Mac's + /// credential relay rather than the broker (#309), or nil where there is none (a remote host). + var gitHubToken: (@Sendable () async throws -> String)? = nil var agentBroker: AgentEnrolment.AgentBroker = .standard /// A connection to the agent on a host, bootstrapping one there first. var connect: @Sendable (HostID) async throws -> AgentVCSConnection @@ -26,7 +30,8 @@ enum RemoteProvisioning { var revoke: @Sendable (BrokerClient.CloneToken) async -> Void = CloneToken.revoke init( - driver: any HostDriver, agentSocket: String, client: BrokerClient, + driver: any HostDriver, agentSocket: String, client: BrokerClient?, + gitHubToken: (@Sendable () async throws -> String)? = nil, agentBroker: AgentEnrolment.AgentBroker = .standard, connect: (@Sendable (HostID) async throws -> AgentVCSConnection)? = nil, revoke: @escaping @Sendable (BrokerClient.CloneToken) async -> Void = CloneToken.revoke @@ -34,6 +39,7 @@ enum RemoteProvisioning { self.driver = driver self.agentSocket = agentSocket self.client = client + self.gitHubToken = gitHubToken self.agentBroker = agentBroker self.connect = connect ?? { host in @@ -53,12 +59,17 @@ enum RemoteProvisioning { let cloneURL: String /// The clone on the host. let path: String + /// Whether its workrooms take git's credentials from the Mac's relay instead of enrolling with + /// the broker (#309): made when signed out of Codaset, or when the repository's owner hasn't + /// installed the Codaset App. nil, as every base before #309 has it, is the broker. + var relayed: Bool? = nil } /// A workroom derived from a base, serving requests over `connection`. struct Instance: Sendable { let host: HostID - let grantID: String + /// Its broker grant; nil for a relayed workroom, which never enrols. + let grantID: String? let path: String let branch: String let connection: AgentVCSConnection @@ -105,12 +116,14 @@ enum RemoteProvisioning { guard case .remote(let id) = host else { throw HostDriverError.unknownHost(host) } let connection = try await environment.connect(host) defer { Task { await connection.close() } } - try await withCloneToken(repository, in: environment) { header in + let relayed = try await withCloneToken(repository, relayed: nil, in: environment) { header in _ = try await git( ["clone", "--quiet", "--origin", "origin", "--", cloneURL, path], in: "/", environment: header, on: connection) } - let base = Base(host: id, repository: repository, cloneURL: cloneURL, path: path) + let base = Base( + host: id, repository: repository, cloneURL: cloneURL, path: path, + relayed: relayed ? true : nil) try await record(base) return base } catch { @@ -133,7 +146,8 @@ enum RemoteProvisioning { try await BaseLocks.shared.exclusively(on: base.host) { let connection = try await environment.connect(.remote(base.host)) defer { Task { await connection.close() } } - try await withCloneToken(base.repository, in: environment) { header in + try await withCloneToken(base.repository, relayed: base.relayed == true, in: environment) { + header in try await fetch(base.path, environment: header, on: connection) } } @@ -163,19 +177,47 @@ enum RemoteProvisioning { } /// Mints a clone token, hands `body` the exec environment carrying it, and revokes it after, - /// whatever `body` did. + /// whatever `body` did. A relayed base (#309) uses the Mac's own GitHub token instead, which is + /// the user's and not revoked. `relayed` nil decides, for a new base: the broker when signed in, + /// unless the repository's owner hasn't installed the Codaset App; the Mac's token otherwise. + /// Returns whether it was relayed. + @discardableResult private static func withCloneToken( - _ repository: String, in environment: Environment, + _ repository: String, relayed: Bool?, in environment: Environment, _ body: ([String: String]) async throws -> Void - ) async throws { - let token = try await environment.client.baseCloneToken(repository: repository) - do { - try await body(cloneEnvironment(token: token.token)) - } catch { + ) async throws -> Bool { + if relayed != true, let client = environment.client { + let token: BrokerClient.CloneToken + do { + token = try await client.baseCloneToken(repository: repository) + } catch BrokerError.refused(let refusal) + where relayed == nil && refusal.code == "app_not_installed" + && environment.gitHubToken != nil + { + try await body(cloneEnvironment(token: try await relayToken(environment))) + return true + } + do { + try await body(cloneEnvironment(token: token.token)) + } catch { + await cleanUp { await environment.revoke(token) } + throw error + } await cleanUp { await environment.revoke(token) } - throw error + return false } - await cleanUp { await environment.revoke(token) } + // The broker's base needs the broker. + guard relayed != false else { throw RemoteWorkrooms.Failure.signedOut } + try await body(cloneEnvironment(token: try await relayToken(environment))) + return true + } + + /// The Mac's GitHub token, for a relayed base or workroom (#309). + private static func relayToken(_ environment: Environment) async throws -> String { + guard let gitHubToken = environment.gitHubToken else { + throw RemoteWorkrooms.Failure.signedOut + } + return try await gitHubToken() } /// git's configuration through its environment (`GIT_CONFIG_COUNT`), so the token is in no @@ -215,26 +257,35 @@ enum RemoteProvisioning { try await checkpoint(host, nil) let connected = try await environment.connect(host) connection = connected - let grantID: String - do { - grantID = try await AgentEnrolment.enrol( - client: environment.client, driver: environment.driver, host: host, - agentBinary: environment.agentBinary, workroomID: workroom, - repository: base.repository, agentBroker: environment.agentBroker) - } catch let live as AgentEnrolment.GrantStillLive { - // The enrolment's own cancel failed; the grant is still live, and only this knows it. - throw RollbackStart.grantLive(live.grantID, cause: live.cause, failure: live.cancelFailure) + // A relayed workroom never enrols (#309): these two git commands carry the Mac's token, + // and later ones ask the Mac through the relay its connect installs. + var header: [String: String] = [:] + if base.relayed == true { + header = cloneEnvironment(token: try await relayToken(environment)) + } else { + guard let client = environment.client else { throw RemoteWorkrooms.Failure.signedOut } + let grantID: String + do { + grantID = try await AgentEnrolment.enrol( + client: client, driver: environment.driver, host: host, + agentBinary: environment.agentBinary, workroomID: workroom, + repository: base.repository, agentBroker: environment.agentBroker) + } catch let live as AgentEnrolment.GrantStillLive { + // The enrolment's own cancel failed; the grant is still live, and only this knows it. + throw RollbackStart.grantLive( + live.grantID, cause: live.cause, failure: live.cancelFailure) + } + grant = grantID + try await checkpoint(host, grantID) } - grant = grantID - try await checkpoint(host, grantID) // With the instance's own credentials: `wr-agent enrol` made its helper git's. - try await fetch(base.path, on: connected) + try await fetch(base.path, environment: header, on: connected) // Fully qualified: a tag or branch named `origin/HEAD` would make the short form ambiguous. _ = try await git( ["switch", "--quiet", "--no-track", "--create", branch, "refs/remotes/origin/HEAD"], - in: base.path, on: connected) + in: base.path, environment: header, on: connected) return Instance( - host: host, grantID: grantID, path: base.path, branch: branch, connection: connected) + host: host, grantID: grant, path: base.path, branch: branch, connection: connected) } catch { var error = error var alreadyFailed: (grant: String, failure: String)? @@ -249,7 +300,7 @@ enum RemoteProvisioning { var grantLive: String? var hostLive: HostID? var failures: [String] = [] - if let made { + if let made, let client { do { try await client.cancelGrant(made) } catch { grantLive = made failures.append("cancelling grant \(made): \(error.localizedDescription)") @@ -296,7 +347,11 @@ enum RemoteProvisioning { var failures: [String] = [] var (grantLive, hostLive): (String?, HostID?) = (nil, nil) if let grantID { - do { try await environment.client.cancelGrant(grantID) } catch { + do { + // A grant needs the broker to cancel; signed out, it is left for a later delete. + guard let client = environment.client else { throw RemoteWorkrooms.Failure.signedOut } + try await client.cancelGrant(grantID) + } catch { grantLive = grantID failures.append("cancelling grant \(grantID): \(error.localizedDescription)") } diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index a7dfff66e..c2ed79998 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -48,7 +48,7 @@ enum RemoteWorkrooms { appleSilicon: appleSilicon, macOS26: ProcessInfo.processInfo.isOperatingSystemAtLeast( OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0)), - signedIn: BrokerSession.shared.client() != nil) + signedIn: BrokerSession.shared.client() != nil || CredentialRelay.hasGitHubSignIn()) } static func unavailability( @@ -60,7 +60,8 @@ enum RemoteWorkrooms { if !macOS26 { return "needs macOS 26" } } if !installed { return "not installed" } - if !signedIn { return "sign in to Codaset in Settings" } + // Codaset's broker, or the Mac's own `gh` through the relay (#309). + if !signedIn { return "sign in to Codaset or run gh auth login" } return nil } /// The descriptor's `provisioner`: this build, whose ssh key and Docker labels its hosts carry. @@ -112,8 +113,8 @@ enum RemoteWorkrooms { var errorDescription: String? { switch self { case .signedOut: - return "Sign in to Codaset in Settings → Remote workrooms first: it gives remote " - + "workrooms their GitHub access." + return "Sign in to Codaset in Settings → Remote workrooms, or to GitHub with " + + "`gh auth login`: one of them gives the workroom its GitHub access." case .notOnGitHub(let detail): return "A remote workroom needs a project whose origin is on github.com. \(detail)" case .anotherBuildsBase(let build): @@ -260,7 +261,8 @@ enum RemoteWorkrooms { driver: runtime.rawValue, provisioner: provisioner, id: base.host, repository: base.repository, cloneURL: base.cloneURL, path: base.path, - container: driver.record(of: .remote(base.host))), + container: driver.record(of: .remote(base.host)), + credentials: base.relayed == true ? "relay" : nil), in: projectHost)) } } @@ -307,7 +309,8 @@ enum RemoteWorkrooms { name, HostDescriptor( driver: runtime.rawValue, provisioner: provisioner, id: id, grantID: instance.grantID, - workroomID: workroomID, container: driver.record(of: instance.host))) + workroomID: workroomID, container: driver.record(of: instance.host), + credentials: base.relayed == true ? "relay" : nil)) } catch { // Unrecorded, the instance would be found by nothing but the sweep, and its grant by nothing. do { @@ -439,6 +442,8 @@ final class RemoteHosts: @unchecked Sendable { private var made: [DriverKey: ContainerHostDriver] = [:] /// The runtime each host config records is on, whether or not a driver holds it. private var runtimes: [UUID: RemoteWorkrooms.Runtime] = [:] + /// Workroom hosts whose git credentials come through the Mac's relay (#309). + private var relayed: Set = [] private var swept = false /// A call reached the sweep while a delete was in flight and left it for later (#296). private var held = false @@ -540,6 +545,8 @@ final class RemoteHosts: @unchecked Sendable { let already = lock.withLock { for descriptor in recorded { if let id = descriptor.id, let key = DriverKey(descriptor) { runtimes[id] = key.runtime } + // A base never has git ask for credentials; only its workrooms are relayed. + if let id = descriptor.id, descriptor.isRelayed, !bases.contains(id) { relayed.insert(id) } } return Set(made.keys) } @@ -598,12 +605,13 @@ final class RemoteHosts: @unchecked Sendable { func environment(_ key: DriverKey = DriverKey()) throws -> ( ContainerHostDriver, RemoteProvisioning.Environment ) { - guard let client = BrokerSession.shared.client() else { - throw RemoteWorkrooms.Failure.signedOut - } + // Signed out of Codaset, a local container workroom's git credentials come from the Mac's own + // `gh` instead (#309): every runtime here is a local one. + let client = BrokerSession.shared.client() let driver = try driver(key) var environment = RemoteProvisioning.Environment( - driver: driver, agentSocket: RemoteWorkrooms.agentSocket, client: client) + driver: driver, agentSocket: RemoteWorkrooms.agentSocket, client: client, + gitHubToken: CredentialRelay.gitHubToken) #if DEBUG // A Debug agent reaches this Mac's Codaset through a listener on its host, which lives on // the host's service connection (`BrokerReverseForwards`), so that connection comes first. @@ -657,8 +665,24 @@ final class RemoteHosts: @unchecked Sendable { try await AgentBootstrap.connect( host: host, driver: driver, socket: RemoteWorkrooms.agentSocket) } + // A relayed workroom's git asks this Mac for credentials (#309): its listener goes with the + // connection, and its secret with this launch, so it is set up again on every connect. A + // failure leaves the workroom usable except for git's remote commands, so it is logged. + guard case .remote(let id) = host, isRelayed(id) else { return } + do { + try await CredentialRelay.shared.install( + on: host, driver: driver, + agentBinary: AgentBootstrap.binary(besideSocket: RemoteWorkrooms.agentSocket)) + } catch { + Self.logger.error( + "credential relay for \(id, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + } } + /// Whether host `id` is a workroom whose git credentials come through the Mac's relay (#309). + func isRelayed(_ id: UUID) -> Bool { lock.withLock { relayed.contains(id) } } + /// Connects `host`'s service connection unless it is up, for a read, write or listing that needs /// it (`RepositoryRouter`). One attempt per host at a time: the inspector's panels ask together /// when a remote workroom is selected, and every caller after the first waits for that attempt diff --git a/macapp/WorkroomApp/Core/ReverseForwardRegistry.swift b/macapp/WorkroomApp/Core/ReverseForwardRegistry.swift new file mode 100644 index 000000000..93053f118 --- /dev/null +++ b/macapp/WorkroomApp/Core/ReverseForwardRegistry.swift @@ -0,0 +1,255 @@ +import Foundation +import os + +/// Listeners on remote hosts that carry each connection back to a port on this Mac, one per +/// workroom, kept open across reconnects (`ReverseForward`). For the Debug builds' route to this +/// Mac's development Codaset (`BrokerReverseForwards`), and for the git credential relay of a local +/// container workroom that never enrolled (`CredentialRelay`, #309). +/// +/// **The listener lives only as long as the connection to the host** (every forward does), so this +/// reopens it on each new connection, on the same port: what the agent saved (its broker URL, its +/// relay's port) must keep working after a reattach. +@MainActor +final class ReverseForwardRegistry { + + /// How the registry reaches a host's connection, so a test can hand it a real agent without + /// `HostConnectionManager`: the shape `PortForwardingModel.Transport` set. + struct Transport { + var forwarding: + @Sendable (HostID) async throws -> (HostConnectionManager.Lease, AgentForwardService) + var updates: @Sendable (HostID) async -> AsyncStream + + static let live = Transport( + forwarding: { try await HostConnectionManager.shared.forwarding(host: $0) }, + updates: { await HostConnectionManager.shared.updates(for: $0) }) + } + + /// How long the agent may take to answer a `listen`. + static let listenTimeout: Duration = .seconds(5) + /// A bind that lost the race with the old connection's teardown is retried this often, this + /// many times: the agent notices a departed connection within its 200 ms accept poll. + static let bindRetries = 10 + static let bindRetryDelay: Duration = .milliseconds(200) + /// How many times a connection's listener is tried, `reopenRetry` apart, before the registry + /// waits for the next connection: five minutes at the default. + static let reopenAttempts = 60 + + private let transport: Transport + /// The agent's port for a workroom: the same on every connection and every launch. + private let port: @Sendable (UUID) -> UInt16 + /// The port on this Mac each connection is carried to, asked again for each listener. + private let target: @Sendable () async -> UInt16 + /// The error a listener the agent refuses becomes, from the agent's reason. + private let failure: @Sendable (String) -> Error + private let reopenRetry: Duration + private let log = Logger(subsystem: "com.developwithstyle.workroom", category: "broker") + + private struct Entry { + let host: HostID + /// The connection the listener is open on, once it is. + var lease: HostConnectionManager.Lease? + var forward: ReverseForward? + var watch: Task? + /// An open in flight, which a second caller waits on rather than opening a second listener. + /// The id is how the task, finishing, clears only itself. + var opening: (id: UUID, task: Task)? + } + private var entries: [UUID: Entry] = [:] + + init( + transport: Transport = .live, port: @escaping @Sendable (UUID) -> UInt16, + target: @escaping @Sendable () async -> UInt16, + failure: @escaping @Sendable (String) -> Error, reopenRetry: Duration = .seconds(5) + ) { + self.transport = transport + self.port = port + self.target = target + self.failure = failure + self.reopenRetry = reopenRetry + } + + /// Open the listener for `workroom` on `host` and keep it open on every later connection. Throws + /// when the agent does not listen now. + func open(workroom: UUID, host: HostID) async throws { + if entries[workroom] == nil { + entries[workroom] = Entry(host: host) + watch(workroom) + } + try await listen(workroom) + } + + /// Stop carrying `workroom`'s connections: the workroom is gone, or no longer needs them. + func close(workroom: UUID) { + guard let entry = entries.removeValue(forKey: workroom) else { return } + entry.watch?.cancel() + entry.opening?.task.cancel() + entry.forward?.stop() + } + + /// Whether `workroom`'s listener is open on `lease`, for the tests. + func isOpen(_ workroom: UUID, on lease: HostConnectionManager.Lease) -> Bool { + entries[workroom]?.lease == lease && entries[workroom]?.forward != nil + } + + private func listen(_ workroom: UUID) async throws { + // An open in flight is waited out, then this caller runs its own: the one in flight may be + // for an earlier connection than the caller's, and `reopen` does nothing when the current + // connection already has a listener. + while let opening = entries[workroom]?.opening { _ = try? await opening.task.value } + guard entries[workroom] != nil else { throw CancellationError() } + let id = UUID() + // The task clears its own entry as its last step, on this actor, BEFORE it completes: a + // waiter above resumes only after that, so it never sees a finished open still recorded + // (awaiting a finished task does not suspend, and it would spin on this actor forever). + let task = Task { + defer { + if self.entries[workroom]?.opening?.id == id { self.entries[workroom]?.opening = nil } + } + try await self.reopen(workroom) + } + entries[workroom]?.opening = (id, task) + try await task.value + } + + /// A listener on the host's current connection, unless one is already open there. + private func reopen(_ workroom: UUID) async throws { + guard let host = entries[workroom]?.host else { return } + let (lease, service) = try await transport.forwarding(host) + guard var entry = entries[workroom] else { return } + if entry.lease == lease, entry.forward != nil { return } + entry.forward?.stop() + entry.forward = nil + entries[workroom] = entry + + // A bind can lose a race on a reconnect: the agent releases the old connection's port only + // once it notices that connection ended, which can be just after the new one asks. The + // port is this workroom's alone, so a bind failure is retried for a moment, not reported. + var attempt = 0 + var outcome = await listen(service, port: port(workroom), workroom, lease) + while case .failure(let refusal) = outcome, refusal.detail.hasPrefix("bind:"), + attempt < Self.bindRetries + { + attempt += 1 + try await Task.sleep(for: Self.bindRetryDelay) + outcome = await listen(service, port: port(workroom), workroom, lease) + } + let forward: ReverseForward + switch outcome { + case .success(let listening): forward = listening + case .failure(let refusal): + throw failure(refusal.detail) + } + // Closed meanwhile, or another connection took over: this listener is nobody's. + guard entries[workroom] != nil else { + forward.stop() + throw CancellationError() + } + entries[workroom]?.lease = lease + entries[workroom]?.forward = forward + } + + /// One `listen`, answered or timed out. A refused listener is stopped before it is returned. + private func listen( + _ service: AgentForwardService, port: UInt16, _ workroom: UUID, + _ lease: HostConnectionManager.Lease + ) async -> Result { + let ready = ListenOutcome() + let forward = service.reverse(remotePort: port, target: await target()) { + [weak self, log] event in + switch event { + case .listening: + ready.resolve(nil) + case .failed(let detail): + log.info("reverse forward: \(detail, privacy: .public)") + case .stopped(let detail): + ready.resolve(detail) + Task { @MainActor in self?.lost(workroom, on: lease) } + } + } + forward.start() + if let refusal = await ready.wait(Self.listenTimeout) { + forward.stop() + return .failure(.init(refusal)) + } + return .success(forward) + } + + /// The listener ended. With its connection, the next connection opens a new one; on its own + /// (the agent's listener broke while the connection lives), no new connection is coming, so it + /// is reopened here. + private func lost(_ workroom: UUID, on lease: HostConnectionManager.Lease) { + guard entries[workroom]?.lease == lease else { return } + entries[workroom]?.lease = nil + entries[workroom]?.forward = nil + Task { await keepTrying(workroom) } + } + + /// Listen until it works, the workroom is closed, or the attempts run out. A link lost without + /// a goodbye leaves the old connection's listener holding the port until the agent notices it + /// has gone, which can take as long as ssh's keepalives, so a bind that keeps failing is tried + /// again every `reopenRetry` rather than given up on until the next connection. + private func keepTrying(_ workroom: UUID) async { + for attempt in 1...Self.reopenAttempts { + guard entries[workroom] != nil, !Task.isCancelled else { return } + do { + try await listen(workroom) + return + } catch { + if attempt == 1 || attempt == Self.reopenAttempts { + log.error( + "reverse forward did not reopen (attempt \(attempt)): \(error.localizedDescription, privacy: .public)" + ) + } + } + try? await Task.sleep(for: reopenRetry) + } + } + + /// Reopen on every new connection to the host. + private func watch(_ workroom: UUID) { + guard let host = entries[workroom]?.host else { return } + entries[workroom]?.watch = Task { [weak self, transport] in + for await snapshot in await transport.updates(host) { + guard let self, !Task.isCancelled else { return } + guard snapshot.status == .connected, let lease = snapshot.lease, + self.entries[workroom]?.lease != lease + else { continue } + await self.keepTrying(workroom) + } + } + } +} + +/// The first answer to a `listen`, waited on with a deadline: nil once the agent listens, the +/// reason when it will not. +private final class ListenOutcome: @unchecked Sendable { + private let lock = NSLock() + private var outcome: String?? + private var waiter: CheckedContinuation? + + func resolve(_ refusal: String?) { + let waiter: CheckedContinuation? = lock.withLock { + guard outcome == nil else { return nil } + outcome = .some(refusal) + defer { self.waiter = nil } + return self.waiter + } + waiter?.resume(returning: refusal) + } + + func wait(_ timeout: Duration) async -> String? { + let timer = Task { [weak self] in + try? await Task.sleep(for: timeout) + self?.resolve("the agent did not answer the listen request") + } + defer { timer.cancel() } + return await withCheckedContinuation { continuation in + let settled: String?? = lock.withLock { + if let outcome { return outcome } + waiter = continuation + return nil + } + if let settled { continuation.resume(returning: settled) } + } + } +} diff --git a/macapp/WorkroomAppTests/CredentialRelayTests.swift b/macapp/WorkroomAppTests/CredentialRelayTests.swift new file mode 100644 index 000000000..ab5367647 --- /dev/null +++ b/macapp/WorkroomAppTests/CredentialRelayTests.swift @@ -0,0 +1,89 @@ +import XCTest + +@testable import Workroom + +/// The Mac's end of a local container workroom's git credential relay (#309): who is answered, for +/// what, and with what. +final class CredentialRelayTests: XCTestCase { + private final class Asked: @unchecked Sendable { + private let lock = NSLock() + private var requests: [String] = [] + var all: [String] { lock.withLock { requests } } + func add(_ request: String) { lock.withLock { requests.append(request) } } + } + + /// A known secret asking for github.com over https gets gh's username and password, and nothing + /// else gh said; gh is asked for github.com only, whatever else the request carried. + func testAKnownWorkroomIsAnsweredWithGhsCredentialOnly() { + let asked = Asked() + let relay = CredentialRelay(answer: { request in + asked.add(request) + return "protocol=https\nhost=github.com\nusername=joel\npassword=gho_x\nextra=1\n" + }) + let secret = relay.secret(for: UUID()) + XCTAssertEqual( + relay.respond(to: "\(secret)\nprotocol=https\nhost=github.com\npath=o/r.git\n\n"), + "username=joel\npassword=gho_x\n") + XCTAssertEqual(asked.all, ["protocol=https\nhost=github.com\n\n"]) + } + + /// An unknown secret, another host, or plain http is answered with an error, and gh is never + /// asked: another user of this Mac can reach the listener too. + func testAnythingElseIsRefusedWithoutAskingGh() { + let asked = Asked() + let relay = CredentialRelay(answer: { request in + asked.add(request) + return "username=joel\npassword=gho_x\n" + }) + let secret = relay.secret(for: UUID()) + for request in [ + "guess\nprotocol=https\nhost=github.com\n\n", + "\nprotocol=https\nhost=github.com\n\n", + "\(secret)\nprotocol=https\nhost=gitlab.com\n\n", + "\(secret)\nprotocol=http\nhost=github.com\n\n", + ] { + XCTAssertTrue(relay.respond(to: request).hasPrefix("error="), request) + } + XCTAssertEqual(asked.all, []) + } + + /// gh with no sign-in, or one that fails, says so in an `error=` line the agent hands git. + func testAGhWithoutASignInIsAnErrorLine() { + let empty = CredentialRelay(answer: { _ in "protocol=https\nhost=github.com\n" }) + let secret = empty.secret(for: UUID()) + XCTAssertEqual( + empty.respond(to: "\(secret)\nprotocol=https\nhost=github.com\n\n"), + "error=gh has no GitHub sign-in on this Mac: run `gh auth login`\n") + let failing = CredentialRelay(answer: { _ in + throw HostDriverError.provisioning("gh\nbroke") + }) + let reply = failing.respond( + to: "\(failing.secret(for: UUID()))\nprotocol=https\nhost=github.com\n\n") + XCTAssertTrue(reply.hasPrefix("error=") && !reply.dropLast().contains("\n"), reply) + } + + /// A workroom's secret is its own and stays the same for the launch; its port is the same on + /// every launch, below Linux's ephemeral range. + func testSecretsAreOwnAndStablePortsBelowTheEphemeralRange() { + let relay = CredentialRelay(answer: { _ in "" }) + let (one, two) = (UUID(), UUID()) + XCTAssertEqual(relay.secret(for: one), relay.secret(for: one)) + XCTAssertNotEqual(relay.secret(for: one), relay.secret(for: two)) + XCTAssertEqual(relay.secret(for: one).count, 64) + for _ in 0..<200 { + let port = CredentialRelay.port(for: UUID()) + XCTAssertTrue((30_000..<32_000).contains(port), "\(port)") + } + } + + /// gh lists github.com in its hosts file when signed in there; the menu reads that, not gh. + func testAGhSignInIsReadFromItsHostsFile() throws { + let file = FileManager.default.temporaryDirectory.appendingPathComponent("gh-\(UUID()).yml") + defer { try? FileManager.default.removeItem(at: file) } + XCTAssertFalse(CredentialRelay.hasGitHubSignIn(hosts: file)) + try "github.example.com:\n user: x\n".write(to: file, atomically: true, encoding: .utf8) + XCTAssertFalse(CredentialRelay.hasGitHubSignIn(hosts: file)) + try "github.com:\n user: joel\n".write(to: file, atomically: true, encoding: .utf8) + XCTAssertTrue(CredentialRelay.hasGitHubSignIn(hosts: file)) + } +} diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index 2135689fe..92bf718c4 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -784,7 +784,7 @@ final class RemoteHostsTests: XCTestCase { XCTAssertEqual( RemoteWorkrooms.unavailability( of: .docker, installed: true, appleSilicon: true, macOS26: true, signedIn: false), - "sign in to Codaset in Settings") + "sign in to Codaset or run gh auth login") } /// A driver takes on only a host of its own context: its commands would not reach another's. diff --git a/macapp/WorkroomAppTests/RemoteProvisioningIntegrationTests.swift b/macapp/WorkroomAppTests/RemoteProvisioningIntegrationTests.swift index 75987b1ab..fcda93272 100644 --- a/macapp/WorkroomAppTests/RemoteProvisioningIntegrationTests.swift +++ b/macapp/WorkroomAppTests/RemoteProvisioningIntegrationTests.swift @@ -781,6 +781,86 @@ final class RemoteProvisioningIntegrationTests: XCTestCase { try onBox("git -C /srv/origin.git rev-parse refs/heads/wr-pushed"), "\(pushed)\nexit=0") } + /// Signed out of Codaset (#309), a local container's base clones with the Mac's own GitHub token, + /// its workroom never enrols, and git in the workroom gets credentials through the Mac's relay: + /// a push works while the app is connected and says so when it isn't. The fixture's GitHub takes + /// its clone token, which stands in for the Mac's `gh`. + @MainActor + func testARelayedWorkroomPushesThroughTheMacWithoutEnrolling() async throws { + let fixture = try fixture() + let driver = ContainerHostDriver( + hosts: [:], directory: directory, provisioning: fixture.provisioning) + let agent = fixture.agent + let socket = fixture.provisioning.agentSocket + let token = Self.token + let environment = RemoteProvisioning.Environment( + driver: driver, agentSocket: socket, client: nil, gitHubToken: { token }, + connect: { host in + try await AgentBootstrap.connect( + host: host, driver: driver, socket: socket, + agent: { $0 == "aarch64" || $0 == "x86_64" ? agent : nil }, handOff: false, + resources: nil) + }) + let base = try await RemoteProvisioning.buildBase( + repository: "o/r", cloneURL: "https://github.com/origin.git", path: Self.path, + in: environment, record: { _ in }) + cleanups.append { try? await driver.destroy(.remote(base.host)) } + XCTAssertEqual(base.relayed, true) + let instance = try await RemoteProvisioning.derive( + from: base, workroom: UUID(), branch: "wr-relayed", in: environment) + cleanups.append { try? await driver.destroy(instance.host) } + XCTAssertNil(instance.grantID, "a relayed workroom enrolled") + + // The relay, over a connection of its own as the app's registry holds one. + let generation = UUID() + let relay = CredentialRelay( + answer: { _ in "username=x-access-token\npassword=\(token)\n" }, + transport: .init( + forwarding: { host in + let connection = try await AgentVCSConnection.connect( + host: host, stream: try await driver.openStream(to: host)) + return (.init(host: host, generation: generation), try connection.forwarding()) + }, + updates: { host in + AsyncStream { + $0.yield(.init(lease: .init(host: host, generation: generation), status: .connected)) + } + })) + try await relay.install( + on: instance.host, driver: driver, agentBinary: AgentBootstrap.binary(besideSocket: socket)) + + guard case .remote(let id) = instance.host else { return XCTFail("not a remote host") } + let container = "workroom-\(id.uuidString.lowercased())" + func onBox(_ script: String) throws -> String { + try docker( + runtime, + [ + "exec", "--user", "workroom", "--env", "HOME=/home/workroom", "--workdir", Self.path, + container, "sh", "-c", script + "; echo exit=$?", + ]) + } + XCTAssertEqual( + try onBox("test -e \(socket.replacingOccurrences(of: "agent.sock", with: "broker.json"))"), + "exit=1", "a relayed workroom has a broker enrolment") + _ = try onBox( + "git -c user.name=W -c user.email=w@example.com commit -q --allow-empty -m relayed") + let push = try onBox("git push -q origin HEAD 2>&1") + XCTAssertTrue(push.hasSuffix("exit=0"), push) + XCTAssertTrue( + try onBox("git -C /srv/origin.git rev-parse --verify -q refs/heads/wr-relayed") + .hasSuffix("exit=0")) + + // With the relay gone, git is told why rather than asked for a password. + relay.close(id) + var failed = "" + for _ in 0..<50 { + failed = try onBox("git -c credential.interactive=false push -q origin HEAD:wr-later 2>&1") + if !failed.hasSuffix("exit=0") { break } + try await Task.sleep(for: .milliseconds(200)) + } + XCTAssertTrue(failed.contains("isn't connected to this workroom"), failed) + } + /// A workroom branches from the remote's default branch as it is now, not as it was when the /// base was cloned: `fetch` never moves `origin/HEAD`, and once `--prune` drops the old default /// it names nothing. From 6113cb9b69f3f7a3dd5d0544508cb37309679fec Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 16:18:24 +0100 Subject: [PATCH 11/29] docs(remote-workrooms): describe local container workrooms as built (#309) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- docs/designs/remote-workrooms.md | 58 ++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/docs/designs/remote-workrooms.md b/docs/designs/remote-workrooms.md index 92225f83f..e6df39a23 100644 --- a/docs/designs/remote-workrooms.md +++ b/docs/designs/remote-workrooms.md @@ -1630,6 +1630,64 @@ these are the subsystems that actually gate "a remote workroom is a real workroo instead of a fresh shell: its terminals as panes on its host, each attaching with `--no-create`. A held session whose workroom is deleted is dropped at the next save. - **Not done.** A held session is restored only in the window that claimed it. +- **As built (#309, local container workrooms on Docker and Apple's runtime).** A workroom can + be created in a container on the Mac, on Docker (Desktop, OrbStack, Colima) or on Apple's + `container`. These are not remote workrooms: they stop when the Mac sleeps. They ship with the + remote targets, behind the same preview and Nightly gate, until #260. + - **Choosing.** With the preview on, a project's New Workroom is a submenu: This Mac, then Docker + and Apple Container under "Local containers". It replaced New Remote Workroom. An entry that + can't be used says why in its title (`RemoteWorkrooms.unavailability`): Apple's runtime needs + Apple silicon and macOS 26, either may not be installed, and a create needs a Codaset or `gh` sign-in. + - **Drivers.** `RemoteHosts` keeps a `ContainerHostDriver` per `DriverKey`: runtime, and for + Docker the context. A descriptor's `driver` names the runtime; `container` stays Docker, so + every record before #309 is unchanged, and `apple-container` is Apple's. Each driver's sweep + keeps every recorded host and image, since two keys can reach one daemon. + - **Docker contexts.** A new Docker base is pinned to `docker context show` (never `default`, + which is whatever `DOCKER_HOST` says), recorded in its container record, and every command for + it names `--context`, which outranks `DOCKER_HOST` and `DOCKER_CONTEXT` (Docker 29, measured). + A record with no context runs with no `--context`, as before. + - **Bases.** A project keeps a base per runtime and context, in its descriptor's `bases` list + once it has more than one; a descriptor without the list is one base, as before. A workroom + derives from the base where it is asked for, made there first when there is none; a base made + before #309 (no context) still serves Docker. A delete builds an environment per key. + - **Apple's runtime (1.5.0, measured; its docs on `main` describe an unreleased CLI).** + `ContainerHostDriver.Dialect.apple` covers what differs. There is no `commit`: a derive stops + the base if running, exports its disk, starts it again in a task of its own, builds the disk + back into an image `FROM scratch` with the host image's own entrypoint and environment + (`AppleContainerCLI.dockerfile`, `$` escaped), runs it, and removes the image, since a + container's disk outlives it. `build --quiet` never returns. The builder is one VM for the whole + Mac and is left running. Apple removes an image a container still uses, so its sweep keeps any + image a container was run from. There is no `--filter` or template output, so it reads JSON; + no `--restart`, so a stopped container is started when its workroom is opened (below); + published ports survive a restart. Images are pulled `--arch arm64`. Each container gets + explicit CPUs and memory (half the cores, a quarter of memory, 2-8 GB; hidden + `containerCPUs`/`containerMemory` override), since Apple's default is a 1 GB VM. + - **Credentials.** A base decides how its workrooms reach GitHub, once, and records it + (`credentials: relay`): the broker when signed in to Codaset, else, or when the broker refuses + with `app_not_installed`, the Mac's own `gh`. A relayed base clones and a relayed workroom + fetches and branches with `gh auth token` as git's header (`cloneEnvironment`); the workroom + never enrols. Later git in it asks `wr-agent credential`, which, unenrolled, sends a per-launch + secret and the request to a port on its loopback (`relay.json`, set by `wr-agent credential + relay` on every connect); `ReverseForwardRegistry`, lifted out of the Debug broker route, + carries it to `CredentialRelay`'s listener on the Mac's loopback, which answers github.com over + https only, from `gh auth git-credential get`. Accepted (D9): anything in the workroom can ask, + so it has the user's `gh` access while the app is connected, as a workroom on the Mac does; git + is told to open the workroom when it isn't. An enrolled workroom never uses the relay. + - **Starting.** Selecting a container workroom marks its host opened (`RemoteHosts.activate`), and + its connects then start a stopped container first. Background status probes reach every + reachable remote workroom, so they never start one. + - **The image.** Nightly and Release build the `FIXTURE=0` image once per architecture, run + `host-image-test.sh` against that exact image (`WR_HOST_IMAGE`), push it by digest and join the + two into one manifest on ghcr (`host-image.yml`). The manifest's digest is pinned into the app + (`WorkroomHostImage`); the hidden `remoteHostImage` overrides it, and a local build runs + `workroom-host`. A missing image is pulled before `run`, which never pulls (`--pull=never`). + The ghcr package must be made public once, by hand, after its first push. + - **Tests.** `RemoteHostsTests` drive both dialects through a stub CLI. The Docker fixture suites + (`run.sh`) cover the Docker path; `AppleContainerIntegrationTests` run the whole lifecycle on + the real Apple runtime, opt-in (`TEST_RUNNER_WR_APPLE_CONTAINER_TESTS=1`), since CI's macOS + runners can't run its VMs. + - **Not done.** Pull progress is not shown, only a pull's failure. A development environment in + the container (toolchains, agent CLIs) is #308; Docker on another machine is #307. - **Cross-machine session enumeration** (from Phase 1's S4 note): agent-side session naming, a pane→tab mapping on the wire, and workroom UI state stored with the workroom. - **OSC 7 and cmd-click (C7).** `SessionDaemon.swift:395-401` deliberately emits From becb9f8fff1c6d302a08933514909357dc18bedd Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 16:36:36 +0100 Subject: [PATCH 12/29] fix(macapp): harden the credential relay's listener (#309) From the review of the relay: - Set SO_NOSIGPIPE on every accepted connection. Without it, any local process could connect, send junk and reset, and the reply's `send` raised SIGPIPE, which ended the app. A test of reset peers crashes with "signal pipe" without the fix. - End a `gh` that hangs (a keychain prompt) at its deadline: its output is now read on another queue, so the wait can time out. Ignore SIGPIPE on its stdin too. - Keep accepting through descriptor or memory pressure, and forget a listener that stops for any other reason, so the next install listens again. - Serve at most 8 requests at once and turn the rest away, and bound each request's arrival as a whole, so a flood of slow connections can't hold threads or descriptors. - Refuse to make a secret the system's random source didn't fill, and compare secrets in time that depends on their lengths only. - Close a workroom's relay when it is deleted, retry an install that failed, and keep the broker's "install the App" refusal when the gh fallback has nothing either. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/CredentialRelay.swift | 95 +++++++++++++++---- .../WorkroomApp/Core/RemoteProvisioning.swift | 6 +- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 28 ++++-- .../CredentialRelayTests.swift | 61 +++++++++--- 4 files changed, 152 insertions(+), 38 deletions(-) diff --git a/macapp/WorkroomApp/Core/CredentialRelay.swift b/macapp/WorkroomApp/Core/CredentialRelay.swift index 9a8148e9b..5c1ba9363 100644 --- a/macapp/WorkroomApp/Core/CredentialRelay.swift +++ b/macapp/WorkroomApp/Core/CredentialRelay.swift @@ -48,18 +48,32 @@ final class CredentialRelay: @unchecked Sendable { return 30_000 + (UInt16(bytes.2) << 8 | UInt16(bytes.3)) % 2_000 } - /// The secret host `id`'s agent sends, made the first time it is asked for this launch. - func secret(for id: UUID) -> String { - lock.withLock { + /// The secret host `id`'s agent sends, made the first time it is asked for this launch. Throws + /// rather than make one the system's random source didn't fill: a guessable secret would answer + /// anyone on this Mac. + func secret(for id: UUID) throws -> String { + try lock.withLock { if let secret = secrets[id] { return secret } var bytes = [UInt8](repeating: 0, count: 32) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + throw HostDriverError.provisioning("couldn't make a secret for git's credentials") + } let secret = bytes.map { String(format: "%02x", $0) }.joined() secrets[id] = secret return secret } } + /// Requests served at once; one more is turned away. A request takes a thread for up to + /// `requestDeadline`, so this bounds what a flood of connections can hold. + static let maxConcurrent = 8 + /// The most one request may take to arrive, whatever pace its bytes come at. + static let requestDeadline: TimeInterval = 15 + private let serving = DispatchSemaphore(value: maxConcurrent) + private let queue = DispatchQueue( + label: "com.developwithstyle.workroom.credential-relay", qos: .userInitiated, + attributes: .concurrent) + /// The listener on this Mac's loopback, started the first time it is needed. func localPort() throws -> UInt16 { try lock.withLock { @@ -77,12 +91,29 @@ final class CredentialRelay: @unchecked Sendable { while true { let connection = Darwin.accept(descriptor, nil, nil) if connection < 0 { - if errno == EINTR || errno == ECONNABORTED { continue } - Self.logger.error("credential relay stopped accepting: errno \(errno)") - return + switch errno { + case EINTR, ECONNABORTED: continue + case EMFILE, ENFILE, ENOBUFS, ENOMEM: + // Out of descriptors or memory for now: wait it out rather than stop relaying. + Thread.sleep(forTimeInterval: 0.1) + continue + default: + // Forgotten, so the next install listens again. + Self.logger.error("credential relay stopped accepting: errno \(errno)") + lock.withLock { if listener?.descriptor == descriptor { listener = nil } } + Darwin.close(descriptor) + return + } + } + guard serving.wait(timeout: .now()) == .success else { + Darwin.close(connection) + continue } - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - defer { Darwin.close(connection) } + queue.async { [weak self] in + defer { + Darwin.close(connection) + self?.serving.signal() + } self?.serve(connection) } } @@ -90,9 +121,13 @@ final class CredentialRelay: @unchecked Sendable { /// One request: a known secret, then `protocol=https` and `host=github.com`, or nothing back. private func serve(_ connection: Int32) { - var timeout = timeval(tv_sec: 15, tv_usec: 0) - setsockopt( - connection, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + // A peer that resets before the reply would otherwise raise SIGPIPE, which ends the app. + var on: Int32 = 1 + guard + setsockopt(connection, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout.size)) + == 0 + else { return } + var timeout = timeval(tv_sec: Int(Self.requestDeadline), tv_usec: 0) setsockopt( connection, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) guard let request = Self.readRequest(connection) else { return } @@ -100,11 +135,19 @@ final class CredentialRelay: @unchecked Sendable { _ = reply.withCString { send(connection, $0, strlen($0), 0) } } - /// Reads up to the blank line that ends git's request, or nil past `maxRequest` or a timeout. + /// Reads up to the blank line that ends git's request, or nil past `maxRequest` or + /// `requestDeadline`, which bounds the whole request however slowly its bytes come. static func readRequest(_ connection: Int32) -> String? { var data = Data() var buffer = [UInt8](repeating: 0, count: 1024) + let deadline = Date().addingTimeInterval(requestDeadline) while data.count < maxRequest { + let left = deadline.timeIntervalSinceNow + guard left > 0 else { return nil } + var timeout = timeval( + tv_sec: Int(left), tv_usec: Int32(left.truncatingRemainder(dividingBy: 1) * 1_000_000)) + setsockopt( + connection, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) let count = recv(connection, &buffer, buffer.count, 0) guard count > 0 else { return nil } data.append(buffer, count: count) @@ -117,8 +160,8 @@ final class CredentialRelay: @unchecked Sendable { /// `error=` line the agent passes on to git. func respond(to request: String) -> String { let lines = request.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - guard let secret = lines.first, - lock.withLock({ secrets.values.contains(secret) }) + guard let secret = lines.first, !secret.isEmpty, + lock.withLock({ secrets.values.contains { Self.same($0, secret) } }) else { return "error=this workroom's credential relay isn't known to the Workroom app\n" } var fields: [String: String] = [:] for line in lines.dropFirst() where !line.isEmpty { @@ -143,6 +186,13 @@ final class CredentialRelay: @unchecked Sendable { } } + /// Compares in time that depends on the lengths only. + static func same(_ one: String, _ other: String) -> Bool { + let (a, b) = (Array(one.utf8), Array(other.utf8)) + guard a.count == b.count else { return false } + return zip(a, b).reduce(0) { $0 | ($1.0 ^ $1.1) } == 0 + } + // MARK: gh /// `gh auth git-credential get` with `request` on stdin: gh's own answer to git, for the account @@ -188,14 +238,23 @@ final class CredentialRelay: @unchecked Sendable { process.standardError = FileHandle.nullDevice let exited = DispatchSemaphore(value: 0) process.terminationHandler = { _ in exited.signal() } + // A gh gone before its input is written would raise SIGPIPE, which ends the app. + _ = fcntl(stdin.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) try process.run() - if let input { stdin.fileHandleForWriting.write(Data(input.utf8)) } + // Read on another queue, so a gh that hangs (a keychain prompt) is ended by the deadline + // rather than holding this thread until it closes its output. + let read = DispatchGroup() + nonisolated(unsafe) var output = Data() + DispatchQueue.global().async(group: read) { + output = stdout.fileHandleForReading.readDataToEndOfFile() + } + if let input { try? stdin.fileHandleForWriting.write(contentsOf: Data(input.utf8)) } try? stdin.fileHandleForWriting.close() - let output = stdout.fileHandleForReading.readDataToEndOfFile() guard exited.wait(timeout: .now() + 15) == .success else { process.terminate() throw HostDriverError.provisioning("gh didn't answer in time") } + read.wait() guard process.terminationStatus == 0 else { throw HostDriverError.provisioning( "gh has no GitHub sign-in on this Mac: run `gh auth login`") @@ -217,7 +276,7 @@ final class CredentialRelay: @unchecked Sendable { ContainerHostDriver.shellQuoted(agentBinary) + " credential relay --port \(Self.port(for: id))" let (status, output) = try await driver.exec(command, on: host).communicate( - Data((secret(for: id) + "\n").utf8), timeout: 30) + Data((try secret(for: id) + "\n").utf8), timeout: 30) guard status == 0 else { throw HostDriverError.provisioning("setting up git's credentials failed: \(output)") } diff --git a/macapp/WorkroomApp/Core/RemoteProvisioning.swift b/macapp/WorkroomApp/Core/RemoteProvisioning.swift index 67c643cf2..03e36b0a0 100644 --- a/macapp/WorkroomApp/Core/RemoteProvisioning.swift +++ b/macapp/WorkroomApp/Core/RemoteProvisioning.swift @@ -194,7 +194,11 @@ enum RemoteProvisioning { where relayed == nil && refusal.code == "app_not_installed" && environment.gitHubToken != nil { - try await body(cloneEnvironment(token: try await relayToken(environment))) + // Without `gh` either, the App's install is what the user needs to hear about. + guard let token = try? await relayToken(environment) else { + throw BrokerError.refused(refusal) + } + try await body(cloneEnvironment(token: token)) return true } do { diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index c2ed79998..c4ced7e94 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -395,6 +395,7 @@ enum RemoteWorkrooms { throw RemoteProvisioning.Failure.rollbackIncomplete( cause: cause, host: liveHost, grantID: grant, cleanup: left) } + if let id = host.id { await RemoteHosts.shared.forgetRelay(id) } try await recorder.forget(name) } @@ -669,17 +670,28 @@ final class RemoteHosts: @unchecked Sendable { // connection, and its secret with this launch, so it is set up again on every connect. A // failure leaves the workroom usable except for git's remote commands, so it is logged. guard case .remote(let id) = host, isRelayed(id) else { return } - do { - try await CredentialRelay.shared.install( - on: host, driver: driver, - agentBinary: AgentBootstrap.binary(besideSocket: RemoteWorkrooms.agentSocket)) - } catch { - Self.logger.error( - "credential relay for \(id, privacy: .public): \(error.localizedDescription, privacy: .public)" - ) + // Tried a few times: a listen can lose a race with the last connection's teardown. + for attempt in 1...3 { + do { + try await CredentialRelay.shared.install( + on: host, driver: driver, + agentBinary: AgentBootstrap.binary(besideSocket: RemoteWorkrooms.agentSocket)) + return + } catch { + Self.logger.error( + "credential relay for \(id, privacy: .public) (attempt \(attempt)): \(error.localizedDescription, privacy: .public)" + ) + try? await Task.sleep(for: .seconds(1)) + } } } + /// A relayed workroom's host is gone: its relay with it (#309). + func forgetRelay(_ id: UUID) async { + guard lock.withLock({ relayed.remove(id) }) != nil else { return } + await CredentialRelay.shared.close(id) + } + /// Whether host `id` is a workroom whose git credentials come through the Mac's relay (#309). func isRelayed(_ id: UUID) -> Bool { lock.withLock { relayed.contains(id) } } diff --git a/macapp/WorkroomAppTests/CredentialRelayTests.swift b/macapp/WorkroomAppTests/CredentialRelayTests.swift index ab5367647..26c5a6f61 100644 --- a/macapp/WorkroomAppTests/CredentialRelayTests.swift +++ b/macapp/WorkroomAppTests/CredentialRelayTests.swift @@ -14,13 +14,13 @@ final class CredentialRelayTests: XCTestCase { /// A known secret asking for github.com over https gets gh's username and password, and nothing /// else gh said; gh is asked for github.com only, whatever else the request carried. - func testAKnownWorkroomIsAnsweredWithGhsCredentialOnly() { + func testAKnownWorkroomIsAnsweredWithGhsCredentialOnly() throws { let asked = Asked() let relay = CredentialRelay(answer: { request in asked.add(request) return "protocol=https\nhost=github.com\nusername=joel\npassword=gho_x\nextra=1\n" }) - let secret = relay.secret(for: UUID()) + let secret = try relay.secret(for: UUID()) XCTAssertEqual( relay.respond(to: "\(secret)\nprotocol=https\nhost=github.com\npath=o/r.git\n\n"), "username=joel\npassword=gho_x\n") @@ -29,13 +29,13 @@ final class CredentialRelayTests: XCTestCase { /// An unknown secret, another host, or plain http is answered with an error, and gh is never /// asked: another user of this Mac can reach the listener too. - func testAnythingElseIsRefusedWithoutAskingGh() { + func testAnythingElseIsRefusedWithoutAskingGh() throws { let asked = Asked() let relay = CredentialRelay(answer: { request in asked.add(request) return "username=joel\npassword=gho_x\n" }) - let secret = relay.secret(for: UUID()) + let secret = try relay.secret(for: UUID()) for request in [ "guess\nprotocol=https\nhost=github.com\n\n", "\nprotocol=https\nhost=github.com\n\n", @@ -48,9 +48,9 @@ final class CredentialRelayTests: XCTestCase { } /// gh with no sign-in, or one that fails, says so in an `error=` line the agent hands git. - func testAGhWithoutASignInIsAnErrorLine() { + func testAGhWithoutASignInIsAnErrorLine() throws { let empty = CredentialRelay(answer: { _ in "protocol=https\nhost=github.com\n" }) - let secret = empty.secret(for: UUID()) + let secret = try empty.secret(for: UUID()) XCTAssertEqual( empty.respond(to: "\(secret)\nprotocol=https\nhost=github.com\n\n"), "error=gh has no GitHub sign-in on this Mac: run `gh auth login`\n") @@ -58,18 +58,18 @@ final class CredentialRelayTests: XCTestCase { throw HostDriverError.provisioning("gh\nbroke") }) let reply = failing.respond( - to: "\(failing.secret(for: UUID()))\nprotocol=https\nhost=github.com\n\n") + to: "\(try failing.secret(for: UUID()))\nprotocol=https\nhost=github.com\n\n") XCTAssertTrue(reply.hasPrefix("error=") && !reply.dropLast().contains("\n"), reply) } /// A workroom's secret is its own and stays the same for the launch; its port is the same on /// every launch, below Linux's ephemeral range. - func testSecretsAreOwnAndStablePortsBelowTheEphemeralRange() { + func testSecretsAreOwnAndStablePortsBelowTheEphemeralRange() throws { let relay = CredentialRelay(answer: { _ in "" }) let (one, two) = (UUID(), UUID()) - XCTAssertEqual(relay.secret(for: one), relay.secret(for: one)) - XCTAssertNotEqual(relay.secret(for: one), relay.secret(for: two)) - XCTAssertEqual(relay.secret(for: one).count, 64) + XCTAssertEqual(try relay.secret(for: one), try relay.secret(for: one)) + XCTAssertNotEqual(try relay.secret(for: one), try relay.secret(for: two)) + XCTAssertEqual(try relay.secret(for: one).count, 64) for _ in 0..<200 { let port = CredentialRelay.port(for: UUID()) XCTAssertTrue((30_000..<32_000).contains(port), "\(port)") @@ -86,4 +86,43 @@ final class CredentialRelayTests: XCTestCase { try "github.com:\n user: joel\n".write(to: file, atomically: true, encoding: .utf8) XCTAssertTrue(CredentialRelay.hasGitHubSignIn(hosts: file)) } + + /// Secrets compare whole, and only equal ones match. + func testSecretsCompareWhole() { + XCTAssertTrue(CredentialRelay.same("abc", "abc")) + XCTAssertFalse(CredentialRelay.same("abc", "abd")) + XCTAssertFalse(CredentialRelay.same("abc", "ab")) + XCTAssertFalse(CredentialRelay.same("", "a")) + } + + /// A peer that sends junk and resets at once can't end the app (SIGPIPE), nor stop the listener: + /// a real request after a run of them is still answered, over the loopback socket itself. + func testResetPeersNeitherCrashNorStopTheListener() throws { + let relay = CredentialRelay(answer: { _ in "username=u\npassword=p\n" }) + let secret = try relay.secret(for: UUID()) + let port = try relay.localPort() + for _ in 0..<30 { + guard let socket = LoopbackSocket.connect(port: port, timeout: 2) else { + return XCTFail("connect \(errno)") + } + _ = "x\n\n".withCString { send(socket, $0, 3, 0) } + var linger = linger(l_onoff: 1, l_linger: 0) + setsockopt(socket, SOL_SOCKET, SO_LINGER, &linger, socklen_t(MemoryLayout.size)) + Darwin.close(socket) + } + guard let socket = LoopbackSocket.connect(port: port, timeout: 2) else { + return XCTFail("connect \(errno)") + } + defer { Darwin.close(socket) } + let request = "\(secret)\nprotocol=https\nhost=github.com\n\n" + _ = request.withCString { send(socket, $0, strlen($0), 0) } + var reply = Data() + var buffer = [UInt8](repeating: 0, count: 256) + while true { + let count = recv(socket, &buffer, buffer.count, 0) + guard count > 0 else { break } + reply.append(buffer, count: count) + } + XCTAssertEqual(String(decoding: reply, as: UTF8.self), "username=u\npassword=p\n") + } } From afe42a6bdc6693ee007ce68cea48a6109d1415e0 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 16:44:37 +0100 Subject: [PATCH 13/29] test(macapp): XCUITest the New Workroom places submenu (#309) With the remote preview on, the context menu's New Workroom opens a submenu with This Mac, Docker and Apple Container, and a runtime entry is disabled exactly when its title gives a reason. Without the preview it stays one plain item. A fixture launch turns the preview on with -WorkroomUITestRemotePreview, mirrored into Defaults, since a launch argument would shadow the Bool key. Without the flag it's pinned off, so no other test sees the submenu. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/UITestFixture.swift | 9 +++ .../NewWorkroomPlacesUITests.swift | 56 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift diff --git a/macapp/WorkroomApp/Core/UITestFixture.swift b/macapp/WorkroomApp/Core/UITestFixture.swift index c9ceff92e..349c88355 100644 --- a/macapp/WorkroomApp/Core/UITestFixture.swift +++ b/macapp/WorkroomApp/Core/UITestFixture.swift @@ -617,6 +617,13 @@ enum UITestFixture { /// header fades all key off this, so a drifted value silently rebaselines every visual QA pass /// (issue #162 review). Fixture-namespaced because a bare `-dimUnfocusedPanes 0` cannot work: see /// `applyFixtureDefaults`'s note on argument-domain strings. + /// Whether a fixture launch has the remote preview on (`-WorkroomUITestRemotePreview 1`, #309): + /// New Workroom becomes a submenu of places. Off unless given, so no other test sees it; mirrored + /// into `Defaults` (`applyFixtureDefaults`), since the argument domain would shadow the key. + static var remotePreview: Bool { + text("WorkroomUITestRemotePreview").map { ($0 as NSString).boolValue } ?? false + } + static var dimUnfocusedPanes: Bool { // The arg arrives as a STRING, so parse it as one; unset ⇒ the shipped default. text("WorkroomUITestDimUnfocusedPanes").map { ($0 as NSString).boolValue } ?? true @@ -931,6 +938,8 @@ enum UITestFixture { // Auto-even splits: same persistence trap, and it decides where every divider in a split lands // (issue #126) — an unpinned value rebaselines any test or screenshot that measures panes. Defaults[.autoResizeSplitsEvenly] = autoResizeSplitsEvenly + // The remote preview PERSISTS too, and decides whether New Workroom is a submenu (#309). + Defaults[.remoteWorkroomsPreview] = remotePreview // Pinned "already onboarded" for the same reason as `themeFamily`/`diffViewMode` above: the flag // PERSISTS in the real Dev `Defaults` domain, so a fresh machine with zero registered projects // would otherwise pop the onboarding wizard (issue #151) over e.g. `NewWorkroomDialogUITests`' diff --git a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift new file mode 100644 index 000000000..d2853dc37 --- /dev/null +++ b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift @@ -0,0 +1,56 @@ +import XCTest + +/// New Workroom asks where once the remote preview is on (#309): This Mac, then Docker and Apple +/// Container under "Local containers", each disabled with its reason when it can't be used here. +/// Without the preview it stays one plain item. +final class NewWorkroomPlacesUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + private func launch(preview: Bool) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += [ + "-WorkroomUITestFixture", "1", "-WorkroomUITestRemotePreview", preview ? "1" : "0", + "-ApplePersistenceIgnoreState", "YES", + ] + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15)) + let project = app.otherElements["sidebar.project.UITestProject"] + XCTAssertTrue(project.waitForExistence(timeout: 10), "fixture project row should exist") + project.rightClick() + return app + } + + func testNewWorkroomAsksWhereWithThePreviewOn() { + let app = launch(preview: true) + let newWorkroom = app.menuItems["New Workroom"] + XCTAssertTrue(newWorkroom.waitForExistence(timeout: 5)) + newWorkroom.hover() + + let thisMac = app.menuItems["This Mac"] + XCTAssertTrue(thisMac.waitForExistence(timeout: 5), "the submenu has no This Mac") + XCTAssertTrue(thisMac.isEnabled) + // Each runtime is listed, usable or saying why not; which depends on this Mac. + for runtime in ["Docker", "Apple Container"] { + let entry = app.menuItems.matching( + NSPredicate(format: "title == %@ OR title BEGINSWITH %@", runtime, "\(runtime) — ") + ).firstMatch + XCTAssertTrue(entry.waitForExistence(timeout: 5), "\(runtime) isn't listed") + let title = entry.title + XCTAssertEqual( + entry.isEnabled, title == runtime, + "\(title): an entry is disabled exactly when its title gives a reason") + } + app.typeKey(.escape, modifierFlags: []) + } + + func testNewWorkroomIsOneItemWithThePreviewOff() { + let app = launch(preview: false) + XCTAssertTrue(app.menuItems["New Workroom"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.menuItems["This Mac"].exists, "a submenu appeared without the preview") + XCTAssertFalse(app.menuItems["New Remote Workroom"].exists) + app.typeKey(.escape, modifierFlags: []) + } +} From 79e826d1aa32c7bead72bfd629787417a05cbb4b Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 17:07:05 +0100 Subject: [PATCH 14/29] feat(macapp): show how far a container workroom's image download has got (#309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project's first container workroom pulls the workroom-host image, a couple of hundred MB, and the row's spinner said nothing for however long that took. The pull's output is now read as it arrives (HostStream.communicate's onOutput, stdout and stderr alike) and turned into a fraction by ImagePullProgress. Without a terminal, Docker prints only each layer's state, so its fraction is the layers done of those seen. Apple's container prints `[step/steps] … N%`, which gives its step and percent. The fraction reaches the create through a task-local, so HostDriver.create keeps its shape and two creates at once each hear only their own. The project row's spinner becomes a circle that fills, with the percentage in its tooltip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- docs/designs/remote-workrooms.md | 10 +++- macapp/WorkroomApp/Core/AppStore.swift | 26 +++++++-- .../Core/ContainerHostDriver.swift | 36 +++++++++++- macapp/WorkroomApp/Core/HostDriver.swift | 18 ++++-- .../WorkroomApp/Core/ImagePullProgress.swift | 56 +++++++++++++++++++ macapp/WorkroomApp/Views/ProjectSidebar.swift | 14 ++++- .../ImagePullProgressTests.swift | 40 +++++++++++++ .../WorkroomAppTests/RemoteHostsTests.swift | 26 +++++++++ 8 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 macapp/WorkroomApp/Core/ImagePullProgress.swift create mode 100644 macapp/WorkroomAppTests/ImagePullProgressTests.swift diff --git a/docs/designs/remote-workrooms.md b/docs/designs/remote-workrooms.md index e6df39a23..71bb28dba 100644 --- a/docs/designs/remote-workrooms.md +++ b/docs/designs/remote-workrooms.md @@ -1686,8 +1686,14 @@ these are the subsystems that actually gate "a remote workroom is a real workroo (`run.sh`) cover the Docker path; `AppleContainerIntegrationTests` run the whole lifecycle on the real Apple runtime, opt-in (`TEST_RUNNER_WR_APPLE_CONTAINER_TESTS=1`), since CI's macOS runners can't run its VMs. - - **Not done.** Pull progress is not shown, only a pull's failure. A development environment in - the container (toolchains, agent CLIs) is #308; Docker on another machine is #307. + - **Pull progress.** The first create in a project pulls the host image, a couple of hundred MB. + The pull's output is read as it arrives (`HostStream.communicate`'s `onOutput`) and turned + into a fraction (`ImagePullProgress`): Docker prints only each layer's state without a + terminal, so it is the layers done of those seen; Apple's `[step/steps] … N%` lines give its + step and percent. It reaches the create through a task-local (`pullProgress`), and the + project row's spinner fills as a circle with the percentage as its tooltip. + - **Not done.** A development environment in the container (toolchains, agent CLIs) is #308; + Docker on another machine is #307. - **Cross-machine session enumeration** (from Phase 1's S4 note): agent-side session naming, a pane→tab mapping on the wire, and workroom UI state stored with the workroom. - **OSC 7 and cmd-click (C7).** `SessionDaemon.swift:395-401` deliberately emits diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index b7055a805..76af751d0 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -3515,6 +3515,11 @@ final class AppStore: ObservableObject { /// Whether creating a container workroom is on for `project`: not while another create holds it busy, since /// a second create would build a second base. + /// How far each project's container workroom create has got downloading its host image, 0 to 1 + /// (#309): the first create in a project pulls it, a couple of hundred MB. The row's spinner + /// shows it. + @Published var imagePulls: [String: Double] = [:] + func canCreateRemoteWorkroom(in project: Project) -> Bool { RemoteWorkrooms.isEnabled && !isBusyProject(project.path) && !deletingProjects.contains(project.path) @@ -3543,11 +3548,22 @@ final class AppStore: ObservableObject { guard repository.host == "github.com" else { throw RemoteWorkrooms.Failure.notOnGitHub("Its origin is on \(repository.host).") } - let created = try await RemoteWorkrooms.create( - repository: repository, cloneURL: RemoteWorkrooms.cloneURL(for: repository), - base: base, project: project.host, runtime: runtime, driver: driver, - environment: environment, - recorder: remoteRecorder(project: project.path)) + let path = project.path + defer { imagePulls[path] = nil } + let report: @Sendable (Double) -> Void = { fraction in + Task { @MainActor [weak self] in + // A report that arrives after the create ended shows nothing. + guard let self, self.isBusyProject(path) else { return } + self.imagePulls[path] = fraction + } + } + let created = try await ContainerHostDriver.$pullProgress.withValue(report) { + try await RemoteWorkrooms.create( + repository: repository, cloneURL: RemoteWorkrooms.cloneURL(for: repository), + base: base, project: project.host, runtime: runtime, driver: driver, + environment: environment, + recorder: remoteRecorder(project: project.path)) + } // Its panes attach through their own ssh; Changes and the rest connect for themselves. await created.instance.connection.close() await reload() diff --git a/macapp/WorkroomApp/Core/ContainerHostDriver.swift b/macapp/WorkroomApp/Core/ContainerHostDriver.swift index aea9c4158..923f2fb92 100644 --- a/macapp/WorkroomApp/Core/ContainerHostDriver.swift +++ b/macapp/WorkroomApp/Core/ContainerHostDriver.swift @@ -141,6 +141,11 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { /// Pulls `image` unless the runtime already has it (#309). `run` itself never pulls /// (`--pull=never`): left to it, a missing `workroom-host` was looked for on Docker Hub as /// `library/workroom-host`, and failed with a registry error that said nothing about why. + /// Told how far a host image's pull has got, 0 to 1, for a create run inside + /// `$pullProgress.withValue` (#309): a task-local, so `HostDriver.create` keeps its shape and two + /// creates at once each hear only their own. + @TaskLocal static var pullProgress: (@Sendable (Double) -> Void)? + private func ensureImage(_ image: String) async throws { let apple = provisioning?.dialect == .apple let inspect = @@ -149,8 +154,13 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { do { // Silence-bounded, and a pull reports its progress as it goes. Apple's unpacks every // platform of a multi-arch image unless told which, and it runs on Apple silicon only. + let report = Self.pullProgress + let progress = PullReader() _ = try await runtime( - apple ? ["image", "pull", "--arch", "arm64", image] : ["pull", image], timeout: 900) + apple ? ["image", "pull", "--arch", "arm64", image] : ["pull", image], timeout: 900, + onOutput: report.map { report in + { data in if let fraction = progress.read(data) { report(fraction) } } + }) } catch { throw HostDriverError.provisioning( "couldn't download the workroom host image \(image). Check your network connection, " @@ -288,6 +298,25 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { throw HostDriverError.provisioning("couldn't read the base's image to derive from") } + /// A pull's output, read as it arrives from two streams at once. + private final class PullReader: @unchecked Sendable { + private let lock = NSLock() + private var progress = ImagePullProgress() + private var last: Double? + + /// The fraction now, when it moved by a whole percent or more since the last one told. + func read(_ data: Data) -> Double? { + lock.withLock { + progress.read(String(decoding: data, as: UTF8.self)) + guard let fraction = progress.fraction, + last.map({ abs(fraction - $0) >= 0.01 }) ?? true + else { return nil } + last = fraction + return fraction + } + } + } + /// Where an Apple derive stages a base's exported disk, and the derived image's name. static let deriveDirectoryPrefix = "workroom-derive-" @@ -623,7 +652,8 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { /// or with `allLines` all of it. Its environment is the few variables that say which daemon to /// talk to, never the app's whole one. private func runtime( - _ arguments: [String], timeout: TimeInterval = 120, allLines: Bool = false + _ arguments: [String], timeout: TimeInterval = 120, allLines: Bool = false, + onOutput: (@Sendable (Data) -> Void)? = nil ) async throws -> String { guard let provisioning else { throw HostDriverError.notImplemented("Provisioning") } let environment = ProcessInfo.processInfo.environment.filter { @@ -632,7 +662,7 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { let (status, output) = try await HostStream.spawn( provisioning.runtime, Self.runtimeArguments(arguments, context: provisioning.context), environment: environment, handshakeTimeout: 20, purpose: .exchange - ).communicate(nil, timeout: timeout) + ).communicate(nil, timeout: timeout, onOutput: onOutput) let said = output.trimmingCharacters(in: .whitespacesAndNewlines) guard status == 0 else { if let context = provisioning.context, said.contains("context not found") { diff --git a/macapp/WorkroomApp/Core/HostDriver.swift b/macapp/WorkroomApp/Core/HostDriver.swift index 7db5159fe..b514fbe73 100644 --- a/macapp/WorkroomApp/Core/HostDriver.swift +++ b/macapp/WorkroomApp/Core/HostDriver.swift @@ -142,6 +142,8 @@ final class HostStream: @unchecked Sendable { private var errors = Data() /// stderr reached EOF: everything the carrier said is in `errors`. private var errorsClosed = false + /// Told each piece of output as it arrives, stdout's and stderr's alike (`communicate`). + private var onOutput: (@Sendable (Data) -> Void)? /// `end()` stopped a carrier that was still running, so its exit status is our own signal. private var endedByUs = false /// A connection took the descriptor and the process (`handOff`), so `deinit` leaves them alone. @@ -304,10 +306,16 @@ final class HostStream: @unchecked Sendable { /// /// The writes and reads block, on GCD (`runBlocking`), not the cooperative pool: an 11 MB /// binary over a slow link is exactly the kind of wait that starves other blocking work there. - func communicate(_ input: Data?, timeout: TimeInterval) async throws -> ( + /// + /// `onOutput` is told each piece of its output as it arrives, from stdout and stderr, for a + /// command that reports progress (`ContainerHostDriver`'s image pull). + func communicate( + _ input: Data?, timeout: TimeInterval, onOutput: (@Sendable (Data) -> Void)? = nil + ) async throws -> ( status: Int32, output: String ) { precondition(purpose == .exchange, "communicate needs a stream spawned for an exchange") + lock.withLock { self.onOutput = onOutput } let fd = descriptor // Claimed once: only this exchange closes it, after its last `send` on it. A second call, even // a concurrent one, traps rather than sending on a descriptor this one may have closed. @@ -368,6 +376,7 @@ final class HostStream: @unchecked Sendable { // and the scripts' report, which comes after whatever a shell startup printed, // survives even a startup that printed more than that. collected.append(chunk, count: count) + onOutput?(Data(chunk[0.. 1024 * 1024 { collected.removeFirst(collected.count - 1024 * 1024) } @@ -497,9 +506,10 @@ final class HostStream: @unchecked Sendable { /// Keeps the first 16 KiB of stderr: enough for any diagnosis, and bounded against a chatty one. private func collect(_ data: Data) { - lock.withLock { - guard errors.count < 16 * 1024 else { return } - errors.append(data.prefix(16 * 1024 - errors.count)) + let listener = lock.withLock { () -> (@Sendable (Data) -> Void)? in + if errors.count < 16 * 1024 { errors.append(data.prefix(16 * 1024 - errors.count)) } + return onOutput } + listener?(data) } } diff --git a/macapp/WorkroomApp/Core/ImagePullProgress.swift b/macapp/WorkroomApp/Core/ImagePullProgress.swift new file mode 100644 index 000000000..418dcaf5c --- /dev/null +++ b/macapp/WorkroomApp/Core/ImagePullProgress.swift @@ -0,0 +1,56 @@ +import Foundation + +/// How far a container runtime's image pull has got (#309), read from what its CLI prints without +/// a terminal, as the app runs it. Measured: Docker (29) prints each layer's state on a line of its +/// own, `: Pulling fs layer`, `… Download complete`, `… Pull complete`, `… Already exists`, +/// and no byte counts, so its progress is the layers done of those seen. Apple's `container` (1.5.0) +/// prints `[/] % …`, so its progress is its step's share plus its +/// percent of that. +struct ImagePullProgress: Sendable { + private var layers: Set = [] + private var done: Set = [] + private var stepped: Double? + private var pending = "" + + /// The fraction done, 0 to 1, or nil before anything says how far. + var fraction: Double? { + if let stepped { return stepped } + guard !layers.isEmpty else { return nil } + return Double(done.count) / Double(layers.count) + } + + /// Takes a piece of output, which may end mid-line. + mutating func read(_ text: String) { + pending += text + // `\r` too: a progress bar redraws its line that way. + var lines = pending.split(omittingEmptySubsequences: false) { $0 == "\n" || $0 == "\r" } + pending = String(lines.removeLast()) + for line in lines { take(String(line)) } + } + + private mutating func take(_ line: String) { + if let step = Self.appleStep(line) { + stepped = step + return + } + guard let colon = line.firstIndex(of: ":") else { return } + let layer = String(line[..: Pulling from" line is not one. + guard layer.count == 12, layer.allSatisfy(\.isHexDigit) else { return } + let state = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) + layers.insert(layer) + if state == "Pull complete" || state == "Already exists" { done.insert(layer) } + } + + /// `[2/3] Unpacking image … 40% …` → (1 + 0.4) / 3. + private static func appleStep(_ line: String) -> Double? { + guard line.hasPrefix("["), let close = line.firstIndex(of: "]") else { return nil } + let parts = line[line.index(after: line.startIndex).. 0, + step >= 1, step <= steps + else { return nil } + let percent = line[close...].split(separator: " ").first { $0.hasSuffix("%") } + .flatMap { Double($0.dropLast()) } + return min(1, ((step - 1) + (percent ?? 0) / 100) / steps) + } +} diff --git a/macapp/WorkroomApp/Views/ProjectSidebar.swift b/macapp/WorkroomApp/Views/ProjectSidebar.swift index a2405a021..0bdbc5b47 100644 --- a/macapp/WorkroomApp/Views/ProjectSidebar.swift +++ b/macapp/WorkroomApp/Views/ProjectSidebar.swift @@ -272,7 +272,19 @@ struct ProjectSidebar: View { .opacity(busy ? 0 : 1) .allowsHitTesting(!busy) .overlay { - if busy { ProgressView().controlSize(.small) } + if busy { + // A container workroom's first create downloads its host image, which can take a while: + // how far it has got, rather than a spinner that says nothing (#309). + if let pulled = store.imagePulls[project.path] { + ProgressView(value: pulled) + .progressViewStyle(.circular) + .controlSize(.small) + .help("Downloading the workroom image: \(Int(pulled * 100))%") + .accessibilityIdentifier("sidebar.project.imagePull") + } else { + ProgressView().controlSize(.small) + } + } } } .frame(height: rowHeight) diff --git a/macapp/WorkroomAppTests/ImagePullProgressTests.swift b/macapp/WorkroomAppTests/ImagePullProgressTests.swift new file mode 100644 index 000000000..39261f3b3 --- /dev/null +++ b/macapp/WorkroomAppTests/ImagePullProgressTests.swift @@ -0,0 +1,40 @@ +import XCTest + +@testable import Workroom + +/// Reading how far an image pull has got from what each runtime's CLI prints (#309). +final class ImagePullProgressTests: XCTestCase { + /// Docker: the layers done of those seen, counting one already present as done, and ignoring + /// lines that aren't a layer's. + func testDockerProgressIsLayersDoneOfThoseSeen() { + var progress = ImagePullProgress() + progress.read("1.36: Pulling from containerd/busybox\n") + XCTAssertNil(progress.fraction) + progress.read( + "f78e6840ded1: Pulling fs layer\naaaaaaaaaaaa: Already exists\nbbbbbbbbbbbb: Pulling fs layer\n" + + "cccccccccccc: Pulling fs layer\n") + XCTAssertEqual(progress.fraction, 0.25) + // A line split across two pieces of output. + progress.read("f78e6840ded1: Download complete\nf78e6840ded1: Pull ") + XCTAssertEqual(progress.fraction, 0.25) + progress.read("complete\n") + XCTAssertEqual(progress.fraction, 0.5) + progress.read( + "bbbbbbbbbbbb: Pull complete\ncccccccccccc: Pull complete\nDigest: sha256:7b3c\n" + + "Status: Downloaded newer image for x\n") + XCTAssertEqual(progress.fraction, 1) + } + + /// Apple: each step's share, plus its percent of that, as the lines `container` prints. + func testAppleProgressIsTheStepAndItsPercent() { + var progress = ImagePullProgress() + progress.read("[1/2] Fetching image 50% (10 of 20 blobs)\n") + XCTAssertEqual(progress.fraction, 0.25) + progress.read( + "[2/2] Unpacking image for platform linux/arm64 100% (440 of 440 entries, 3.9/3.9 MB) [3s]\r") + XCTAssertEqual(progress.fraction, 1) + var odd = ImagePullProgress() + odd.read("[3/2] nonsense 40%\n[x/y] 10%\n") + XCTAssertNil(odd.fraction) + } +} diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index 92bf718c4..5a16dda63 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -311,6 +311,32 @@ final class RemoteHostsTests: XCTestCase { Array(calls.prefix(3)), ["image inspect", "pull workroom-host", "run --pull=never"]) } + private final class Fractions: @unchecked Sendable { + private let lock = NSLock() + private var heard: [Double] = [] + var all: [Double] { lock.withLock { heard } } + func add(_ fraction: Double) { lock.withLock { heard.append(fraction) } } + } + + /// A pull's progress reaches the create that asked for it as it goes, rising to the whole (#309). + func testAPullsProgressReachesItsCreate() async throws { + let (runtime, _) = try scriptedRuntime( + """ + "image inspect --format {{.Id}} workroom-host") exit 1 ;; + "pull workroom-host") + printf 'aaaaaaaaaaaa: Pulling fs layer\nbbbbbbbbbbbb: Pulling fs layer\n' + sleep 0.2; printf 'aaaaaaaaaaaa: Pull complete\n' + sleep 0.2; printf 'bbbbbbbbbbbb: Pull complete\n' ;; + run*) exit 1 ;; + """) + let heard = Fractions() + let report: @Sendable (Double) -> Void = { heard.add($0) } + try? await ContainerHostDriver.$pullProgress.withValue(report) { + _ = try await Self.driver(runtime: runtime, context: nil).create() + } + XCTAssertEqual(heard.all, [0, 0.5, 1]) + } + /// A pull that fails says the host image couldn't be downloaded, and runs nothing. func testAFailedPullSaysTheImageCouldNotBeDownloaded() async throws { let (runtime, log) = try failingRuntime(pullFails: true) From d31d0eb93f673e76b70e6a464e7a35e4fc11a25f Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 17:16:38 +0100 Subject: [PATCH 15/29] fix(macapp): keep New Workroom's tooltip off its submenu, and UI-test the download ring (#309) A tooltip on the submenu's parent showed on top of the submenu, covering This Mac (seen in a screenshot of the XCUITest). Each entry keeps its own tooltip. -WorkroomUITestImagePull seeds the fixture project mid-download, so an XCUITest checks the row's ring shows that fraction, and both tests attach screenshots. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/AppStore.swift | 5 +++ macapp/WorkroomApp/Core/UITestFixture.swift | 5 +++ macapp/WorkroomApp/Views/ProjectSidebar.swift | 4 +-- .../NewWorkroomPlacesUITests.swift | 31 +++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 76af751d0..6cc38ba5f 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -2925,6 +2925,11 @@ final class AppStore: ObservableObject { private func loadFixture() { let fixtures = applyingDeletionTombstones(UITestFixture.projects()) projects = fixtures + // A create mid-download, for the row's progress (#309). + if let pulled = UITestFixture.imagePull, let project = fixtures.first { + beginBusy(project.path) + imagePulls[project.path] = pulled + } // Fixture mode never reaches `apply`, so restore has to be released here or saves would stay // suspended for the whole run (issue #46). Fixture projects live in a temp directory that a real // saved session can't address anyway. diff --git a/macapp/WorkroomApp/Core/UITestFixture.swift b/macapp/WorkroomApp/Core/UITestFixture.swift index 349c88355..f62d4b706 100644 --- a/macapp/WorkroomApp/Core/UITestFixture.swift +++ b/macapp/WorkroomApp/Core/UITestFixture.swift @@ -620,6 +620,11 @@ enum UITestFixture { /// Whether a fixture launch has the remote preview on (`-WorkroomUITestRemotePreview 1`, #309): /// New Workroom becomes a submenu of places. Off unless given, so no other test sees it; mirrored /// into `Defaults` (`applyFixtureDefaults`), since the argument domain would shadow the key. + /// A container workroom create downloading its image, this far along (`-WorkroomUITestImagePull + /// 0.42`, #309), seeded on the fixture project so the row's progress can be seen without a real + /// pull. nil unless given. + static var imagePull: Double? { text("WorkroomUITestImagePull").flatMap(Double.init) } + static var remotePreview: Bool { text("WorkroomUITestRemotePreview").map { ($0 as NSString).boolValue } ?? false } diff --git a/macapp/WorkroomApp/Views/ProjectSidebar.swift b/macapp/WorkroomApp/Views/ProjectSidebar.swift index 0bdbc5b47..fe792d7c5 100644 --- a/macapp/WorkroomApp/Views/ProjectSidebar.swift +++ b/macapp/WorkroomApp/Views/ProjectSidebar.swift @@ -280,7 +280,6 @@ struct ProjectSidebar: View { .progressViewStyle(.circular) .controlSize(.small) .help("Downloading the workroom image: \(Int(pulled * 100))%") - .accessibilityIdentifier("sidebar.project.imagePull") } else { ProgressView().controlSize(.small) } @@ -307,7 +306,8 @@ struct ProjectSidebar: View { } label: { Label("New Workroom", systemImage: "plus") } - .help("Create a workroom of \(project.displayName), on this Mac or in a local container") + // No tooltip here: a submenu's parent shows it on top of the submenu, over This Mac. Each + // entry has its own. } else { newLocalWorkroomButton(Label("New Workroom", systemImage: "plus"), in: project) } diff --git a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift index d2853dc37..238dff501 100644 --- a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift +++ b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift @@ -31,6 +31,7 @@ final class NewWorkroomPlacesUITests: XCTestCase { let thisMac = app.menuItems["This Mac"] XCTAssertTrue(thisMac.waitForExistence(timeout: 5), "the submenu has no This Mac") + thisMac.hover() XCTAssertTrue(thisMac.isEnabled) // Each runtime is listed, usable or saying why not; which depends on this Mac. for runtime in ["Docker", "Apple Container"] { @@ -43,9 +44,39 @@ final class NewWorkroomPlacesUITests: XCTestCase { entry.isEnabled, title == runtime, "\(title): an entry is disabled exactly when its title gives a reason") } + let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + shot.name = "new-workroom-places" + shot.lifetime = .keepAlways + add(shot) app.typeKey(.escape, modifierFlags: []) } + /// A create downloading its host image shows how far it has got in place of the row's spinner. + func testADownloadingImageShowsItsProgressOnTheRow() { + let app = XCUIApplication() + app.launchArguments += [ + "-WorkroomUITestFixture", "1", "-WorkroomUITestRemotePreview", "1", + "-WorkroomUITestImagePull", "0.42", "-ApplePersistenceIgnoreState", "YES", + ] + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15)) + // The row's own identifier is the indicator's too. + let progress = app.progressIndicators.matching(identifier: "sidebar.project.UITestProject") + .firstMatch + XCTAssertTrue(progress.waitForExistence(timeout: 10), "the row shows no download progress") + XCTAssertEqual(progress.value as? Double, 0.42) + progress.hover() + sleep(2) // for the tooltip + attach(app, "image-pull-progress") + } + + private func attach(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.windows.firstMatch.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } + func testNewWorkroomIsOneItemWithThePreviewOff() { let app = launch(preview: false) XCTAssertTrue(app.menuItems["New Workroom"].waitForExistence(timeout: 5)) From a2ef9b13a3d3e4d96db58b64b65f2613413f9823 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 17:28:51 +0100 Subject: [PATCH 16/29] feat(macapp): say why New Workroom's entries are off while a create runs (#309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a create holds a project, or a delete is taking it, its New Workroom entries were greyed out with nothing to say why. They now read "— a workroom is already being created" or "— the project is being deleted". A runtime's own reason (not installed, needs macOS 26) comes first, since it outlasts the create in flight. The XCUITest checks it against a project the fixture seeds mid-download. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/AppStore.swift | 8 +++++++ macapp/WorkroomApp/Views/ProjectSidebar.swift | 8 +++++-- .../WorkroomAppTests/RemoteHostsTests.swift | 13 +++++++++++ .../NewWorkroomPlacesUITests.swift | 22 +++++++++++++++++-- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 6cc38ba5f..a658eece4 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -3525,6 +3525,14 @@ final class AppStore: ObservableObject { /// shows it. @Published var imagePulls: [String: Double] = [:] + /// Why no workroom can be created in `project` right now, as the New Workroom menu shows it beside + /// an entry (#309), or nil when one can: another create holds the project, or it is being deleted. + func createBlockedReason(in project: Project) -> String? { + if deletingProjects.contains(project.path) { return "the project is being deleted" } + if isBusyProject(project.path) { return "a workroom is already being created" } + return nil + } + func canCreateRemoteWorkroom(in project: Project) -> Bool { RemoteWorkrooms.isEnabled && !isBusyProject(project.path) && !deletingProjects.contains(project.path) diff --git a/macapp/WorkroomApp/Views/ProjectSidebar.swift b/macapp/WorkroomApp/Views/ProjectSidebar.swift index fe792d7c5..132f1df29 100644 --- a/macapp/WorkroomApp/Views/ProjectSidebar.swift +++ b/macapp/WorkroomApp/Views/ProjectSidebar.swift @@ -206,7 +206,9 @@ struct ProjectSidebar: View { private func newContainerWorkroomButton( on runtime: RemoteWorkrooms.Runtime, in project: Project ) -> some View { - let reason = RemoteWorkrooms.unavailability(of: runtime) + // The runtime's own reason first: it outlasts the create in flight. + let reason = + RemoteWorkrooms.unavailability(of: runtime) ?? store.createBlockedReason(in: project) return Button { Task { await store.createRemoteWorkroom(in: project, runtime: runtime) } } label: { @@ -297,7 +299,9 @@ struct ProjectSidebar: View { // or a local container. Without the preview there is only this Mac, so no submenu. if RemoteWorkrooms.isEnabled { Menu { - newLocalWorkroomButton(Text("This Mac"), in: project) + newLocalWorkroomButton( + Text(store.createBlockedReason(in: project).map { "This Mac — \($0)" } ?? "This Mac"), + in: project) Section("Local containers") { ForEach(RemoteWorkrooms.Runtime.allCases, id: \.self) { runtime in newContainerWorkroomButton(on: runtime, in: project) diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index 5a16dda63..c25302335 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -216,6 +216,19 @@ final class RemoteHostsTests: XCTestCase { XCTAssertFalse(store.canCreateRemoteWorkroom(in: project)) } + /// The New Workroom menu says why its entries are off while a create holds the project, or a + /// delete is taking it (#309). + @MainActor + func testACreateBlockedProjectSaysWhy() { + let store = AppStore() + let project = Project(path: "/proj", vcs: "git", workrooms: []) + XCTAssertNil(store.createBlockedReason(in: project)) + store.busyProjects["/proj"] = 1 + XCTAssertEqual(store.createBlockedReason(in: project), "a workroom is already being created") + store.deletingProjects.insert("/proj") + XCTAssertEqual(store.createBlockedReason(in: project), "the project is being deleted") + } + /// No watcher refreshes a remote tree, so coming back to it reads it again; the same local path /// would be a no-op. @MainActor diff --git a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift index 238dff501..f303d8fc5 100644 --- a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift +++ b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift @@ -65,9 +65,27 @@ final class NewWorkroomPlacesUITests: XCTestCase { .firstMatch XCTAssertTrue(progress.waitForExistence(timeout: 10), "the row shows no download progress") XCTAssertEqual(progress.value as? Double, 0.42) - progress.hover() - sleep(2) // for the tooltip attach(app, "image-pull-progress") + + // Meanwhile the project's New Workroom entries say why they are off. + app.otherElements["sidebar.project.UITestProject"].rightClick() + app.menuItems["New Workroom"].hover() + let busy = " — a workroom is already being created" + let thisMac = app.menuItems["This Mac\(busy)"] + XCTAssertTrue(thisMac.waitForExistence(timeout: 5), "This Mac doesn't say why it's off") + XCTAssertFalse(thisMac.isEnabled) + for runtime in ["Docker", "Apple Container"] { + let entry = app.menuItems.matching( + NSPredicate(format: "title BEGINSWITH %@", "\(runtime) — ") + ).firstMatch + XCTAssertTrue(entry.waitForExistence(timeout: 5), "\(runtime) isn't listed") + XCTAssertFalse(entry.isEnabled, entry.title) + } + let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + shot.name = "new-workroom-busy" + shot.lifetime = .keepAlways + add(shot) + app.typeKey(.escape, modifierFlags: []) } private func attach(_ app: XCUIApplication, _ name: String) { From 2ef2432faddbc106ccf3a2c604617c1c7928d86d Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 17:32:12 +0100 Subject: [PATCH 17/29] fix(macapp): say once why New Workroom's entries are off (#309) The reason a create in flight blocks the project was repeated after every entry. It is now one line at the top of the submenu, above entries that are simply greyed out. A runtime's own reason (not installed, needs macOS 26) stays on its entry, since it is that entry's alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Views/ProjectSidebar.swift | 15 +++++++++------ .../NewWorkroomPlacesUITests.swift | 14 +++++++++----- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/macapp/WorkroomApp/Views/ProjectSidebar.swift b/macapp/WorkroomApp/Views/ProjectSidebar.swift index 132f1df29..ac4964d5a 100644 --- a/macapp/WorkroomApp/Views/ProjectSidebar.swift +++ b/macapp/WorkroomApp/Views/ProjectSidebar.swift @@ -206,9 +206,9 @@ struct ProjectSidebar: View { private func newContainerWorkroomButton( on runtime: RemoteWorkrooms.Runtime, in project: Project ) -> some View { - // The runtime's own reason first: it outlasts the create in flight. - let reason = - RemoteWorkrooms.unavailability(of: runtime) ?? store.createBlockedReason(in: project) + // The runtime's own reason only: one that holds the whole project (a create in flight) is said + // once, above the entries. + let reason = RemoteWorkrooms.unavailability(of: runtime) return Button { Task { await store.createRemoteWorkroom(in: project, runtime: runtime) } } label: { @@ -299,9 +299,12 @@ struct ProjectSidebar: View { // or a local container. Without the preview there is only this Mac, so no submenu. if RemoteWorkrooms.isEnabled { Menu { - newLocalWorkroomButton( - Text(store.createBlockedReason(in: project).map { "This Mac — \($0)" } ?? "This Mac"), - in: project) + if let blocked = store.createBlockedReason(in: project) { + // Said once for every entry, rather than after each. + Text(blocked.prefix(1).uppercased() + blocked.dropFirst()) + Divider() + } + newLocalWorkroomButton(Text("This Mac"), in: project) Section("Local containers") { ForEach(RemoteWorkrooms.Runtime.allCases, id: \.self) { runtime in newContainerWorkroomButton(on: runtime, in: project) diff --git a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift index f303d8fc5..b829c391b 100644 --- a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift +++ b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift @@ -67,19 +67,23 @@ final class NewWorkroomPlacesUITests: XCTestCase { XCTAssertEqual(progress.value as? Double, 0.42) attach(app, "image-pull-progress") - // Meanwhile the project's New Workroom entries say why they are off. + // Meanwhile the project's New Workroom says once why its entries are off, and each is off. app.otherElements["sidebar.project.UITestProject"].rightClick() app.menuItems["New Workroom"].hover() - let busy = " — a workroom is already being created" - let thisMac = app.menuItems["This Mac\(busy)"] - XCTAssertTrue(thisMac.waitForExistence(timeout: 5), "This Mac doesn't say why it's off") + XCTAssertTrue( + app.menuItems["A workroom is already being created"].waitForExistence(timeout: 5), + "the submenu doesn't say why its entries are off") + let thisMac = app.menuItems["This Mac"] + XCTAssertTrue(thisMac.waitForExistence(timeout: 5)) XCTAssertFalse(thisMac.isEnabled) for runtime in ["Docker", "Apple Container"] { let entry = app.menuItems.matching( - NSPredicate(format: "title BEGINSWITH %@", "\(runtime) — ") + NSPredicate(format: "title == %@ OR title BEGINSWITH %@", runtime, "\(runtime) — ") ).firstMatch XCTAssertTrue(entry.waitForExistence(timeout: 5), "\(runtime) isn't listed") XCTAssertFalse(entry.isEnabled, entry.title) + XCTAssertFalse( + entry.title.contains("already being created"), "the reason is repeated: \(entry.title)") } let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) shot.name = "new-workroom-busy" From 04fe78d4c22650f10398a355d04e9f49b0b5c8f7 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 17:47:47 +0100 Subject: [PATCH 18/29] feat(macapp): the row's + asks where, and a workroom's icon says what host it's on (#309) With the remote preview on, a project row's "+" opens the same places menu as the context menu's New Workroom: This Mac, Docker, Apple Container. Both come from one builder, so they can't drift. The menu is drawn as a plain button, so the "+" looks exactly as it did; checked against a screenshot of the preview-off button. Without the preview, "+" still creates on this Mac at once. A remote workroom's network icon now says what kind of host it is on in its tooltip: "Docker container (orbstack) on this Mac", "Apple container on this Mac", or a provider by its driver's name, instead of just "Remote workroom". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/Models.swift | 15 +++ macapp/WorkroomApp/Views/ProjectSidebar.swift | 92 +++++++++++++------ .../WorkroomAppTests/RemoteHostsTests.swift | 13 +++ .../NewWorkroomPlacesUITests.swift | 37 ++++++++ 4 files changed, 131 insertions(+), 26 deletions(-) diff --git a/macapp/WorkroomApp/Core/Models.swift b/macapp/WorkroomApp/Core/Models.swift index 4c88cc2e0..259892207 100644 --- a/macapp/WorkroomApp/Core/Models.swift +++ b/macapp/WorkroomApp/Core/Models.swift @@ -45,6 +45,21 @@ struct HostDescriptor: Codable, Hashable { /// relay instead of the broker (#309); nil for the broker. var credentials: String? = nil + /// What kind of host this is, for the workroom's icon in the sidebar (#309): a container on this + /// Mac, on Docker (naming its context when it has one) or Apple's runtime, or a remote provider by + /// its driver's name. + var kindDescription: String { + switch driver.flatMap(RemoteWorkrooms.Runtime.init(rawValue:)) { + case .docker: + let context = container?.context.map { " (\($0))" } ?? "" + return "Docker container\(context) on this Mac" + case .apple: + return "Apple container on this Mac" + case nil: + return driver.map { "Remote workroom on \($0)" } ?? "Remote workroom" + } + } + /// Whether git's credentials on this host come through the Mac's relay (#309). var isRelayed: Bool { credentials == "relay" } diff --git a/macapp/WorkroomApp/Views/ProjectSidebar.swift b/macapp/WorkroomApp/Views/ProjectSidebar.swift index ac4964d5a..c0859d884 100644 --- a/macapp/WorkroomApp/Views/ProjectSidebar.swift +++ b/macapp/WorkroomApp/Views/ProjectSidebar.swift @@ -188,6 +188,23 @@ struct ProjectSidebar: View { // MARK: Rows + /// Where a new workroom of `project` can go (#309), for both doors to it: the row's "+" and its + /// context menu's New Workroom. + @ViewBuilder + private func newWorkroomPlaces(in project: Project) -> some View { + if let blocked = store.createBlockedReason(in: project) { + // Said once for every entry, rather than after each. + Text(blocked.prefix(1).uppercased() + blocked.dropFirst()) + Divider() + } + newLocalWorkroomButton(Text("This Mac"), in: project) + Section("Local containers") { + ForEach(RemoteWorkrooms.Runtime.allCases, id: \.self) { runtime in + newContainerWorkroomButton(on: runtime, in: project) + } + } + } + /// New Workroom on this Mac. Disabled while a create is in flight, matching the row's own "+" /// button (issue #167): ungated, the menu was one right-click away from N concurrent pre-name /// creates, only one of which can own the loader slot. @@ -268,8 +285,17 @@ struct ProjectSidebar: View { ) { store.pendingProjectSettings = PendingProjectSettings(project: project) } - CreateRowButton(help: "New workroom in \(project.displayName)") { - Task { await store.createWorkroom(in: project) } + Group { + // With the preview on, "+" asks where, as the context menu does (#309). + if RemoteWorkrooms.isEnabled { + CreateRowButton( + help: "New workroom in \(project.displayName), on this Mac or in a local container", + places: { newWorkroomPlaces(in: project) }) + } else { + CreateRowButton(help: "New workroom in \(project.displayName)") { + Task { await store.createWorkroom(in: project) } + } + } } .opacity(busy ? 0 : 1) .allowsHitTesting(!busy) @@ -299,17 +325,7 @@ struct ProjectSidebar: View { // or a local container. Without the preview there is only this Mac, so no submenu. if RemoteWorkrooms.isEnabled { Menu { - if let blocked = store.createBlockedReason(in: project) { - // Said once for every entry, rather than after each. - Text(blocked.prefix(1).uppercased() + blocked.dropFirst()) - Divider() - } - newLocalWorkroomButton(Text("This Mac"), in: project) - Section("Local containers") { - ForEach(RemoteWorkrooms.Runtime.allCases, id: \.self) { runtime in - newContainerWorkroomButton(on: runtime, in: project) - } - } + newWorkroomPlaces(in: project) } label: { Label("New Workroom", systemImage: "plus") } @@ -419,7 +435,7 @@ struct ProjectSidebar: View { if target.unavailability == .remote { Image(systemName: "network") .foregroundStyle(.secondary) - .help("Remote workroom") + .help(workroom.host?.kindDescription ?? "Remote workroom") } // Spinner/delete slot: a progress spinner while the workroom's setup runs (issue #116) or a // command runs (issue #28), swapped for the delete button on hover — so a workroom stays @@ -721,24 +737,48 @@ private struct SetupSpinner: View { /// The always-visible "new workroom" button on a project row. Its own hover paints a /// subtle neutral background to read as an actionable control. -private struct CreateRowButton: View { +private struct CreateRowButton: View { let help: String - let action: () -> Void + /// What a click does, or nil when it opens `places` instead (#309). + let action: (() -> Void)? + let places: (() -> Places)? @State private var hovering = false private let theme = ThemeService.shared + init(help: String, action: @escaping () -> Void) where Places == EmptyView { + (self.help, self.action, self.places) = (help, action, nil) + } + + init(help: String, @ViewBuilder places: @escaping () -> Places) { + (self.help, self.action, self.places) = (help, nil, places) + } + + private var glyph: some View { + Image(systemName: "plus") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(4) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(theme.tokens.hover.opacity(hovering ? 1 : 0)) + ) + } + var body: some View { - Button(action: action) { - Image(systemName: "plus") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .padding(4) - .background( - RoundedRectangle(cornerRadius: 5) - .fill(theme.tokens.hover.opacity(hovering ? 1 : 0)) - ) + Group { + if let places { + // A menu that looks exactly like the button it replaces: drawn by SwiftUI as a plain + // button, so the glyph keeps its size, weight and colour, with no arrow. + Menu(content: places) { glyph.accessibilityLabel(help) } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + } else if let action { + Button(action: action) { glyph } + .buttonStyle(.plain) + } } - .buttonStyle(.plain) .onHover { hovering = $0 } .help(help) .accessibilityLabel(help) diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index c25302335..e566512f0 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -216,6 +216,19 @@ final class RemoteHostsTests: XCTestCase { XCTAssertFalse(store.canCreateRemoteWorkroom(in: project)) } + /// A workroom's icon says what kind of host it is on (#309). + func testAHostSaysWhatKindItIs() { + let host = { (driver: String?, context: String?) in + HostDescriptor(driver: driver, container: Self.record(context: context)) + } + XCTAssertEqual(host("container", nil).kindDescription, "Docker container on this Mac") + XCTAssertEqual( + host("container", "orbstack").kindDescription, "Docker container (orbstack) on this Mac") + XCTAssertEqual(host("apple-container", nil).kindDescription, "Apple container on this Mac") + XCTAssertEqual(host("boxd", nil).kindDescription, "Remote workroom on boxd") + XCTAssertEqual(host(nil, nil).kindDescription, "Remote workroom") + } + /// The New Workroom menu says why its entries are off while a create holds the project, or a /// delete is taking it (#309). @MainActor diff --git a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift index b829c391b..0daac55fc 100644 --- a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift +++ b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift @@ -99,8 +99,45 @@ final class NewWorkroomPlacesUITests: XCTestCase { add(shot) } + /// The row's "+" asks where too, with the same places as the context menu. + func testTheRowsPlusAsksWhereWithThePreviewOn() { + let app = launch(preview: true) + app.typeKey(.escape, modifierFlags: []) // the context menu `launch` opened + let plus = app.menuButtons.matching( + NSPredicate(format: "title BEGINSWITH %@", "New workroom in UITestProject, on this Mac") + ).firstMatch + XCTAssertTrue(plus.waitForExistence(timeout: 5), "the row has no places +") + plus.hover() + let idle = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + idle.name = "plus-places-idle" + idle.lifetime = .keepAlways + add(idle) + plus.click() + XCTAssertTrue(app.menuItems["This Mac"].waitForExistence(timeout: 5), "+ didn't ask where") + for runtime in ["Docker", "Apple Container"] { + XCTAssertTrue( + app.menuItems.matching( + NSPredicate(format: "title == %@ OR title BEGINSWITH %@", runtime, "\(runtime) — ") + ).firstMatch.exists, "\(runtime) isn't listed") + } + let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + shot.name = "plus-places" + shot.lifetime = .keepAlways + add(shot) + app.typeKey(.escape, modifierFlags: []) + } + func testNewWorkroomIsOneItemWithThePreviewOff() { let app = launch(preview: false) + defer { + // The row's "+" as it looks without the preview, to compare with the places one. + app.typeKey(.escape, modifierFlags: []) + app.otherElements["sidebar.project.UITestProject"].hover() + let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + shot.name = "plus-plain" + shot.lifetime = .keepAlways + add(shot) + } XCTAssertTrue(app.menuItems["New Workroom"].waitForExistence(timeout: 5)) XCTAssertFalse(app.menuItems["This Mac"].exists, "a submenu appeared without the preview") XCTAssertFalse(app.menuItems["New Remote Workroom"].exists) From f88d92088b1fe6a28857d9ab4504233ec06302e5 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 17:50:44 +0100 Subject: [PATCH 19/29] feat(macapp): mark a workroom on a host with the network glyph on its tabs and panes (#309) A workroom tab, a pane's title bar and a detached pane's title showed the cube for every workroom. One on a host (a container on this Mac or a remote provider) now wears the network glyph instead, the sidebar's own sign for it, with the host's kind ("Docker container (orbstack) on this Mac") as the tab's and pane's tooltip. One on this Mac keeps the cube, and a root its house. TerminalTarget.workroomGlyph is the one rule every site reads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/AppStore.swift | 5 +-- .../Core/DetachedPaneController.swift | 32 +++++++++++-------- macapp/WorkroomApp/Core/Models.swift | 9 +++++- .../WorkroomApp/Views/WorkroomSplitView.swift | 3 +- macapp/WorkroomApp/Views/WorkroomTabBar.swift | 3 +- .../WorkroomAppTests/RemoteHostsTests.swift | 23 +++++++++++++ 6 files changed, 57 insertions(+), 18 deletions(-) diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index a658eece4..22c98ba71 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -1458,7 +1458,7 @@ final class AppStore: ObservableObject { let location = detachedLocation(for: target) detachedPanes.open( tabID: tabID, title: tab.title, project: location.project, workroom: location.workroom, - origin: origin, frame: frame, + glyph: target.workroomGlyph, origin: origin, frame: frame, onCloseTab: { [weak self] in self?.requestCloseTerminalTab(tabID, for: target) }, onDock: { [weak self] in self?.dockPane(tabID, for: target) }, content: { @@ -1469,7 +1469,8 @@ final class AppStore: ObservableObject { guard let self else { return } let location = self.detachedLocation(for: target) self.detachedPanes.setTitle( - title, project: location.project, workroom: location.workroom, for: tabID) + title, project: location.project, workroom: location.workroom, + glyph: target.workroomGlyph, for: tabID) } ) }) diff --git a/macapp/WorkroomApp/Core/DetachedPaneController.swift b/macapp/WorkroomApp/Core/DetachedPaneController.swift index 412756abc..d38029586 100644 --- a/macapp/WorkroomApp/Core/DetachedPaneController.swift +++ b/macapp/WorkroomApp/Core/DetachedPaneController.swift @@ -116,16 +116,19 @@ final class DetachedPaneWindows { /// `title` also stays on the window for the Window menu and Mission Control, which read it even /// though the title bar itself draws the toolbar's label. func setTitle( - _ title: String, project: String, workroom: String?, for tabID: TerminalTab.ID + _ title: String, project: String, workroom: String?, glyph: String = "cube", + for tabID: TerminalTab.ID ) { windows[tabID]?.title = title - toolbarDelegates[tabID]?.update(title: title, project: project, workroom: workroom) + toolbarDelegates[tabID]?.update( + title: title, project: project, workroom: workroom, glyph: glyph) } /// Open a window for a freshly-detached pane. `origin` is a SCREEN point (the cursor at drop time); /// the window is placed so its top-left lands there, then nudged onto a visible screen. func open( - tabID: TerminalTab.ID, title: String, project: String, workroom: String?, origin: CGPoint?, + tabID: TerminalTab.ID, title: String, project: String, workroom: String?, + glyph: String = "cube", origin: CGPoint?, frame: NSRect? = nil, onCloseTab: @escaping () -> Void, onDock: @escaping () -> Void, content: () -> Content ) { @@ -158,7 +161,7 @@ final class DetachedPaneWindows { // `.unifiedCompact` rather than `.unified`: the tall variant leaves a band of empty space under // the title, which on a window whose whole job is to show one pane is just less pane. let toolbarDelegate = ToolbarDelegate( - title: title, project: project, workroom: workroom, onDock: onDock) + title: title, project: project, workroom: workroom, glyph: glyph, onDock: onDock) let toolbar = NSToolbar(identifier: "detachedPane") toolbar.delegate = toolbarDelegate toolbar.showsBaselineSeparator = false @@ -231,27 +234,30 @@ final class DetachedPaneWindows { private let label = NSTextField(labelWithString: "") private let onDock: () -> Void - init(title: String, project: String, workroom: String?, onDock: @escaping () -> Void) { + init( + title: String, project: String, workroom: String?, glyph: String, + onDock: @escaping () -> Void + ) { self.onDock = onDock super.init() label.attributedStringValue = Self.attributed( - title: title, project: project, workroom: workroom) + title: title, project: project, workroom: workroom, glyph: glyph) label.lineBreakMode = .byTruncatingTail label.setAccessibilityIdentifier("detachedPane.title") } - func update(title: String, project: String, workroom: String?) { + func update(title: String, project: String, workroom: String?, glyph: String) { label.attributedStringValue = Self.attributed( - title: title, project: project, workroom: workroom) + title: title, project: project, workroom: workroom, glyph: glyph) } /// `Terminal 2 ⌂ my-project · ▣ my-workroom` — the pane's name, then where it lives, in /// smaller secondary type. The glyphs are the sidebar's own vocabulary (`ProjectSidebar`: `house` /// for a project root, `cube` for a workroom), so the window names the pane the same way the - /// sidebar does. - private static func attributed(title: String, project: String, workroom: String?) - -> NSAttributedString - { + /// sidebar does, and a workroom on a host takes the network (`TerminalTarget.workroomGlyph`). + private static func attributed( + title: String, project: String, workroom: String?, glyph: String + ) -> NSAttributedString { let small = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) let secondary: [NSAttributedString.Key: Any] = [ .font: small, .foregroundColor: NSColor.secondaryLabelColor, @@ -272,7 +278,7 @@ final class DetachedPaneWindows { result.append(NSAttributedString(string: project, attributes: secondary)) if let workroom { result.append(NSAttributedString(string: " · ", attributes: secondary)) - result.append(symbol("cube", font: small)) + result.append(symbol(glyph, font: small)) result.append(NSAttributedString(string: workroom, attributes: secondary)) } return result diff --git a/macapp/WorkroomApp/Core/Models.swift b/macapp/WorkroomApp/Core/Models.swift index 259892207..e15d3eaeb 100644 --- a/macapp/WorkroomApp/Core/Models.swift +++ b/macapp/WorkroomApp/Core/Models.swift @@ -215,9 +215,16 @@ struct TerminalTarget: Identifiable, Hashable { /// Why a remote workroom's panes don't open here, when that isn't simply this build /// (`Workroom.remoteNote`). var remoteNote: String? = nil + /// What kind of host a remote workroom is on (`HostDescriptor.kindDescription`), for its icon's + /// tooltip (#309). + var hostKind: String? = nil var isMissing: Bool { unavailability != nil } + /// The glyph that marks a workroom: the network for one on a host, a container on this Mac or a + /// remote provider (#309), and a cube for one on this Mac itself. A root takes a house instead. + var workroomGlyph: String { isRemoteWorkroom ? "network" : "cube" } + /// A remote workroom, reachable or not: its path names nothing on this Mac. var isRemoteWorkroom: Bool { remoteHost != nil || unavailability == .remote || unavailability == .hostDestroyed @@ -299,7 +306,7 @@ extension Workroom { TerminalTarget( id: TerminalTarget.workroomID(project: projectPath, name: name), title: displayName, path: path, unavailability: unavailability, - remoteHost: reachableHost, remoteNote: remoteNote) + remoteHost: reachableHost, remoteNote: remoteNote, hostKind: host?.kindDescription) } /// Why this remote workroom's panes don't open here, for one this build could otherwise reach: diff --git a/macapp/WorkroomApp/Views/WorkroomSplitView.swift b/macapp/WorkroomApp/Views/WorkroomSplitView.swift index c532b7992..7f50e4d65 100644 --- a/macapp/WorkroomApp/Views/WorkroomSplitView.swift +++ b/macapp/WorkroomApp/Views/WorkroomSplitView.swift @@ -499,9 +499,10 @@ private struct WorkroomPaneTitleBar: View { HStack(spacing: 6) { // Leading glyph mirrors the tab chip's: a house for a project root, a cube for a workroom // (`workroomName` is nil only for a root). Accent on the focused pane, muted otherwise. - Image(systemName: workroomName == nil ? "house" : "cube") + Image(systemName: workroomName == nil ? "house" : target.workroomGlyph) .font(.system(size: 10)) .foregroundStyle(focused ? theme.tokens.accent : theme.tokens.fgMuted) + .help(workroomName == nil ? "" : target.hostKind ?? "") if let reason = target.terminalUnavailability { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 10)) diff --git a/macapp/WorkroomApp/Views/WorkroomTabBar.swift b/macapp/WorkroomApp/Views/WorkroomTabBar.swift index 3d361fe91..71d98bb8c 100644 --- a/macapp/WorkroomApp/Views/WorkroomTabBar.swift +++ b/macapp/WorkroomApp/Views/WorkroomTabBar.swift @@ -534,9 +534,10 @@ private struct WorkroomTabChip: View { HStack(spacing: 6) { // Leading glyph: a house marks a project root, a cube an isolated workroom — set before the name. // Its tint carries the VCS dirty signal (orange) in place of a separate status dot. - Image(systemName: isRoot ? "house" : "cube") + Image(systemName: isRoot ? "house" : target.workroomGlyph) .font(.system(size: 10)) .foregroundStyle(VCSStatusPresentation.iconTint(store.workroomStatuses[sid] ?? .unresolved)) + .help(isRoot ? "" : target.hostKind ?? "") if let reason = target.terminalUnavailability { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 10)) diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index e566512f0..949bbb799 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -216,6 +216,29 @@ final class RemoteHostsTests: XCTestCase { XCTAssertFalse(store.canCreateRemoteWorkroom(in: project)) } + /// A workroom on a host, a container or a remote provider, wears the network glyph on its tab and + /// pane in place of the cube, with what kind of host it is as the tooltip; one on this Mac keeps + /// the cube (#309). + func testAWorkroomOnAHostWearsTheNetworkGlyph() { + let local = Workroom(name: "w", path: "/tmp/w", vcsName: "workroom/w", warnings: []) + .target(inProject: "/proj") + XCTAssertEqual(local.workroomGlyph, "cube") + XCTAssertNil(local.hostKind) + let docker = Workroom( + name: "d", path: "/home/workroom/r", vcsName: "workroom/d", warnings: [], + host: HostDescriptor( + driver: "container", provisioner: RemoteWorkrooms.provisioner, id: UUID(), + container: Self.record(context: "orbstack")) + ).target(inProject: "/proj") + XCTAssertEqual(docker.workroomGlyph, "network") + XCTAssertEqual(docker.hostKind, "Docker container (orbstack) on this Mac") + let destroyed = Workroom( + name: "x", path: "/home/workroom/r", vcsName: "workroom/x", warnings: [], + host: HostDescriptor(state: "destroyed", driver: "apple-container") + ).target(inProject: "/proj") + XCTAssertEqual(destroyed.workroomGlyph, "network") + } + /// A workroom's icon says what kind of host it is on (#309). func testAHostSaysWhatKindItIs() { let host = { (driver: String?, context: String?) in From 02a6c9a9747d97463da4bfc8d5202b8f9ac5b84b Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 18:33:10 +0100 Subject: [PATCH 20/29] feat(macapp): a remote workroom's sidebar icon is its host glyph, not a trailing badge (#309) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Views/ProjectSidebar.swift | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/macapp/WorkroomApp/Views/ProjectSidebar.swift b/macapp/WorkroomApp/Views/ProjectSidebar.swift index c0859d884..d3749f5cc 100644 --- a/macapp/WorkroomApp/Views/ProjectSidebar.swift +++ b/macapp/WorkroomApp/Views/ProjectSidebar.swift @@ -431,12 +431,6 @@ struct ProjectSidebar: View { .foregroundStyle(.yellow) .help(warning.message) } - // A destroyed host already shows its HostDestroyed warning above. - if target.unavailability == .remote { - Image(systemName: "network") - .foregroundStyle(.secondary) - .help(workroom.host?.kindDescription ?? "Remote workroom") - } // Spinner/delete slot: a progress spinner while the workroom's setup runs (issue #116) or a // command runs (issue #28), swapped for the delete button on hover — so a workroom stays // deletable even mid-run. The delete button is always laid out (it reveals via opacity), so it @@ -524,11 +518,14 @@ struct ProjectSidebar: View { store.toggleTerminals(for: target.id) } } else { - // The glyph's tint carries the VCS dirty signal (orange) in place of a separate status dot. - Image(systemName: root ? "house" : "cube") + // The glyph's tint carries the VCS dirty signal (orange) in place of a separate status dot. A + // workroom on a host wears the network, with the host's kind as its tooltip (#309). + Image(systemName: root ? "house" : target.workroomGlyph) .font(.system(size: 10)) .foregroundStyle(VCSStatusPresentation.iconTint(store.workroomStatuses[id] ?? .unresolved)) .frame(width: caretWidth, height: 18, alignment: .center) + .help(root ? "" : target.hostKind ?? "") + .accessibilityLabel(root ? "Project root" : target.hostKind ?? "Workroom") } } From dcad15a52ef8524541017f9e8e56af6a43984882 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 18:50:23 +0100 Subject: [PATCH 21/29] feat(macapp): New Workroom's picker asks where too (#309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the remote preview on, picking a project in File ▸ New Workroom… (⌘N, ⌥⌘N and the tab bar's +) no longer creates on this Mac straight away: it asks This Mac, Docker or Apple Container, with the sidebar menu's rules for what's off and why. A container workroom can now land as a split, as one on this Mac does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomApp/Core/AppStore.swift | 12 +- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 2 + .../WorkroomApp/Views/NewWorkroomDialog.swift | 262 ++++++++++++++---- macapp/WorkroomApp/Views/ProjectSidebar.swift | 2 +- .../NewWorkroomPlacesUITests.swift | 58 ++++ 5 files changed, 285 insertions(+), 51 deletions(-) diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 22c98ba71..0ca768cd3 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -3541,9 +3541,9 @@ final class AppStore: ObservableObject { /// Creates a container workroom for `project` on `runtime` (#253, #309), derived from the /// project's base machine (built first if it has none), then selects it, which opens its first - /// pane on the far side. + /// pane on the far side. `splitAnchor` lands it beside that workroom instead, as `createWorkroom`. func createRemoteWorkroom( - in project: Project, runtime: RemoteWorkrooms.Runtime = .docker + in project: Project, runtime: RemoteWorkrooms.Runtime = .docker, splitAnchor: SidebarID? = nil ) async { guard canCreateRemoteWorkroom(in: project) else { return } beginBusy(project.path) @@ -3582,7 +3582,13 @@ final class AppStore: ObservableObject { await created.instance.connection.close() await reload() selectedProjectID = project.id - selectedTargetID = .workroom(project: project.path, name: created.name) + let sid = SidebarID.workroom(project: project.path, name: created.name) + let landedInSplit = + splitAnchor.map { + insertWorkroomSplit( + sid, beside: $0, edge: .right, destinationRect: workroomPaneRect(for: $0)) + } ?? false + if !landedInSplit { selectedTargetID = sid } } catch { await reload() present(error) diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index c4ced7e94..e10cd093e 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -31,6 +31,8 @@ enum RemoteWorkrooms { case apple = "apple-container" var displayName: String { self == .docker ? "Docker" : "Apple Container" } + /// One of its containers, in a sentence: "a Docker container", "an Apple container". + var containerPhrase: String { self == .docker ? "a Docker container" : "an Apple container" } } /// The descriptor's `driver` for a Docker host. diff --git a/macapp/WorkroomApp/Views/NewWorkroomDialog.swift b/macapp/WorkroomApp/Views/NewWorkroomDialog.swift index 1229b4974..1baf59a83 100644 --- a/macapp/WorkroomApp/Views/NewWorkroomDialog.swift +++ b/macapp/WorkroomApp/Views/NewWorkroomDialog.swift @@ -12,6 +12,10 @@ import SwiftUI /// replacing it (issue #163); raised by ⌥⌘N the whole dialog is already in split mode, so a plain /// ⏎ splits — which is why the title and footer are derived from `PickerSplitIntent`, not fixed. /// +/// With the remote preview on (#309), picking a project doesn't create yet: the dialog then asks +/// where, This Mac or a local container, as the sidebar's New Workroom menu does, and the place +/// picked creates. +/// /// ┌─ "New Workroom [(split right)]" ─ Done ─┐ /// │ 🔍 [ filter… ] │ ← auto-focused; single-line, so ↑/↓/⏎ bubble up /// │ ┌─────────────────────────────────┐ │ @@ -34,6 +38,11 @@ struct NewWorkroomDialog: View { /// Index into `filtered` of the keyboard-highlighted row (↑/↓ move it, ⏎ / click pick it). @State private var highlighted = 0 @FocusState private var searchFocused: Bool + /// The project picked while the remote preview is on (#309), whose workroom's place is asked next. + @State private var placing: Project? + /// Where that workroom lands beside, taken when its project was picked. + @State private var placeAnchor: SidebarID? + @FocusState private var placesFocused: Bool private var filtered: [Project] { ProjectPickerModel.filtered(store.projects, query: query) @@ -42,79 +51,163 @@ struct NewWorkroomDialog: View { /// Pick a project: dismiss first, then kick off the (async) create+open. `createWorkroom` mounts /// and selects the new workroom, so the detail pane opens it — no extra wiring here. private func pick(_ project: Project, split: Bool = false) { - onClose() // Capture the anchor NOW, not at landing: the create is async and the user can select a // different workroom while a setup script runs. let anchor = (split || splitIntent) ? store.selectedTargetID : nil + if RemoteWorkrooms.isEnabled { + placing = project + placeAnchor = anchor + highlighted = WorkroomPlace.all.firstIndex { isUsable($0, in: project) } ?? 0 + return + } + onClose() Task { await store.createWorkroom(in: project, splitAnchor: anchor) } } + /// Creates `project`'s workroom at `place`, unless it can't go there now. + private func pick(_ place: WorkroomPlace, in project: Project, split: Bool = false) { + guard isUsable(place, in: project) else { return } + onClose() + let anchor = placeAnchor ?? (split ? store.selectedTargetID : nil) + Task { + switch place { + case .thisMac: await store.createWorkroom(in: project, splitAnchor: anchor) + case .container(let runtime): + await store.createRemoteWorkroom(in: project, runtime: runtime, splitAnchor: anchor) + } + } + } + + /// Whether a workroom of `project` can be created at `place` now. The same rules as the sidebar's + /// New Workroom menu. + private func isUsable(_ place: WorkroomPlace, in project: Project) -> Bool { + switch place { + case .thisMac: !store.isBusyProject(project.path) + case .container(let runtime): + RemoteWorkrooms.unavailability(of: runtime) == nil + && store.canCreateRemoteWorkroom(in: project) + } + } + + private var rowCount: Int { placing == nil ? filtered.count : WorkroomPlace.all.count } + + /// Where `project`'s workroom can go: the reason none can, said once, then each place, a + /// container one saying why it can't be used. + private func placesList(_ project: Project) -> some View { + VStack(alignment: .leading, spacing: 2) { + if let blocked = store.createBlockedReason(in: project) { + Text(blocked.prefix(1).uppercased() + blocked.dropFirst()) + .font(.footnote) + .foregroundStyle(theme.tokens.fgMuted) + .padding(.horizontal, 8) + .padding(.bottom, 6) + .accessibilityIdentifier("newWorkroom.placesBlocked") + } + ForEach(Array(WorkroomPlace.all.enumerated()), id: \.element) { index, place in + placeRow(place, in: project, isHighlighted: index == highlighted) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + // Nothing in here takes the keyboard, so the list itself does: ↑/↓/⏎ reach the handlers below. + .focusable() + .focusEffectDisabled() + .focused($placesFocused) + .onAppear { placesFocused = true } + } + + private func placeRow(_ place: WorkroomPlace, in project: Project, isHighlighted: Bool) + -> some View + { + let usable = isUsable(place, in: project) + let reason: String? = + if case .container(let runtime) = place { + RemoteWorkrooms.unavailability(of: runtime) + } else { + nil + } + return Button { + pick(place, in: project, split: PickerSplitIntent.requestedFromCurrentModifiers()) + } label: { + PickerRow( + icon: place.icon, title: place.name, detail: reason ?? place.detail, + help: reason.map { "\(place.name) can't be used: \($0)" } ?? place.detail, + isHighlighted: isHighlighted, dimmed: !usable) + } + .buttonStyle(.plain) + .disabled(!usable) + .accessibilityIdentifier("newWorkroom.place.\(place.id)") + } + private func projectRow(_ project: Project, isHighlighted: Bool) -> some View { - ProjectRow(project: project, isHighlighted: isHighlighted) - .contentShape(Rectangle()) - .onTapGesture { pick(project, split: PickerSplitIntent.requestedFromCurrentModifiers()) } - .accessibilityIdentifier("newWorkroom.project.\(project.displayName)") + PickerRow( + icon: "folder", title: project.displayName, detail: project.path, help: project.path, + detailTruncation: .head, isHighlighted: isHighlighted + ) + .contentShape(Rectangle()) + .onTapGesture { pick(project, split: PickerSplitIntent.requestedFromCurrentModifiers()) } + .accessibilityIdentifier("newWorkroom.project.\(project.displayName)") } var body: some View { VStack(spacing: 0) { HStack { - Text(PickerSplitIntent.title(open: false, split: splitIntent)).font(.headline) + if let placing { + Text("New Workroom in \(placing.displayName)").font(.headline) + } else { + Text(PickerSplitIntent.title(open: false, split: splitIntent)).font(.headline) + } Spacer() + if placing != nil { + Button("Back") { + placing = nil + placeAnchor = nil + highlighted = 0 + searchFocused = true + } + .accessibilityIdentifier("newWorkroom.back") + } Button("Cancel") { onClose() }.keyboardShortcut(.cancelAction) } .padding(12) Divider() - searchField - - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 2) { - if filtered.isEmpty { - Text("No projects match “\(query)”") - .font(.footnote) - .foregroundStyle(theme.tokens.fgMuted) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } else { - ForEach(Array(filtered.enumerated()), id: \.element.id) { index, project in - projectRow(project, isHighlighted: index == highlighted) - .id(project.id) - } - } - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - } - .onChange(of: highlighted) { _, new in - if filtered.indices.contains(new) { - withAnimation(.easeInOut(duration: 0.1)) { proxy.scrollTo(filtered[new].id) } - } - } + if let placing { + placesList(placing) + } else { + searchField + projectList } PickerHintFooter(open: false, split: splitIntent) } .frame(width: 420, height: 460) .onAppear { searchFocused = true } - // ↑/↓ move the highlight (no create); ⏎ picks the highlighted project. The single-line search + // ↑/↓ move the highlight (no create); ⏎ picks the highlighted row. The single-line search // field doesn't consume the arrow keys, so they bubble here (same as ThemePicker). ⏎ is wired // ONLY here — never on the field's `.onSubmit` — so a pick can't double-fire into a double-create. .onKeyPress(.upArrow) { - highlighted = ProjectPickerModel.move(highlight: highlighted, by: -1, count: filtered.count) + highlighted = ProjectPickerModel.move(highlight: highlighted, by: -1, count: rowCount) return .handled } .onKeyPress(.downArrow) { - highlighted = ProjectPickerModel.move(highlight: highlighted, by: 1, count: filtered.count) + highlighted = ProjectPickerModel.move(highlight: highlighted, by: 1, count: rowCount) return .handled } // ⌥⏎ splits, plain ⏎ creates (issue #163). The `keys:` overload is what carries the modifiers; // the plain `.onKeyPress(.return)` closure has none. Still wired ONLY here, never on the // field's `.onSubmit` — a double-fire would be a double *create*. .onKeyPress(keys: [.return]) { press in - if let project = ProjectPickerModel.selection(filtered: filtered, highlight: highlighted) { - pick(project, split: PickerSplitIntent.requested(press.modifiers)) + let split = PickerSplitIntent.requested(press.modifiers) + if let placing { + if WorkroomPlace.all.indices.contains(highlighted) { + pick(WorkroomPlace.all[highlighted], in: placing, split: split) + } + } else if let project = ProjectPickerModel.selection( + filtered: filtered, highlight: highlighted) + { + pick(project, split: split) } return .handled } @@ -122,6 +215,34 @@ struct NewWorkroomDialog: View { .onChange(of: query) { _, _ in highlighted = 0 } } + private var projectList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 2) { + if filtered.isEmpty { + Text("No projects match “\(query)”") + .font(.footnote) + .foregroundStyle(theme.tokens.fgMuted) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } else { + ForEach(Array(filtered.enumerated()), id: \.element.id) { index, project in + projectRow(project, isHighlighted: index == highlighted) + .id(project.id) + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + .onChange(of: highlighted) { _, new in + if filtered.indices.contains(new) { + withAnimation(.easeInOut(duration: 0.1)) { proxy.scrollTo(filtered[new].id) } + } + } + } + } + private var searchField: some View { HStack(spacing: 6) { Image(systemName: "magnifyingglass").foregroundStyle(theme.tokens.fgDim) @@ -191,30 +312,76 @@ struct NewWorkroomPresenter: ViewModifier { } } -/// One project row: the project's display name, with its full path dimmed beneath to disambiguate -/// same-named directories. Highlight + hover styling mirrors `ThemePicker`'s `FamilyRow`. -private struct ProjectRow: View { +/// Where a new workroom can go (#309): this Mac, or a local container on one of the runtimes. +enum WorkroomPlace: Hashable { + case thisMac + case container(RemoteWorkrooms.Runtime) + + static var all: [WorkroomPlace] { + [.thisMac] + RemoteWorkrooms.Runtime.allCases.map { .container($0) } + } + + var name: String { + switch self { + case .thisMac: "This Mac" + case .container(let runtime): runtime.displayName + } + } + + var id: String { + switch self { + case .thisMac: "thisMac" + case .container(let runtime): runtime.rawValue + } + } + + var icon: String { + switch self { + case .thisMac: "laptopcomputer" + case .container: "network" + } + } + + var detail: String { + switch self { + case .thisMac: "A workroom on this Mac" + case .container(let runtime): "A workroom in \(runtime.containerPhrase) on this Mac" + } + } +} + +/// One picker row: a title, with a dimmed detail beneath — a project's full path, to disambiguate +/// same-named directories, or what a place is. Highlight + hover styling mirrors `ThemePicker`'s +/// `FamilyRow`. +private struct PickerRow: View { private let theme = ThemeService.shared - let project: Project + let icon: String + let title: String + let detail: String + let help: String + var detailTruncation: Text.TruncationMode = .tail var isHighlighted = false + /// A row that can't be picked now. + var dimmed = false @State private var hovered = false var body: some View { HStack(spacing: 8) { - Image(systemName: "folder") + Image(systemName: icon) .font(.system(size: 12)) .foregroundStyle(theme.tokens.fgDim) + .frame(width: 16) VStack(alignment: .leading, spacing: 1) { - Text(project.displayName) + Text(title) .font(.system(size: 12, weight: .medium)) - .foregroundStyle(theme.tokens.fg) + .foregroundStyle(dimmed ? theme.tokens.fgMuted : theme.tokens.fg) .lineLimit(1) .truncationMode(.tail) - Text(project.path) + Text(detail) .font(.system(size: 10)) .foregroundStyle(theme.tokens.fgMuted) .lineLimit(1) - .truncationMode(.head) + .truncationMode(detailTruncation) } Spacer(minLength: 0) } @@ -229,7 +396,8 @@ private struct ProjectRow: View { RoundedRectangle(cornerRadius: 6) .strokeBorder(isHighlighted ? theme.tokens.fgDim : .clear, lineWidth: 1.5) ) - .help(project.path) + .contentShape(Rectangle()) + .help(help) .onHover { hovered = $0 } } } diff --git a/macapp/WorkroomApp/Views/ProjectSidebar.swift b/macapp/WorkroomApp/Views/ProjectSidebar.swift index d3749f5cc..80994736c 100644 --- a/macapp/WorkroomApp/Views/ProjectSidebar.swift +++ b/macapp/WorkroomApp/Views/ProjectSidebar.swift @@ -233,7 +233,7 @@ struct ProjectSidebar: View { } .help( reason.map { "\(runtime.displayName) can't be used: \($0)" } - ?? "A workroom of \(project.displayName) in a \(runtime.displayName) container on this Mac" + ?? "A workroom of \(project.displayName) in \(runtime.containerPhrase) on this Mac" ) .disabled(reason != nil || !store.canCreateRemoteWorkroom(in: project)) } diff --git a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift index 0daac55fc..74f183b66 100644 --- a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift +++ b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift @@ -127,6 +127,64 @@ final class NewWorkroomPlacesUITests: XCTestCase { app.typeKey(.escape, modifierFlags: []) } + /// File ▸ New Workroom… (⌘N, and the tab bar's +) asks where too: picking a project offers the + /// same places, rather than creating on this Mac. Nothing is picked, so nothing is created. + func testThePickerAsksWhereWithThePreviewOn() { + let app = openPicker(extraArgs: []) + XCTAssertFalse(app.textFields["newWorkroom.filter"].exists, "still listing projects") + let thisMac = app.buttons["newWorkroom.place.thisMac"] + XCTAssertTrue(thisMac.waitForExistence(timeout: 5), "the picker didn't ask where") + XCTAssertTrue(thisMac.isEnabled) + for runtime in ["container", "apple-container"] { + XCTAssertTrue(app.buttons["newWorkroom.place.\(runtime)"].exists, "\(runtime) isn't offered") + } + XCTAssertFalse(app.staticTexts["newWorkroom.placesBlocked"].exists) + attach(app, "picker-places") + + // Back lists the projects again. + app.buttons["newWorkroom.back"].click() + XCTAssertTrue(app.textFields["newWorkroom.filter"].waitForExistence(timeout: 5)) + app.typeKey(.escape, modifierFlags: []) + } + + /// While a create runs in the project, the picker says so once and offers no place. + func testThePickersPlacesAreOffWhileACreateRuns() { + let app = openPicker(extraArgs: ["-WorkroomUITestImagePull", "0.42"]) + let blocked = app.staticTexts["newWorkroom.placesBlocked"] + XCTAssertTrue(blocked.waitForExistence(timeout: 5), "the picker doesn't say why") + XCTAssertEqual(blocked.value as? String, "A workroom is already being created") + for place in ["thisMac", "container", "apple-container"] { + let button = app.buttons["newWorkroom.place.\(place)"] + XCTAssertTrue(button.exists, "\(place) isn't listed") + XCTAssertFalse(button.isEnabled, "\(place) can be picked while a create runs") + } + attach(app, "picker-places-busy") + app.typeKey(.escape, modifierFlags: []) + } + + /// Opens File ▸ New Workroom… with the preview on, and picks the fixture project. + private func openPicker(extraArgs: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += [ + "-WorkroomUITestFixture", "1", "-WorkroomUITestRemotePreview", "1", + "-ApplePersistenceIgnoreState", "YES", + ] + app.launchArguments += extraArgs + app.launch() + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15)) + XCTAssertTrue( + app.otherElements["sidebar.project.UITestProject"].waitForExistence(timeout: 10), + "fixture project row should exist") + let file = app.menuBars.menuBarItems["File"] + file.click() + file.menuItems["New Workroom…"].click() + let project = app.descendants(matching: .any) + .matching(identifier: "newWorkroom.project.UITestProject").firstMatch + XCTAssertTrue(project.waitForExistence(timeout: 5), "the picker didn't list the project") + project.click() + return app + } + func testNewWorkroomIsOneItemWithThePreviewOff() { let app = launch(preview: false) defer { From 372be59475d44aab5e9a0132ae4e33baa8e13033 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 21:34:47 +0100 Subject: [PATCH 22/29] fix(macapp): address the pre-landing review of local container workrooms (#309) - The credential relay re-forwards when its listener's port moves, retries an install that didn't take, bounds gh however it ends, and only answers a known secret's request from its answering slots, so idle strangers can't hold them. - A derive checks the broker or the Mac's gh token before it copies the base. - New Workroom's menus and picker read sign-in from memory, not the Keychain, and find gh's sign-in where gh does (GH_TOKEN, GH_CONFIG_DIR, XDG). - A create re-reads its project, so a stale picker can't overwrite a base's record, can land as a split, and says when a signed-in user's base fell back to their own gh. - Opening a workroom starts its container once, even mid-probe; a container stopped later stays stopped. - Image errors say why (runtime not running, registry refused, network); the download ring clears when the pull ends; stderr counts against silence. - Each remote workroom carries its own base's repository; Apple's sweep removes leftover derive snapshots. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- docs/designs/remote-workrooms.md | 30 ++- macapp/WorkroomApp/Core/AppStore.swift | 42 ++- .../Core/Broker/BrokerSession.swift | 5 + .../Core/ContainerHostDriver.swift | 65 ++++- macapp/WorkroomApp/Core/CredentialRelay.swift | 120 ++++++--- macapp/WorkroomApp/Core/HostDriver.swift | 15 +- .../WorkroomApp/Core/RemoteProvisioning.swift | 11 +- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 97 +++++-- .../Core/ReverseForwardRegistry.swift | 18 +- macapp/WorkroomApp/Core/UITestFixture.swift | 18 +- .../AppleContainerIntegrationTests.swift | 17 +- .../Broker/ReverseForwardTests.swift | 41 +++ .../CredentialRelayTests.swift | 177 +++++++++++- macapp/WorkroomAppTests/HostStreamTests.swift | 13 + .../WorkroomAppTests/RemoteHostsTests.swift | 251 ++++++++++++++++-- .../RemoteProvisioningTests.swift | 175 ++++++++++++ .../RemoteWorkroomRoutingTests.swift | 28 ++ vcs/scripts/ssh-fixture/entrypoint.sh | 2 + 18 files changed, 987 insertions(+), 138 deletions(-) diff --git a/docs/designs/remote-workrooms.md b/docs/designs/remote-workrooms.md index 71bb28dba..1641cb10f 100644 --- a/docs/designs/remote-workrooms.md +++ b/docs/designs/remote-workrooms.md @@ -1634,8 +1634,10 @@ these are the subsystems that actually gate "a remote workroom is a real workroo be created in a container on the Mac, on Docker (Desktop, OrbStack, Colima) or on Apple's `container`. These are not remote workrooms: they stop when the Mac sleeps. They ship with the remote targets, behind the same preview and Nightly gate, until #260. - - **Choosing.** With the preview on, a project's New Workroom is a submenu: This Mac, then Docker - and Apple Container under "Local containers". It replaced New Remote Workroom. An entry that + - **Choosing.** With the preview on, every way in asks where: a project's New Workroom is a + submenu, its row's "+" opens the same menu, and File ▸ New Workroom… (⌘N, ⌥⌘N, the tab bar's + "+") asks once its project is picked. The places are This Mac, then Docker and Apple Container + under "Local containers". It replaced New Remote Workroom. An entry that can't be used says why in its title (`RemoteWorkrooms.unavailability`): Apple's runtime needs Apple silicon and macOS 26, either may not be installed, and a create needs a Codaset or `gh` sign-in. - **Drivers.** `RemoteHosts` keeps a `ContainerHostDriver` per `DriverKey`: runtime, and for @@ -1671,17 +1673,21 @@ these are the subsystems that actually gate "a remote workroom is a real workroo relay` on every connect); `ReverseForwardRegistry`, lifted out of the Debug broker route, carries it to `CredentialRelay`'s listener on the Mac's loopback, which answers github.com over https only, from `gh auth git-credential get`. Accepted (D9): anything in the workroom can ask, - so it has the user's `gh` access while the app is connected, as a workroom on the Mac does; git - is told to open the workroom when it isn't. An enrolled workroom never uses the relay. + so it has the user's `gh` access, as a workroom on the Mac does. It can ask only while the app + is connected (git is told to open the workroom when it isn't), but a token it was answered with + works until the user revokes it. An enrolled workroom never uses the relay. - **Starting.** Selecting a container workroom marks its host opened (`RemoteHosts.activate`), and - its connects then start a stopped container first. Background status probes reach every - reachable remote workroom, so they never start one. - - **The image.** Nightly and Release build the `FIXTURE=0` image once per architecture, run - `host-image-test.sh` against that exact image (`WR_HOST_IMAGE`), push it by digest and join the - two into one manifest on ghcr (`host-image.yml`). The manifest's digest is pinned into the app - (`WorkroomHostImage`); the hidden `remoteHostImage` overrides it, and a local build runs - `workroom-host`. A missing image is pulled before `run`, which never pulls (`--pull=never`). - The ghcr package must be made public once, by hand, after its first push. + its next connect starts a stopped container first, once: one the user stops afterwards stays + stopped until its workroom is selected again. Background status probes reach every reachable + remote workroom, so they never start one; an opened connect that finds one under way goes + after it rather than joining it. + - **The image.** Nightly (and Release, once #260 lifts the gate) builds the `FIXTURE=0` image + once per architecture, runs `host-image-test.sh` against that exact image (`WR_HOST_IMAGE`), + pushes it by digest and joins the two into one manifest on ghcr (`host-image.yml`). The + manifest's digest is pinned into the app (`WorkroomHostImage`); the hidden `remoteHostImage` + overrides it, and a local build runs `workroom-host`. A missing image is pulled before `run`, which never pulls (`--pull=never`). + The ghcr package must be made public once, by hand, after its first push: the workflow checks + the digest can be pulled anonymously, and `release.sh` won't build a Nightly without a pin. - **Tests.** `RemoteHostsTests` drive both dialects through a stub CLI. The Docker fixture suites (`run.sh`) cover the Docker path; `AppleContainerIntegrationTests` run the whole lifecycle on the real Apple runtime, opt-in (`TEST_RUNNER_WR_APPLE_CONTAINER_TESTS=1`), since CI's macOS diff --git a/macapp/WorkroomApp/Core/AppStore.swift b/macapp/WorkroomApp/Core/AppStore.swift index 0ca768cd3..b668b681b 100644 --- a/macapp/WorkroomApp/Core/AppStore.swift +++ b/macapp/WorkroomApp/Core/AppStore.swift @@ -3095,9 +3095,14 @@ final class AppStore: ObservableObject { acceptedPaths.contains($0.localSourcePath ?? $0.location.path) }) RepositoryRouter.shared.replaceRemote(RemoteWorkrooms.registrations(fresh)) + let resolved = selectedWorkroom?.reachableHost projects = fresh - // A selection restored before its projects were loaded opened nothing then (#309). - if let host = selectedWorkroom?.reachableHost { RemoteHosts.shared.activate(.remote(host)) } + // A selection restored before its projects were loaded opened nothing then (#309). Only then: + // every reload passes here, and opening again on each would restart a container the user + // stopped. + if resolved == nil, let host = selectedWorkroom?.reachableHost { + RemoteHosts.shared.activate(.remote(host)) + } // Prune shared caches only when publishing an accepted snapshot. let liveIDs = Set(fresh.map(\.id)) rootRefs = rootRefs.filter { liveIDs.contains($0.key) } @@ -3519,8 +3524,6 @@ final class AppStore: ObservableObject { }) } - /// Whether creating a container workroom is on for `project`: not while another create holds it busy, since - /// a second create would build a second base. /// How far each project's container workroom create has got downloading its host image, 0 to 1 /// (#309): the first create in a project pulls it, a couple of hundred MB. The row's spinner /// shows it. @@ -3534,6 +3537,17 @@ final class AppStore: ObservableObject { return nil } + /// Whether a create must say that its new base fell back to the Mac's own GitHub token (#309, D9): + /// signed in to Codaset, yet the base it made relays, which only an uninstalled Codaset App does. + /// Said once, when the base is made; signed out, the relay is the only way, and expected. + nonisolated static func fellBackToGitHub( + signedIn: Bool, hadBase: Bool, made: HostDescriptor? + ) -> Bool { + signedIn && !hadBase && made?.isRelayed == true + } + + /// Whether creating a container workroom is on for `project`: not while another create holds it + /// busy, since a second create would build a second base. func canCreateRemoteWorkroom(in project: Project) -> Bool { RemoteWorkrooms.isEnabled && !isBusyProject(project.path) && !deletingProjects.contains(project.path) @@ -3545,6 +3559,9 @@ final class AppStore: ObservableObject { func createRemoteWorkroom( in project: Project, runtime: RemoteWorkrooms.Runtime = .docker, splitAnchor: SidebarID? = nil ) async { + // As it is now, not as a menu or the picker caught it: a create that finished meanwhile may + // have recorded the project's first base, which this one must derive from, not overwrite. + let project = projects.first { $0.path == project.path } ?? project guard canCreateRemoteWorkroom(in: project) else { return } beginBusy(project.path) defer { endBusy(project.path) } @@ -3564,7 +3581,7 @@ final class AppStore: ObservableObject { } let path = project.path defer { imagePulls[path] = nil } - let report: @Sendable (Double) -> Void = { fraction in + let report: @Sendable (Double?) -> Void = { fraction in Task { @MainActor [weak self] in // A report that arrives after the create ended shows nothing. guard let self, self.isBusyProject(path) else { return } @@ -3589,6 +3606,18 @@ final class AppStore: ObservableObject { sid, beside: $0, edge: .right, destinationRect: workroomPaneRect(for: $0)) } ?? false if !landedInSplit { selectedTargetID = sid } + let made = RemoteWorkrooms.base( + in: projects.first { $0.path == project.path }?.host, for: wanted) + if Self.fellBackToGitHub( + signedIn: environment.client != nil, hadBase: base != nil, made: made) + { + errorTitle = + "\(runtime.displayName) workrooms of \(project.displayName) use your own GitHub access" + errorMessage = + "The Codaset App isn't installed for \(repository.owner), so git in this project's " + + "\(runtime.displayName) workrooms asks your gh sign-in on this Mac, with all of its " + + "access, as a workroom on this Mac does. They keep doing so after the App is installed." + } } catch { await reload() present(error) @@ -4129,7 +4158,8 @@ final class AppStore: ObservableObject { let cli = self.cli var left = project.host for base in bases { - let rest = base.id.flatMap { RemoteWorkrooms.removing($0, from: left) } + // A base without an id can't be told apart, so the record stays as it is. + let rest = base.id.map { RemoteWorkrooms.removing($0, from: left) } ?? left try await RemoteWorkrooms.deleteBase(base, environment: remote?.environment(for: base)) { try await cli.setHost( diff --git a/macapp/WorkroomApp/Core/Broker/BrokerSession.swift b/macapp/WorkroomApp/Core/Broker/BrokerSession.swift index 06562c4fa..9765e7267 100644 --- a/macapp/WorkroomApp/Core/Broker/BrokerSession.swift +++ b/macapp/WorkroomApp/Core/Broker/BrokerSession.swift @@ -13,6 +13,11 @@ final class BrokerSession: ObservableObject { } @Published private(set) var state: State = .signedOut + + var isSignedIn: Bool { + if case .signedIn = state { return true } + return false + } @Published var error: String? private let credentials: BrokerCredentials diff --git a/macapp/WorkroomApp/Core/ContainerHostDriver.swift b/macapp/WorkroomApp/Core/ContainerHostDriver.swift index 923f2fb92..a2130a394 100644 --- a/macapp/WorkroomApp/Core/ContainerHostDriver.swift +++ b/macapp/WorkroomApp/Core/ContainerHostDriver.swift @@ -138,19 +138,25 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { return try await run(provisioning.image, image: nil, isBase: true) } + /// Told how far a host image's pull has got, 0 to 1, then nil once it is done, for a create run + /// inside `$pullProgress.withValue` (#309): a task-local, so `HostDriver.create` keeps its shape + /// and two creates at once each hear only their own. + @TaskLocal static var pullProgress: (@Sendable (Double?) -> Void)? + /// Pulls `image` unless the runtime already has it (#309). `run` itself never pulls /// (`--pull=never`): left to it, a missing `workroom-host` was looked for on Docker Hub as /// `library/workroom-host`, and failed with a registry error that said nothing about why. - /// Told how far a host image's pull has got, 0 to 1, for a create run inside - /// `$pullProgress.withValue` (#309): a task-local, so `HostDriver.create` keeps its shape and two - /// creates at once each hear only their own. - @TaskLocal static var pullProgress: (@Sendable (Double) -> Void)? - private func ensureImage(_ image: String) async throws { let apple = provisioning?.dialect == .apple let inspect = apple ? ["image", "inspect", image] : ["image", "inspect", "--format", "{{.Id}}", image] - if (try? await runtime(inspect)) != nil { return } + do { + _ = try await runtime(inspect) + return + } catch { + // Missing, unless it's the runtime itself that didn't answer. + if let down = Self.runtimeDown(error.localizedDescription, apple: apple) { throw down } + } do { // Silence-bounded, and a pull reports its progress as it goes. Apple's unpacks every // platform of a multi-arch image unless told which, and it runs on Apple silicon only. @@ -161,11 +167,44 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { onOutput: report.map { report in { data in if let fraction = progress.read(data) { report(fraction) } } }) + // The rest of the create (a clone, a derive) can take minutes, and isn't a download. + report?(nil) } catch { + let said = error.localizedDescription + if let down = Self.runtimeDown(said, apple: apple) { throw down } + if ["denied", "unauthorized", "forbidden", "manifest unknown"].contains(where: { + said.lowercased().contains($0) + }) { + throw HostDriverError.provisioning( + "the registry refused the workroom host image \(image): it isn't public, or this build " + + "names one that doesn't exist. (\(said))") + } throw HostDriverError.provisioning( "couldn't download the workroom host image \(image). Check your network connection, " - + "then try again. (\(error.localizedDescription))") + + "then try again. (\(said))") + } + } + + /// The error for a runtime that didn't answer at all, by what it printed, or nil when it did + /// (#309): every create's first command would otherwise blame the image or the network. + static func runtimeDown(_ said: String, apple: Bool) -> Error? { + if apple { + guard said.contains("container system start") || said.contains("XPC connection") else { + return nil + } + return HostDriverError.provisioning( + "Apple's container runtime isn't running. Run `container system start`, then try again.") } + // "Cannot connect to…", and a socket the user can't open ("permission denied while trying to + // connect to the Docker daemon socket"), which the registry check would read as a refusal. + let lowered = said.lowercased() + guard + ["connect to the docker daemon", "error during connect", "unable to resolve docker"] + .contains(where: lowered.contains) + else { return nil } + return HostDriverError.provisioning( + "Docker didn't answer. Start it (Docker Desktop, OrbStack or Colima), or check that the " + + "Docker context is one on this Mac, then try again.") } /// A snapshot of `base`'s disk, run as a container of its own. The base keeps running: `commit` @@ -242,8 +281,8 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { let rootfs = work.appendingPathComponent("rootfs.tar") // Stopped only if running, and started again whatever happens, even if this derive is // cancelled: a cancelled `runtime` call ends its CLI, so the start runs in a task of its own. - // `stop` and `start` are both no-ops when already done (1.5.0, measured), so a base an - // earlier derive left stopped is exported and started here. + // A base found stopped (an earlier derive cut off mid-export) is exported as it is and left + // stopped: nothing needs it running, since a derive reads its disk, not its processes. let wasRunning = try AppleContainerCLI.objects( try await runtime(["inspect", source.container], allLines: true) @@ -511,7 +550,13 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { for container in try AppleContainerCLI.objects( try await runtime(["list", "--all", "--format", "json"], allLines: true)) { - if let image = AppleContainerCLI.imageReference(of: container) { inUse.insert(image) } + // A derive's snapshot is the exception: its container runs without it (1.5.0, measured), + // and one whose delete failed would otherwise hold a whole disk for the workroom's life. + if let image = AppleContainerCLI.imageReference(of: container), + !image.contains(Self.deriveDirectoryPrefix) + { + inUse.insert(image) + } let labels = AppleContainerCLI.labels(of: container) guard let id = AppleContainerCLI.id(of: container), ours(labels), old(labels), !keptContainers.contains(id) diff --git a/macapp/WorkroomApp/Core/CredentialRelay.swift b/macapp/WorkroomApp/Core/CredentialRelay.swift index 5c1ba9363..92541cfd0 100644 --- a/macapp/WorkroomApp/Core/CredentialRelay.swift +++ b/macapp/WorkroomApp/Core/CredentialRelay.swift @@ -13,8 +13,9 @@ import os /// and installed on every connect (`install`). /// /// **Accepted exposure (D9, #309).** Anything in the workroom can read the secret and ask, so such -/// a workroom has the GitHub access the user's own `gh` has, while the app is connected to it, as a -/// workroom on the Mac itself does. A remote workroom never uses this: it always enrols. +/// a workroom has the GitHub access the user's own `gh` has, as a workroom on the Mac itself does. +/// It can ask only while the app is connected, but a token it was answered with works until the +/// user revokes it. A remote workroom never uses this: it always enrols. final class CredentialRelay: @unchecked Sendable { static let shared = CredentialRelay() @@ -64,11 +65,17 @@ final class CredentialRelay: @unchecked Sendable { } } - /// Requests served at once; one more is turned away. A request takes a thread for up to - /// `requestDeadline`, so this bounds what a flood of connections can hold. + /// Connections read at once, before anything is known of them; one more is turned away. A read + /// takes a thread for up to `requestDeadline`, so this bounds what a flood can hold. + static let maxReading = 32 + /// Requests answered at once, each a gh run of up to 15 s. Only a request with a known secret + /// gets here, so connections that never say one, from anyone who can reach the listener, can't + /// keep a workroom from being answered. static let maxConcurrent = 8 - /// The most one request may take to arrive, whatever pace its bytes come at. - static let requestDeadline: TimeInterval = 15 + /// The most one request may take to arrive, whatever pace its bytes come at. The agent writes it + /// whole, at once (`relay` in `broker.rs`), so a second is plenty. + static let requestDeadline: TimeInterval = 2 + private let reading = DispatchSemaphore(value: maxReading) private let serving = DispatchSemaphore(value: maxConcurrent) private let queue = DispatchQueue( label: "com.developwithstyle.workroom.credential-relay", qos: .userInitiated, @@ -105,15 +112,12 @@ final class CredentialRelay: @unchecked Sendable { return } } - guard serving.wait(timeout: .now()) == .success else { + guard reading.wait(timeout: .now()) == .success else { Darwin.close(connection) continue } queue.async { [weak self] in - defer { - Darwin.close(connection) - self?.serving.signal() - } + defer { Darwin.close(connection) } self?.serve(connection) } } @@ -130,11 +134,31 @@ final class CredentialRelay: @unchecked Sendable { var timeout = timeval(tv_sec: Int(Self.requestDeadline), tv_usec: 0) setsockopt( connection, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) - guard let request = Self.readRequest(connection) else { return } - let reply = respond(to: request) + let request = { () -> String? in + defer { reading.signal() } + return Self.readRequest(connection) + }() + guard let request else { return } + // Only a known secret waits its turn for an answer; anything else is answered at once. + let reply: String + if knows(request) { + guard serving.wait(timeout: .now() + 5) == .success else { return } + defer { serving.signal() } + reply = respond(to: request) + } else { + reply = respond(to: request) + } _ = reply.withCString { send(connection, $0, strlen($0), 0) } } + /// Whether `request` starts with a secret this launch made. + private func knows(_ request: String) -> Bool { + guard let secret = request.split(separator: "\n", omittingEmptySubsequences: false).first, + !secret.isEmpty + else { return false } + return lock.withLock { secrets.values.contains { Self.same($0, String(secret)) } } + } + /// Reads up to the blank line that ends git's request, or nil past `maxRequest` or /// `requestDeadline`, which bounds the whole request however slowly its bytes come. static func readRequest(_ connection: Int32) -> String? { @@ -212,24 +236,53 @@ final class CredentialRelay: @unchecked Sendable { } } - /// Whether `gh` lists a sign-in for github.com, read from its config without running it: for the - /// New Workroom menu, which can't wait. A create checks for real (`gitHubToken`). + /// Whether `gh` has a sign-in for github.com, read from its config without running it: for the + /// New Workroom menu, which can't wait. A create checks for real (`gitHubToken`). Found as the gh + /// this app runs finds it, in this app's environment: a token there, else `hosts.yml` in + /// `GH_CONFIG_DIR`, `$XDG_CONFIG_HOME/gh` or `~/.config/gh`, in gh's order. static func hasGitHubSignIn( - hosts: URL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config/gh/hosts.yml") + environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser ) -> Bool { - guard let text = try? String(contentsOf: hosts, encoding: .utf8) else { return false } + let set = { (name: String) in environment[name].flatMap { $0.isEmpty ? nil : $0 } } + if set("GH_TOKEN") != nil || set("GITHUB_TOKEN") != nil { return true } + let directory = + set("GH_CONFIG_DIR").map { URL(fileURLWithPath: $0) } + ?? set("XDG_CONFIG_HOME").map { URL(fileURLWithPath: $0).appendingPathComponent("gh") } + ?? home.appendingPathComponent(".config/gh") + guard + let text = try? String( + contentsOf: directory.appendingPathComponent("hosts.yml"), encoding: .utf8) + else { return false } return text.split(separator: "\n").contains { $0.hasPrefix("github.com:") } } private static func runGH(_ arguments: [String], input: String?) throws -> String { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["gh"] + arguments // A Finder-launched app's PATH has no Homebrew; gh is wherever the user's shell finds it. var environment = ProcessInfo.processInfo.environment environment["PATH"] = ShellEnvironment.path() environment["GH_PROMPT_DISABLED"] = "1" + let (status, output) = try run( + URL(fileURLWithPath: "/usr/bin/env"), ["gh"] + arguments, input: input, + environment: environment, name: "gh") + guard status == 0 else { + throw HostDriverError.provisioning( + "gh has no GitHub sign-in on this Mac: run `gh auth login`") + } + return output + } + + /// Runs `executable`, bounded by `deadline` from start to the end of its output, whatever it or + /// anything it started does: a relay request holds a serving slot until this returns. One that + /// ignores SIGTERM is killed; a child of its that keeps its stdout open is left to it, unread. + static func run( + _ executable: URL, _ arguments: [String], input: String?, environment: [String: String], + name: String, deadline seconds: TimeInterval = 15 + ) throws -> (status: Int32, output: String) { + let deadline = DispatchTime.now() + seconds + let process = Process() + process.executableURL = executable + process.arguments = arguments process.environment = environment let stdin = Pipe() let stdout = Pipe() @@ -250,16 +303,15 @@ final class CredentialRelay: @unchecked Sendable { } if let input { try? stdin.fileHandleForWriting.write(contentsOf: Data(input.utf8)) } try? stdin.fileHandleForWriting.close() - guard exited.wait(timeout: .now() + 15) == .success else { + guard exited.wait(timeout: deadline) == .success else { process.terminate() - throw HostDriverError.provisioning("gh didn't answer in time") + if exited.wait(timeout: .now() + 1) != .success { kill(process.processIdentifier, SIGKILL) } + throw HostDriverError.provisioning("\(name) didn't answer in time") } - read.wait() - guard process.terminationStatus == 0 else { - throw HostDriverError.provisioning( - "gh has no GitHub sign-in on this Mac: run `gh auth login`") + guard read.wait(timeout: deadline) == .success else { + throw HostDriverError.provisioning("\(name)'s output didn't end in time") } - return String(decoding: output, as: UTF8.self) + return (process.terminationStatus, String(decoding: output, as: UTF8.self)) } // MARK: Hosts @@ -270,8 +322,9 @@ final class CredentialRelay: @unchecked Sendable { @MainActor func install(on host: HostID, driver: any HostDriver, agentBinary: String) async throws { guard case .remote(let id) = host else { return } - let target = try localPort() - try await registry(target: target).open(workroom: id, host: host) + // Listening first, so a listener that can't be made fails here rather than in the registry. + _ = try localPort() + try await registry().open(workroom: id, host: host) let command = ContainerHostDriver.shellQuoted(agentBinary) + " credential relay --port \(Self.port(for: id))" @@ -290,10 +343,13 @@ final class CredentialRelay: @unchecked Sendable { } @MainActor - private func registry(target: UInt16) -> ReverseForwardRegistry { + private func registry() -> ReverseForwardRegistry { if let made = lock.withLock({ forwards }) { return made } + // Asked for each listener: a listener that stopped accepting is made again on a new port, and + // the next connect's install carries the forwards there. let made = ReverseForwardRegistry( - transport: transport, port: { Self.port(for: $0) }, target: { target }, + transport: transport, port: { Self.port(for: $0) }, + target: { [weak self] in (try? self?.localPort()) ?? 0 }, failure: { HostDriverError.provisioning("the workroom's agent couldn't relay git's credentials: \($0)") }) diff --git a/macapp/WorkroomApp/Core/HostDriver.swift b/macapp/WorkroomApp/Core/HostDriver.swift index b514fbe73..43e68a3fb 100644 --- a/macapp/WorkroomApp/Core/HostDriver.swift +++ b/macapp/WorkroomApp/Core/HostDriver.swift @@ -144,6 +144,9 @@ final class HostStream: @unchecked Sendable { private var errorsClosed = false /// Told each piece of output as it arrives, stdout's and stderr's alike (`communicate`). private var onOutput: (@Sendable (Data) -> Void)? + /// Told stderr spoke, so output there counts against `communicate`'s silence bound too: a pull or + /// build may report its progress there alone. + private var heard: (@Sendable () -> Void)? /// `end()` stopped a carrier that was still running, so its exit status is our own signal. private var endedByUs = false /// A connection took the descriptor and the process (`handOff`), so `deinit` leaves them alone. @@ -338,7 +341,11 @@ final class HostStream: @unchecked Sendable { shutdown(fd, SHUT_RD) shutdown(fd, SHUT_WR) } - defer { watchdog.stop() } + lock.withLock { heard = { watchdog.heard() } } + defer { + lock.withLock { heard = nil } + watchdog.stop() + } // A cancelled caller (a pane closed, a host removed mid-push) ends the exchange the same way, // rather than leaving `runBlocking`, which cannot be cancelled, pushing 11 MB to no one. let (printed, exit): (Data, (status: Int32, reason: Process.TerminationReason)?) = @@ -506,10 +513,12 @@ final class HostStream: @unchecked Sendable { /// Keeps the first 16 KiB of stderr: enough for any diagnosis, and bounded against a chatty one. private func collect(_ data: Data) { - let listener = lock.withLock { () -> (@Sendable (Data) -> Void)? in + let (listener, heard) = lock.withLock { + () -> ((@Sendable (Data) -> Void)?, (@Sendable () -> Void)?) in if errors.count < 16 * 1024 { errors.append(data.prefix(16 * 1024 - errors.count)) } - return onOutput + return (onOutput, self.heard) } + heard?() listener?(data) } } diff --git a/macapp/WorkroomApp/Core/RemoteProvisioning.swift b/macapp/WorkroomApp/Core/RemoteProvisioning.swift index 03e36b0a0..ee44fa6a2 100644 --- a/macapp/WorkroomApp/Core/RemoteProvisioning.swift +++ b/macapp/WorkroomApp/Core/RemoteProvisioning.swift @@ -182,7 +182,7 @@ enum RemoteProvisioning { /// unless the repository's owner hasn't installed the Codaset App; the Mac's token otherwise. /// Returns whether it was relayed. @discardableResult - private static func withCloneToken( + static func withCloneToken( _ repository: String, relayed: Bool?, in environment: Environment, _ body: ([String: String]) async throws -> Void ) async throws -> Bool { @@ -250,6 +250,11 @@ enum RemoteProvisioning { from base: Base, workroom: UUID, branch: String, in environment: Environment, checkpoint: @Sendable (_ host: HostID, _ grant: String?) async throws -> Void = { _, _ in } ) async throws -> Instance { + // What the workroom's git will need, checked before the copy, which can take many minutes: a + // relayed base the Mac's token, any other the broker. + let relayHeader = + base.relayed == true ? cloneEnvironment(token: try await relayToken(environment)) : nil + if relayHeader == nil, environment.client == nil { throw RemoteWorkrooms.Failure.signedOut } // The snapshot only: a commit taken while a refresh's fetch holds its ref locks would hand // every workroom derived from it those stale `.lock` files. let host = try await BaseLocks.shared.exclusively(on: base.host) { @@ -264,8 +269,8 @@ enum RemoteProvisioning { // A relayed workroom never enrols (#309): these two git commands carry the Mac's token, // and later ones ask the Mac through the relay its connect installs. var header: [String: String] = [:] - if base.relayed == true { - header = cloneEnvironment(token: try await relayToken(environment)) + if let relayHeader { + header = relayHeader } else { guard let client = environment.client else { throw RemoteWorkrooms.Failure.signedOut } let grantID: String diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index e10cd093e..ab0c0a4ef 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -50,7 +50,9 @@ enum RemoteWorkrooms { appleSilicon: appleSilicon, macOS26: ProcessInfo.processInfo.isOperatingSystemAtLeast( OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0)), - signedIn: BrokerSession.shared.client() != nil || CredentialRelay.hasGitHubSignIn()) + // The session's state, not `client()`: that reads the Keychain and rebuilds the device key, + // and this runs in menus' and the picker's bodies. A create checks for real. + signedIn: BrokerSession.shared.isSignedIn || CredentialRelay.hasGitHubSignIn()) } static func unavailability( @@ -171,16 +173,20 @@ enum RemoteWorkrooms { } /// The router registrations of `projects`' reachable remote workrooms (#253). Each is its own - /// shared root on its host, as an independent clone is. Its GitHub identity is the base's, so its - /// PR and CI status read through `gh` here. + /// shared root on its host, as an independent clone is. Its GitHub identity is its own base's, so + /// its PR and CI status read through `gh` here: a project's bases on other runtimes may be clones + /// of another repository, made before its origin changed (#309). static func registrations(_ projects: [Project]) -> [RepositoryRouter.Registration] { projects.flatMap { project in - let github = project.host?.allBases.lazy.compactMap(\.repository).first.flatMap( - gitHubRepository) + let any = project.host?.allBases.lazy.compactMap(\.repository).first return project.workrooms.compactMap { workroom -> RepositoryRouter.Registration? in guard let host = workroom.reachableHost, let location = try? RepositoryLocation.remote(host: host, path: workroom.path) else { return nil } + let own = workroom.host.flatMap(RemoteHosts.DriverKey.init).flatMap { + base(in: project.host, for: $0)?.repository + } + let github = (own ?? any).flatMap(gitHubRepository) return try? RepositoryRouter.Registration( location: location, sharedLocation: location, github: github) } @@ -450,15 +456,21 @@ final class RemoteHosts: @unchecked Sendable { private var swept = false /// A call reached the sweep while a delete was in flight and left it for later (#296). private var held = false - /// The connection attempt running for each host, which `ensureConnected` callers share. - private var connecting: [HostID: Task] = [:] + /// The connection attempt running for each host, which `ensureConnected` callers share, and + /// whether it starts a stopped container first. + private var connecting: [HostID: (task: Task, starts: Bool)] = [:] /// When each host's last attempt failed, which answers for it for `retryAfter`. private var failedAt: [HostID: ContinuousClock.Instant] = [:] /// Hosts of workrooms the user has opened this launch (`activate`), whose connect starts a /// stopped container first. private var activated: Set = [] + /// Relayed hosts whose relay couldn't be set up on their connection, and when that was last + /// tried (#309): nothing else tries again before a reconnect, so a connection check does. + private var relayPending: [UUID: ContinuousClock.Instant] = [:] /// `ensureConnected`'s seams, nil in the app: the connect itself, whether a host is up, the clock. private let connectHost: (@Sendable (HostID) async throws -> Void)? + /// Sets up a relayed host's credential relay on its connection; nil in the app. + private let relayHost: (@Sendable (HostID) async throws -> Void)? private let isConnected: (@Sendable (HostID) async -> Bool)? private let startHost: (@Sendable (HostID) async throws -> Void)? private let now: @Sendable () -> ContinuousClock.Instant @@ -473,9 +485,11 @@ final class RemoteHosts: @unchecked Sendable { startHost: (@Sendable (HostID) async throws -> Void)? = nil, now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }, makeDriver: (@Sendable (DriverKey) throws -> ContainerHostDriver)? = nil, - sweepDriver: (@Sendable (ContainerHostDriver, Set, Set) async -> [String])? = nil + sweepDriver: (@Sendable (ContainerHostDriver, Set, Set) async -> [String])? = nil, + relayHost: (@Sendable (HostID) async throws -> Void)? = nil ) { self.connectHost = connectHost + self.relayHost = relayHost self.isConnected = isConnected self.startHost = startHost self.now = now @@ -673,19 +687,34 @@ final class RemoteHosts: @unchecked Sendable { // failure leaves the workroom usable except for git's remote commands, so it is logged. guard case .remote(let id) = host, isRelayed(id) else { return } // Tried a few times: a listen can lose a race with the last connection's teardown. - for attempt in 1...3 { + await installRelay(host, attempts: 3, driver: driver) + } + + /// Sets up `host`'s credential relay on its connection, up to `attempts` times. One that never + /// takes is left pending, for a later connection check to try again. + func installRelay( + _ host: HostID, attempts: Int, driver: ContainerHostDriver? = nil + ) async { + guard case .remote(let id) = host else { return } + for attempt in 1...attempts { do { - try await CredentialRelay.shared.install( - on: host, driver: driver, - agentBinary: AgentBootstrap.binary(besideSocket: RemoteWorkrooms.agentSocket)) + if let relayHost { + try await relayHost(host) + } else { + try await CredentialRelay.shared.install( + on: host, driver: try driver ?? heldDriver(host), + agentBinary: AgentBootstrap.binary(besideSocket: RemoteWorkrooms.agentSocket)) + } + _ = lock.withLock { relayPending.removeValue(forKey: id) } return } catch { Self.logger.error( "credential relay for \(id, privacy: .public) (attempt \(attempt)): \(error.localizedDescription, privacy: .public)" ) - try? await Task.sleep(for: .seconds(1)) + if attempt < attempts { try? await Task.sleep(for: .seconds(1)) } } } + lock.withLock { relayPending[id] = now() } } /// A relayed workroom's host is gone: its relay with it (#309). @@ -713,8 +742,21 @@ final class RemoteHosts: @unchecked Sendable { } else { up = await HostConnectionManager.shared.snapshot(for: host).status == .connected } - if up { return } - if let failed = lock.withLock({ failedAt[host] }), now() - failed < Self.retryAfter { + // An opened host is up already: there is nothing to start, and later probes mustn't start it. + if up { + _ = lock.withLock { activated.remove(host) } + // A relay that didn't take on this connection is tried again, at most once a `retryAfter`. + if case .remote(let id) = host, + let tried = lock.withLock({ relayPending[id] }), now() - tried >= Self.retryAfter + { + await installRelay(host, attempts: 1) + } + return + } + // An opened host is tried whatever a probe of it found: its connect starts it. + if !lock.withLock({ activated.contains(host) }), + let failed = lock.withLock({ failedAt[host] }), now() - failed < Self.retryAfter + { throw RepositoryRoutingError.unavailable(host) } do { @@ -730,15 +772,24 @@ final class RemoteHosts: @unchecked Sendable { } : nil let task = lock.withLock { () -> Task in - if let running = connecting[host] { return running } + if let running = connecting[host], running.starts || start == nil { return running.task } + // A probe that won't start the container is under way: joined, it would fail on a stopped + // one and hold the opened workroom back for `retryAfter`. This one goes after it instead. + let probe = connecting[host]?.task let task = Task { - try await start?(host) - try await connect(host) + if let probe, (try? await probe.value) != nil { + } else { + try await start?(host) + try await connect(host) + } + // Once: a container the user stops later stays stopped for the status sweep, until its + // workroom is opened again. + if start != nil { _ = self.lock.withLock { self.activated.remove(host) } } } - connecting[host] = task + connecting[host] = (task, start != nil) return task } - defer { lock.withLock { if connecting[host] == task { connecting[host] = nil } } } + defer { lock.withLock { if connecting[host]?.task == task { connecting[host] = nil } } } try await task.value lock.withLock { failedAt[host] = nil } } catch is CancellationError { @@ -751,9 +802,9 @@ final class RemoteHosts: @unchecked Sendable { } } - /// The user opened a workroom on `host` (#309): its connects start its container if it is stopped, - /// and a failure from before, such as a status probe of the stopped container, no longer holds - /// the next attempt back. + /// The user opened a workroom on `host` (#309): its next connect starts its container if it is + /// stopped, and a failure from before, such as a status probe of the stopped container, no longer + /// holds that attempt back. func activate(_ host: HostID) { lock.withLock { activated.insert(host) diff --git a/macapp/WorkroomApp/Core/ReverseForwardRegistry.swift b/macapp/WorkroomApp/Core/ReverseForwardRegistry.swift index 93053f118..0bad6743c 100644 --- a/macapp/WorkroomApp/Core/ReverseForwardRegistry.swift +++ b/macapp/WorkroomApp/Core/ReverseForwardRegistry.swift @@ -42,13 +42,15 @@ final class ReverseForwardRegistry { /// The error a listener the agent refuses becomes, from the agent's reason. private let failure: @Sendable (String) -> Error private let reopenRetry: Duration - private let log = Logger(subsystem: "com.developwithstyle.workroom", category: "broker") + private let log = Logger(subsystem: "com.developwithstyle.workroom", category: "reverse-forward") private struct Entry { let host: HostID /// The connection the listener is open on, once it is. var lease: HostConnectionManager.Lease? var forward: ReverseForward? + /// The port on this Mac the listener carries to, which can move (the relay listening again). + var target: UInt16? var watch: Task? /// An open in flight, which a second caller waits on rather than opening a second listener. /// The id is how the task, finishing, clears only itself. @@ -115,8 +117,11 @@ final class ReverseForwardRegistry { private func reopen(_ workroom: UUID) async throws { guard let host = entries[workroom]?.host else { return } let (lease, service) = try await transport.forwarding(host) + let target = await target() + // Nothing is listening on this Mac to carry to (the relay couldn't listen again). + guard target != 0 else { throw failure("nothing on this Mac is listening for it") } guard var entry = entries[workroom] else { return } - if entry.lease == lease, entry.forward != nil { return } + if entry.lease == lease, entry.forward != nil, entry.target == target { return } entry.forward?.stop() entry.forward = nil entries[workroom] = entry @@ -125,13 +130,13 @@ final class ReverseForwardRegistry { // once it notices that connection ended, which can be just after the new one asks. The // port is this workroom's alone, so a bind failure is retried for a moment, not reported. var attempt = 0 - var outcome = await listen(service, port: port(workroom), workroom, lease) + var outcome = await listen(service, port: port(workroom), to: target, workroom, lease) while case .failure(let refusal) = outcome, refusal.detail.hasPrefix("bind:"), attempt < Self.bindRetries { attempt += 1 try await Task.sleep(for: Self.bindRetryDelay) - outcome = await listen(service, port: port(workroom), workroom, lease) + outcome = await listen(service, port: port(workroom), to: target, workroom, lease) } let forward: ReverseForward switch outcome { @@ -146,15 +151,16 @@ final class ReverseForwardRegistry { } entries[workroom]?.lease = lease entries[workroom]?.forward = forward + entries[workroom]?.target = target } /// One `listen`, answered or timed out. A refused listener is stopped before it is returned. private func listen( - _ service: AgentForwardService, port: UInt16, _ workroom: UUID, + _ service: AgentForwardService, port: UInt16, to target: UInt16, _ workroom: UUID, _ lease: HostConnectionManager.Lease ) async -> Result { let ready = ListenOutcome() - let forward = service.reverse(remotePort: port, target: await target()) { + let forward = service.reverse(remotePort: port, target: target) { [weak self, log] event in switch event { case .listening: diff --git a/macapp/WorkroomApp/Core/UITestFixture.swift b/macapp/WorkroomApp/Core/UITestFixture.swift index f62d4b706..e0736b3e3 100644 --- a/macapp/WorkroomApp/Core/UITestFixture.swift +++ b/macapp/WorkroomApp/Core/UITestFixture.swift @@ -617,22 +617,22 @@ enum UITestFixture { /// header fades all key off this, so a drifted value silently rebaselines every visual QA pass /// (issue #162 review). Fixture-namespaced because a bare `-dimUnfocusedPanes 0` cannot work: see /// `applyFixtureDefaults`'s note on argument-domain strings. + static var dimUnfocusedPanes: Bool { + // The arg arrives as a STRING, so parse it as one; unset ⇒ the shipped default. + text("WorkroomUITestDimUnfocusedPanes").map { ($0 as NSString).boolValue } ?? true + } + /// Whether a fixture launch has the remote preview on (`-WorkroomUITestRemotePreview 1`, #309): /// New Workroom becomes a submenu of places. Off unless given, so no other test sees it; mirrored /// into `Defaults` (`applyFixtureDefaults`), since the argument domain would shadow the key. - /// A container workroom create downloading its image, this far along (`-WorkroomUITestImagePull - /// 0.42`, #309), seeded on the fixture project so the row's progress can be seen without a real - /// pull. nil unless given. - static var imagePull: Double? { text("WorkroomUITestImagePull").flatMap(Double.init) } - static var remotePreview: Bool { text("WorkroomUITestRemotePreview").map { ($0 as NSString).boolValue } ?? false } - static var dimUnfocusedPanes: Bool { - // The arg arrives as a STRING, so parse it as one; unset ⇒ the shipped default. - text("WorkroomUITestDimUnfocusedPanes").map { ($0 as NSString).boolValue } ?? true - } + /// A container workroom create downloading its image, this far along (`-WorkroomUITestImagePull + /// 0.42`, #309), seeded on the fixture project so the row's progress can be seen without a real + /// pull. nil unless given. + static var imagePull: Double? { text("WorkroomUITestImagePull").flatMap(Double.init) } /// Whether splits auto-even on add/remove in a fixture launch /// (`-WorkroomUITestAutoResizeSplitsEvenly 0` to turn it off). Defaults to the shipped `true`, and diff --git a/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift b/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift index 0c247cc6b..7d93665f8 100644 --- a/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift +++ b/macapp/WorkroomAppTests/AppleContainerIntegrationTests.swift @@ -38,16 +38,16 @@ final class AppleContainerIntegrationTests: XCTestCase { // Whatever a failed test left, by this run's label. if runtime != nil { let containers = (try? AppleContainerCLI.objects(cli(["list", "--all", "--format", "json"]))) - for container in containers ?? [] where Self.carries(AppleContainerCLI.labels(of: container)) - { + for container in containers ?? [] where carries(AppleContainerCLI.labels(of: container)) { if let id = AppleContainerCLI.id(of: container) { _ = try? cli(["delete", "--force", id]) } } } if let directory { try? FileManager.default.removeItem(at: directory) } } - private static func carries(_ labels: [String: String]) -> Bool { - labels["workroom.provisioner"]?.hasPrefix("apple-test-") == true + /// This run's own, never another run's: two runs at once would otherwise delete each other's. + private func carries(_ labels: [String: String]) -> Bool { + labels["workroom.provisioner"] == label.split(separator: "=")[1].description } /// The real CLI, run from here rather than through the driver, so a check is never the driver @@ -68,9 +68,7 @@ final class AppleContainerIntegrationTests: XCTestCase { private func ours() throws -> [String] { try AppleContainerCLI.objects(cli(["list", "--all", "--format", "json"])).filter { - AppleContainerCLI.labels(of: $0)["workroom.provisioner"] - == label.split(separator: "=")[1] - .description + carries(AppleContainerCLI.labels(of: $0)) }.compactMap(AppleContainerCLI.id) } @@ -111,7 +109,10 @@ final class AppleContainerIntegrationTests: XCTestCase { XCTAssertNotEqual(instanceKey, baseKey) let images = try AppleContainerCLI.objects(cli(["image", "list", "--format", "json"])) XCTAssertFalse( - images.contains { AppleContainerCLI.name(ofImage: $0)?.contains("workroom-derive-") == true }, + images.contains { + AppleContainerCLI.name(ofImage: $0)?.contains("workroom-derive-") == true + && carries(AppleContainerCLI.labels(ofImage: $0)) + }, "the derived image was left behind") // The base was stopped for the export and started again. let back = try await onHost(driver, base, "cat ~/marker") diff --git a/macapp/WorkroomAppTests/Broker/ReverseForwardTests.swift b/macapp/WorkroomAppTests/Broker/ReverseForwardTests.swift index c6107d98b..e2b2aa39d 100644 --- a/macapp/WorkroomAppTests/Broker/ReverseForwardTests.swift +++ b/macapp/WorkroomAppTests/Broker/ReverseForwardTests.swift @@ -203,6 +203,39 @@ final class ReverseForwardTests: XCTestCase { registry.close(workroom: workroom) } + /// The port on this Mac can move on the same connection (the credential relay listening again, + /// #309): the next open carries the workroom's listener to the new one rather than keeping the + /// old, which nothing answers any more. + @MainActor + func testAnOpenAfterTheTargetMovesCarriesToTheNewTarget() async throws { + let first = try echo() + let second = try echo() + let agent = try agent() + let host = HostID.remote(UUID()) + let current = Current( + lease: .init(host: host, generation: UUID()), connection: try await connection(to: agent)) + let target = Target(first.port) + let registry = ReverseForwardRegistry( + transport: .init( + forwarding: { _ in + let (lease, connection) = current.get() + return (lease, try connection.forwarding()) + }, + updates: { _ in AsyncStream { _ in } }), + port: { BrokerReverseForwards.port(for: $0) }, target: { target.get() }, + failure: { HostDriverError.provisioning($0) }) + let workroom = UUID() + let port = BrokerReverseForwards.port(for: workroom) + try await registry.open(workroom: workroom, host: host) + try roundTrip(port) + + target.set(second.port) + first.stop() + try await registry.open(workroom: workroom, host: host) + try roundTrip(port) + registry.close(workroom: workroom) + } + /// A link lost without a goodbye: the old connection's listener still holds the port when the new /// connection asks, for longer than a bind is retried. The registry keeps trying on the new /// connection, and gets the port once the old one lets it go. @@ -353,6 +386,14 @@ private final class Current: @unchecked Sendable { } } +private final class Target: @unchecked Sendable { + private let lock = NSLock() + private var port: UInt16 + init(_ port: UInt16) { self.port = port } + func get() -> UInt16 { lock.withLock { port } } + func set(_ port: UInt16) { lock.withLock { self.port = port } } +} + /// A reverse forward's events, with the first answer to its `listen` awaitable. final class ReverseEvents: @unchecked Sendable { private let lock = NSLock() diff --git a/macapp/WorkroomAppTests/CredentialRelayTests.swift b/macapp/WorkroomAppTests/CredentialRelayTests.swift index 26c5a6f61..80c291c93 100644 --- a/macapp/WorkroomAppTests/CredentialRelayTests.swift +++ b/macapp/WorkroomAppTests/CredentialRelayTests.swift @@ -78,13 +78,52 @@ final class CredentialRelayTests: XCTestCase { /// gh lists github.com in its hosts file when signed in there; the menu reads that, not gh. func testAGhSignInIsReadFromItsHostsFile() throws { - let file = FileManager.default.temporaryDirectory.appendingPathComponent("gh-\(UUID()).yml") - defer { try? FileManager.default.removeItem(at: file) } - XCTAssertFalse(CredentialRelay.hasGitHubSignIn(hosts: file)) + let home = try directory() + let file = home.appendingPathComponent(".config/gh/hosts.yml") + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + XCTAssertFalse(CredentialRelay.hasGitHubSignIn(environment: [:], home: home)) try "github.example.com:\n user: x\n".write(to: file, atomically: true, encoding: .utf8) - XCTAssertFalse(CredentialRelay.hasGitHubSignIn(hosts: file)) + XCTAssertFalse(CredentialRelay.hasGitHubSignIn(environment: [:], home: home)) try "github.com:\n user: joel\n".write(to: file, atomically: true, encoding: .utf8) - XCTAssertTrue(CredentialRelay.hasGitHubSignIn(hosts: file)) + XCTAssertTrue(CredentialRelay.hasGitHubSignIn(environment: [:], home: home)) + } + + /// gh's sign-in is found where gh looks in this app's environment: a token, then + /// `GH_CONFIG_DIR`, then `XDG_CONFIG_HOME`, in gh's order. + func testAGhSignInIsFoundWhereGhLooks() throws { + let home = try directory() + let signedIn = "github.com:\n user: joel\n" + let config = home.appendingPathComponent("config-dir") + let xdg = home.appendingPathComponent("xdg") + for directory in [config, xdg.appendingPathComponent("gh")] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try signedIn.write( + to: directory.appendingPathComponent("hosts.yml"), atomically: true, encoding: .utf8) + } + XCTAssertTrue(CredentialRelay.hasGitHubSignIn(environment: ["GH_TOKEN": "x"], home: home)) + XCTAssertTrue(CredentialRelay.hasGitHubSignIn(environment: ["GITHUB_TOKEN": "x"], home: home)) + XCTAssertFalse(CredentialRelay.hasGitHubSignIn(environment: ["GH_TOKEN": ""], home: home)) + XCTAssertTrue( + CredentialRelay.hasGitHubSignIn(environment: ["GH_CONFIG_DIR": config.path], home: home)) + XCTAssertTrue( + CredentialRelay.hasGitHubSignIn(environment: ["XDG_CONFIG_HOME": xdg.path], home: home)) + // GH_CONFIG_DIR wins, even over a signed-in XDG config. + XCTAssertFalse( + CredentialRelay.hasGitHubSignIn( + environment: [ + "GH_CONFIG_DIR": home.appendingPathComponent("empty").path, + "XDG_CONFIG_HOME": xdg.path, + ], + home: home)) + } + + private func directory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "gh-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + return directory } /// Secrets compare whole, and only equal ones match. @@ -102,9 +141,14 @@ final class CredentialRelayTests: XCTestCase { let secret = try relay.secret(for: UUID()) let port = try relay.localPort() for _ in 0..<30 { - guard let socket = LoopbackSocket.connect(port: port, timeout: 2) else { - return XCTFail("connect \(errno)") + // A burst like this can overrun the listen backlog for a moment, which refuses a connect; + // that's the kernel, not the relay, so it is tried again. The request below must still land. + var connected: Int32? + for _ in 0..<50 where connected == nil { + connected = LoopbackSocket.connect(port: port, timeout: 2) + if connected == nil { Thread.sleep(forTimeInterval: 0.01) } } + guard let socket = connected else { return XCTFail("connect \(errno)") } _ = "x\n\n".withCString { send(socket, $0, 3, 0) } var linger = linger(l_onoff: 1, l_linger: 0) setsockopt(socket, SOL_SOCKET, SO_LINGER, &linger, socklen_t(MemoryLayout.size)) @@ -125,4 +169,123 @@ final class CredentialRelayTests: XCTestCase { } XCTAssertEqual(String(decoding: reply, as: UTF8.self), "username=u\npassword=p\n") } + + /// Everything `socket` is sent before it closes. + private func reply(_ socket: Int32) -> String { + var reply = Data() + var buffer = [UInt8](repeating: 0, count: 256) + while true { + let count = recv(socket, &buffer, buffer.count, 0) + guard count > 0 else { break } + reply.append(buffer, count: count) + } + return String(decoding: reply, as: UTF8.self) + } + + /// A request past `maxRequest` with no end in sight is dropped unanswered, and gh never asked. + func testAnOversizedRequestIsDroppedUnanswered() throws { + let asked = Asked() + let relay = CredentialRelay(answer: { request in + asked.add(request) + return "username=u\npassword=p\n" + }) + let secret = try relay.secret(for: UUID()) + let port = try relay.localPort() + guard let socket = LoopbackSocket.connect(port: port, timeout: 2) else { + return XCTFail("connect \(errno)") + } + defer { Darwin.close(socket) } + // The relay may close before it has read all of this: an EPIPE, not a signal that ends the run. + var on: Int32 = 1 + setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout.size)) + let request = + "\(secret)\nprotocol=https\nhost=github.com\n" + + String(repeating: "x", count: CredentialRelay.maxRequest) + "\n\n" + _ = request.withCString { send(socket, $0, strlen($0), 0) } + XCTAssertEqual(reply(socket), "") + XCTAssertEqual(asked.all, []) + } + + /// Idle connections that never say a secret, more than there are answering slots, don't keep a + /// workroom's request from being answered: anyone who can reach the listener could open them. + func testIdleStrangersDoNotBlockAKnownWorkroom() throws { + let relay = CredentialRelay(answer: { _ in "username=u\npassword=p\n" }) + let secret = try relay.secret(for: UUID()) + let port = try relay.localPort() + var held: [Int32] = [] + defer { for socket in held { Darwin.close(socket) } } + for _ in 0..<(CredentialRelay.maxConcurrent + 4) { + guard let socket = LoopbackSocket.connect(port: port, timeout: 2) else { + return XCTFail("connect \(errno)") + } + held.append(socket) + } + Thread.sleep(forTimeInterval: 0.3) + guard let socket = LoopbackSocket.connect(port: port, timeout: 2) else { + return XCTFail("connect \(errno)") + } + defer { Darwin.close(socket) } + let request = "\(secret)\nprotocol=https\nhost=github.com\n\n" + _ = request.withCString { send(socket, $0, strlen($0), 0) } + XCTAssertEqual(reply(socket), "username=u\npassword=p\n") + } + + /// Past `maxReading` connections at once, one more is closed at once rather than read; the idle + /// ones are dropped after `requestDeadline`, and a real request is answered again. + func testConnectionsPastTheReadingCapAreTurnedAway() throws { + let relay = CredentialRelay(answer: { _ in "username=u\npassword=p\n" }) + let secret = try relay.secret(for: UUID()) + let port = try relay.localPort() + var held: [Int32] = [] + defer { for socket in held { Darwin.close(socket) } } + for _ in 0..&2; sleep 0.3; done; echo done"], + environment: [:], handshakeTimeout: 5, purpose: .exchange) + let (status, output) = try await stream.communicate(nil, timeout: 1) + XCTAssertEqual(status, 0) + // stdout, then what stderr said. + XCTAssertTrue(output.hasPrefix("done\n"), output) + } + /// The binary is derived from the socket and run by path from any cwd, so a relative socket is /// refused before anything is written. func testARelativeAgentSocketIsRefused() { diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index 949bbb799..df11a2402 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -125,6 +125,129 @@ final class RemoteHostsTests: XCTestCase { XCTAssertEqual(connects.calls, 2) } + /// Opening a workroom while a status probe of its stopped container is under way starts it: joined, + /// the probe would fail and hold the workroom back for `retryAfter`. + func testOpeningDuringAProbeStartsTheHost() async throws { + let connects = Connects() + connects.fail(true) + let started = Swept() + let remote = RemoteHosts( + connectHost: { try await connects.connect($0) }, + isConnected: { _ in + connects.ask() + return false + }, + startHost: { host in + guard case .remote(let id) = host else { return } + started.add(nil, [id]) + connects.fail(false) + }, now: { connects.now }) + let id = UUID() + + let probe = Task { try await remote.ensureConnected(.remote(id)) } + while connects.calls < 1 { try await Task.sleep(for: .milliseconds(1)) } + remote.activate(.remote(id)) + let opened = Task { try await remote.ensureConnected(.remote(id)) } + while connects.arrived < 2 { try await Task.sleep(for: .milliseconds(1)) } + try await Task.sleep(for: .milliseconds(20)) + connects.hold(false) + _ = try? await probe.value + try await opened.value + XCTAssertEqual(started.calls.map(\.known), [[id]], "the opened workroom's host wasn't started") + } + + /// Opening starts a container once: one the user stops afterwards is not started again by the + /// status sweep's next probe. + func testOpeningStartsTheHostOnce() async throws { + let connects = Connects() + connects.hold(false) + let started = Swept() + let remote = RemoteHosts( + connectHost: { try await connects.connect($0) }, isConnected: { _ in false }, + startHost: { host in + guard case .remote(let id) = host else { return } + started.add(nil, [id]) + }) + let id = UUID() + remote.activate(.remote(id)) + try await remote.ensureConnected(.remote(id)) + try await remote.ensureConnected(.remote(id)) + XCTAssertEqual(started.calls.count, 1, "a later probe started the container again") + remote.activate(.remote(id)) + try await remote.ensureConnected(.remote(id)) + XCTAssertEqual(started.calls.count, 2, "opening it again didn't start it") + } + + /// A workroom opened while its container runs has nothing to start, and the opening is spent: + /// stopped later, the status sweep's probe doesn't start it. + func testOpeningARunningHostLeavesItStoppedLater() async throws { + let connects = Connects() + connects.hold(false) + let running = Connects() + let started = Swept() + let remote = RemoteHosts( + connectHost: { try await connects.connect($0) }, + isConnected: { _ in + // Up the first time it's asked, stopped after. + defer { running.ask() } + return running.arrived == 0 + }, + startHost: { host in + guard case .remote(let id) = host else { return } + started.add(nil, [id]) + }) + let id = UUID() + remote.activate(.remote(id)) + try await remote.ensureConnected(.remote(id)) + try await remote.ensureConnected(.remote(id)) + XCTAssertTrue(started.calls.isEmpty, "a probe started a container opened while it ran") + } + + /// A probe of the stopped container that fails after the workroom was opened doesn't hold it back: + /// the opened workroom's next connect starts it at once. + func testAProbeFailingAfterTheOpeningDoesNotHoldItBack() async throws { + let connects = Connects() + connects.fail(true) + let remote = RemoteHosts( + connectHost: { try await connects.connect($0) }, isConnected: { _ in false }, + startHost: { _ in connects.fail(false) }, now: { connects.now }) + let host = HostID.remote(UUID()) + let probe = Task { try await remote.ensureConnected(host) } + while connects.calls < 1 { try await Task.sleep(for: .milliseconds(1)) } + remote.activate(host) + connects.hold(false) + _ = try? await probe.value + try await remote.ensureConnected(host) + XCTAssertEqual(connects.calls, 2) + } + + /// A relay that didn't take on a connection is tried again by a later connection check, at most + /// once a `retryAfter`, until it does; nothing else would try before a reconnect. + func testARelayThatDidNotTakeIsTriedAgain() async throws { + let connects = Connects() + connects.fail(true) + let relays = Connects() + relays.hold(false) + relays.fail(true) + let remote = RemoteHosts( + connectHost: { _ in }, isConnected: { _ in true }, startHost: { _ in }, + now: { connects.now }, relayHost: { try await relays.connect($0) }) + let host = HostID.remote(UUID()) + + await remote.installRelay(host, attempts: 1) + XCTAssertEqual(relays.calls, 1) + try await remote.ensureConnected(host) + XCTAssertEqual(relays.calls, 1, "tried again inside its window") + + relays.fail(false) + connects.advance(RemoteHosts.retryAfter) + try await remote.ensureConnected(host) + XCTAssertEqual(relays.calls, 2, "a later check didn't try again") + connects.advance(RemoteHosts.retryAfter) + try await remote.ensureConnected(host) + XCTAssertEqual(relays.calls, 2, "tried again once it had taken") + } + /// `destroy` removes a host's image with `--force`, so a record's image must be a commit's ID. func testOnlyAWholeLowercaseSHA256IsAnImageID() { let hex = String(repeating: "a1", count: 32) @@ -333,14 +456,16 @@ final class RemoteHostsTests: XCTestCase { /// A stand-in runtime CLI that logs every call and fails `image inspect` (the image is missing), /// `run`, and `pull` when `pullFails`, so `create` stops at its first container. - private func failingRuntime(pullFails: Bool) throws -> (runtime: URL, log: URL) { + private func failingRuntime(pullFails: Bool, said: String = "denied") throws -> ( + runtime: URL, log: URL + ) { let (runtime, log) = try stubRuntime() let script = try String(contentsOf: runtime, encoding: .utf8) try (script + """ case "$1 $2" in "image inspect") exit 1 ;; esac - case "$1" in run) exit 1 ;; pull) \(pullFails ? "echo 'denied' >&2; exit 1" : "exit 0") ;; esac + case "$1" in run) exit 1 ;; pull) \(pullFails ? "echo '\(said)' >&2; exit 1" : "exit 0") ;; esac """).write(to: runtime, atomically: true, encoding: .utf8) return (runtime, log) } @@ -362,12 +487,14 @@ final class RemoteHostsTests: XCTestCase { private final class Fractions: @unchecked Sendable { private let lock = NSLock() - private var heard: [Double] = [] - var all: [Double] { lock.withLock { heard } } - func add(_ fraction: Double) { lock.withLock { heard.append(fraction) } } + private var heard: [Double?] = [] + var all: [Double?] { lock.withLock { heard } } + func add(_ fraction: Double?) { lock.withLock { heard.append(fraction) } } } - /// A pull's progress reaches the create that asked for it as it goes, rising to the whole (#309). + /// A pull's progress reaches the create that asked for it as it goes, rising to the whole, then + /// says it is done (#309). How many steps arrive between depends on how the pipe's reads fall, so + /// only their order and ends are pinned (`ImagePullProgressTests` pins the parsing). func testAPullsProgressReachesItsCreate() async throws { let (runtime, _) = try scriptedRuntime( """ @@ -379,25 +506,75 @@ final class RemoteHostsTests: XCTestCase { run*) exit 1 ;; """) let heard = Fractions() - let report: @Sendable (Double) -> Void = { heard.add($0) } + let report: @Sendable (Double?) -> Void = { heard.add($0) } + // The scripted `run` fails, after the pull. try? await ContainerHostDriver.$pullProgress.withValue(report) { _ = try await Self.driver(runtime: runtime, context: nil).create() } - XCTAssertEqual(heard.all, [0, 0.5, 1]) + let all = heard.all + XCTAssertEqual(all.last, .some(nil), "the pull never said it was done: \(all)") + let fractions = all.dropLast().compactMap { $0 } + XCTAssertEqual(fractions.count, all.count - 1, "done before the end: \(all)") + XCTAssertEqual(fractions.last, 1, "\(all)") + XCTAssertEqual(fractions, fractions.sorted(), "\(all)") + XCTAssertTrue(fractions.allSatisfy { (0...1).contains($0) }, "\(all)") } - /// A pull that fails says the host image couldn't be downloaded, and runs nothing. - func testAFailedPullSaysTheImageCouldNotBeDownloaded() async throws { - let (runtime, log) = try failingRuntime(pullFails: true) - do { - _ = try await Self.driver(runtime: runtime, context: nil).create() - XCTFail("a failed pull created a host") - } catch { - XCTAssertTrue( - error.localizedDescription.contains("couldn't download the workroom host image"), - error.localizedDescription) + /// A pull that fails says why, by cause, and runs nothing: a registry that refused the image, a + /// runtime that isn't running, or else the network. + func testAFailedPullSaysWhy() async throws { + for (said, expected) in [ + ("denied", "the registry refused the workroom host image"), + ( + "Cannot connect to the Docker daemon at unix:///x. Is the docker daemon running?", + "Docker didn't answer" + ), + ( + "Got permission denied while trying to connect to the Docker daemon socket at unix:///x", + "Docker didn't answer" + ), + ("manifest unknown", "the registry refused the workroom host image"), + ("read: connection reset by peer", "couldn't download the workroom host image"), + ] { + let (runtime, log) = try failingRuntime(pullFails: true, said: said) + do { + _ = try await Self.driver(runtime: runtime, context: nil).create() + XCTFail("a failed pull created a host") + } catch { + XCTAssertTrue(error.localizedDescription.contains(expected), error.localizedDescription) + } + XCTAssertFalse(try String(contentsOf: log, encoding: .utf8).contains("run ")) + } + } + + /// A runtime that isn't running says so at its first command, rather than pulling: its inspect + /// failing is not a missing image. + func testARuntimeThatIsNotRunningSaysSo() async throws { + for (dialect, said, expected) in [ + ( + ContainerHostDriver.Dialect.docker, + "Cannot connect to the Docker daemon at unix:///x. Is the docker daemon running?", + "Docker didn't answer" + ), + ( + .apple, + "Error: interrupted: \"XPC connection error: Connection invalid\"\nEnsure container " + + "system service has been started with `container system start`.", + "container system start" + ), + ] { + let (runtime, log) = try stubRuntime() + let script = try String(contentsOf: runtime, encoding: .utf8) + try (script + "\nprintf '%s\\n' \(ContainerHostDriver.shellQuoted(said)) >&2; exit 1\n") + .write(to: runtime, atomically: true, encoding: .utf8) + do { + _ = try await Self.driver(runtime: runtime, context: nil, dialect: dialect).create() + XCTFail("created without a runtime") + } catch { + XCTAssertTrue(error.localizedDescription.contains(expected), error.localizedDescription) + } + XCTAssertFalse(try String(contentsOf: log, encoding: .utf8).contains("pull"), "it pulled") } - XCTAssertFalse(try String(contentsOf: log, encoding: .utf8).contains("run ")) } /// The image a new base runs: the hidden override, else the build's pinned digest, else a local @@ -675,6 +852,42 @@ final class RemoteHostsTests: XCTestCase { ["delete --force \(ContainerHostDriver.containerName(swept))", "image delete leftover:1"]) } + /// A derive's snapshot image whose delete failed goes with the next sweep, though its workroom's + /// container was run from it: Apple's container runs without its image, and the snapshot is a + /// whole disk. + func testAppleSweepRemovesALeftoverSnapshotItsContainerRanFrom() async throws { + let workroom = UUID() + let snapshot = ContainerHostDriver.deriveDirectoryPrefix + "abc:latest" + let ours = ["workroom.provisioner": "test", "workroom.created": "1"] + let list: [[String: Any]] = [ + [ + "id": ContainerHostDriver.containerName(workroom), "status": ["state": "running"], + "configuration": ["labels": ours, "image": ["reference": snapshot]], + ] + ] + let images: [[String: Any]] = [ + [ + "configuration": ["name": snapshot], + "variants": [ + ["platform": ["architecture": "arm64"], "config": ["config": ["Labels": ours]]] + ], + ] + ] + let json = { (o: Any) in + String(decoding: try JSONSerialization.data(withJSONObject: o), as: UTF8.self) + } + let (runtime, log) = try scriptedRuntime( + """ + "list --all --format json") printf '%s' \(ContainerHostDriver.shellQuoted(try json(list))) ;; + "image list --format json") printf '%s' \(ContainerHostDriver.shellQuoted(try json(images))) ;; + """) + let failures = await Self.driver(runtime: runtime, context: nil, dialect: .apple).sweep( + keeping: [workroom], images: []) + XCTAssertEqual(failures, []) + let removed = try calls(log).filter { $0.hasPrefix("delete ") || $0.hasPrefix("image delete ") } + XCTAssertEqual(removed, ["image delete \(snapshot)"]) + } + /// A derived image is rebuilt from the exported disk with the host image's own process: its /// entrypoint, command and environment, not the run-time ones a container carries. func testAppleDerivesWithTheImagesOwnProcess() throws { diff --git a/macapp/WorkroomAppTests/RemoteProvisioningTests.swift b/macapp/WorkroomAppTests/RemoteProvisioningTests.swift index 8b7f1ce2b..44fa1a254 100644 --- a/macapp/WorkroomAppTests/RemoteProvisioningTests.swift +++ b/macapp/WorkroomAppTests/RemoteProvisioningTests.swift @@ -1,3 +1,4 @@ +import CryptoKit import XCTest @testable import Workroom @@ -65,3 +66,177 @@ final class RemoteProvisioningTests: XCTestCase { XCTAssertEqual(probe.overlap, 2, "different bases did not run at once") } } + +/// Which credentials a base's clone and a workroom's derive take (#309): the broker's clone token, +/// or the Mac's own GitHub token through the relay. +final class RemoteProvisioningCredentialsTests: XCTestCase { + private let repository = "o/r" + + private func environment( + client: Bool, gitHubToken: (@Sendable () async throws -> String)?, + driver: any HostDriver = RefusingDriver(), revoked: Revoked = Revoked() + ) -> RemoteProvisioning.Environment { + RemoteProvisioning.Environment( + driver: driver, agentSocket: RemoteWorkrooms.agentSocket, + client: client + ? BrokerClient( + baseURL: URL(string: "https://codaset.localhost")!, + key: .software(P256.Signing.PrivateKey()), session: BrokerStub.session) + : nil, + gitHubToken: gitHubToken, revoke: { _ in revoked.add() }) + } + + private static let appNotInstalled = BrokerStub.Answer( + status: 409, body: #"{"error":"app_not_installed","message":"x"}"#) + + func testTheBrokersTokenClonesAndIsRevoked() async throws { + BrokerStub.reset([.init(body: #"{"token":"ghs_broker","expires_at":"x"}"#)]) + let revoked = Revoked() + let heard = Heard() + let relayed = try await RemoteProvisioning.withCloneToken( + repository, relayed: nil, + in: environment(client: true, gitHubToken: { "gho_mac" }, revoked: revoked) + ) { heard.set($0) } + XCTAssertFalse(relayed) + XCTAssertEqual(heard.get(), RemoteProvisioning.cloneEnvironment(token: "ghs_broker")) + XCTAssertEqual(revoked.count, 1) + } + + /// The repository's owner hasn't installed the Codaset App: the Mac's own token clones, and the + /// base is relayed. It is the user's, so it is never revoked. + func testAnUninstalledAppFallsBackToTheMacsToken() async throws { + BrokerStub.reset([Self.appNotInstalled]) + let revoked = Revoked() + let heard = Heard() + let relayed = try await RemoteProvisioning.withCloneToken( + repository, relayed: nil, + in: environment(client: true, gitHubToken: { "gho_mac" }, revoked: revoked) + ) { heard.set($0) } + XCTAssertTrue(relayed) + XCTAssertEqual(heard.get(), RemoteProvisioning.cloneEnvironment(token: "gho_mac")) + XCTAssertEqual(revoked.count, 0) + } + + /// Without `gh` either, the App's install is what the user needs to hear about. + func testAnUninstalledAppWithoutGhIsTheRefusal() async throws { + BrokerStub.reset([Self.appNotInstalled]) + do { + try await RemoteProvisioning.withCloneToken( + repository, relayed: nil, + in: environment(client: true, gitHubToken: { throw RemoteWorkrooms.Failure.signedOut }) + ) { _ in XCTFail("cloned") } + XCTFail("expected the refusal") + } catch BrokerError.refused(let refusal) { + XCTAssertEqual(refusal.code, "app_not_installed") + } + } + + /// An existing broker base doesn't fall back: its workrooms enrol. + func testABrokerBaseKeepsItsRefusal() async throws { + BrokerStub.reset([Self.appNotInstalled]) + do { + try await RemoteProvisioning.withCloneToken( + repository, relayed: false, in: environment(client: true, gitHubToken: { "gho_mac" }) + ) { _ in XCTFail("cloned") } + XCTFail("expected the refusal") + } catch BrokerError.refused(let refusal) { + XCTAssertEqual(refusal.code, "app_not_installed") + } + } + + func testSignedOutOfCodasetANewBaseIsRelayed() async throws { + let heard = Heard() + let relayed = try await RemoteProvisioning.withCloneToken( + repository, relayed: nil, in: environment(client: false, gitHubToken: { "gho_mac" }) + ) { heard.set($0) } + XCTAssertTrue(relayed) + XCTAssertEqual(heard.get(), RemoteProvisioning.cloneEnvironment(token: "gho_mac")) + } + + func testSignedOutOfCodasetABrokerBaseIsSignedOut() async throws { + do { + try await RemoteProvisioning.withCloneToken( + repository, relayed: false, in: environment(client: false, gitHubToken: { "gho_mac" }) + ) { _ in XCTFail("cloned") } + XCTFail("expected signedOut") + } catch RemoteWorkrooms.Failure.signedOut {} + } + + /// A workroom of a broker base needs the broker before its base is copied, which can take many + /// minutes, not after. + func testSignedOutOfCodasetADeriveFromABrokerBaseCopiesNothing() async throws { + let driver = RefusingDriver() + do { + _ = try await RemoteProvisioning.derive( + from: .init(host: UUID(), repository: repository, cloneURL: "u", path: "/p"), + workroom: UUID(), branch: "b", + in: environment(client: false, gitHubToken: { "gho_mac" }, driver: driver)) + XCTFail("expected signedOut") + } catch RemoteWorkrooms.Failure.signedOut {} + XCTAssertEqual(driver.derives, 0, "the base was copied first") + } + + /// A relayed base's workroom needs the Mac's token, also before the copy. + func testWithoutGhADeriveFromARelayedBaseCopiesNothing() async throws { + let driver = RefusingDriver() + do { + _ = try await RemoteProvisioning.derive( + from: .init(host: UUID(), repository: repository, cloneURL: "u", path: "/p", relayed: true), + workroom: UUID(), branch: "b", + in: environment( + client: true, gitHubToken: { throw RemoteWorkrooms.Failure.signedOut }, driver: driver)) + XCTFail("expected signedOut") + } catch RemoteWorkrooms.Failure.signedOut {} + XCTAssertEqual(driver.derives, 0, "the base was copied first") + } +} + +extension RemoteProvisioningCredentialsTests { + /// The fallback to the Mac's token is said once, to someone signed in, when the base is made. + func testOnlyASignedInFirstBaseThatRelaysSaysItFellBack() { + let relayed = HostDescriptor(id: UUID(), credentials: "relay") + let broker = HostDescriptor(id: UUID()) + XCTAssertTrue(AppStore.fellBackToGitHub(signedIn: true, hadBase: false, made: relayed)) + XCTAssertFalse(AppStore.fellBackToGitHub(signedIn: false, hadBase: false, made: relayed)) + XCTAssertFalse(AppStore.fellBackToGitHub(signedIn: true, hadBase: true, made: relayed)) + XCTAssertFalse(AppStore.fellBackToGitHub(signedIn: true, hadBase: false, made: broker)) + XCTAssertFalse(AppStore.fellBackToGitHub(signedIn: true, hadBase: false, made: nil)) + } +} + +private final class Revoked: @unchecked Sendable { + private let lock = NSLock() + private var revokes = 0 + func add() { lock.withLock { revokes += 1 } } + var count: Int { lock.withLock { revokes } } +} + +private final class Heard: @unchecked Sendable { + private let lock = NSLock() + private var environment: [String: String]? + func set(_ environment: [String: String]) { lock.withLock { self.environment = environment } } + func get() -> [String: String]? { lock.withLock { environment } } +} + +/// Counts derives, and refuses everything. +private final class RefusingDriver: HostDriver, @unchecked Sendable { + private let lock = NSLock() + private var derived = 0 + var derives: Int { lock.withLock { derived } } + let traits = HostDriverTraits( + transport: .sshStdio, deriveSpeed: nil, deriveCarriesLiveProcesses: false, + durableDisk: false, maxLifetime: nil) + + func create() async throws -> HostID { throw HostDriverError.notImplemented("create") } + func deriveFromBase(_ base: HostID) async throws -> HostID { + lock.withLock { derived += 1 } + throw HostDriverError.notImplemented("derive") + } + func destroy(_ host: HostID) async throws { throw HostDriverError.notImplemented("destroy") } + func openStream(to host: HostID) async throws -> HostStream { + throw HostDriverError.notImplemented("openStream") + } + func exec(_ command: String, on host: HostID) async throws -> HostStream { + throw HostDriverError.notImplemented("exec") + } +} diff --git a/macapp/WorkroomAppTests/RemoteWorkroomRoutingTests.swift b/macapp/WorkroomAppTests/RemoteWorkroomRoutingTests.swift index 316772ae5..b4ab8983a 100644 --- a/macapp/WorkroomAppTests/RemoteWorkroomRoutingTests.swift +++ b/macapp/WorkroomAppTests/RemoteWorkroomRoutingTests.swift @@ -57,6 +57,34 @@ final class RemoteWorkroomRoutingTests: XCTestCase { } } + /// A workroom carries its own base's GitHub identity: a base on another runtime, made after the + /// project's origin changed, is a clone of another repository (#309). + func testEachWorkroomCarriesItsOwnBasesRepository() throws { + let (docker, apple) = (UUID(), UUID()) + let workroom = { (name: String, host: UUID, driver: String) in + Workroom( + name: name, path: self.path, vcsName: "workroom/\(name)", warnings: [], + host: HostDescriptor(driver: driver, provisioner: RemoteWorkrooms.provisioner, id: host)) + } + let registrations = RemoteWorkrooms.registrations([ + Project( + path: "/proj", vcs: "git", + workrooms: [workroom("d", docker, "container"), workroom("a", apple, "apple-container")], + host: HostDescriptor(bases: [ + HostDescriptor( + driver: "container", provisioner: RemoteWorkrooms.provisioner, id: UUID(), + repository: "o/old"), + HostDescriptor( + driver: "apple-container", provisioner: RemoteWorkrooms.provisioner, id: UUID(), + repository: "o/new"), + ])) + ]) + let github = Dictionary( + uniqueKeysWithValues: registrations.map { ($0.location, $0.entry.github?.flag) }) + XCTAssertEqual(github[try .remote(host: docker, path: path)], "github.com/o/old") + XCTAssertEqual(github[try .remote(host: apple, path: path)], "github.com/o/new") + } + /// No base identity, no GitHub identity: its PR and CI stay off rather than guess. func testAProjectWithoutABaseRepositoryRegistersNoGitHubIdentity() { let registrations = RemoteWorkrooms.registrations([ diff --git a/vcs/scripts/ssh-fixture/entrypoint.sh b/vcs/scripts/ssh-fixture/entrypoint.sh index b977d6a4d..0e828e68f 100755 --- a/vcs/scripts/ssh-fixture/entrypoint.sh +++ b/vcs/scripts/ssh-fixture/entrypoint.sh @@ -26,6 +26,8 @@ if [ "$(cat "$IDENTITY" 2>/dev/null)" != "$(hostname)" ]; then tr -d '-' < /proc/sys/kernel/random/uuid > /etc/machine-id # With the agent's interrupted saves (`.broker.json.`), which can hold a key. rm -f /run/workroom/broker.json /run/workroom/broker-token.json /run/workroom/.broker* + # The Mac's relay (#309): its port and secret are the base's, and the app installs its own. + rm -f /run/workroom/relay.json /run/workroom/.relay* rm -rf /home/workroom/.local/state/workroom/screens hostname > "$IDENTITY" fi From 8a41d23548afb3da522bf1fcc87854692ad93420 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 21:34:47 +0100 Subject: [PATCH 23/29] ci: keep Release off the host image, and pin a public one into each Nightly (#309) Container workrooms are Nightly-only until #260, so Release no longer waits on the host-image build. release.sh refuses a Nightly without a digest-pinned image, and host-image.yml checks the pinned digest can be pulled anonymously. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- .github/workflows/host-image.yml | 23 +++++++++++++++++++++-- .github/workflows/release.yml | 16 +++------------- macapp/Scripts/release.sh | 13 +++++++++---- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/.github/workflows/host-image.yml b/.github/workflows/host-image.yml index db4d8ef7b..ae26ac141 100644 --- a/.github/workflows/host-image.yml +++ b/.github/workflows/host-image.yml @@ -1,7 +1,7 @@ name: Host image -# Publishes the `workroom-host` image a local container workroom runs (#309), for the Nightly and -# Release workflows, which pin the app they build to the digest this outputs. +# Publishes the `workroom-host` image a local container workroom runs (#309), for the Nightly +# workflow, which pins the app it builds to the digest this outputs. Release joins it with #260. # # Each architecture is built once, smoke-tested as that exact image (host-image-test.sh with # WR_HOST_IMAGE), and only then pushed by digest: a second build for the push could resolve other @@ -99,3 +99,22 @@ jobs: case "$digest" in sha256:*) ;; *) echo "::error::no manifest digest: $digest"; exit 1 ;; esac echo "image=$repo@$digest" >> "$GITHUB_OUTPUT" echo "Published $repo@$digest ($TAG)" >> "$GITHUB_STEP_SUMMARY" + + # The app pulls without signing in to ghcr, so a package left private would ship a digest + # every create fails to pull. Asked as a stranger: an anonymous token, then the manifest. + - name: Check the image can be pulled anonymously + env: + IMAGE: ${{ steps.create.outputs.image }} + run: | + path="${IMAGE#ghcr.io/}"; name="${path%@*}"; digest="${IMAGE#*@}" + # A private package refuses the token itself, which the manifest's refusal below reports. + token="$(curl -sS "https://ghcr.io/token?scope=repository:$name:pull" | jq -r '.token // empty')" + status="$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $token" \ + -H 'Accept: application/vnd.oci.image.index.v1+json' \ + -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \ + "https://ghcr.io/v2/$name/manifests/$digest")" + if [ "$status" != 200 ]; then + echo "::error::$IMAGE can't be pulled anonymously (HTTP $status): make the package public in its settings" + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b8fc161d..e40e0a9b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,18 +41,10 @@ jobs: # NOTARY_KEY / NOTARY_KEY_ID / NOTARY_ISSUER_ID. # Optional: SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT enable Sentry dSYM upload so release # crashes symbolicate; release.sh skips that step when any of them (or sentry-cli) is absent. - # The `workroom-host` image this release's local container workrooms run (#309), built, tested - # and published per architecture; its digest is pinned into the app below. - host-image: - uses: ./.github/workflows/host-image.yml - permissions: - contents: read - packages: write - with: - tag: ${{ github.ref_name }} - + # No `workroom-host` image (#309) yet: container workrooms are Nightly-only until #260, so a + # release has nothing to pin, and an image build that failed on tag day would hold the DMG back + # after the CLI has shipped. Add the `host-image` job (as nightly.yml has it) with #260. macapp: - needs: host-image runs-on: macos-15 steps: - uses: actions/checkout@v5 @@ -135,8 +127,6 @@ jobs: # Empty until the secret is added — release.sh then skips signing (DMG still ships). SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} VERSION: ${{ github.ref_name }} - # The host image this app runs, by digest (release.sh → WorkroomHostImage). - WORKROOM_HOST_IMAGE: ${{ needs.host-image.outputs.image }} # Sentry dSYM upload (optional — release.sh skips when any is empty). ORG/PROJECT are # slugs, not secrets, but kept as secrets here to match this job's convention. SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/macapp/Scripts/release.sh b/macapp/Scripts/release.sh index 935631466..31d8c79b7 100755 --- a/macapp/Scripts/release.sh +++ b/macapp/Scripts/release.sh @@ -97,10 +97,15 @@ BUILD_NUMBER="$(git -C "$MACAPP_DIR/.." rev-list --count HEAD 2>/dev/null || ech # rejects. Archiving also produces a distribution build with get-task-allow omitted. # The `workroom-host` image the app's local container workrooms run, by digest (#309): CI's # host-image workflow publishes it and passes it here. Without it the app falls back to a locally -# built `workroom-host` (`make remote-host-image`), which a user won't have. -if [ -z "${WORKROOM_HOST_IMAGE:-}" ]; then - echo "warning: WORKROOM_HOST_IMAGE is unset; this build runs a local workroom-host image" >&2 -fi +# built `workroom-host` (`make remote-host-image`), which a user won't have, so a Nightly, the only +# build that runs container workrooms until #260, refuses to build without one. +case "$CONFIGURATION:${WORKROOM_HOST_IMAGE:-}" in + Nightly:*@sha256:*) ;; + Nightly:*) + echo "error: a Nightly needs WORKROOM_HOST_IMAGE, the workroom-host image by digest" \ + "(@sha256:...), got '${WORKROOM_HOST_IMAGE:-}'." >&2 + exit 1 ;; +esac echo "==> Archiving $CONFIGURATION ($APP_NAME) $SHORT_VERSION ($BUILD_NUMBER)" rm -rf "$ARCHIVE" "$EXPORT_DIR" xcodebuild -project "$PROJ" -scheme WorkroomApp -configuration "$CONFIGURATION" \ From b9798e1c6a18178a637162ebaa4bbd238f5acb34 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 21:34:47 +0100 Subject: [PATCH 24/29] test(wr-agent): pin the credential relay's error, cap and port paths (#309) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- vcs/crates/wr-agent/tests/broker.rs | 93 ++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/vcs/crates/wr-agent/tests/broker.rs b/vcs/crates/wr-agent/tests/broker.rs index a823875bf..19f176643 100644 --- a/vcs/crates/wr-agent/tests/broker.rs +++ b/vcs/crates/wr-agent/tests/broker.rs @@ -741,7 +741,8 @@ fn an_unreadable_state_file_is_not_an_enrolment() { /// A stand-in for the Mac's end of a relay (#309): takes one connection, records what was sent, /// and answers with `answer`. -fn relay_listener(answer: &'static str) -> (u16, std::thread::JoinHandle) { +fn relay_listener(answer: impl Into) -> (u16, std::thread::JoinHandle) { + let answer = answer.into(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let thread = std::thread::spawn(move || { @@ -884,3 +885,93 @@ fn a_relay_needs_a_port_and_a_one_word_secret() { ); } } + +/// `get` through the relay with `answer` from the Mac: what git is told, and what reached it. +fn relayed_get(name: &str, answer: impl Into) -> (Result<(), BrokerError>, Vec) { + let workspace = Workspace::new(name); + let (port, mac) = relay_listener(answer); + broker::install_relay(&workspace.dir, port, "s3cret").unwrap(); + let mut output = Vec::new(); + let result = broker::credential( + &workspace.dir, + "get", + &b"protocol=https\nhost=github.com\n\n"[..], + &mut output, + ); + mac.join().unwrap(); + (result, output) +} + +#[test] +fn a_relay_answer_with_an_error_is_the_error_and_gives_git_nothing() { + // The credential before the error: the error still wins, and none of it reaches git. + let (result, output) = relayed_get( + "broker-relay-error", + "username=joel\npassword=gho_mac\nerror=the Mac said no\n", + ); + match result { + Err(BrokerError::Invalid(message)) => assert_eq!(message, "the Mac said no"), + other => panic!("expected the Mac's error, got {other:?}"), + } + assert!(output.is_empty()); +} + +#[test] +fn a_relay_answer_without_a_username_and_password_says_to_sign_in_to_gh() { + for (name, answer) in [ + ("broker-relay-bare", "protocol=https\nhost=github.com\n"), + ("broker-relay-half", "username=joel\n"), + ("broker-relay-empty", ""), + ] { + let (result, output) = relayed_get(name, answer); + match result { + Err(BrokerError::Invalid(message)) => { + assert!(message.contains("gh auth login"), "{name}: {message}") + } + other => panic!("{name}: expected a sign-in error, got {other:?}"), + } + assert!(output.is_empty(), "{name}"); + } +} + +#[test] +fn a_relay_answer_is_read_only_up_to_the_cap() { + // 16 KiB is RELAY_MAX: the password and an error sit past it, so neither is ever seen. + let answer = format!( + "username=joel\npad={}\npassword=gho_beyond\nerror=beyond\n", + "a".repeat(16 * 1024) + ); + let (result, output) = relayed_get("broker-relay-oversized", answer); + match result { + Err(BrokerError::Invalid(message)) => { + assert!(message.contains("gh auth login"), "{message}") + } + other => panic!("expected a sign-in error, got {other:?}"), + } + assert!(output.is_empty()); +} + +/// `wr-agent credential relay` with a missing or unusable `--port` fails before it touches stdin, +/// the agent's directory or git config (a valid port would write beside this test binary's agent). +#[test] +fn credential_relay_without_a_usable_port_fails() { + for args in [ + &["credential", "relay"][..], + &["credential", "relay", "--port"], + &["credential", "relay", "--port", "abc"], + &["credential", "relay", "--port", "70000"], + &["credential", "relay", "--port", "-1"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_wr-agent")) + .args(args) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!(!output.status.success(), "{args:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("credential relay needs --port"), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} From 19a426d34213d92275706cbfdbfe9150b62303d1 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 21:37:32 +0100 Subject: [PATCH 25/29] ci: lint the GitHub workflows with actionlint `make actions-lint` and a `workflows` CI job run actionlint 1.7.12, shellcheck included, over every workflow. The job isn't behind the changes selector: it takes seconds, and a workflow edit is what the selector would skip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- .github/workflows/ci.yml | 14 ++++++++++++++ AGENTS.md | 1 + CONTRIBUTING.md | 7 ++++++- Makefile | 10 ++++++++-- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 199a81102..9db404723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,20 @@ jobs: DIFF_HEAD: ${{ github.sha }} run: python3 scripts/ci/changes.py >> "$GITHUB_OUTPUT" + # Every workflow, shellchecked `run:` blocks included. Not behind `changes`: it takes seconds, + # and a workflow edit is exactly what the selector would skip. The pin moves with the Makefile's + # `actions-lint` (CONTRIBUTING); the download script is fetched at the same tag, not from main. + workflows: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + + - name: Lint the workflows + run: | + bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash) 1.7.12 + ./actionlint -color + cli: needs: changes if: always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run != 'false') diff --git a/AGENTS.md b/AGENTS.md index c47c9ca59..52525d357 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Run commands from the repository root; `make` lists available targets. - `make cli-build`, `make cli-test`: build the CLI and run `go test ./...`. - `make cli-lint`: run golangci-lint v2 and check gofmt/goimports formatting. +- `make actions-lint`: lint `.github/workflows` with actionlint, shellcheck included. - `make app-build`: build Rust dependencies, generate the Xcode project, and build Debug. - `make app-run`: rebuild and relaunch this checkout's Workroom Dev; stops its persisted session helpers. - `make app-test`: run app unit/integration tests; safe alongside other workrooms' runs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6600e9f91..1a80ef7d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,8 @@ Workroom *is* and how to use it, see [`README.md`](README.md). Before opening a PR: -- Run the linters on everything you touched: `make cli-lint` (Go) and `make app-lint` (Swift). +- Run the linters on everything you touched: `make cli-lint` (Go), `make app-lint` (Swift) and + `make actions-lint` (GitHub workflows). - Run the relevant tests: `make cli-test` and/or `make app-test`. - Keep the `--json` contract stable — it's the boundary between the CLI and the app. Breaking changes bump `schema_version` (`cmd/json.go`). See @@ -68,6 +69,10 @@ older Go cannot read the current toolchain's export data. v1 is not an option he `go/types` caps out at export-data version 2, so it cannot analyze a Go 1.27 stdlib, and it rejects the v2-format `.golangci.yml`. +**To change a GitHub workflow:** **`actionlint` 1.7.12** for `make actions-lint` +(`go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12`), with `shellcheck` on `PATH` so +`run:` blocks are checked too. CI's `workflows` job runs the same version: bump both together. + Runtime requirements for *using* Workroom are in [the README](README.md#requirements). --- diff --git a/Makefile b/Makefile index cd16aadd8..d5f1d591e 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,8 @@ # # App recipes run inside macapp/ and need its toolchain on PATH (xcodegen via Homebrew). The # Xcode build also runs project.yml phases — a non-fatal swift-format lint and embedding the Go -# helper (macapp/Scripts/build-helper.sh). `cli-lint` needs golangci-lint installed (see AGENTS.md). +# helper (macapp/Scripts/build-helper.sh). `cli-lint` needs golangci-lint installed (see AGENTS.md), +# `actions-lint` actionlint (CONTRIBUTING). export PATH := /opt/homebrew/bin:/usr/local/bin:$(PATH) VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) @@ -11,7 +12,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) .PHONY: help \ cli-build cli-test cli-install cli-lint cli-clean \ app-run app-build app-test app-uitest app-identity app-test-supervisor app-test-scripts app-generate app-format app-lint app-release app-icon app-tool-logos app-clean \ - remote-host-image remote-host-image-test + remote-host-image remote-host-image-test actions-lint help: ## List available targets @grep -hE '^[a-z][a-zA-Z0-9_-]*:.*## ' $(MAKEFILE_LIST) \ @@ -39,6 +40,11 @@ cli-lint: ## Lint Go with golangci-lint (analyzers + formatters) cli-clean: ## Remove the built binary rm -f workroom +# --- GitHub Actions --- + +actions-lint: ## Lint .github/workflows with actionlint (shellcheck included) + actionlint + # --- macOS app (macapp/) --- # The Debug product is "Workroom Dev" (distinct bundle id + name) so it runs alongside the From 1a094d577e9fdeca3c40a0e6d4259ca587fd7f16 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 21:58:02 +0100 Subject: [PATCH 26/29] fix(macapp): build a container's run arguments in steps CI's Swift can type-check (#309) The one chained expression timed out type-checking on CI's Xcode ("unable to type-check this expression in reasonable time"). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- .../WorkroomApp/Core/ContainerHostDriver.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/macapp/WorkroomApp/Core/ContainerHostDriver.swift b/macapp/WorkroomApp/Core/ContainerHostDriver.swift index a2130a394..adbf177c8 100644 --- a/macapp/WorkroomApp/Core/ContainerHostDriver.swift +++ b/macapp/WorkroomApp/Core/ContainerHostDriver.swift @@ -624,17 +624,17 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { // Docker's `unless-stopped`: a Docker or Mac restart brings it back, on the port its record // names. Apple's runtime has no restart policy; opening the workroom starts it // (`startIfStopped`), on the port it keeps. - let start = + // In steps, typed: as one expression, CI's Swift gave up type-checking it. + var arguments: [String] = provisioning.dialect == .apple ? ["run", "--detach", "--init", "--arch", "arm64"] : ["run", "--pull=never", "--detach", "--init", "--restart", "unless-stopped"] - let resources = - (provisioning.cpus.map { ["--cpus", "\($0)"] } ?? []) - + (provisioning.memory.map { ["--memory", $0] } ?? []) - _ = try await runtime( - start + resources + ["--name", container, "--publish", "127.0.0.1:\(port):22"] - + (provisioning.labels + [Self.created()]).flatMap { ["--label", $0] } - + ["--env", "AUTHORIZED_KEY=\(provisioning.publicKey)", source]) + if let cpus = provisioning.cpus { arguments += ["--cpus", String(cpus)] } + if let memory = provisioning.memory { arguments += ["--memory", memory] } + arguments += ["--name", container, "--publish", "127.0.0.1:\(port):22"] + for label in provisioning.labels + [Self.created()] { arguments += ["--label", label] } + arguments += ["--env", "AUTHORIZED_KEY=\(provisioning.publicKey)", source] + _ = try await runtime(arguments) let hostKey = try await identity(of: container) lock.withLock { hosts[id] = Host( From 1f14e7f215ce90593d306b912e9a7f6efeb790ac Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 22:14:00 +0100 Subject: [PATCH 27/29] test(macapp): compare a stub runtime's calls as Strings, which CI's Swift infers (#309) Xcode 26.3 couldn't convert the interpolated String literals to the split's Substrings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- macapp/WorkroomAppTests/RemoteHostsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index df11a2402..c53e086d1 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -631,7 +631,7 @@ final class RemoteHostsTests: XCTestCase { for context in [nil, "orbstack"] as [String?] { let (runtime, log) = try stubRuntime() _ = await Self.driver(runtime: runtime, context: context).sweep(keeping: []) - let calls = try String(contentsOf: log, encoding: .utf8).split(separator: "\n") + let calls = try self.calls(log) let prefix = context.map { "--context \($0) " } ?? "" XCTAssertEqual( calls, From 6254eeae08595384d760f0bb8e56a584a3b0e756 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 22:44:27 +0100 Subject: [PATCH 28/29] fix(macapp): address PR #320's review comments (#309) - An opened workroom's start is spent whatever comes of it: a container that won't start is left to the status sweep's backoff, not restarted by it. - An Apple derive cut off mid-stop starts its base again. - gh's output is read on the relay's own thread, to EOF or the deadline, so a timeout leaves no reader or descriptor behind. - A pull's stdout and stderr keep their own line buffers. - The row "+" UI test waits for each runtime's entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- .../Core/ContainerHostDriver.swift | 19 ++++-- macapp/WorkroomApp/Core/CredentialRelay.swift | 41 +++++++++---- macapp/WorkroomApp/Core/HostDriver.swift | 14 +++-- .../WorkroomApp/Core/ImagePullProgress.swift | 14 +++-- macapp/WorkroomApp/Core/RemoteWorkrooms.swift | 14 ++--- .../CredentialRelayTests.swift | 9 +++ .../ImagePullProgressTests.swift | 14 +++++ .../WorkroomAppTests/RemoteHostsTests.swift | 61 +++++++++++++++++++ .../NewWorkroomPlacesUITests.swift | 2 +- 9 files changed, 149 insertions(+), 39 deletions(-) diff --git a/macapp/WorkroomApp/Core/ContainerHostDriver.swift b/macapp/WorkroomApp/Core/ContainerHostDriver.swift index adbf177c8..3bf738d2f 100644 --- a/macapp/WorkroomApp/Core/ContainerHostDriver.swift +++ b/macapp/WorkroomApp/Core/ContainerHostDriver.swift @@ -165,7 +165,9 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { _ = try await runtime( apple ? ["image", "pull", "--arch", "arm64", image] : ["pull", image], timeout: 900, onOutput: report.map { report in - { data in if let fraction = progress.read(data) { report(fraction) } } + { data, stderr in + if let fraction = progress.read(data, stderr: stderr) { report(fraction) } + } }) // The rest of the create (a clone, a derive) can take minutes, and isn't a download. report?(nil) @@ -287,7 +289,14 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { try AppleContainerCLI.objects( try await runtime(["inspect", source.container], allLines: true) ).first.flatMap(AppleContainerCLI.state) == "running" - if wasRunning { _ = try await runtime(["stop", source.container]) } + if wasRunning { + do { _ = try await runtime(["stop", source.container]) } catch { + // Cut off mid-stop (a cancelled create ends its CLI), it may have stopped all the same: + // started again, as after the export, so a failed create never leaves the base down. + _ = try? await Task { _ = try await self.runtime(["start", source.container]) }.value + throw error + } + } let exported: Error? do { // ponytail: `export` prints nothing until the whole disk is written, so this silence bound @@ -344,9 +353,9 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { private var last: Double? /// The fraction now, when it moved by a whole percent or more since the last one told. - func read(_ data: Data) -> Double? { + func read(_ data: Data, stderr: Bool) -> Double? { lock.withLock { - progress.read(String(decoding: data, as: UTF8.self)) + progress.read(String(decoding: data, as: UTF8.self), stderr: stderr) guard let fraction = progress.fraction, last.map({ abs(fraction - $0) >= 0.01 }) ?? true else { return nil } @@ -698,7 +707,7 @@ final class ContainerHostDriver: HostTerminalDriver, @unchecked Sendable { /// talk to, never the app's whole one. private func runtime( _ arguments: [String], timeout: TimeInterval = 120, allLines: Bool = false, - onOutput: (@Sendable (Data) -> Void)? = nil + onOutput: (@Sendable (Data, _ stderr: Bool) -> Void)? = nil ) async throws -> String { guard let provisioning else { throw HostDriverError.notImplemented("Provisioning") } let environment = ProcessInfo.processInfo.environment.filter { diff --git a/macapp/WorkroomApp/Core/CredentialRelay.swift b/macapp/WorkroomApp/Core/CredentialRelay.swift index 92541cfd0..d5602be4d 100644 --- a/macapp/WorkroomApp/Core/CredentialRelay.swift +++ b/macapp/WorkroomApp/Core/CredentialRelay.swift @@ -273,8 +273,9 @@ final class CredentialRelay: @unchecked Sendable { } /// Runs `executable`, bounded by `deadline` from start to the end of its output, whatever it or - /// anything it started does: a relay request holds a serving slot until this returns. One that - /// ignores SIGTERM is killed; a child of its that keeps its stdout open is left to it, unread. + /// anything it started does: a relay request holds a serving slot until this returns. Its output + /// is read here, on this thread, so a deadline leaves no reader behind: one that ignores SIGTERM + /// is killed, and a child of its that keeps its stdout open loses the pipe. static func run( _ executable: URL, _ arguments: [String], input: String?, environment: [String: String], name: String, deadline seconds: TimeInterval = 15 @@ -294,23 +295,37 @@ final class CredentialRelay: @unchecked Sendable { // A gh gone before its input is written would raise SIGPIPE, which ends the app. _ = fcntl(stdin.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) try process.run() - // Read on another queue, so a gh that hangs (a keychain prompt) is ended by the deadline - // rather than holding this thread until it closes its output. - let read = DispatchGroup() - nonisolated(unsafe) var output = Data() - DispatchQueue.global().async(group: read) { - output = stdout.fileHandleForReading.readDataToEndOfFile() + let out = stdout.fileHandleForReading + defer { try? out.close() } + let stop = { + process.terminate() + if exited.wait(timeout: .now() + 1) != .success { kill(process.processIdentifier, SIGKILL) } } if let input { try? stdin.fileHandleForWriting.write(contentsOf: Data(input.utf8)) } try? stdin.fileHandleForWriting.close() + // To EOF or the deadline: a gh that hangs (a keychain prompt) is ended rather than waited on. + var output = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + var reader = pollfd(fd: out.fileDescriptor, events: Int16(POLLIN), revents: 0) + while true { + let left = Int( + (deadline.uptimeNanoseconds &- DispatchTime.now().uptimeNanoseconds) / 1_000_000) + guard deadline > .now(), left > 0 else { + stop() + throw HostDriverError.provisioning("\(name) didn't answer in time") + } + let ready = poll(&reader, 1, Int32(min(left, Int(Int32.max)))) + if ready < 0, errno == EINTR { continue } + guard ready > 0 else { continue } + let count = Darwin.read(out.fileDescriptor, &buffer, buffer.count) + if count < 0, errno == EINTR { continue } + guard count > 0 else { break } + output.append(buffer, count: count) + } guard exited.wait(timeout: deadline) == .success else { - process.terminate() - if exited.wait(timeout: .now() + 1) != .success { kill(process.processIdentifier, SIGKILL) } + stop() throw HostDriverError.provisioning("\(name) didn't answer in time") } - guard read.wait(timeout: deadline) == .success else { - throw HostDriverError.provisioning("\(name)'s output didn't end in time") - } return (process.terminationStatus, String(decoding: output, as: UTF8.self)) } diff --git a/macapp/WorkroomApp/Core/HostDriver.swift b/macapp/WorkroomApp/Core/HostDriver.swift index 43e68a3fb..44d2d4f21 100644 --- a/macapp/WorkroomApp/Core/HostDriver.swift +++ b/macapp/WorkroomApp/Core/HostDriver.swift @@ -143,7 +143,7 @@ final class HostStream: @unchecked Sendable { /// stderr reached EOF: everything the carrier said is in `errors`. private var errorsClosed = false /// Told each piece of output as it arrives, stdout's and stderr's alike (`communicate`). - private var onOutput: (@Sendable (Data) -> Void)? + private var onOutput: (@Sendable (Data, _ stderr: Bool) -> Void)? /// Told stderr spoke, so output there counts against `communicate`'s silence bound too: a pull or /// build may report its progress there alone. private var heard: (@Sendable () -> Void)? @@ -310,10 +310,12 @@ final class HostStream: @unchecked Sendable { /// The writes and reads block, on GCD (`runBlocking`), not the cooperative pool: an 11 MB /// binary over a slow link is exactly the kind of wait that starves other blocking work there. /// - /// `onOutput` is told each piece of its output as it arrives, from stdout and stderr, for a + /// `onOutput` is told each piece of its output as it arrives, from stdout or stderr (which it is + /// told, since the two interleave), for a /// command that reports progress (`ContainerHostDriver`'s image pull). func communicate( - _ input: Data?, timeout: TimeInterval, onOutput: (@Sendable (Data) -> Void)? = nil + _ input: Data?, timeout: TimeInterval, + onOutput: (@Sendable (Data, _ stderr: Bool) -> Void)? = nil ) async throws -> ( status: Int32, output: String ) { @@ -383,7 +385,7 @@ final class HostStream: @unchecked Sendable { // and the scripts' report, which comes after whatever a shell startup printed, // survives even a startup that printed more than that. collected.append(chunk, count: count) - onOutput?(Data(chunk[0.. 1024 * 1024 { collected.removeFirst(collected.count - 1024 * 1024) } @@ -514,11 +516,11 @@ final class HostStream: @unchecked Sendable { /// Keeps the first 16 KiB of stderr: enough for any diagnosis, and bounded against a chatty one. private func collect(_ data: Data) { let (listener, heard) = lock.withLock { - () -> ((@Sendable (Data) -> Void)?, (@Sendable () -> Void)?) in + () -> ((@Sendable (Data, Bool) -> Void)?, (@Sendable () -> Void)?) in if errors.count < 16 * 1024 { errors.append(data.prefix(16 * 1024 - errors.count)) } return (onOutput, self.heard) } heard?() - listener?(data) + listener?(data, true) } } diff --git a/macapp/WorkroomApp/Core/ImagePullProgress.swift b/macapp/WorkroomApp/Core/ImagePullProgress.swift index 418dcaf5c..e03d5bd74 100644 --- a/macapp/WorkroomApp/Core/ImagePullProgress.swift +++ b/macapp/WorkroomApp/Core/ImagePullProgress.swift @@ -10,7 +10,9 @@ struct ImagePullProgress: Sendable { private var layers: Set = [] private var done: Set = [] private var stepped: Double? - private var pending = "" + /// Each stream's line so far: stdout's and stderr's arrive interleaved, and a line of one must + /// never run on into the other's. + private var pending: [Bool: String] = [:] /// The fraction done, 0 to 1, or nil before anything says how far. var fraction: Double? { @@ -19,12 +21,12 @@ struct ImagePullProgress: Sendable { return Double(done.count) / Double(layers.count) } - /// Takes a piece of output, which may end mid-line. - mutating func read(_ text: String) { - pending += text + /// Takes a piece of output from one stream, which may end mid-line. + mutating func read(_ text: String, stderr: Bool = false) { + let joined = pending[stderr, default: ""] + text // `\r` too: a progress bar redraws its line that way. - var lines = pending.split(omittingEmptySubsequences: false) { $0 == "\n" || $0 == "\r" } - pending = String(lines.removeLast()) + var lines = joined.split(omittingEmptySubsequences: false) { $0 == "\n" || $0 == "\r" } + pending[stderr] = String(lines.removeLast()) for line in lines { take(String(line)) } } diff --git a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift index ab0c0a4ef..05e344372 100644 --- a/macapp/WorkroomApp/Core/RemoteWorkrooms.swift +++ b/macapp/WorkroomApp/Core/RemoteWorkrooms.swift @@ -777,14 +777,12 @@ final class RemoteHosts: @unchecked Sendable { // one and hold the opened workroom back for `retryAfter`. This one goes after it instead. let probe = connecting[host]?.task let task = Task { - if let probe, (try? await probe.value) != nil { - } else { - try await start?(host) - try await connect(host) - } - // Once: a container the user stops later stays stopped for the status sweep, until its - // workroom is opened again. - if start != nil { _ = self.lock.withLock { self.activated.remove(host) } } + // Once, whatever comes of it: a container the user stops later, or one that won't start, + // is left to the status sweep's backoff until its workroom is opened again. + defer { if start != nil { _ = self.lock.withLock { self.activated.remove(host) } } } + if let probe, (try? await probe.value) != nil { return } + try await start?(host) + try await connect(host) } connecting[host] = (task, start != nil) return task diff --git a/macapp/WorkroomAppTests/CredentialRelayTests.swift b/macapp/WorkroomAppTests/CredentialRelayTests.swift index 80c291c93..e5438a2d7 100644 --- a/macapp/WorkroomAppTests/CredentialRelayTests.swift +++ b/macapp/WorkroomAppTests/CredentialRelayTests.swift @@ -275,6 +275,15 @@ final class CredentialRelayTests: XCTestCase { XCTAssertTrue(relay.respond(to: request).hasPrefix("error=")) } + /// What gh prints and its exit status come back as they are, its input reaching it. + func testGhsOutputAndStatusComeBack() throws { + let ran = try CredentialRelay.run( + URL(fileURLWithPath: "/bin/sh"), ["-c", "cat; printf done; exit 3"], input: "asked\n", + environment: [:], name: "gh") + XCTAssertEqual(ran.status, 3) + XCTAssertEqual(ran.output, "asked\ndone") + } + /// gh is bounded however it ends: one that exits while something it started keeps its output /// open, and one that ignores SIGTERM, each give the slot back by the deadline. func testGhIsBoundedWhateverItDoes() throws { diff --git a/macapp/WorkroomAppTests/ImagePullProgressTests.swift b/macapp/WorkroomAppTests/ImagePullProgressTests.swift index 39261f3b3..14ca17166 100644 --- a/macapp/WorkroomAppTests/ImagePullProgressTests.swift +++ b/macapp/WorkroomAppTests/ImagePullProgressTests.swift @@ -37,4 +37,18 @@ final class ImagePullProgressTests: XCTestCase { odd.read("[3/2] nonsense 40%\n[x/y] 10%\n") XCTAssertNil(odd.fraction) } + + /// stdout and stderr interleave mid-line: each stream's line is its own, so neither garbles the + /// other and every layer's completion counts. + func testInterleavedStreamsKeepTheirOwnLines() { + var progress = ImagePullProgress() + progress.read("aaaaaaaaaaaa: Pulling fs layer\nbbbbbbbbbbbb: Pulling fs layer\n") + progress.read("aaaaaaaaaaaa: Pull com") + progress.read("some warning on std", stderr: true) + progress.read("plete\n") + progress.read("err\n", stderr: true) + XCTAssertEqual(progress.fraction, 0.5) + progress.read("bbbbbbbbbbbb: Already exists\n") + XCTAssertEqual(progress.fraction, 1) + } } diff --git a/macapp/WorkroomAppTests/RemoteHostsTests.swift b/macapp/WorkroomAppTests/RemoteHostsTests.swift index c53e086d1..c127a2f07 100644 --- a/macapp/WorkroomAppTests/RemoteHostsTests.swift +++ b/macapp/WorkroomAppTests/RemoteHostsTests.swift @@ -178,6 +178,26 @@ final class RemoteHostsTests: XCTestCase { XCTAssertEqual(started.calls.count, 2, "opening it again didn't start it") } + /// An opening whose start fails is spent too: the status sweep's later probes neither start the + /// container again nor skip its backoff. + func testAFailedOpeningIsSpent() async throws { + let connects = Connects() + connects.hold(false) + let starts = Connects() + starts.hold(false) + starts.fail(true) + let remote = RemoteHosts( + connectHost: { try await connects.connect($0) }, isConnected: { _ in false }, + startHost: { try await starts.connect($0) }, now: { connects.now }) + let host = HostID.remote(UUID()) + remote.activate(host) + do { try await remote.ensureConnected(host) } catch {} + XCTAssertEqual(starts.calls, 1) + do { try await remote.ensureConnected(host) } catch {} + XCTAssertEqual(starts.calls, 1, "a probe started the container again") + XCTAssertEqual(connects.calls, 0, "a probe skipped the backoff") + } + /// A workroom opened while its container runs has nothing to start, and the opening is spent: /// stopped later, the status sweep's probe doesn't start it. func testOpeningARunningHostLeavesItStoppedLater() async throws { @@ -975,6 +995,47 @@ final class RemoteHostsTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: live.path), "a live derive's was taken") } + /// A derive whose stop of the base fails, as a cancelled create's does, starts the base again: + /// the stop may have taken effect all the same, and the base must not be left down. + func testAnAppleDeriveCutOffMidStopStartsTheBaseAgain() async throws { + let base = UUID() + let container = ContainerHostDriver.containerName(base) + let inspected: [[String: Any]] = [ + [ + "id": container, "status": ["state": "running"], + "configuration": ["image": ["reference": "host:1"]], + ] + ] + let image: [[String: Any]] = [ + [ + "variants": [ + [ + "platform": ["architecture": "arm64"], + "config": ["config": ["Entrypoint": ["/usr/local/bin/entrypoint.sh"]]], + ] + ] + ] + ] + let json = { (o: Any) in + String(decoding: try JSONSerialization.data(withJSONObject: o), as: UTF8.self) + } + let (runtime, log) = try scriptedRuntime( + """ + "inspect \(container)") printf '%s' \(ContainerHostDriver.shellQuoted(try json(inspected))) ;; + "image inspect host:1") printf '%s' \(ContainerHostDriver.shellQuoted(try json(image))) ;; + "stop \(container)") exit 1 ;; + """) + let driver = Self.driver(runtime: runtime, context: nil, dialect: .apple) + try driver.adopt(base, Self.record(context: nil), isBase: true) + do { + _ = try await driver.deriveFromBase(.remote(base)) + XCTFail("derived through a failed stop") + } catch {} + let made = try calls(log) + XCTAssertTrue(made.contains("stop \(container)"), "\(made)") + XCTAssertTrue(made.contains("start \(container)"), "the base was left stopped: \(made)") + } + /// Only a base is derived from. An Apple instance records no image, as a base doesn't, so a /// host is a base only when its adopter says so; deriving from an instance would copy its /// enrolment. diff --git a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift index 74f183b66..363c9a8bb 100644 --- a/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift +++ b/macapp/WorkroomAppUITests/NewWorkroomPlacesUITests.swift @@ -118,7 +118,7 @@ final class NewWorkroomPlacesUITests: XCTestCase { XCTAssertTrue( app.menuItems.matching( NSPredicate(format: "title == %@ OR title BEGINSWITH %@", runtime, "\(runtime) — ") - ).firstMatch.exists, "\(runtime) isn't listed") + ).firstMatch.waitForExistence(timeout: 5), "\(runtime) isn't listed") } let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) shot.name = "plus-places" From 225949d1698e65d90377944e29371f2addaee459 Mon Sep 17 00:00:00 2001 From: Joel Moss Date: Sat, 3 Oct 2026 22:44:27 +0100 Subject: [PATCH 29/29] ci: check actionlint's release archive against a pinned sha256 Fetching and running its download script from a tag ran whatever that tag pointed at. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017TT213ESeZ5wmW6ZYty3mD --- .github/workflows/ci.yml | 11 +++++++++-- CONTRIBUTING.md | 3 ++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9db404723..4696ac69e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,16 +65,23 @@ jobs: # Every workflow, shellchecked `run:` blocks included. Not behind `changes`: it takes seconds, # and a workflow edit is exactly what the selector would skip. The pin moves with the Makefile's - # `actions-lint` (CONTRIBUTING); the download script is fetched at the same tag, not from main. + # `actions-lint` (CONTRIBUTING). The release archive is checked against its pinned sha256 before + # it runs, so a replaced asset or tag fails here instead of running. workflows: runs-on: ubuntu-latest timeout-minutes: 5 + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 steps: - uses: actions/checkout@v5 - name: Lint the workflows run: | - bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash) 1.7.12 + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -fsSLO "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/$archive" + echo "$ACTIONLINT_SHA256 $archive" | sha256sum --check --strict + tar -xzf "$archive" actionlint ./actionlint -color cli: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a80ef7d4..695b408ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,8 @@ the v2-format `.golangci.yml`. **To change a GitHub workflow:** **`actionlint` 1.7.12** for `make actions-lint` (`go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12`), with `shellcheck` on `PATH` so -`run:` blocks are checked too. CI's `workflows` job runs the same version: bump both together. +`run:` blocks are checked too. CI's `workflows` job runs the same version, checked by the +release archive's sha256: bump the version and the hash there together. Runtime requirements for *using* Workroom are in [the README](README.md#requirements).