Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2b3d7be
test: Codex and opencode start and send on the person's own Cloud VM …
teamleaderleo Oct 31, 2026
10b999e
Cloud: Codex and opencode chats run on the person's own Cloud VM
teamleaderleo Oct 31, 2026
6ca5030
test: drop an unused frame list
teamleaderleo Oct 31, 2026
514fd1e
gallery: a Codex chat on a team Cloud machine and on the person's own
teamleaderleo Oct 31, 2026
b28fc61
webviews: format remoteComposer's signature
teamleaderleo Oct 31, 2026
e5e49e9
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/cloud-…
teamleaderleo Oct 31, 2026
52ffd7d
Cloud owner chats: the owner answer's wire closure lives in CmuxNextA…
teamleaderleo Oct 31, 2026
be6e244
Cloud: the link owner answer's closure moves from the App to CloudLin…
teamleaderleo Oct 31, 2026
69e1719
Cloud owner chats: list agent-session-start-owner-cloud-v1 in the dae…
teamleaderleo Oct 31, 2026
6a8638e
Cloud owner chats: advertise agent-session-start-owner-cloud-v1 only …
teamleaderleo Oct 31, 2026
cf1cab3
RemoteAcpmuxOwnerCloudTests: build the owner closures outside #require
teamleaderleo Oct 31, 2026
2573801
Merge branch 'feat-cmux-next' into lane/cloud-codex-owner-vm
teamleaderleo Oct 31, 2026
094bb77
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/cloud-…
teamleaderleo Oct 31, 2026
ceacffc
cloud: owner-cloud start is a Cloud-only capability, not a DaemonCapa…
teamleaderleo Oct 31, 2026
d89674f
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/cloud-…
teamleaderleo Oct 31, 2026
9a033c2
cmux-tui test: a team VM takes the creator's owner word (#29107), and…
teamleaderleo Oct 31, 2026
ed6a560
Merge branch 'feat-cmux-next' into lane/cloud-codex-owner-vm
cmux-bot[bot] Oct 31, 2026
f6936d8
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/cloud-…
teamleaderleo Oct 31, 2026
f6e277f
arch-ratchet: allow #28613's 3 App lines of owner-Cloud wiring
teamleaderleo Oct 31, 2026
7bfef8a
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/cloud-…
teamleaderleo Oct 31, 2026
d6a0b93
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/cloud-…
teamleaderleo Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ public nonisolated final class RemoteAcpmuxWire: AcpmuxPaneWire {
public typealias Trust = @Sendable (_ cwd: String, _ level: String?, _ homeChosen: Bool) async throws -> Data
/// The relay's mark on a Trust answer for a home folder the person chose there.
public static let homeChosenParam = "cmuxHomeChosen"
/// Whether the chat's machine is the person's own Cloud VM now: the link's Cloud API owner
/// answer holds, and that machine's daemon starts any agent on it
/// (`agent-session-start-owner-cloud-v1`). Read at each `initialize`; the page then lets a
/// Codex or opencode chat send (`ownerCloud`, remoteEditing.ts).
public typealias OwnerCloud = @Sendable () async -> Bool

/// `agent-session-start-owner-cloud-v1`: on a Cloud VM, `vm_kind: "owner"` starts any agent (the
/// person's own disposable VM is the boundary). Only a Cloud VM's daemon serves it, so it is no
/// `DaemonCapability` case (check-app-capability-references.py `CONDITIONAL`, like `fs-v1`).
public static let ownerCloudCapability = "agent-session-start-owner-cloud-v1"

/// A Cloud machine's ``OwnerCloud`` from its link's owner answer (the running link id while the
/// Cloud API says the caller owns it, else nil), when its daemon serves the owner start
/// (`served`). Nil for a machine that is not a Cloud one, or a daemon that does not serve it.
public static func ownerCloud(_ ownerLink: (@Sendable () async -> Int?)?, served: Bool) -> OwnerCloud? {
guard let ownerLink, served else { return nil }
return { await ownerLink() != nil }
}

private struct State {
var onFrame: (@Sendable (String) -> Void)?
Expand All @@ -54,6 +72,7 @@ public nonisolated final class RemoteAcpmuxWire: AcpmuxPaneWire {
private static let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "agent-pane.remote")
private let start: Start?
private let trust: Trust?
private let ownerCloud: OwnerCloud?
private let host: String?
/// The daemon's own store host (`registry:<id>`) when the tab's `host` names another (an
/// `install:` tab of that machine): its links are noted under both, so a lookup by either finds them.
Expand All @@ -62,20 +81,23 @@ public nonisolated final class RemoteAcpmuxWire: AcpmuxPaneWire {

/// The wire of the chat `session` (its store record), through `client`.
public init(client: any AgentSessionRemoteClient, session: String, host: String? = nil, linksAlias: String? = nil,
trust: Trust? = nil) {
trust: Trust? = nil, ownerCloud: OwnerCloud? = nil) {
start = nil
self.host = host
self.linksAlias = linksAlias
self.trust = trust
self.ownerCloud = ownerCloud
state = Mutex(State(client: client, session: session))
}

/// The wire of a new chat that has no session yet: the page's first `session/new` runs `start`.
public init(start: @escaping Start, host: String? = nil, linksAlias: String? = nil, trust: Trust? = nil) {
public init(start: @escaping Start, host: String? = nil, linksAlias: String? = nil, trust: Trust? = nil,
ownerCloud: OwnerCloud? = nil) {
self.start = start
self.host = host
self.linksAlias = linksAlias
self.trust = trust
self.ownerCloud = ownerCloud
state = Mutex(State())
}

Expand All @@ -98,8 +120,15 @@ public nonisolated final class RemoteAcpmuxWire: AcpmuxPaneWire {
case "initialize":
// `acp.trust.set` listed: the person answers the folder question here (acpmux never
// lists it, so a remote browser's page still cannot).
reply(id, ["protocolVersion": 1, "agentCapabilities": [String: Any](),
"_meta": ["acpmux": ["origin": "remote", "extensions": trust == nil ? [] : ["acp.trust.set"]]]])
let extensions = trust == nil ? [] : ["acp.trust.set"]
guard let ownerCloud else {
return reply(id, Self.initialized(extensions: extensions, ownerCloud: false))
}
// task-owner: one owner-answer read per initialize; its reply ends it
Task { [weak self] in
let owned = await ownerCloud()
self?.reply(id, Self.initialized(extensions: extensions, ownerCloud: owned))
}
case "_acpmux/status":
reply(id, ["peers": [Any]()])
case "session/new":
Expand Down Expand Up @@ -440,6 +469,13 @@ public nonisolated final class RemoteAcpmuxWire: AcpmuxPaneWire {
emit(#"{"jsonrpc":"2.0","id":\#(id),"error":"# + String(decoding: Self.json(error), as: UTF8.self) + "}")
}

/// The `initialize` answer: always `origin: "remote"`; `ownerCloud` only on the person's own Cloud VM.
static func initialized(extensions: [String], ownerCloud: Bool) -> [String: Any] {
var acpmux: [String: Any] = ["origin": "remote", "extensions": extensions]
if ownerCloud { acpmux["ownerCloud"] = true }
return ["protocolVersion": 1, "agentCapabilities": [String: Any](), "_meta": ["acpmux": acpmux]]
}

private func reply(_ id: String?, _ result: [String: Any]) {
emit(id.map { #"{"jsonrpc":"2.0","id":\#($0),"result":"# + String(decoding: Self.json(result), as: UTF8.self) + "}" })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ nonisolated struct AgentTabRemoteHost: AgentPaneHostProviding {
/// A Cloud machine's link owner answer (``CloudMachineLinks/ownerLink``), read at each trust call
/// and never kept. Nil for a machine that is not a Cloud machine.
var ownerLink: AgentSessionAttachClient.OwnerLink?
/// The chat runs on the person's own Cloud VM (``RemoteAcpmuxWire/ownerCloud(_:served:)``).
var ownerCloud: RemoteAcpmuxWire.OwnerCloud?

/// The keyed restart of the machine's acpmux (an SSH machine, or a paired server on the ssh
/// route); nil where the app has no exec channel of its own (cx-0q7o).
Expand Down Expand Up @@ -72,7 +74,7 @@ nonisolated struct AgentTabRemoteHost: AgentPaneHostProviding {
if let session {
route = AgentPaneRemoteRoute(machine: machine, hostID: hostID, approvals: approvals) { [surface, session, endpoint, reconnect] in
let client = AgentSessionAttachClient(surface: surface, mode: mode, reconnect: reconnect, endpoint: endpoint)
return RemoteAcpmuxWire(client: AgentTabRemoteClient(client: client, mode: mode), session: session, host: hostID, linksAlias: linksHost, trust: mode == .attach && servesTrust ? ask : nil)
return RemoteAcpmuxWire(client: AgentTabRemoteClient(client: client, mode: mode), session: session, host: hostID, linksAlias: linksHost, trust: mode == .attach && servesTrust ? ask : nil, ownerCloud: mode == .attach ? ownerCloud : nil)
}
} else if let start, mode == .attach {
// A new chat: nothing runs on that machine until the page's first send (cx-k21v). The
Expand All @@ -88,7 +90,7 @@ nonisolated struct AgentTabRemoteHost: AgentPaneHostProviding {
}
let client = AgentSessionAttachClient(surface: started.surface, reconnect: reconnect, endpoint: endpoint)
return (AgentTabRemoteClient(client: client, mode: .attach), started.session)
}, host: hostID, linksAlias: linksHost, trust: servesTrust ? ask : nil)
}, host: hostID, linksAlias: linksHost, trust: servesTrust ? ask : nil, ownerCloud: ownerCloud)
}
} else {
throw AgentRemoteStartRefusal.startUnsupported(machine: machine)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ extension AgentTabStore {
// it is sent, through any daemon (the legacy link's or the app link's own answer).
let links = services.machines.session(daemon.machineID).map { CloudMachineLinks(appLink: $0.appLink, link: $0.link) }
host.ownerLink = links?.ownerLink
host.ownerCloud = RemoteAcpmuxWire.ownerCloud(links?.ownerLink, served: daemon.supports(RemoteAcpmuxWire.ownerCloudCapability))
if started {
// The tab may still be provisional here: each handshake reads its store surface.
host.existing = { [weak tabs] in
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation
import CmuxNextCompat
import Testing
@testable import CmuxNextAgentPane

/// A chat on the person's own Cloud VM: the wire's `initialize` names `ownerCloud` only while the
/// link's Cloud API owner answer holds, so the page lets a Codex or opencode chat send there and
/// nowhere else (Leo, 2026-10-30: "we should just have it"). Every answer stays `origin: "remote"`.
@Suite
struct RemoteAcpmuxOwnerCloudTests {
/// The wire's first `initialize` answer, as the page reads it.
private func initialized(_ wire: RemoteAcpmuxWire) async throws -> [String: Any] {
let text: String = await withCheckedContinuation { done in
let once = Mutex(false)
Task {
try await wire.open(onFrame: { frame in
guard frame.contains("\"protocolVersion\""), once.withLock({ was in defer { was = true }; return !was }) else { return }
done.resume(returning: frame)
}, onClose: { _, _ in })
wire.send(#"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{}}"#) { _ in }
}
}
let reply = try #require(try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any])
let result = try #require(reply["result"] as? [String: Any])
let meta = try #require(result["_meta"] as? [String: Any])
return try #require(meta["acpmux"] as? [String: Any])
}

@Test func onlyTheOwnersCloudVmIsNamedOwnerCloud() async throws {
let client = RemoteAcpmuxWireAnswerTests.DaemonClient()
let owned = try await initialized(RemoteAcpmuxWire(client: client, session: "s", ownerCloud: { true }))
#expect(owned["origin"] as? String == "remote")
#expect(owned["ownerCloud"] as? Bool == true)

// The link's owner answer is gone (another link, a teammate's machine): not named.
let lost = try await initialized(RemoteAcpmuxWire(client: client, session: "s", ownerCloud: { false }))
#expect(lost["origin"] as? String == "remote")
#expect(lost["ownerCloud"] == nil)

// A machine that is not a Cloud one (a Mac, an SSH host): never named.
let plain = try await initialized(RemoteAcpmuxWire(client: client, session: "s"))
#expect(plain["origin"] as? String == "remote")
#expect(plain["ownerCloud"] == nil)

// A new chat's wire, before its first send, takes the same answer.
let start: RemoteAcpmuxWire.Start = { _, _, _ in throw CancellationError() }
let fresh = try await initialized(RemoteAcpmuxWire(start: start, ownerCloud: { true }))
#expect(fresh["ownerCloud"] as? Bool == true)
}

/// The link's owner answer decides, and only through a daemon that serves the owner start.
@Test func theCapabilityIsTheCloudDaemonsName() {
#expect(RemoteAcpmuxWire.ownerCloudCapability == "agent-session-start-owner-cloud-v1")
}

@Test func theOwnerAnswerAndTheDaemonDecide() async throws {
// Built outside the macros: #require's expansion passes its call's arguments on without
// the parameter's @Sendable.
let ownerLink: @Sendable () async -> Int? = { 7 }
let noOwnerLink: @Sendable () async -> Int? = { nil }
let noLink = RemoteAcpmuxWire.ownerCloud(nil, served: true)
let unserved = RemoteAcpmuxWire.ownerCloud(ownerLink, served: false)
#expect(noLink == nil)
#expect(unserved == nil)
let ownedAnswer = RemoteAcpmuxWire.ownerCloud(ownerLink, served: true)
let owned = try #require(ownedAnswer)
#expect(await owned())
let lostAnswer = RemoteAcpmuxWire.ownerCloud(noOwnerLink, served: true)
let lost = try #require(lostAnswer)
#expect(await lost() == false)
}
}
7 changes: 4 additions & 3 deletions cmux-tui/crates/cmux-tui-core/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,10 @@ mod agent_session_attach;
pub use agent_session_attach::{
AGENT_SESSION_ATTACH_CAPABILITY, AGENT_SESSION_FOLDERS_CAPABILITY,
AGENT_SESSION_START_CAPABILITY, AGENT_SESSION_START_KEYED_REASON_CAPABILITY,
AGENT_SESSION_START_MODEL_CAPABILITY, AGENT_SESSION_START_VM_KIND_CAPABILITY,
AGENT_SESSION_TRUST_CAPABILITY, AGENT_SESSION_TRUST_VM_KIND_CAPABILITY, AcpmuxStarter,
AgentStartHost, AgentStartHostSource, start_link_share_bridge,
AGENT_SESSION_START_MODEL_CAPABILITY, AGENT_SESSION_START_OWNER_CLOUD_CAPABILITY,
AGENT_SESSION_START_VM_KIND_CAPABILITY, AGENT_SESSION_TRUST_CAPABILITY,
AGENT_SESSION_TRUST_VM_KIND_CAPABILITY, AcpmuxStarter, AgentStartHost, AgentStartHostSource,
start_link_share_bridge,
};
#[cfg(unix)]
pub(crate) use agent_session_attach::{
Expand Down
14 changes: 12 additions & 2 deletions cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ pub use agent_session_folders::AGENT_SESSION_FOLDERS_CAPABILITY;
use agent_session_link::{AcpmuxLink, Inbound, LinkError};
pub use agent_session_start::{
AGENT_SESSION_START_CAPABILITY, AGENT_SESSION_START_KEYED_REASON_CAPABILITY,
AGENT_SESSION_START_MODEL_CAPABILITY, AGENT_SESSION_START_VM_KIND_CAPABILITY, AcpmuxStarter,
AgentStartHost, AgentStartHostSource,
AGENT_SESSION_START_MODEL_CAPABILITY, AGENT_SESSION_START_OWNER_CLOUD_CAPABILITY,
AGENT_SESSION_START_VM_KIND_CAPABILITY, AcpmuxStarter, AgentStartHost, AgentStartHostSource,
};
pub use agent_session_trust::{
AGENT_SESSION_TRUST_CAPABILITY, AGENT_SESSION_TRUST_VM_KIND_CAPABILITY,
Expand Down Expand Up @@ -1290,4 +1290,14 @@ impl Mux {
pub(crate) fn serves_agent_session_start(&self) -> bool {
self.serves_agent_session_attach()
}

/// True when `agent-session-start` starts any agent here on the
/// caller's owner word: this machine reads as a Cloud VM with no team
/// evidence. A Mac, an SSH host and a team VM never advertise
/// `agent-session-start-owner-cloud-v1`, so the app never lets a Codex
/// or opencode chat send to a session there.
pub(crate) fn serves_owner_cloud_start(&self) -> bool {
self.serves_agent_session_start()
&& self.control_clients.agent_sessions.starts.owner_cloud(true)
}
}
Loading
Loading