Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -27,6 +27,7 @@ extension AgentTabStore {
/// send, in the page's folder and agent (nil: the seed's, then a workspace folder there).
func startRemoteChat(_ key: String, machine: String, cwd: String? = nil, harness: String? = nil, model: String? = nil,
personKey: AgentSessionAttachClient.PersonKey? = nil, ownerLink: AgentSessionAttachClient.OwnerLink? = nil,
verifyChatStart: (@Sendable () async throws -> Bool)? = nil,
teammate: AgentSessionAttachClient.Teammate? = nil, reconnect: AgentSessionAttachClient.Reconnect? = nil,
endpoint: @escaping AgentSessionAttachClient.EndpointProvider) async throws
-> AgentTabRemoteStart.Started {
Expand All @@ -36,7 +37,8 @@ extension AgentTabStore {
let task = Task<AgentTabRemoteStart.Started, any Error> { [weak self] in
guard let self else { throw CancellationError() }
return try await runRemoteStart(key, machine: machine, cwd: cwd, harness: harness, model: model, personKey: personKey,
ownerLink: ownerLink, teammate: teammate, reconnect: reconnect, endpoint: endpoint)
ownerLink: ownerLink, verifyChatStart: verifyChatStart,
teammate: teammate, reconnect: reconnect, endpoint: endpoint)
}
remoteStarts[resolveRemote(key)] = task
defer { if remoteStarts[resolveRemote(key)] == task { remoteStarts[resolveRemote(key)] = nil } }
Expand All @@ -45,6 +47,7 @@ extension AgentTabStore {

private func runRemoteStart(_ key: String, machine: String, cwd chosen: String?, harness chosenHarness: String?, model chosenModel: String?,
personKey: AgentSessionAttachClient.PersonKey?, ownerLink: AgentSessionAttachClient.OwnerLink?,
verifyChatStart: (@Sendable () async throws -> Bool)?,
teammate: AgentSessionAttachClient.Teammate?, reconnect: AgentSessionAttachClient.Reconnect?,
endpoint: @escaping AgentSessionAttachClient.EndpointProvider) async throws
-> AgentTabRemoteStart.Started {
Expand Down Expand Up @@ -76,6 +79,17 @@ extension AgentTabStore {
if fallback == nil && !supportsNoCwd {
throw AgentRemoteStartRefusal.folderNeeded(machine: machine)
}
// A delegated start can outlive its chat grant on an existing Cloud link: the link still
// carries the earlier attach-time owner word until reconnect. Ask the API for the current
// grant and team membership before the first remote command. This is a client-side guard;
// the remote daemon also needs a revocable action authority for atomic revoke/start fencing.
if let verifyChatStart {
let allowed: Bool
do { allowed = try await verifyChatStart() }
catch is CancellationError { throw CancellationError() }
catch { throw AgentRemoteStartRefusal(code: "agent_session.host_unverified", machine: machine) }
guard allowed else { throw AgentRemoteStartRefusal.teamMachine(machine: machine) }
}
let client = AgentSessionAttachClient(surface: surface, requestTimeout: AgentTabRemoteStart.timeout, reconnect: reconnect, endpoint: endpoint)
let result: Result<String, any Error>
let home = fallback.map { existingView(key)?.model.transport.remoteHomeChosen.contains($0) == true } ?? false // the person's Use Home Folder (cx-1bhjj)
Expand Down
26 changes: 24 additions & 2 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,29 @@ extension AgentTabStore {
// A Cloud machine (the app's own record, never the guest's) starts a chat or asks folder
// trust only on the Cloud API's owner answer on the link the call goes over, checked as
// 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) }
let cloudSession = services.machines.session(daemon.machineID)
let links = cloudSession.map { CloudMachineLinks(appLink: $0.appLink, link: $0.link) }
host.ownerLink = links?.ownerLink
// A chat grant makes a *teammate* look like the owner on this link, but taking that
// grant back cannot change an already-connected link's cached owner word. Recheck the
// current classic API authorization for every new delegated start, before any remote
// chat command. Creators retain their original offline start behavior.
let verifyDelegatedStart: (@Sendable () async throws -> Bool)?
if let cloudSession, cloudSession.link != nil,
!(services.cloud.auth.user?.id.map { $0 == cloudSession.machine.createdBy } ?? false) {
let machineID = cloudSession.machineID
let account = services.cloud.account
verifyDelegatedStart = { [weak services] in
guard let services, await services.cloud.account == account else { return false }
// The link's attach endpoint has side effects (leases, wake-up). This GET
// checks the current grant and membership without creating another attach.
let role = try await services.cloud.api.pinningTeam(services.cloud.auth.teamID).chatStartAccess(machineID)
let stillSameAccount = await services.cloud.account == account
return role != .none && stillSameAccount
}
} else {
verifyDelegatedStart = nil
}
// A start its older daemon refused updates the machine to this app's build and tries
// once more, only on the caller's own machine (the link's owner answer, read then).
if let cloudMachineID = host.cloudMachineID, let ownerLink = links?.ownerLink {
Expand All @@ -424,7 +445,8 @@ extension AgentTabStore {
host.start = { [weak tabs] cwd, harness, model, personKey in
guard let tabs else { throw CancellationError() }
return try await tabs.startRemoteChat(key, machine: machine, cwd: cwd, harness: harness, model: model, personKey: personKey,
ownerLink: links?.ownerLink, teammate: links?.teammate, reconnect: reconnect, endpoint: endpoint)
ownerLink: links?.ownerLink, verifyChatStart: verifyDelegatedStart,
teammate: links?.teammate, reconnect: reconnect, endpoint: endpoint)
}
}
// cx-0q7o: the app's own ssh exec to that machine restarts its acpmux with a key.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ public struct CloudChatGrant: Decodable, Equatable, Sendable {
}
}

/// Current permission for a NEW app chat start, checked separately from an existing link's
/// cached vmKind. `none` after revoke or team departure prevents another managed chat start.
public enum CloudChatStartAccess: String, Decodable, Equatable, Sendable {
case owner, granted, none
}

/// Sharing a team Cloud machine's chats: the classic web backend's `/api/vm/{id}/chat-grants`
/// routes, as the person's Stack session in the active team (``CloudAPIClient/send``). Anyone who
/// can reach the machine reads the list; only its creator (while still on its team) or a team admin
Expand All @@ -29,6 +35,13 @@ extension CloudAPIClient {
return try await send("GET", chatGrantsPath(id), as: List.self).grants
}

/// A side-effect-free GET: reads current team membership and grant state without creating
/// a new attach token, waking a machine or writing a lease. Classic API only.
public func chatStartAccess(_ id: String) async throws -> CloudChatStartAccess {
struct Answer: Decodable { let chatStart: CloudChatStartAccess }
return try await send("GET", "/api/vm/\(pathSegment(id))/chat-start-access", as: Answer.self).chatStart
}

/// Shares the machine's chats with `userID`. Idempotent: an existing grant is kept.
public func grantChat(_ id: String, to userID: String) async throws {
_ = try await send("PUT", chatGrantsPath(id) + "/" + pathSegment(userID), as: Ignored.self)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,34 @@ struct AgentTabRemoteStartTests {
#expect(tabs.sessions[key] == nil)
}

/// The linked Mac still has its old attach-time owner answer, but the owner has revoked the
/// teammate's chat-start grant. A *new* chat must never reach the remote daemon on that lease.
@Test func revokingAChatGrantRefusesAnotherStartOnAnAlreadyOpenLink() async throws {
let (tabs, key) = try makeTabs(seedCwd: "/home/dev/project")
await #expect(throws: AgentRemoteStartRefusal.teamMachine(machine: "Shared VM")) {
_ = try await tabs.startRemoteChat(key, machine: "Shared VM", ownerLink: { 17 },
verifyChatStart: { false }, teammate: { false },
endpoint: { Issue.record("a revoked grant started another chat"); throw CancellationError() })
}
#expect(tabs.sessions[key] == nil)
}

/// An unavailable role/grant check must fail closed even if the old link still says owner.
@Test func anUnreadableGrantRefusesAnotherStartWithoutDialing() async throws {
let (tabs, key) = try makeTabs(seedCwd: "/home/dev/project")
await #expect(throws: AgentRemoteStartRefusal(code: "agent_session.host_unverified", machine: "Shared VM")) {
_ = try await tabs.startRemoteChat(key, machine: "Shared VM", ownerLink: { 17 },
verifyChatStart: { throw URLError(.timedOut) }, teammate: { false },
endpoint: { Issue.record("the permission check failed but a start was dialed"); throw CancellationError() })
}
#expect(tabs.sessions[key] == nil)
}

@Test func anExistingGrantCanStillStartAfterAFreshAuthorizationCheck() async throws {
let request = try await start(harness: nil, model: nil, verifyChatStart: { true })
#expect(request["vm_kind"] as? String == "owner")
}

/// Only an owner check's refusal becomes the teammate one, and only on a teammate's link; the
/// owner check itself is never skipped (`host_unverified` stays for a machine of no known kind).
@Test func onlyAnUnverifiedOwnerOnATeammatesLinkIsATeamRefusal() {
Expand Down Expand Up @@ -97,12 +125,14 @@ struct AgentTabRemoteStartTests {
/// Exercise the tab store's first-Send route through the real attach client, including
/// capability negotiation, and inspect the frame received by the remote daemon.
private func start(harness: String?, model: String?, seedCwd: String? = "/home/dev/project",
expectedCwd: String? = "/home/dev/project", supportsNoCwd: Bool = false) async throws -> [String: Any] {
expectedCwd: String? = "/home/dev/project", supportsNoCwd: Bool = false,
verifyChatStart: (@Sendable () async throws -> Bool)? = nil) async throws -> [String: Any] {
let (tabs, key) = try makeTabs(seedCwd: seedCwd)
let daemon = try RemoteStartDaemon(supportsNoCwd: supportsNoCwd)
async let received = daemon.firstStart()
let started = try await tabs.startRemoteChat(key, machine: "Cloud fixture", harness: harness, model: model,
ownerLink: { 1 }, endpoint: { DaemonEndpoint(socketPath: daemon.path) })
ownerLink: { 1 }, verifyChatStart: verifyChatStart,
endpoint: { DaemonEndpoint(socketPath: daemon.path) })
#expect(started == AgentTabRemoteStart.Started(surface: 7, session: "remote-session"))
#expect(tabs.sessions[key] == "remote-session")
let data = try await received
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ struct CloudMachineShareTests {
#expect(GrantsAPI.seen.map(\.path) == ["/api/vm/vm%201/chat-grants", "/api/vm/vm%201/chat-grants/user%2Fb", "/api/vm/vm%201/chat-grants/user-b"])
}

@Test func currentChatStartAccessReadsOnlyTheCurrentRoleWithoutAnAttach() async throws {
GrantsAPI.seen = []
GrantsAPI.answer = (200, #"{"chatStart":"granted"}"#)
#expect(try await api().chatStartAccess("vm 1") == .granted)
#expect(GrantsAPI.seen.map(\.method) == ["GET"])
#expect(GrantsAPI.seen.map(\.path) == ["/api/vm/vm%201/chat-start-access"])

// A revoked grant on the same machine immediately reads as no app-start authority.
GrantsAPI.answer = (200, #"{"chatStart":"none"}"#)
#expect(try await api().chatStartAccess("vm 1") == .none)
}

@Test func aRefusalKeepsTheServersCode() async throws {
GrantsAPI.answer = (403, #"{"error":"vm_chat_share_forbidden","message":"Only the machine's creator or a team admin can share chats on it."}"#)
do {
Expand Down
Loading