diff --git a/Packages/Shared/CmuxInstallAuthCore/Sources/CmuxInstallAuthCore/InstallAuthClient.swift b/Packages/Shared/CmuxInstallAuthCore/Sources/CmuxInstallAuthCore/InstallAuthClient.swift index 1e2d5afc3bcd..51333a938f10 100644 --- a/Packages/Shared/CmuxInstallAuthCore/Sources/CmuxInstallAuthCore/InstallAuthClient.swift +++ b/Packages/Shared/CmuxInstallAuthCore/Sources/CmuxInstallAuthCore/InstallAuthClient.swift @@ -11,6 +11,15 @@ public protocol InstallAuthTransport: Sendable { /// bearer and extra request `headers` (for example the client version). /// Returns the status and body. func post(_ path: String, json: Data, bearer: String?, headers: [String: String]) async throws -> (status: Int, body: Data) + /// GETs `path` (relative to the API Worker, query included) with an optional bearer and extra `headers`. + func get(_ path: String, bearer: String?, headers: [String: String]) async throws -> (status: Int, body: Data) +} + +extension InstallAuthTransport { + /// A transport that only posts (the install-auth exchange needs no GET) answers a GET as unsupported. + public func get(_ path: String, bearer: String?, headers: [String: String]) async throws -> (status: Int, body: Data) { + throw URLError(.unsupportedURL) + } } /// What the install keeps between launches (never the token). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index ad7102c23d0c..12255a72d6aa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -235,8 +235,8 @@ final class CloudService { signedIn: { [auth] in await MainActor.run { auth.isSignedIn } }, allowedOps: { await CloudCredentialRelay.bundledServedOps() }) credentialProvider = CloudCredentialProvider(relay: relay) - // The app server link: this Mac's `cmux link`, or with `CMUX_NEXT_CLOUD_NATIVE_LINK=1` the Native - // device route (#28947) with `cmux link` as its fallback when Native does not start. + // The app server link: the Native device route (#28947) with `cmux link` as its fallback when Native + // does not start, or with `CMUX_NEXT_CLOUD_NATIVE_LINK=0` this Mac's `cmux link` alone. let linkBuild = LaunchIdentity.current().build let appServerLink = CloudAppServerLink(appServer: configuration.linkSource == .appServer, link: CloudLinkLauncher.forApp(build: linkBuild, relay: relay), native: { CloudNativeRouteHolder.forApp(identity: identity, auth: auth, paths: paths, build: linkBuild, deviceName: Self.deviceName) }) { error in diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Identity/InstallHTTPTransport.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Identity/InstallHTTPTransport.swift index cb005862ba2f..7a2cb93556e2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Identity/InstallHTTPTransport.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Identity/InstallHTTPTransport.swift @@ -27,6 +27,22 @@ public struct InstallHTTPTransport: InstallAuthTransport { let (data, response) = try await session.data(for: request) return ((response as? HTTPURLResponse)?.statusCode ?? 0, data) } + + public func get(_ path: String, bearer: String?, headers: [String: String]) async throws -> (status: Int, body: Data) { + // The path joins the base as `post` does; a query after "?" is kept as given. + let parts = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) + guard var components = URLComponents(url: baseURL.appendingPathComponent(String(parts[0])), resolvingAgainstBaseURL: false) + else { throw URLError(.badURL) } + if parts.count == 2 { components.percentEncodedQuery = String(parts[1]) } + guard let url = components.url else { throw URLError(.badURL) } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "accept") + if let bearer { request.setValue("Bearer \(bearer)", forHTTPHeaderField: "authorization") } + for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) } + let (data, response) = try await session.data(for: request) + return ((response as? HTTPURLResponse)?.statusCode ?? 0, data) + } } /// Answers every redirect with "do not follow" (the completion-handler form: diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Identity/MacInstallIdentity.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Identity/MacInstallIdentity.swift index a1578baeaafb..a0e93ec0304e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Identity/MacInstallIdentity.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Identity/MacInstallIdentity.swift @@ -122,6 +122,14 @@ public actor MacInstallIdentity { return reply } + /// A GET on the API Worker under the install's token (`path` includes its query). + func nativeOwnerGet(_ path: String, context: NativeInstallContext) async throws -> (status: Int, body: Data) { + try await nativeCheck(context) + let reply = try await transport.get(path, bearer: context.token, headers: requestHeaders) + try await nativeCheck(context) + return reply + } + func nativeOwnerRead(_ body: Data, context: NativeInstallContext) async throws -> (status: Int, body: Data) { try await nativeCheck(context) let reply = try await transport.post("/v1/read", json: body, bearer: context.token, headers: requestHeaders) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Network/NativeDeviceEnrollment.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Network/NativeDeviceEnrollment.swift index f2353ddb8c67..f23c79a46e8f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Network/NativeDeviceEnrollment.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Network/NativeDeviceEnrollment.swift @@ -255,15 +255,25 @@ public actor NativeDeviceEnrollment { } private func owner(_ op: String, _ params: P, key: String, context: NativeInstallContext) async throws -> T { let response = try await identity.nativeOwnerPost(JSONEncoder().encode(NativeOwnerCall(op: op, params: params, idempotency_key: key)), context: context) - guard response.status == 200, response.body.count <= 1_048_576 else { throw NativeDeviceEnrollmentError.refused(response.status, step: op) } + guard response.status == 200, response.body.count <= 1_048_576 else { + NativeNetworkRefusal.log(op, status: response.status, body: response.body) + throw NativeDeviceEnrollmentError.refused(response.status, step: op) + } let decoded = try JSONDecoder().decode(NativeOwnerReply.self, from: response.body) - guard decoded.ok, let value = decoded.value else { throw NativeDeviceEnrollmentError.invalidIdentity } + guard decoded.ok, let value = decoded.value else { + // An owner refusal comes back as 200 with `ok: false` and its code under `error`. + let refused = try? JSONDecoder().decode(NativeOwnerRefusal.self, from: response.body) + NativeNetworkRefusal.log(op, status: response.status, code: refused?.error?.code, message: refused?.error?.message) + throw NativeDeviceEnrollmentError.invalidIdentity + } return value } private func readNative(_ method: String, _ path: String, body: Data? = nil, bearer: String? = nil) async throws -> T { let reply = try await native.send(method: method, path: path, body: body, bearer: bearer, tenant: bearer == nil ? nil : scope.tenant) guard reply.status == 200 || reply.status == 201, reply.body.count <= 1_048_576 else { - throw NativeDeviceEnrollmentError.refused(reply.status, step: NativeDeviceScope.step(method, path)) + let step = NativeDeviceScope.step(method, path) + NativeNetworkRefusal.log(step, status: reply.status, body: reply.body) + throw NativeDeviceEnrollmentError.refused(reply.status, step: step) } return try JSONDecoder().decode(T.self, from: reply.body) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Network/NativeNetworkTarget.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Network/NativeNetworkTarget.swift index 3215e1a012c1..c01f8435ae49 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Network/NativeNetworkTarget.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Network/NativeNetworkTarget.swift @@ -1,5 +1,6 @@ import CryptoKit public import Foundation +import os /// Routing metadata verified by the original owner transport, not transport or serving authority. public struct NativeNetworkTarget: Equatable, Sendable { @@ -39,12 +40,18 @@ extension MacInstallIdentity { // Validate caller selectors before token minting or any read. _ = try NativeDeviceScope(team: team, tenant: "selector", mesh: mesh ?? "mesh_" + String(repeating: "0", count: 26)) let context = try await nativeContext(team: team) - let body = try JSONEncoder().encode(NativeNetworkTargetCall(params: .init(mesh: mesh))) - let reply = try await nativeOwnerRead(body, context: context) - guard reply.status == 200, reply.body.count <= 1_048_576 else { throw NativeDeviceEnrollmentError.refused(reply.status, step: "host.network.target") } - let decoded = try JSONDecoder().decode(NativeNetworkTargetEnvelope.self, from: reply.body) - guard decoded.op == "host.network.target" else { throw NativeDeviceEnrollmentError.invalidIdentity } - let target = try NativeNetworkTarget(decoded.value, team: team, mesh: mesh, context: context) + // The platform serves this read on its own route (cmux-platform NativeNetwork.ts), not as an op on + // /v1/read: TeamDO answers that with "unknown read host.network.target". The mesh is a validated + // NativeMeshId (above), so it needs no escaping. + let reply = try await nativeOwnerGet(mesh.map { "/v1/host/network/target?mesh=\($0)" } ?? "/v1/host/network/target", + context: context) + guard reply.status == 200, reply.body.count <= 1_048_576 else { + // validation.invalid here is a tenant with more than one mesh; selector.not_found is none. + NativeNetworkRefusal.log("host.network.target", status: reply.status, body: reply.body) + throw NativeDeviceEnrollmentError.refused(reply.status, step: "host.network.target") + } + let decoded = try JSONDecoder().decode(NativeNetworkTargetReply.self, from: reply.body) + let target = try NativeNetworkTarget(decoded, team: team, mesh: mesh, context: context) try await nativeCheck(context) return target } @@ -58,6 +65,23 @@ struct NativeNetworkTargetReply: Decodable, Sendable { let mesh: String let ipv4_cidr: String } -private struct NativeNetworkTargetEnvelope: Decodable { let op: String; let value: NativeNetworkTargetReply } -private struct NativeNetworkTargetParams: Encodable { let mesh: String? } -private struct NativeNetworkTargetCall: Encodable { let op = "host.network.target"; let params: NativeNetworkTargetParams } +/// A Native refusal's stable code and message (server text, no credential), logged so a route that does not +/// start says why instead of a bare HTTP status. +struct NativeNetworkRefusal: Decodable { + let code: String? + let message: String? + + static func log(_ step: String, status: Int, body: Data) { + let refusal = try? JSONDecoder().decode(Self.self, from: body.prefix(4096)) + log(step, status: status, code: refusal?.code, message: refusal?.message) + } + + static func log(_ step: String, status: Int, code: String?, message: String?) { + let code = code ?? "no code", message = String((message ?? "").prefix(300)) + Logger(subsystem: "com.cmuxterm.app.next", category: "cloud.native") + .error("\(step, privacy: .public) refused \(status): \(code, privacy: .public): \(message, privacy: .public)") + } +} + +/// An owner op's refusal body: `{"ok": false, "error": {"code", "message"}}`. +struct NativeOwnerRefusal: Decodable { let error: NativeNetworkRefusal? } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudNativeRouteHolder.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudNativeRouteHolder.swift index 2e83276ef223..064243840c17 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudNativeRouteHolder.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudCredentials/CloudNativeRouteHolder.swift @@ -57,12 +57,13 @@ public final class CloudNativeRouteHolder: CloudLinkLaunching { } -/// The app server link's Native device route, opt-in with `CMUX_NEXT_CLOUD_NATIVE_LINK=1`, with this Mac's -/// `cmux link` for an install whose Native route fails to start (a team the Worker has no Native mesh for -/// answers 400), so turning Native on never leaves such a team with no machine. The fallback holds until -/// sign-out (``archive(install:)``), and `onFallback` says why it was taken. Not the default: a team with -/// a Native mesh can still hold machines made on the direct transport, whose `connect_info` has no Native -/// network, and the Native link cannot dial those (cmux#28950 review at 30a7996aabee). +/// The app server link's Native device route, the default (`CMUX_NEXT_CLOUD_NATIVE_LINK=0` turns it off), +/// with this Mac's `cmux link` for an install whose Native route fails to start (a team the Worker has no +/// Native mesh for answers 400), so Native never leaves such a team with no machine. The fallback holds until +/// sign-out (``archive(install:)``), and `onFallback` says why it was taken. Native is the default because it +/// is the only route that reaches a Worker machine today: `connect_info` answers no gateway, VPC endpoint or +/// public address, so `cmux link` over the relay finds no route to any machine ("refused the dial: +/// unreachable"), while a vm-api machine's Native network routes through the enrolled mesh tunnel. @MainActor public final class CloudNativeFirstLauncher: CloudLinkLaunching { private let native: any CloudLinkLaunching @@ -84,9 +85,9 @@ public final class CloudNativeFirstLauncher: CloudLinkLaunching { return bundled?[key] ?? ProcessInfo.processInfo.environment[key] } - /// Which route the app server link takes: `=1` Native first, anything else `cmux link`. + /// Which route the app server link takes: `=0` `cmux link`, anything else (unset included) Native first. public nonisolated static func choice(setting: String? = CloudNativeFirstLauncher.setting) -> Choice { - setting == "1" ? .native : .link + setting == "0" ? .link : .native } public init(native: any CloudLinkLaunching, link: (any CloudLinkLaunching)?, diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudNativeFirstLauncherTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudNativeFirstLauncherTests.swift index 44da58919204..ffeadd36d6c1 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudNativeFirstLauncherTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudNativeFirstLauncherTests.swift @@ -20,10 +20,10 @@ private final class FakeLauncher: CloudLinkLaunching { @MainActor struct CloudNativeFirstLauncherTests { - @Test func nativeIsOptInAndCmuxLinkIsTheDefault() { - #expect(CloudNativeFirstLauncher.choice(setting: nil) == .link) - #expect(CloudNativeFirstLauncher.choice(setting: "0") == .link) + @Test func nativeIsTheDefaultAndZeroTurnsItOff() { + #expect(CloudNativeFirstLauncher.choice(setting: nil) == .native) #expect(CloudNativeFirstLauncher.choice(setting: "1") == .native) + #expect(CloudNativeFirstLauncher.choice(setting: "0") == .link) } @Test func aWorkingNativeRouteIsUsed() async throws { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NativeDeviceTestWorld.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NativeDeviceTestWorld.swift index ca7b223dcfb4..0908a6eb003c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NativeDeviceTestWorld.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NativeDeviceTestWorld.swift @@ -26,6 +26,8 @@ actor NativeDeviceTestWorld: InstallAuthTransport, NativeDeviceTransport { private var badTarget: String? private var targetStatus = 200 private(set) var requests: [Data] = [] + /// Each GET of the Native target route, query included. + private(set) var targetPaths: [String] = [] private(set) var enrollments = 0 private(set) var deletions = 0 private(set) var deletedDevices: [String] = [] @@ -97,17 +99,6 @@ actor NativeDeviceTestWorld: InstallAuthTransport, NativeDeviceTransport { return (200, try data(["ok": true, "value": ["host": target, "token": "dial-once-\(cloudMints)", "expires_at": 1792430000000 as Int64, "epoch": 1, "services": services]])) } - if op == "host.network.target" { - guard path == "/v1/read", body["idempotency_key"] == nil, body["origin"] == nil, - params["mesh"] == nil || params["mesh"] as? String == scope.mesh else { throw Failure.invalid } - let action = beforeTarget; beforeTarget = nil - await action?() - if targetStatus != 200 { return (targetStatus, try data(["code": "owner.unreachable"])) } - var value = ["team": scope.team, "tenant": scope.tenant, "project": Self.project, - "origin": origin.absoluteString, "mesh": scope.mesh, "ipv4_cidr": "10.128.0.0/20"] - if let badTarget { value[badTarget] = badTarget == "origin" ? "http://attacker.test" : badTarget == "ipv4_cidr" ? "0.0.0.0/0" : "wrong-identity" } - return (200, try data(["op": op, "value": value])) - } if op == "host.enroll" { var value: [String: Any] = ["id": host, "owner_user": Self.user, "enrolled_by": install] if let network = networks[scope.team] { value["network"] = network; value["wg_public_key"] = network["wg"] } @@ -126,6 +117,25 @@ actor NativeDeviceTestWorld: InstallAuthTransport, NativeDeviceTransport { return (200, try data(["ok": true, "value": ["host": badAck ? "host_wrong" : host, "network": network]])) } + /// The platform's Native target route (cmux-platform NativeNetwork.ts): `GET /v1/host/network/target[?mesh=]`, + /// the plain target as its body. A `/v1/read` of the op is refused, as TeamDO refuses it. + func get(_ path: String, bearer: String?, headers: [String: String]) async throws -> (status: Int, body: Data) { + let parts = path.split(separator: "?", maxSplits: 1).map(String.init) + guard parts[0] == "/v1/host/network/target" else { throw Failure.invalid } + let token = try claims(bearer ?? "") + guard let scope = scopes.first(where: { $0.team == token["team"] }), token["sub"] == Self.user else { throw Failure.invalid } + let query = parts.count == 2 ? parts[1] : "" + guard query.isEmpty || query == "mesh=\(scope.mesh)" else { throw Failure.invalid } + targetPaths.append(path) + let action = beforeTarget; beforeTarget = nil + await action?() + if targetStatus != 200 { return (targetStatus, try data(["code": "owner.unreachable"])) } + var value = ["team": scope.team, "tenant": scope.tenant, "project": Self.project, + "origin": origin.absoluteString, "mesh": scope.mesh, "ipv4_cidr": "10.128.0.0/20"] + if let badTarget { value[badTarget] = badTarget == "origin" ? "http://attacker.test" : badTarget == "ipv4_cidr" ? "0.0.0.0/0" : "wrong-identity" } + return (200, try data(value)) + } + func send(method: String, path: String, body: Data?, bearer: String?, tenant: String?) async throws -> (status: Int, body: Data) { if let body { requests.append(body) } if path.hasSuffix("/signed/tunnel") { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NativeNetworkTargetTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NativeNetworkTargetTests.swift index 9544f7030cf4..416ea17225e2 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NativeNetworkTargetTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NativeNetworkTargetTests.swift @@ -21,6 +21,8 @@ import Testing #expect(target.scope == scope) #expect(target.origin == world.origin && target.project == NativeDeviceTestWorld.project) #expect(target.ipv4CIDR == "10.128.0.0/20") + // The platform's route, not a /v1/read of the op (TeamDO answers that "unknown read"). + #expect(await world.targetPaths == [explicit ? "/v1/host/network/target?mesh=\(scope.mesh)" : "/v1/host/network/target"]) #expect(await world.enrollments == 0) #expect(await world.binds == 0) let enrollment = try NativeDeviceEnrollment(scope: target.scope, identity: identity, paths: CloudPaths(root: root.appendingPathComponent("network")), native: world, diff --git a/backend/apps/api/src/platform-forward.ts b/backend/apps/api/src/platform-forward.ts index 045bce4edf97..1608306b7292 100644 --- a/backend/apps/api/src/platform-forward.ts +++ b/backend/apps/api/src/platform-forward.ts @@ -25,8 +25,8 @@ export type PlatformSurface = "cloud" /** The surface of each op owner whose ops (on /v1/ops and /v1/read) can move. Owners not listed always stay. */ export const OWNER_SURFACE: Readonly>> = { "cloud:CloudDO": "cloud" } -/** NEXT Cloud's machine routes outside the op path (the ones index.ts sends to cloud-link, cloud-acpmux, cloud-client-session, cloud-machine-guest and cloud-trajectory). */ -const CLOUD_PATHS: ReadonlyArray = [/^\/v1\/cloud\/bind$/, /^\/v1\/cloud\/keyset$/, ACPMUX_PATH, CLIENT_SESSION_PATH, GUEST_PATH, TRAJECTORY_PATH] +/** NEXT Cloud's machine routes outside the op path (the ones index.ts sends to cloud-link, cloud-acpmux, cloud-client-session, cloud-machine-guest and cloud-trajectory), and the platform's Native network target read (cmux-platform NativeNetwork.ts), which has no op and no route here. */ +const CLOUD_PATHS: ReadonlyArray = [/^\/v1\/cloud\/bind$/, /^\/v1\/cloud\/keyset$/, ACPMUX_PATH, CLIENT_SESSION_PATH, GUEST_PATH, TRAJECTORY_PATH, /^\/v1\/host\/network\/target$/] /** The surfaces a `PLATFORM_SURFACES` value turns on; unknown names are ignored. */ export const platformSurfaces = (value: string | undefined): ReadonlySet => { diff --git a/backend/apps/api/test/platform-forward.test.ts b/backend/apps/api/test/platform-forward.test.ts index fcc2d8a048d0..522238e2da99 100644 --- a/backend/apps/api/test/platform-forward.test.ts +++ b/backend/apps/api/test/platform-forward.test.ts @@ -58,6 +58,7 @@ const requests: ReadonlyArray Request]> = [ ["cloud client-session", () => post(`/v1/cloud/machines/${VM}/client-session`, "{}")], ["cloud guest exec", () => post(`/v1/cloud/machines/${VM}/exec`, "{}")], ["cloud trajectory", () => post(`/v1/cloud/machines/${VM}/trajectories/append`, "{}")], + ["Native network target", () => new Request(`${ORIGIN}/v1/host/network/target`)], ["CloudDO mutation", () => post("/v1/ops", op(cloudMutation))], ["CloudDO read", () => post("/v1/read", read(cloudRead))], ["staying op", () => post("/v1/ops", op(stayingMutation))], @@ -92,7 +93,7 @@ describe("PLATFORM_SURFACES", () => { describe("cloud surface on", () => { it("forwards NEXT Cloud's machine routes and CloudDO ops with the body intact", async () => { - for (const [name, make] of requests.slice(0, 8)) { + for (const [name, make] of requests.slice(0, 9)) { const platform = fakePlatform() const original = make() const body = await original.clone().text() @@ -133,7 +134,7 @@ describe("cloud surface on", () => { it("stays here and logs when the PLATFORM binding is absent", async () => { const log = vi.spyOn(console, "error") - for (const [name, make] of requests.slice(0, 8)) { + for (const [name, make] of requests.slice(0, 9)) { const off = await call(make(), {}) const on = await call(make(), { PLATFORM_SURFACES: "cloud" }) expect(on.status, name).toBe(off.status)