From 5fcc892f6d89218ebaff18828a94bd5f297f6167 Mon Sep 17 00:00:00 2001 From: abeatrix Date: Mon, 19 Oct 2026 16:01:05 -0700 Subject: [PATCH 1/5] refactor(core): move desktop cloud session orchestration into the hub --- .../desktop-app/sidecar/ARCHITECTURE.md | 12 +- .../sidecar/cloud-session-test-runtime.ts | 89 ++++ .../sidecar/cloud-sessions-hub.test.ts | 143 +++--- .../sidecar/cloud-sessions-lifecycle.test.ts | 28 +- .../sidecar/cloud-sessions-state.test.ts | 8 +- .../sidecar/cloud-sessions.test.ts | 53 +- .../desktop-app/sidecar/cloud-sessions.ts | 109 +---- .../desktop-app/sidecar/context.test.ts | 35 +- sdk/AGENTS.md | 2 +- sdk/ARCHITECTURE.md | 5 +- sdk/packages/core/src/cloud/hub-client.ts | 268 +++++++++++ sdk/packages/core/src/cloud/hub-protocol.ts | 32 ++ sdk/packages/core/src/cloud/index.ts | 2 + sdk/packages/core/src/hub/index.ts | 1 + .../core/src/hub/server/boundary.test.ts | 20 + .../core/src/hub/server/browser-websocket.ts | 1 + .../hub/server/cloud-session-service.test.ts | 455 ++++++++++++++++++ .../src/hub/server/cloud-session-service.ts | 317 ++++++++++++ .../core/src/hub/server/hub-server-options.ts | 3 + .../src/hub/server/hub-server-transport.ts | 109 ++++- sdk/packages/shared/src/hub.ts | 3 + 21 files changed, 1476 insertions(+), 219 deletions(-) create mode 100644 apps/examples/desktop-app/sidecar/cloud-session-test-runtime.ts create mode 100644 sdk/packages/core/src/cloud/hub-client.ts create mode 100644 sdk/packages/core/src/cloud/hub-protocol.ts create mode 100644 sdk/packages/core/src/hub/server/cloud-session-service.test.ts create mode 100644 sdk/packages/core/src/hub/server/cloud-session-service.ts diff --git a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md index 757c61a550..9c5799a60e 100644 --- a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md +++ b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md @@ -19,7 +19,7 @@ sidecar/ ├── client-context.ts # Desktop client/account identity for shared telemetry ├── commands.ts # Command router ├── chat-session.ts # Shared-Hub chat session adapter (local + cloud routing) -├── cloud-sessions.ts # Cloud session REST client + Hub-proxy manager +├── cloud-sessions.ts # Desktop projection of hub-owned cloud sessions ├── cline-auth.ts # Refresh-aware Cline auth token resolution ├── feature-flags.ts # Cloud sessions gate (env override + settings toggle) ├── session-data/ # Shared discovery, messages, artifacts, search helpers @@ -117,9 +117,11 @@ const pendingApprovals = new Map(); +const viewers = new WeakMap< + SidecarContext, + () => Promise +>(); +export async function reconnectCloudSessionTestViewer( + ctx: SidecarContext, +): Promise { + const connect = viewers.get(ctx); + if (!connect) throw new Error("Missing cloud test runtime"); + return await connect(); +} +afterEach(async () => { + await Promise.all([...services].map((service) => service.dispose())); + services.clear(); +}); + +export class TestCloudSessionController extends CloudSessionController { + constructor(ctx: SidecarContext, options: CloudSessionControllerOptions) { + super({ + ...options, + lateCreateDisposition: options.lateCreateDisposition ?? "preserve", + clientIdentity: options.clientIdentity ?? { + prefix: "hub-cloud", + type: "hub-cloud", + displayName: "Cline Hub cloud session", + source: "desktop", + }, + }); + for (const [id, state] of ctx.liveSessions) + if (state.config.executionTarget === "cloud") + this.seedSessionState(id, state); + } +} +export async function createCloudSessionTestRuntime( + ctx: SidecarContext, + options: CloudSessionControllerOptions, +): Promise { + const controller = new TestCloudSessionController(ctx, options); + const listeners = new Set<(event: HubEventEnvelope) => void>(); + const service = new HubCloudSessionService({ + getScope: () => "test-account", + createController: () => controller, + publish: (event, version) => { + for (const listener of listeners) + listener({ + version: "v1", + event: "cloud.event", + sessionId: event?.sessionId, + payload: { event, ...version }, + }); + }, + }); + services.add(service); + const connectViewer = async () => { + const projection = new CloudSessionManager(ctx, async () => ({ + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + command: async (command, payload) => ({ + version: "v1", + ok: true, + payload: + command === "cloud.watch" + ? service.watch() + : await service.command(String(payload?.operation), payload?.args), + }), + })); + ctx.cloudSessionManager = projection; + await projection.connect(); + return projection; + }; + viewers.set(ctx, connectViewer); + await connectViewer(); + return controller; +} diff --git a/apps/examples/desktop-app/sidecar/cloud-sessions-hub.test.ts b/apps/examples/desktop-app/sidecar/cloud-sessions-hub.test.ts index 13f24f490f..e9a2258da2 100644 --- a/apps/examples/desktop-app/sidecar/cloud-sessions-hub.test.ts +++ b/apps/examples/desktop-app/sidecar/cloud-sessions-hub.test.ts @@ -5,6 +5,7 @@ import { } from "@cline/core"; import type { HubEventEnvelope } from "@cline/shared"; import { describe, expect, it, vi } from "vitest"; +import { createCloudSessionTestRuntime } from "./cloud-session-test-runtime"; import { CloudSessionApi, CloudSessionError, @@ -209,14 +210,14 @@ class FakeHubClient { } } -function createFixture({ +async function createFixture({ hub = new FakeHubClient(), ...options -}: Partial[1]> & { +}: Partial & { hub?: FakeHubClient; } = {}) { const { ctx, events } = createContext(); - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async () => [REMOTE_SESSION], create: createRemote, @@ -238,7 +239,7 @@ describe("CloudSessionManager Hub runtime", () => { "always", "other", ])("bounds history retry for %s failure", async (failure) => { - const { manager, hub } = createFixture(); + const { manager, hub } = await createFixture(); const connect = vi.spyOn(hub, "connect"); await manager.attach("ses-outer"); let reads = 0; @@ -277,7 +278,7 @@ describe("CloudSessionManager Hub runtime", () => { }; const archive = [{ role: "assistant", content: "saved history" }]; const history = vi.fn<() => Promise>(async () => archive); - const { manager } = createFixture({ + const { manager } = await createFixture({ hub, api: { list: async () => [ @@ -327,7 +328,7 @@ describe("CloudSessionManager Hub runtime", () => { new HubTransportError("hub_connection_closed", "disposed"), ); }; - const { manager, events } = createFixture({ + const { manager, events } = await createFixture({ hub, api: { list: async () => [REMOTE_SESSION], @@ -365,7 +366,7 @@ describe("CloudSessionManager Hub runtime", () => { "session.messages", "session.pending_prompts", ])("stops hydration disposed during %s without later commands or events", async (stage) => { - const { manager, hub, events } = createFixture(); + const { manager, hub, events } = await createFixture(); await manager.attach("ses-outer"); const started = Promise.withResolvers(); const blocked = Promise.withResolvers(); @@ -401,7 +402,7 @@ describe("CloudSessionManager Hub runtime", () => { }); it("stops hydration when disposed by a snapshot subscriber", async () => { - const { manager } = createFixture(); + const { manager } = await createFixture(); await manager.attach("ses-outer"); const unsubscribe = manager.subscribe((event) => { if (event.type === "snapshot" && event.replace) void manager.dispose(); @@ -414,7 +415,7 @@ describe("CloudSessionManager Hub runtime", () => { "abort", "dispose", ] as const)("%s prevents waiting commands from dispatching without clobbering newer state", async (action) => { - const { manager, hub, ctx } = createFixture(); + const { manager, hub, ctx } = await createFixture(); await manager.list(); await manager.attach("ses-outer"); let release!: () => void; @@ -480,7 +481,7 @@ describe("CloudSessionManager Hub runtime", () => { }); it("waits for reconnect discovery to abort an already-dispatched run", async () => { - const { manager, hub, ctx } = createFixture(); + const { manager, hub, ctx } = await createFixture(); await manager.list(); await manager.send("ses-outer", "already dispatched"); await hub.resolveHeaders?.(); @@ -527,7 +528,7 @@ describe("CloudSessionManager Hub runtime", () => { "replaced", "same", ] as const)("revalidates the prompt target when reconnect discovery is %s", async (discovery) => { - const { manager, hub, ctx } = createFixture(); + const { manager, hub, ctx } = await createFixture(); await manager.attach("ses-outer"); await hub.resolveHeaders?.(); const blocked = Promise.withResolvers(); @@ -584,7 +585,7 @@ describe("CloudSessionManager Hub runtime", () => { "same", "disposed", ] as const)("guards queue mutations when the connection is %s at dispatch", async (state) => { - const { manager, hub } = createFixture(); + const { manager, hub } = await createFixture(); await manager.attach("ses-outer"); await hub.resolveHeaders?.(); const blocked = Promise.withResolvers(); @@ -642,7 +643,7 @@ describe("CloudSessionManager Hub runtime", () => { "replaced", "same", ] as const)("revalidates the Stop target when reconnect discovery is %s at dispatch", async (discovery) => { - const { manager, hub, ctx } = createFixture(); + const { manager, hub, ctx } = await createFixture(); await manager.list(); await manager.send("ses-outer", "running prompt"); await hub.resolveHeaders?.(); @@ -709,7 +710,7 @@ describe("CloudSessionManager Hub runtime", () => { }); it("does not acknowledge Stop when reconnect discovery keeps failing", async () => { - const { manager, hub, ctx } = createFixture(); + const { manager, hub, ctx } = await createFixture(); await manager.list(); await manager.send("ses-outer", "running prompt"); await hub.resolveHeaders?.(); @@ -733,7 +734,9 @@ describe("CloudSessionManager Hub runtime", () => { "session.list", "session.create", ])("cancels a send stopped while %s is pending", async (blockedCommand) => { - const { manager, hub } = createFixture({ hub: new FakeHubClient(false) }); + const { manager, hub } = await createFixture({ + hub: new FakeHubClient(false), + }); await manager.create(CREATE_INPUT); let release!: () => void; hub.commandHook = (command) => { @@ -763,7 +766,7 @@ describe("CloudSessionManager Hub runtime", () => { "dispose", ] as const)("%s cancels a provisioning send before opening the Hub", async (action) => { let provisioningSignal: AbortSignal | undefined; - const { manager, hub, ctx } = createFixture({ + const { manager, hub, ctx } = await createFixture({ api: { create: async () => ({ sessionId: "ses-outer", @@ -796,7 +799,7 @@ describe("CloudSessionManager Hub runtime", () => { expect(ctx.liveSessions.has("ses-outer")).toBe(action === "abort"); }); it("reuses the newest inner Hub session and translates events to the outer id", async () => { - const { manager, events, hub } = createFixture(); + const { manager, events, hub } = await createFixture(); await manager.list(); await manager.attach("ses-outer"); @@ -830,7 +833,7 @@ describe("CloudSessionManager Hub runtime", () => { ["run.failed", "error"], ["run.aborted", "aborted"], ] as const)("ignores stale running snapshots after %s", async (event, status) => { - const { manager, ctx, events, hub } = createFixture(); + const { manager, ctx, events, hub } = await createFixture(); await manager.list(); await manager.attach("ses-outer"); @@ -863,7 +866,7 @@ describe("CloudSessionManager Hub runtime", () => { "session.pending_prompt_submitted", "session.attached", ] as const)("accepts a new turn after completion through %s", async (event) => { - const { manager, ctx, events, hub } = createFixture(); + const { manager, ctx, events, hub } = await createFixture(); await manager.list(); await manager.attach("ses-outer"); const envelope = { version: "v1" as const, sessionId: "inner-1" }; @@ -920,7 +923,7 @@ describe("CloudSessionManager Hub runtime", () => { metadata: { parentSessionId: "inner-root" }, }, ]; - const { manager } = createFixture({ hub }); + const { manager } = await createFixture({ hub }); await manager.list(); await manager.attach("ses-outer"); @@ -937,7 +940,7 @@ describe("CloudSessionManager Hub runtime", () => { const clientIds: string[] = []; const hubs = [new FakeHubClient(), new FakeHubClient()]; for (const hub of hubs) { - const { manager } = createFixture({ + const { manager } = await createFixture({ createHubClient: (options) => { clientIds.push(String(options.clientId)); return hub as never; @@ -949,8 +952,8 @@ describe("CloudSessionManager Hub runtime", () => { expect(new Set(clientIds).size).toBe(2); expect(clientIds).toEqual([ - expect.stringMatching(/^code-cloud-ses-outer-/), - expect.stringMatching(/^code-cloud-ses-outer-/), + expect.stringMatching(/^hub-cloud-ses-outer-/), + expect.stringMatching(/^hub-cloud-ses-outer-/), ]); expect(hubs.map((hub) => hub.subscriptionSessionIds)).toEqual([ ["ses-outer", "inner-1"], @@ -969,7 +972,7 @@ describe("CloudSessionManager Hub runtime", () => { } return await originalCommand(command, payload, sessionId, options); }; - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async () => [ { @@ -1020,7 +1023,7 @@ describe("CloudSessionManager Hub runtime", () => { }); vi.spyOn(api, "list").mockResolvedValue([REMOTE_SESSION]); vi.spyOn(api, "history").mockResolvedValue(archive); - const { manager } = createFixture({ + const { manager } = await createFixture({ hub, api, }); @@ -1050,7 +1053,7 @@ describe("CloudSessionManager Hub runtime", () => { hub.commandHook = async (command) => { if (command === "session.list") await blocked; }; - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async () => [REMOTE_SESSION] } as CloudSessionApi, apiBaseUrl: "https://api.example", getAuthToken: async () => "workos:fresh", @@ -1089,7 +1092,7 @@ describe("CloudSessionManager Hub runtime", () => { throw new HubTransportError("hub_connect_failed", "pod starting"); } })(); - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async () => [REMOTE_SESSION] } as CloudSessionApi, apiBaseUrl: "https://api.example", getAuthToken: async () => "workos:fresh", @@ -1115,7 +1118,7 @@ describe("CloudSessionManager Hub runtime", () => { }); it("deduplicates the latest 2,000 Hub event IDs and evicts oldest first", async () => { - const { manager, events, hub } = createFixture(); + const { manager, events, hub } = await createFixture(); await manager.list(); await manager.attach("ses-outer"); const replayed: HubEventEnvelope = { @@ -1162,7 +1165,7 @@ describe("CloudSessionManager Hub runtime", () => { it("resolves fresh bearer headers for each WebSocket connection attempt", async () => { const hub = new FakeHubClient(); const tokens = ["workos:first", "workos:refreshed"]; - const { manager, events } = createFixture({ + const { manager, events } = await createFixture({ getAuthToken: async () => tokens.shift(), hub, }); @@ -1216,7 +1219,7 @@ describe("CloudSessionManager Hub runtime", () => { const { ctx } = createContext(); const hub = new FakeHubClient(); let resolveHeaders: (() => unknown) | undefined; - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async () => [REMOTE_SESSION] } as CloudSessionApi, apiBaseUrl: "https://api.example", getAuthToken: async () => "workos:fresh", @@ -1258,7 +1261,7 @@ describe("CloudSessionManager Hub runtime", () => { it.each([ 0, 2_000, ])("preserves post-snapshot output after buffering %s events", async (count) => { - const { manager, events, hub } = createFixture(); + const { manager, events, hub } = await createFixture(); await manager.attach("ses-outer"); hub.messages = [{ role: "assistant", content: "OK" }]; const emit = (event: HubEventEnvelope["event"], payload = {}) => @@ -1296,7 +1299,7 @@ describe("CloudSessionManager Hub runtime", () => { }; let organizationLookups = 0; const listScopes: Array = []; - const { manager, events } = createFixture({ + const { manager, events } = await createFixture({ api: { list: async (organizationId?: string) => { listScopes.push(organizationId); @@ -1338,7 +1341,7 @@ describe("CloudSessionManager Hub runtime", () => { throw new Error("rehydration failed"); } }; - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async () => [ { @@ -1373,7 +1376,7 @@ describe("CloudSessionManager Hub runtime", () => { }); it("maps cloud approvals into the existing UI and responds on the inner id", async () => { - const { manager, ctx, events, hub } = createFixture(); + const { manager, ctx, events, hub } = await createFixture(); await manager.list(); await manager.attach("ses-outer"); @@ -1419,7 +1422,7 @@ describe("CloudSessionManager Hub runtime", () => { inputJson: '{"path":"README.md"}', }, ]; - const { manager, ctx, events } = createFixture({ hub }); + const { manager, ctx, events } = await createFixture({ hub }); await manager.list(); await manager.attach("ses-outer"); @@ -1459,7 +1462,7 @@ describe("CloudSessionManager Hub runtime", () => { hub.pendingApprovals = [ { approvalId: "approval-replay", toolName: "run_commands" }, ]; - const { manager, ctx, events } = createFixture({ hub }); + const { manager, ctx, events } = await createFixture({ hub }); await manager.attach("ses-outer"); expect(ctx.pendingApprovals.size).toBe(1); // The durable copy and pending replay share an event ID. @@ -1492,7 +1495,7 @@ describe("CloudSessionManager Hub runtime", () => { }); it("does not restore pending approvals after the manager is disposed", async () => { - const { manager, ctx, events, hub } = createFixture(); + const { manager, ctx, events, hub } = await createFixture(); await manager.list(); await manager.attach("ses-outer"); @@ -1522,7 +1525,7 @@ describe("CloudSessionManager Hub runtime", () => { [" tsk-01ABCDEF1234ABCD ", "cline/1234abcd"], [undefined, "cline/es-outer"], ])("includes save-work guidance when the task id is %s", async (taskId, branch) => { - const { manager, hub } = createFixture({ + const { manager, hub } = await createFixture({ hub: new FakeHubClient(false), api: { create: createRemote, @@ -1554,7 +1557,7 @@ describe("CloudSessionManager Hub runtime", () => { }); it("creates and sends to an inner session while preserving the outer id", async () => { - const { manager, hub } = createFixture({ + const { manager, hub } = await createFixture({ hub: new FakeHubClient(false), api: { list: async () => [ @@ -1618,7 +1621,7 @@ describe("CloudSessionManager Hub runtime", () => { it("preserves the live Hub model across REST discovery refreshes", async () => { const hub = new FakeHubClient(); hub.listedModel = "anthropic/claude-opus-4-1"; - const { manager } = createFixture({ + const { manager } = await createFixture({ hub, api: { list: async () => [ @@ -1657,7 +1660,7 @@ describe("CloudSessionManager Hub runtime", () => { const hub = new FakeHubClient(); const originalModel = REMOTE_SESSION.metadata.modelId ?? ""; const externalModel = "anthropic/claude-opus-4-1"; - const { manager, ctx } = createFixture({ hub }); + const { manager, ctx } = await createFixture({ hub }); await manager.list(); await manager.attach("ses-outer"); @@ -1692,7 +1695,7 @@ describe("CloudSessionManager Hub runtime", () => { }); it("rejects a model update to a stale reconnect target and allows retry", async () => { - const { manager, hub } = createFixture({ + const { manager, hub } = await createFixture({ api: { list: async () => [structuredClone(REMOTE_SESSION)], } as CloudSessionApi, @@ -1750,7 +1753,7 @@ describe("CloudSessionManager Hub runtime", () => { const hub = new FakeHubClient(); const selectedModel = REMOTE_SESSION.metadata.modelId ?? ""; const externalModel = "anthropic/claude-opus-4-1"; - const { manager, ctx } = createFixture({ hub }); + const { manager, ctx } = await createFixture({ hub }); await manager.list(); await manager.attach("ses-outer"); hub.attachedModel = externalModel; @@ -1768,7 +1771,7 @@ describe("CloudSessionManager Hub runtime", () => { it("disposes the Hub connection before deleting the outer session", async () => { const hub = new FakeHubClient(); let deleted = ""; - const { manager, ctx } = createFixture({ + const { manager, ctx } = await createFixture({ hub, api: { list: async () => [REMOTE_SESSION], @@ -1791,7 +1794,7 @@ describe("CloudSessionManager Hub runtime", () => { const hub = new FakeHubClient(); const { promise: deleteBlocked, resolve: releaseDelete } = Promise.withResolvers(); - const { manager, ctx } = createFixture({ + const { manager, ctx } = await createFixture({ hub, api: { list: async () => [REMOTE_SESSION], @@ -1819,7 +1822,7 @@ describe("CloudSessionManager Hub runtime", () => { }); it("still cleans up locally when the session is already gone remotely", async () => { - const { manager, ctx, hub } = createFixture({ + const { manager, ctx, hub } = await createFixture({ api: { list: async () => [REMOTE_SESSION], delete: async () => { @@ -1838,7 +1841,7 @@ describe("CloudSessionManager Hub runtime", () => { it("reaps the connection when the sidebar poll reports the session expired", async () => { const hub = new FakeHubClient(); let expired = false; - const { manager, ctx } = createFixture({ + const { manager, ctx } = await createFixture({ hub, api: { list: async () => @@ -1871,7 +1874,7 @@ describe("CloudSessionManager Hub runtime", () => { it("clears expired-session approvals while preserving other sessions", async () => { const hub = new FakeHubClient(); let expired = false; - const { manager, ctx, events } = createFixture({ + const { manager, ctx, events } = await createFixture({ hub, api: { list: async () => @@ -1927,21 +1930,21 @@ describe("CloudSessionManager Hub runtime", () => { ).rejects.toThrow(/closed/i); }); - it("drops authenticated cloud connections when account context changes", async () => { - const { manager, ctx, hub } = createFixture(); - ctx.cloudSessionManager = manager; + it("keeps hub-owned cloud connections when the desktop viewer disconnects", async () => { + const { manager, ctx, hub } = await createFixture(); + await manager.list(); await manager.attach("ses-outer"); await resetCloudSessionManager(ctx); - expect(hub.disposed).toBe(true); + expect(hub.disposed).toBe(false); expect(ctx.cloudSessionManager).toBeNull(); expect(ctx.liveSessions.has("ses-outer")).toBe(false); }); it("propagates the live error when hydration fails and no snapshot exists", async () => { - const { manager, ctx } = createFixture({ + const { manager, ctx } = await createFixture({ api: { list: async () => [REMOTE_SESSION], history: async () => null, @@ -1950,7 +1953,6 @@ describe("CloudSessionManager Hub runtime", () => { throw new Error("sandbox unreachable"); }, }); - ctx.cloudSessionManager = manager; await expect(manager.readMessages(REMOTE_SESSION.id)).rejects.toThrow( "sandbox unreachable", @@ -1964,7 +1966,7 @@ describe("CloudSessionManager Hub runtime", () => { expiredAt: "2026-08-04T00:00:00.000Z", }; let historyCalls = 0; - const { manager, ctx } = createFixture({ + const { manager, ctx } = await createFixture({ api: { list: async () => [expired], create: async () => { @@ -1979,7 +1981,6 @@ describe("CloudSessionManager Hub runtime", () => { throw new Error("expired sessions must not open a websocket"); }, }); - ctx.cloudSessionManager = manager; const attached = await manager.attach(expired.id); expect(attached).toMatchObject({ @@ -1998,7 +1999,7 @@ describe("CloudSessionManager Hub runtime", () => { }); it("falls back to archived history when live hydration fails", async () => { - const { manager, ctx } = createFixture({ + const { manager, ctx } = await createFixture({ api: { list: async () => [REMOTE_SESSION], create: async () => { @@ -2010,7 +2011,6 @@ describe("CloudSessionManager Hub runtime", () => { throw new Error("sandbox unreachable"); }, }); - ctx.cloudSessionManager = manager; const messages = await manager.readMessages(REMOTE_SESSION.id); expect(messages).toEqual([{ role: "assistant", content: "snapshot" }]); @@ -2018,7 +2018,7 @@ describe("CloudSessionManager Hub runtime", () => { it("keeps server provisioning rows visible and reconciles their status", async () => { let status = "provisioning"; - const { manager } = createFixture({ + const { manager } = await createFixture({ api: { list: async () => [{ ...REMOTE_SESSION, status: "provisioning" }], status: async () => ({ sessionId: REMOTE_SESSION.id, status }), @@ -2068,8 +2068,9 @@ describe("CloudSessionManager Hub runtime", () => { return new Response(null, { status: 204 }); }, }); - const { manager, ctx } = createFixture({ + const { manager, ctx } = await createFixture({ api, + lateCreateDisposition: "delete", getAuthToken: async () => authToken, }); const creating = manager.create({ @@ -2100,7 +2101,7 @@ describe("CloudSessionManager Hub runtime", () => { const orgSession = { ...REMOTE_SESSION, id: "ses-org" }; const hub = new FakeHubClient(); vi.spyOn(hub, "connect").mockRejectedValue(new Error("pod offline")); - const { manager, ctx } = createFixture({ + const { manager, ctx } = await createFixture({ hub, api: { list: async (organizationId?: string) => { @@ -2110,7 +2111,6 @@ describe("CloudSessionManager Hub runtime", () => { } as unknown as CloudSessionApi, getActiveOrganizationId: async () => "org-cline-bot", }); - ctx.cloudSessionManager = manager; await manager.list(); await expect(manager.attach("ses-org")).rejects.toThrow("pod offline"); @@ -2122,7 +2122,7 @@ describe("CloudSessionManager Hub runtime", () => { let clientCount = 0; let failNextInnerCreate = true; const clients: FakeHubClient[] = []; - const { manager, ctx } = createFixture({ + const { manager, ctx } = await createFixture({ api: { create: createRemote, list: async () => [{ ...REMOTE_SESSION, title: undefined }], @@ -2140,7 +2140,7 @@ describe("CloudSessionManager Hub runtime", () => { return hub as never; }, }); - ctx.cloudSessionManager = manager; + await manager.create(CREATE_INPUT); await manager.list(); @@ -2156,14 +2156,14 @@ describe("CloudSessionManager Hub runtime", () => { }); it("single-flights inner-session creation under concurrent sends", async () => { - const { manager, ctx, hub } = createFixture({ + const { manager, ctx, hub } = await createFixture({ hub: new FakeHubClient(false), api: { create: createRemote, list: async () => [{ ...REMOTE_SESSION, title: undefined }], } as unknown as CloudSessionApi, }); - ctx.cloudSessionManager = manager; + await manager.create(CREATE_INPUT); await manager.list(); @@ -2186,7 +2186,7 @@ describe("CloudSessionManager Hub runtime", () => { id: "ses-personal", title: undefined, }; - const { manager, ctx } = createFixture({ + const { manager, ctx } = await createFixture({ hub, api: { list: async (organizationId?: string) => @@ -2194,7 +2194,6 @@ describe("CloudSessionManager Hub runtime", () => { } as unknown as CloudSessionApi, getActiveOrganizationId: async () => scope, }); - ctx.cloudSessionManager = manager; await manager.list(); await manager.attach("ses-org-a"); @@ -2219,7 +2218,7 @@ describe("CloudSessionManager Hub runtime", () => { toolName: "run_commands", }, ]; - const { manager, ctx, events } = createFixture({ hub }); + const { manager, ctx, events } = await createFixture({ hub }); hub.commandHook = async (command) => { if (command === "session.attach") throw new Error("attach failed"); }; @@ -2236,7 +2235,7 @@ describe("CloudSessionManager Hub runtime", () => { it("does not respond after disposal races an approval resolver", async () => { const hub = new FakeHubClient(); - const { manager, ctx } = createFixture({ hub }); + const { manager, ctx } = await createFixture({ hub }); await manager.list(); await manager.attach("ses-outer"); hub.events?.({ @@ -2274,7 +2273,7 @@ describe("CloudSessionManager Hub runtime", () => { it("preserves replacement approvals when initial setup finishes after disposal", async () => { const hub = new FakeHubClient(); - const { manager, ctx } = createFixture({ hub }); + const { manager, ctx } = await createFixture({ hub }); const reached = Promise.withResolvers(); const release = Promise.withResolvers(); hub.commandHook = async (command) => { @@ -2304,7 +2303,7 @@ describe("CloudSessionManager Hub runtime", () => { it("does not resurrect approvals from a buffered snapshot after disposal", async () => { const hub = new FakeHubClient(); - const { manager, ctx, events } = createFixture({ hub }); + const { manager, ctx, events } = await createFixture({ hub }); await manager.list(); await manager.attach("ses-outer"); const reached = Promise.withResolvers(); diff --git a/apps/examples/desktop-app/sidecar/cloud-sessions-lifecycle.test.ts b/apps/examples/desktop-app/sidecar/cloud-sessions-lifecycle.test.ts index 43ceab558d..ced319d0d9 100644 --- a/apps/examples/desktop-app/sidecar/cloud-sessions-lifecycle.test.ts +++ b/apps/examples/desktop-app/sidecar/cloud-sessions-lifecycle.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { createCloudSessionTestRuntime } from "./cloud-session-test-runtime"; import { type CloudSessionApi, CloudSessionError, @@ -56,7 +57,7 @@ describe("CloudSessionManager lifecycle", () => { const create = vi.fn(async () => { throw new CloudSessionError("request_failed", message, undefined, 502); }); - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { create } as unknown as CloudSessionApi, apiBaseUrl: "https://api.example", getAuthToken: async () => "workos:fresh", @@ -71,7 +72,7 @@ describe("CloudSessionManager lifecycle", () => { expect(create).toHaveBeenCalledOnce(); }); - it("projects the outer remote-session id as the desktop session id", () => { + it("projects the outer remote-session id as the desktop session id", async () => { expect( cloudSessionToDiscoveryRecord({ ...REMOTE_SESSION, @@ -98,7 +99,7 @@ describe("CloudSessionManager lifecycle", () => { }); }); - it("treats a live session's future expiredAt as a TTL, not an end time", () => { + it("treats a live session's future expiredAt as a TTL, not an end time", async () => { const alive = cloudSessionToDiscoveryRecord({ ...REMOTE_SESSION, expiredAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), @@ -124,7 +125,7 @@ describe("CloudSessionManager lifecycle", () => { prompt: "Fix reconnect behavior\nwith a regression test", attachedViaHub: true, }); - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async () => [REMOTE_SESSION] } as CloudSessionApi, apiBaseUrl: "https://api.example", getAuthToken: async () => "workos:fresh", @@ -151,7 +152,7 @@ describe("CloudSessionManager lifecycle", () => { ] as const)("reconciles a %s session without a Hub connection", async (status) => { const { ctx } = createContext(); const remote: CloudSessionRecord = { ...REMOTE_SESSION }; - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { create: async () => ({ sessionId: remote.id, @@ -216,7 +217,7 @@ describe("CloudSessionManager lifecycle", () => { sandboxUrl: string; }) => void) | undefined; - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async () => [], create: () => { @@ -255,7 +256,7 @@ describe("CloudSessionManager lifecycle", () => { it("keeps identical starts from separate chats independent", async () => { const { ctx } = createContext(); let createCalls = 0; - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async () => [], create: async () => { @@ -296,7 +297,7 @@ describe("CloudSessionManager lifecycle", () => { ? Promise.reject(new Error("Cloud list unavailable")) : refresh.promise, ); - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { create: async () => ({ sessionId: "ses-created", @@ -326,7 +327,6 @@ describe("CloudSessionManager lifecycle", () => { expect.objectContaining({ sessionId: "ses-created", origin: "cloud", - environmentId: "local", prompt: "Fix this", }), ]); @@ -356,7 +356,7 @@ describe("CloudSessionManager lifecycle", () => { if (result === "ready") return { status: "ready" }; throw new CloudSessionError(result, "Status check failed"); }); - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { create: async () => ({ sessionId: "ses-created", @@ -389,7 +389,7 @@ describe("CloudSessionManager lifecycle", () => { const { ctx } = createContext(); let listCalls = 0; let finishRefresh: ((value: CloudSessionRecord[]) => void) | undefined; - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async () => { listCalls += 1; @@ -420,7 +420,7 @@ describe("CloudSessionManager lifecycle", () => { const branchScopes: Array = []; let createInput: Record | undefined; const orgSession = { ...REMOTE_SESSION, id: "ses-org", title: undefined }; - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async (organizationId?: string) => { listCalls.push(organizationId); @@ -473,7 +473,7 @@ describe("CloudSessionManager lifecycle", () => { let cachedScope = serverScope; const lookupOptions: Array<{ fresh?: boolean } | undefined> = []; let createInput: Record | undefined; - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async () => [], create: async (input: Record) => { @@ -508,7 +508,7 @@ describe("CloudSessionManager lifecycle", () => { it("does not silently bill personal credits when account scope lookup fails", async () => { const { ctx } = createContext(); let createInput: Record | undefined; - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api: { list: async () => [], create: async (input: Record) => { diff --git a/apps/examples/desktop-app/sidecar/cloud-sessions-state.test.ts b/apps/examples/desktop-app/sidecar/cloud-sessions-state.test.ts index 9e7e355164..f6299e75e3 100644 --- a/apps/examples/desktop-app/sidecar/cloud-sessions-state.test.ts +++ b/apps/examples/desktop-app/sidecar/cloud-sessions-state.test.ts @@ -1,6 +1,10 @@ import { HubCommandError, HubTransportError } from "@cline/core"; import type { HubEventEnvelope } from "@cline/shared"; import { describe, expect, it, vi } from "vitest"; +import { + createCloudSessionTestRuntime, + type TestCloudSessionController, +} from "./cloud-session-test-runtime"; import { CloudSessionApi, CloudSessionManager, @@ -39,7 +43,7 @@ async function createFixture() { }); vi.spyOn(api, "list").mockResolvedValue([remote]); const updateTitle = vi.spyOn(api, "updateTitle").mockResolvedValue(remote); - const manager = new CloudSessionManager(ctx, { ...options, api }); + const manager = await createCloudSessionTestRuntime(ctx, { ...options, api }); const replies: Record> = { "session.get": { session: { status: "idle" } }, "session.messages": { messages: [] }, @@ -59,7 +63,7 @@ async function createFixture() { ) => reply(name), ); const connection: Awaited< - ReturnType + ReturnType > = { remote, innerSessionId: "inner-1", diff --git a/apps/examples/desktop-app/sidecar/cloud-sessions.test.ts b/apps/examples/desktop-app/sidecar/cloud-sessions.test.ts index 224d5b36d6..8e41947a2b 100644 --- a/apps/examples/desktop-app/sidecar/cloud-sessions.test.ts +++ b/apps/examples/desktop-app/sidecar/cloud-sessions.test.ts @@ -1,10 +1,14 @@ import type { HubEventEnvelope } from "@cline/shared"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { handleChatSessionCommand } from "./chat-session"; +import { + createCloudSessionTestRuntime, + reconnectCloudSessionTestViewer, +} from "./cloud-session-test-runtime"; import { type CloudSessionApi, CloudSessionError, - CloudSessionManager, + type CloudSessionManager, type CloudSessionRecord, getCloudSessionManager, resetCloudSessionManager, @@ -128,7 +132,7 @@ class FakeHubClient { async dispose(): Promise {} } -function createFixture({ +async function createFixture({ hub = new FakeHubClient(), api = { list: async () => [ @@ -140,13 +144,13 @@ function createFixture({ api?: CloudSessionApi; } = {}) { const { ctx, events } = createContext(); - const manager = new CloudSessionManager(ctx, { + const manager = await createCloudSessionTestRuntime(ctx, { api, apiBaseUrl: "https://api.example", getAuthToken: async () => "workos:fresh", createHubClient: () => hub as never, }); - ctx.cloudSessionManager = manager; + return { ctx, events, hub, manager }; } @@ -180,7 +184,7 @@ describe("Cloud sessions sidecar wiring", () => { process.env.CLINE_CODE_CLOUD_AGENTS = "0"; try { const create = vi.fn(); - const { ctx, hub } = createFixture({ + const { ctx, hub } = await createFixture({ api: { create } as unknown as CloudSessionApi, }); const connect = vi.spyOn(hub, "connect"); @@ -221,7 +225,7 @@ describe("Cloud sessions sidecar wiring", () => { expect(result[0]).toMatchObject({ sessionId: "ses-active" }); }); - it("does not project a live cloud session through local discovery", () => { + it("does not project a live cloud session through local discovery", async () => { const { ctx } = createContext(); ctx.liveSessions.set("ses-cloud", { busy: true, @@ -239,14 +243,18 @@ describe("Cloud sessions sidecar wiring", () => { }); it("keeps cloud discovery and run events local after switching to SSH", async () => { - const { ctx, events, hub, manager } = createFixture(); + const { ctx, events, hub, manager } = await createFixture(); await manager.list(); await manager.attach("ses-outer"); ctx.activeEnvironmentId = "ssh-remote"; for (const session of [ - ...(await manager.listForDiscovery()), - manager.getCachedDiscoveryRecord("ses-outer"), + ...(await ( + ctx.cloudSessionManager as CloudSessionManager + ).listForDiscovery()), + (ctx.cloudSessionManager as CloudSessionManager).getCachedDiscoveryRecord( + "ses-outer", + ), ]) { expect(session).toMatchObject({ environmentId: "local", @@ -277,7 +285,7 @@ describe("Cloud sessions sidecar wiring", () => { }); it("updates cloud settings from an SSH-scoped command before sending", async () => { - const { ctx, hub, manager } = createFixture(); + const { ctx, hub, manager } = await createFixture(); await manager.list(); await manager.attach("ses-outer"); @@ -313,7 +321,7 @@ describe("Cloud sessions sidecar wiring", () => { }); it("forwards image-only cloud messages and rejects file attachments", async () => { - const { ctx, hub, manager } = createFixture(); + const { ctx, hub, manager } = await createFixture(); await manager.list(); await manager.attach("ses-outer"); const image = "data:image/png;base64,aGVsbG8="; @@ -410,7 +418,7 @@ describe("Cloud sessions sidecar wiring", () => { }); it("bridges pending-prompt events and queue commands to the hub", async () => { - const { ctx, events, hub, manager } = createFixture(); + const { ctx, events, hub, manager } = await createFixture(); await manager.list(); await manager.attach("ses-outer"); @@ -490,7 +498,7 @@ describe("Cloud sessions sidecar wiring", () => { status: "ready", sandboxUrl: "pod", })); - const { ctx, hub, manager } = createFixture({ + const { ctx, hub, manager } = await createFixture({ hub: new FakeHubClient(false), api: { list: async () => [], @@ -545,7 +553,7 @@ describe("Cloud sessions sidecar wiring", () => { list: async () => [session], create: async () => ({ sessionId: session.id, status: "ready" }), } as unknown as CloudSessionApi; - const { ctx, manager } = createFixture({ + const { ctx, manager, hub } = await createFixture({ hub: new FakeHubClient(false), api, }); @@ -566,14 +574,7 @@ describe("Cloud sessions sidecar wiring", () => { await resetCloudSessionManager(ctx); expect(ctx.liveSessions.has(session.id)).toBe(false); - const hub = new FakeHubClient(false); - const replacement = new CloudSessionManager(ctx, { - api, - apiBaseUrl: "https://api.example", - getAuthToken: async () => "workos:fresh", - createHubClient: () => hub as never, - }); - ctx.cloudSessionManager = replacement; + const replacement = await reconnectCloudSessionTestViewer(ctx); await replacement.attach(session.id); await replacement.send(session.id, "First prompt after reset"); expect( @@ -602,7 +603,7 @@ describe("Cloud sessions sidecar wiring", () => { id: "ses-created", status: "provisioning", }; - const { events, hub, manager } = createFixture({ + const { events, hub, manager } = await createFixture({ hub: new FakeHubClient(false), api: { create: async () => ({ @@ -657,7 +658,7 @@ describe("Cloud sessions sidecar wiring", () => { }); it("retains a failed real session and rejects its first send", async () => { - const { events, hub, manager } = createFixture({ + const { events, hub, manager } = await createFixture({ hub: new FakeHubClient(false), api: { create: async () => ({ @@ -697,7 +698,7 @@ describe("Cloud sessions sidecar wiring", () => { }); it("surfaces run.failed errors as a visible error message", async () => { - const { ctx, events, hub, manager } = createFixture(); + const { ctx, events, hub, manager } = await createFixture(); await manager.list(); await manager.attach("ses-outer"); @@ -729,7 +730,7 @@ describe("Cloud sessions sidecar wiring", () => { ] as const)("%s attaches an existing outer id with a cold registry", async (action) => { const create = vi.fn(); const outerId = "ses-01H9XKYHEC1YFBXMJ8ZBES772P"; - const { ctx, hub } = createFixture({ + const { ctx, hub } = await createFixture({ api: { list: async () => [{ ...REMOTE_SESSION, id: outerId }], create, diff --git a/apps/examples/desktop-app/sidecar/cloud-sessions.ts b/apps/examples/desktop-app/sidecar/cloud-sessions.ts index 35b01c957a..df117b1e24 100644 --- a/apps/examples/desktop-app/sidecar/cloud-sessions.ts +++ b/apps/examples/desktop-app/sidecar/cloud-sessions.ts @@ -1,15 +1,12 @@ -import { ClineAccountService, ProviderSettingsManager } from "@cline/core"; import { type CloudCreationOptions, - CloudSessionApi, - CloudSessionController, - type CloudSessionControllerOptions, + type CloudHubConnection, type CloudSessionEvent, type CreateCloudSessionInput, + HubCloudSessionClient, } from "@cline/core/cloud"; -import { getClineEnvironmentConfig } from "@cline/shared"; -import { resolveFreshClineAuthToken } from "./cline-auth"; import { + ensureSharedHubClient, getEnvironmentContext, getSidecarContextOwner, handleHubLiveEvent, @@ -21,43 +18,21 @@ import { LOCAL_ENVIRONMENT_ID } from "./types"; export * from "@cline/core/cloud"; -// Credential refresh replaces the controller, not the sidecar's creation intent. -const pendingInitialTasksByContext = new WeakMap< - SidecarContext, - Map ->(); - /** Desktop projection adapter. Cloud transport and authoritative state live in core. */ -export class CloudSessionManager extends CloudSessionController { +export class CloudSessionManager extends HubCloudSessionClient { private readonly ownedIds = new Set(); private readonly approvalSnapshots = new Map(); private readonly ctx: SidecarContext; - constructor(ctx: SidecarContext, options: CloudSessionControllerOptions) { + constructor( + ctx: SidecarContext, + connect?: () => Promise, + ) { const owner = getSidecarContextOwner(ctx); - let pendingInitialTasks = pendingInitialTasksByContext.get(owner); - if (!pendingInitialTasks) { - pendingInitialTasks = new Map(); - pendingInitialTasksByContext.set(owner, pendingInitialTasks); - } - super({ - ...options, - pendingInitialTasks, - logger: options.logger ?? ctx.logger, - lateCreateDisposition: options.lateCreateDisposition ?? "delete", - clientIdentity: options.clientIdentity ?? { - prefix: "code-cloud", - type: "code-cloud-sidecar", - displayName: "Cline Code cloud session", - source: "desktop", - }, - }); - this.ctx = getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID); - for (const [id, state] of ctx.liveSessions) - if (state.config.executionTarget === "cloud") - this.seedSessionState(id, state); + super(connect ?? (() => ensureSharedHubClient(owner))); + this.ctx = getEnvironmentContext(owner, LOCAL_ENVIRONMENT_ID); this.subscribe((event) => this.project(event)); } - override async create(input: CreateCloudSessionInput) { + override async create(input: Omit) { return await super.create({ ...input, sandboxType: input.sandboxType ?? "resumable", @@ -79,6 +54,15 @@ export class CloudSessionManager extends CloudSessionController { ? { ...record, environmentId: LOCAL_ENVIRONMENT_ID } : undefined; } + override async getCrossScopeDiscoveryRecord( + sessionId: string, + ): Promise | undefined> { + const record = await super.getCrossScopeDiscoveryRecord(sessionId); + return record + ? { ...record, environmentId: LOCAL_ENVIRONMENT_ID } + : undefined; + } + private project(event: CloudSessionEvent): void { if (event.type === "prompt_accepted") return; const id = event.sessionId; @@ -142,7 +126,7 @@ export class CloudSessionManager extends CloudSessionController { items: snapshot.approvals, }); } - if (event.cause === "queue") + if (event.cause === "queue" || event.replace) sendEvent(this.ctx, "prompts_in_queue_state", { sessionId: id, items: snapshot.promptsInQueue, @@ -182,7 +166,7 @@ export class CloudSessionManager extends CloudSessionController { // Existing desktop commands update this local preference before lazy inner creation. const config = this.ctx.liveSessions.get(id)?.config; if (config) - this.restoreCreationOptions(id, { + await this.restoreCreationOptions(id, { ...(typeof config.autoApproveTools === "boolean" ? { autoApproveTools: config.autoApproveTools } : {}), @@ -214,55 +198,14 @@ export function getCloudSessionManager( ctx: SidecarContext, ): CloudSessionManager { ctx = getSidecarContextOwner(ctx); - const existing = ctx.cloudSessionManager; - if (existing instanceof CloudSessionManager) { - return existing; - } - const environment = getClineEnvironmentConfig(); - const providerSettingsManager = new ProviderSettingsManager(); - const getAuthToken = () => - resolveFreshClineAuthToken(providerSettingsManager, ctx); - const api = new CloudSessionApi({ - apiBaseUrl: environment.apiBaseUrl, - appBaseUrl: environment.appBaseUrl, - getAuthToken, - }); - const accountService = new ClineAccountService({ - apiBaseUrl: environment.apiBaseUrl, - getAuthToken, - }); - // Cache successful org lookups across sidebar polls; never cache failures. - let activeOrgCache: { id: string | undefined; at: number } | undefined; - const getActiveOrganizationId = async (options?: { - fresh?: boolean; - }): Promise => { - if ( - !options?.fresh && - activeOrgCache && - Date.now() - activeOrgCache.at < 60_000 - ) { - return activeOrgCache.id; - } - if (!(await getAuthToken())?.trim()) { - return undefined; - } - const organizations = await accountService.fetchUserOrganizations(); - const id = organizations?.find( - (organization) => organization.active, - )?.organizationId; - activeOrgCache = { id, at: Date.now() }; - return id; - }; - const manager = new CloudSessionManager(ctx, { - api, - apiBaseUrl: environment.apiBaseUrl, - getAuthToken, - getActiveOrganizationId, - }); + if (ctx.cloudSessionManager instanceof CloudSessionManager) + return ctx.cloudSessionManager; + const manager = new CloudSessionManager(ctx); ctx.cloudSessionManager = manager; return manager; } +/** Release this desktop viewer; the hub retains cloud work and sandbox connections. */ export async function resetCloudSessionManager( ctx: SidecarContext, ): Promise { diff --git a/apps/examples/desktop-app/sidecar/context.test.ts b/apps/examples/desktop-app/sidecar/context.test.ts index a5e6d5513f..0302111af4 100644 --- a/apps/examples/desktop-app/sidecar/context.test.ts +++ b/apps/examples/desktop-app/sidecar/context.test.ts @@ -4,7 +4,8 @@ import { join } from "node:path"; import type { RuntimeCapabilities } from "@cline/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { materializeUserFiles } from "./attachments"; -import { CloudSessionManager, type CloudSessionRecord } from "./cloud-sessions"; +import { createCloudSessionTestRuntime } from "./cloud-session-test-runtime"; +import type { CloudSessionManager, CloudSessionRecord } from "./cloud-sessions"; import type { LiveSession, SidecarContext } from "./types"; const createCoreMock = vi.hoisted(() => vi.fn()); @@ -60,16 +61,17 @@ function readEvents(ctx: SidecarContext): Array<{ ); } -function createSearchCloudManager( +async function createSearchCloudManager( ctx: SidecarContext, list: () => Promise, -): CloudSessionManager { +): Promise { vi.stubEnv("CLINE_CODE_CLOUD_AGENTS", "1"); - return new CloudSessionManager(ctx, { + await createCloudSessionTestRuntime(ctx, { api: { list } as never, apiBaseUrl: "https://api.example", getAuthToken: async () => "token", }); + return ctx.cloudSessionManager as CloudSessionManager; } function cloudSearchRecord( @@ -270,7 +272,10 @@ describe("Code sidecar runtime capabilities", () => { sessionManager: { list } as never, unsubscribeSessionEvents: () => {}, }); - ctx.cloudSessionManager = createSearchCloudManager(ctx, async () => []); + ctx.cloudSessionManager = await createSearchCloudManager( + ctx, + async () => [], + ); const results = (await handleCommand(ctx, "search_sessions", { query: "generate", @@ -323,7 +328,7 @@ describe("Code sidecar runtime capabilities", () => { sessionManager: { list } as never, unsubscribeSessionEvents: () => {}, }); - ctx.cloudSessionManager = createSearchCloudManager(ctx, async () => [ + ctx.cloudSessionManager = await createSearchCloudManager(ctx, async () => [ cloudSearchRecord("cloud-out-of-scope", "generate an image"), ]); @@ -368,7 +373,10 @@ describe("Code sidecar runtime capabilities", () => { sessionManager: { list } as never, unsubscribeSessionEvents: () => {}, }); - ctx.cloudSessionManager = createSearchCloudManager(ctx, async () => []); + ctx.cloudSessionManager = await createSearchCloudManager( + ctx, + async () => [], + ); const results = (await handleCommand(ctx, "search_sessions", { query: "generate", @@ -410,7 +418,10 @@ describe("Code sidecar runtime capabilities", () => { sessionManager: { list } as never, unsubscribeSessionEvents: () => {}, }); - ctx.cloudSessionManager = createSearchCloudManager(ctx, async () => []); + ctx.cloudSessionManager = await createSearchCloudManager( + ctx, + async () => [], + ); const pending = handleCommand(ctx, "search_sessions", { query: "generate", @@ -448,7 +459,7 @@ describe("Code sidecar runtime capabilities", () => { sessionManager: { list: vi.fn(async () => []) } as never, unsubscribeSessionEvents: () => {}, }); - ctx.cloudSessionManager = createSearchCloudManager(ctx, list); + ctx.cloudSessionManager = await createSearchCloudManager(ctx, list); const results = (await handleCommand(ctx, "search_sessions", { query: "UNIQUE-CLOUD-TITLE", @@ -500,7 +511,7 @@ describe("Code sidecar runtime capabilities", () => { sessionManager: { list: vi.fn(async () => []) } as never, unsubscribeSessionEvents: () => {}, }); - ctx.cloudSessionManager = createSearchCloudManager(ctx, async () => [ + ctx.cloudSessionManager = await createSearchCloudManager(ctx, async () => [ cloudSearchRecord("shared", "match"), cloudSearchRecord("cloud-2", "match second", "2026-01-03T00:00:00.000Z"), cloudSearchRecord("cloud-3", "match third", "2026-01-02T00:00:00.000Z"), @@ -546,7 +557,7 @@ describe("Code sidecar runtime capabilities", () => { } as never, unsubscribeSessionEvents: () => {}, }); - ctx.cloudSessionManager = createSearchCloudManager( + ctx.cloudSessionManager = await createSearchCloudManager( ctx, async () => await new Promise(() => {}), ); @@ -578,7 +589,7 @@ describe("Code sidecar runtime capabilities", () => { sessionManager: { list: vi.fn(async () => []) } as never, unsubscribeSessionEvents: () => {}, }); - ctx.cloudSessionManager = createSearchCloudManager( + ctx.cloudSessionManager = await createSearchCloudManager( ctx, () => new Promise((resolve) => { diff --git a/sdk/AGENTS.md b/sdk/AGENTS.md index f910c02877..4417ac9a8d 100644 --- a/sdk/AGENTS.md +++ b/sdk/AGENTS.md @@ -37,7 +37,7 @@ Rules: - `shared` stays low-level and reusable - `agents` stays stateless — no session/storage/config concerns - `core` owns stateful orchestration, including the shared-hub daemon, server, and client adapters under `src/hub/` -- `@cline/core/cloud` owns remote cloud-session transport and state; hosts own authentication, feature gating, persistence, and UI projection. +- `@cline/core/cloud` owns remote cloud-session transport and state. The shared hub owns desktop cloud controllers, credentials, organization lookup, and connections; desktop owns feature gating, persistence, and UI projection. ## Change Routing diff --git a/sdk/ARCHITECTURE.md b/sdk/ARCHITECTURE.md index 6df1281b23..940b62a08e 100644 --- a/sdk/ARCHITECTURE.md +++ b/sdk/ARCHITECTURE.md @@ -136,10 +136,11 @@ Design rules: - `core` is the app-facing orchestration layer over `agents`. - `session/fork-metadata` owns fork ancestry and removal of inherited handoff markers; hosts retain title, transcript, and workspace-restore policies. -- `@cline/core/cloud` owns remote cloud-session state and emits immutable snapshots and events. Viewers hydrating active runs with `readMessages` reconcile canonical history at completion even if they missed the run start. Hosts supply authentication and project those snapshots into their UI; feature flags, account selection, and host persistence remain outside the controller. Importing this subpath does not initialize a local agent. +- `@cline/core/cloud` owns remote cloud-session state and emits immutable snapshots and events. Viewers hydrating active runs with `readMessages` reconcile canonical history at completion even if they missed the run start. Desktop uses `HubCloudSessionClient` over its existing shared Hub connection. `HubCloudSessionService` in the daemon owns its account-scoped controller, refresh-aware credentials, organization lookup, provisioning, sandbox connections, queues, approvals, and transcript reconciliation. Desktop projects the snapshots into its UI; feature gating and host persistence remain in the app. Direct controller hosts (including handoff flows) can still supply authentication. Importing this subpath does not initialize a local agent. - `cloud/models` owns cloud model eligibility; `services/cloud-handoff` owns Git preflight, fingerprints, and transcript verification. Hosts own transfer orchestration, source locks, persistence, feature gating, and draft recovery. +- Cloud RPC validates an explicit operation allowlist. Viewer disconnects do not stop cloud work or close daemon-owned sandbox connections. Account changes retire the old controller and invalidate viewer caches; daemon shutdown disposes it. Cloud events carry a hub-instance id, account-scope generation, and revision; reconnecting subscriptions receive current snapshots. Cloud events are excluded from the durable local event log so old-account transcripts cannot be replayed after a scope change. Dispatched cloud mutations are never automatically resent after a lost reply. - The cloud controller resumes suspended sessions and restores saved tasks. -- Desktop retains pending first-task creation options in a context-owned map across credential-driven controller replacement. The shared controller consumes that intent when an inner task exists or is created; retaining an ID without its approval policy is not sufficient. +- The hub retains pending first-task creation options with its controller across desktop viewer disconnects and reconnects. The controller consumes that intent when an inner task exists or is created. Account changes dispose the old scope and its intent. - hub-related modules live under `packages/core/src/hub/`, grouped by service: - `client/` contains host-facing hub clients and browser connection helpers - `daemon/` contains detached daemon startup, entrypoint, and local runtime handler wiring diff --git a/sdk/packages/core/src/cloud/hub-client.ts b/sdk/packages/core/src/cloud/hub-client.ts new file mode 100644 index 0000000000..8c55682b4c --- /dev/null +++ b/sdk/packages/core/src/cloud/hub-client.ts @@ -0,0 +1,268 @@ +import type { HubEventEnvelope, HubReplyEnvelope } from "@cline/shared"; +import type { NodeHubClient } from "../hub/client"; +import type { CloudSessionRecord } from "./api"; +import type { + CloudHubArgs, + CloudHubOperation, + CloudHubResult, +} from "./hub-protocol"; +import { immutableCopy } from "./state"; +import type { + CloudSessionEvent, + CloudSessionSnapshot, + JsonRecord, +} from "./types"; + +export type CloudHubConnection = Pick; +/** Transport facade for a hub-owned cloud controller. Disposing only releases this viewer. */ +export class HubCloudSessionClient { + private readonly snapshots = new Map(); + private readonly records = new Map(); + private readonly listeners = new Set<(event: CloudSessionEvent) => void>(); + private unsubscribe?: () => void; + private connection?: Promise; + private disposed = false; + private serviceId?: string; + private scopeGeneration = -1; + private revision = -1; + private readonly sessionRevisions = new Map(); + constructor( + private readonly getConnection: () => Promise, + ) {} + private async ready(): Promise { + if (this.disposed) throw new Error("Cloud viewer is disposed"); + if (!this.connection) { + const connecting = this.getConnection().then(async (client) => { + if (this.disposed) throw new Error("Cloud viewer is disposed"); + this.unsubscribe = client.subscribe((envelope: HubEventEnvelope) => { + if ( + envelope.event === "cloud.event" && + this.acceptVersion(envelope.payload) + ) + this.receive( + envelope.payload?.event as CloudSessionEvent, + Number(envelope.payload?.revision), + ); + }); + try { + const reply = await client.command("cloud.watch"); + this.checkReply(reply); + if (!this.disposed && this.acceptVersion(reply.payload)) + for (const snapshot of (reply.payload?.snapshots ?? + []) as CloudSessionSnapshot[]) { + this.receive( + { + type: "snapshot", + sessionId: snapshot.sessionId, + snapshot, + replace: true, + }, + Number(reply.payload?.revision), + ); + } + return client; + } catch (error) { + this.unsubscribe?.(); + this.unsubscribe = undefined; + throw error; + } + }); + this.connection = connecting; + void connecting.catch(() => { + if (this.connection === connecting) this.connection = undefined; + }); + } + return this.connection; + } + async connect(): Promise { + await this.ready(); + } + private acceptVersion(payload: Record | undefined): boolean { + if ( + typeof payload?.serviceId !== "string" || + typeof payload.revision !== "number" + ) + return false; + if ( + payload.serviceId === this.serviceId && + Number(payload.scopeGeneration) < this.scopeGeneration + ) + return false; + if ( + payload.serviceId !== this.serviceId || + payload.scopeGeneration !== this.scopeGeneration + ) { + for (const sessionId of this.snapshots.keys()) + this.receive({ type: "removed", sessionId }); + this.records.clear(); + this.sessionRevisions.clear(); + this.serviceId = payload.serviceId; + this.scopeGeneration = Number(payload.scopeGeneration); + this.revision = -1; + } + this.revision = Math.max(this.revision, payload.revision); + return true; + } + private checkReply(reply: HubReplyEnvelope): void { + if (!reply.ok) + throw new Error(reply.error?.message ?? "Cloud hub request failed"); + } + private receive(event: CloudSessionEvent, revision = this.revision): void { + if (this.disposed || !event) return; + if (revision < (this.sessionRevisions.get(event.sessionId) ?? -1)) return; + this.sessionRevisions.set(event.sessionId, revision); + if (event.type === "snapshot") + this.snapshots.set(event.sessionId, structuredClone(event.snapshot)); + if (event.type === "removed") { + this.snapshots.delete(event.sessionId); + this.records.delete(event.sessionId); + } + const delivered = immutableCopy(event); + for (const listener of this.listeners) listener(delivered); + } + subscribe(listener: (event: CloudSessionEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + protected async call( + operation: K, + args: CloudHubArgs, + ): Promise> { + const client = await this.ready(); + if (this.disposed) throw new Error("Cloud viewer is disposed"); + let dispatched = false; + const reply = await client.command( + "cloud.command", + { operation, args }, + undefined, + { + timeoutMs: ["create", "send", "attach"].includes(operation) + ? null + : undefined, + beforeDispatch: () => { + if (this.disposed) throw new Error("Cloud viewer is disposed"); + if (dispatched) + throw new Error( + "Cloud request outcome is unknown; reconnect before retrying", + ); + }, + onDispatch: () => { + dispatched = true; + }, + }, + ); + this.checkReply(reply); + if (!this.acceptVersion(reply.payload)) + throw new Error("Cloud account changed during request"); + if (this.disposed) throw new Error("Cloud viewer is disposed"); + return reply.payload?.result as CloudHubResult; + } + create(...args: CloudHubArgs<"create">): Promise> { + return this.call("create", args); + } + attach(...args: CloudHubArgs<"attach">): Promise> { + return this.call("attach", args); + } + send(...args: CloudHubArgs<"send">): Promise> { + return this.call("send", args); + } + abort(...args: CloudHubArgs<"abort">): Promise> { + return this.call("abort", args); + } + pendingPrompts( + ...args: CloudHubArgs<"pendingPrompts"> + ): Promise> { + return this.call("pendingPrompts", args); + } + updatePendingPrompt( + ...args: CloudHubArgs<"updatePendingPrompt"> + ): Promise> { + return this.call("updatePendingPrompt", args); + } + removePendingPrompt( + ...args: CloudHubArgs<"removePendingPrompt"> + ): Promise> { + return this.call("removePendingPrompt", args); + } + readMessages( + ...args: CloudHubArgs<"readMessages"> + ): Promise> { + return this.call("readMessages", args); + } + updateTitle( + ...args: CloudHubArgs<"updateTitle"> + ): Promise> { + return this.call("updateTitle", args); + } + delete(...args: CloudHubArgs<"delete">): Promise> { + return this.call("delete", args); + } + listModels( + ...args: CloudHubArgs<"listModels"> + ): Promise> { + return this.call("listModels", args); + } + listRepositories( + ...args: CloudHubArgs<"listRepositories"> + ): Promise> { + return this.call("listRepositories", args); + } + listBranches( + ...args: CloudHubArgs<"listBranches"> + ): Promise> { + return this.call("listBranches", args); + } + respondApproval( + ...args: CloudHubArgs<"respondApproval"> + ): Promise> { + return this.call("respondApproval", args); + } + restoreCreationOptions( + ...args: CloudHubArgs<"restoreCreationOptions"> + ): Promise> { + return this.call("restoreCreationOptions", args); + } + getCrossScopeDiscoveryRecord( + ...args: CloudHubArgs<"getCrossScopeDiscoveryRecord"> + ): Promise> { + return this.call("getCrossScopeDiscoveryRecord", args); + } + async list(): Promise { + const records = await this.call("list", []); + return records; + } + async listForDiscovery( + ...args: CloudHubArgs<"listForDiscovery"> + ): Promise { + const records = await this.call("listForDiscovery", args); + for (const record of records) + this.records.set(String(record.sessionId), structuredClone(record)); + return records; + } + getCachedDiscoveryRecord(sessionId: string): JsonRecord | undefined { + const record = this.records.get(sessionId); + return record ? structuredClone(record) : undefined; + } + getSnapshot(sessionId: string): CloudSessionSnapshot | undefined { + const snapshot = this.snapshots.get(sessionId); + return snapshot ? structuredClone(snapshot) : undefined; + } + isCloudSession(sessionId: string): boolean { + return ( + sessionId.trim().startsWith("ses-") || + this.records.has(sessionId) || + this.snapshots.has(sessionId) + ); + } + async dispose(): Promise { + this.disposed = true; + this.unsubscribe?.(); + this.unsubscribe = undefined; + for (const sessionId of this.snapshots.keys()) + for (const listener of this.listeners) + listener({ type: "removed", sessionId }); + this.snapshots.clear(); + this.records.clear(); + this.listeners.clear(); + } +} diff --git a/sdk/packages/core/src/cloud/hub-protocol.ts b/sdk/packages/core/src/cloud/hub-protocol.ts new file mode 100644 index 0000000000..a1333ea792 --- /dev/null +++ b/sdk/packages/core/src/cloud/hub-protocol.ts @@ -0,0 +1,32 @@ +import type { CreateCloudSessionInput } from "./api"; +import type { CloudSessionController } from "./controller"; + +/** Operations available to cloud UI clients. Handoff callbacks remain host-local. */ +export const CLOUD_HUB_OPERATIONS = [ + "create", + "attach", + "send", + "abort", + "pendingPrompts", + "updatePendingPrompt", + "removePendingPrompt", + "readMessages", + "updateTitle", + "delete", + "list", + "listForDiscovery", + "getCrossScopeDiscoveryRecord", + "listModels", + "listRepositories", + "listBranches", + "respondApproval", + "restoreCreationOptions", +] as const; +export type CloudHubOperation = (typeof CLOUD_HUB_OPERATIONS)[number]; +export type CloudHubMethods = Pick; +export type CloudHubArgs = K extends "create" + ? [input: Omit] + : Parameters; +export type CloudHubResult = Awaited< + ReturnType +>; diff --git a/sdk/packages/core/src/cloud/index.ts b/sdk/packages/core/src/cloud/index.ts index dfa7bf4cb2..8ad774cd2e 100644 --- a/sdk/packages/core/src/cloud/index.ts +++ b/sdk/packages/core/src/cloud/index.ts @@ -3,6 +3,8 @@ export * from "../services/cloud-handoff"; export * from "./api"; export * from "./controller"; +export * from "./hub-client"; +export * from "./hub-protocol"; export * from "./models"; export * from "./repositories"; export * from "./snapshots"; diff --git a/sdk/packages/core/src/hub/index.ts b/sdk/packages/core/src/hub/index.ts index 4081a4c372..40bf822616 100644 --- a/sdk/packages/core/src/hub/index.ts +++ b/sdk/packages/core/src/hub/index.ts @@ -53,6 +53,7 @@ export * from "./discovery/instance-lock"; export * from "./discovery/workspace"; export * from "./server"; export * from "./server/browser-websocket"; +export * from "./server/cloud-session-service"; export * from "./server/command-transport"; export * from "./server/hub-event-log"; export * from "./server/hub-run-queue"; diff --git a/sdk/packages/core/src/hub/server/boundary.test.ts b/sdk/packages/core/src/hub/server/boundary.test.ts index cd08f77a6f..dfb95cdaf4 100644 --- a/sdk/packages/core/src/hub/server/boundary.test.ts +++ b/sdk/packages/core/src/hub/server/boundary.test.ts @@ -60,6 +60,26 @@ describe("HubServerTransport boundaries", () => { return (transport as unknown as { ctx: HubTransportContext }).ctx; } + it("requires connection registration before cloud access even when another client id is known", async () => { + const transport = createTransport({ eventLog: false, runQueue: false }); + try { + await transport.handleCommand({ + version: "v1", + command: "client.register", + clientId: "registered", + payload: { clientType: "test" }, + }); + const reply = await transport.handleCommand( + { version: "v1", command: "cloud.watch", clientId: "registered" }, + null, + ); + expect(reply.ok).toBe(false); + expect(reply.error?.code).toBe("client_not_registered"); + } finally { + await transport.stop(); + } + }); + it("continues publishing when one listener throws", () => { const transport = createTransport(); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/sdk/packages/core/src/hub/server/browser-websocket.ts b/sdk/packages/core/src/hub/server/browser-websocket.ts index 5a1e88e217..eaf8de918a 100644 --- a/sdk/packages/core/src/hub/server/browser-websocket.ts +++ b/sdk/packages/core/src/hub/server/browser-websocket.ts @@ -141,6 +141,7 @@ export class BrowserWebSocketHubAdapter { }; const onEvent = (envelope: HubEventEnvelope): void => { + if (envelope.event === "cloud.event" && !authority) return; sendFrame({ kind: "event", envelope }); }; diff --git a/sdk/packages/core/src/hub/server/cloud-session-service.test.ts b/sdk/packages/core/src/hub/server/cloud-session-service.test.ts new file mode 100644 index 0000000000..19557d4ec7 --- /dev/null +++ b/sdk/packages/core/src/hub/server/cloud-session-service.test.ts @@ -0,0 +1,455 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { HubEventEnvelope } from "@cline/shared"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CloudSessionController } from "../../cloud/controller"; +import { + type CloudHubConnection, + HubCloudSessionClient, +} from "../../cloud/hub-client"; +import type { + CloudSessionEvent, + CloudSessionSnapshot, +} from "../../cloud/types"; +import { + OAuthReauthRequiredError, + RuntimeOAuthTokenManager, +} from "../../runtime/orchestration/runtime-oauth-token-manager"; +import { ProviderSettingsManager } from "../../services/storage/provider-settings-manager"; +import { NodeHubClient } from "../client"; +import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers"; +import { createInMemoryHubOwnerContext } from "../discovery"; +import { HubCloudSessionService } from "./cloud-session-service"; +import { startHubWebSocketServer } from "./hub-websocket-server"; + +const services: HubCloudSessionService[] = []; +afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + await Promise.all(services.splice(0).map((service) => service.dispose())); +}); +function deferred() { + let resolve: (value: T | PromiseLike) => void = () => { + throw new Error("Deferred not initialized"); + }; + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + return { promise, resolve }; +} +function snapshot(sessionId = "ses-one"): CloudSessionSnapshot { + return { + sessionId, + config: { executionTarget: "cloud" }, + messages: [], + promptsInQueue: [], + approvals: [], + busy: true, + status: "running", + startedAt: 1, + connectionState: "connected", + transcriptKnown: true, + }; +} +function fixture() { + let scope = "account-one"; + const listeners = new Set<(event: HubEventEnvelope) => void>(); + const controllers: ReturnType[] = []; + function makeController() { + let emit: (event: CloudSessionEvent) => void = () => {}; + const controller = { + subscribe: (listener: typeof emit) => { + emit = listener; + return () => {}; + }, + create: vi.fn(async () => ({ sessionId: "ses-one", status: "ready" })), + attach: vi.fn(async (sessionId: string) => { + emit({ + type: "snapshot", + sessionId, + snapshot: snapshot(sessionId), + replace: true, + }); + return { sessionId, status: "running" }; + }), + send: vi.fn(async () => ({ sessionId: "ses-one", ok: true })), + listForDiscovery: vi.fn(async () => [{ sessionId: "ses-one" }]), + dispose: vi.fn(async () => {}), + }; + return { controller, emit: (event: CloudSessionEvent) => emit(event) }; + } + const cloudSessionOptions = { + getScope: () => scope, + createController: () => { + const current = makeController(); + controllers.push(current); + return current.controller as unknown as CloudSessionController; + }, + }; + const service = new HubCloudSessionService({ + ...cloudSessionOptions, + publish: (event, version) => { + for (const listener of listeners) + listener({ + version: "v1", + event: "cloud.event", + sessionId: event?.sessionId, + payload: { event, ...version }, + }); + }, + }); + services.push(service); + const command: CloudHubConnection["command"] = async ( + name, + payload, + _session, + options, + ) => { + options?.beforeDispatch?.(); + options?.onDispatch?.("request"); + const body = JSON.parse(JSON.stringify(payload ?? {})); + const result = + name === "cloud.watch" + ? service.watch() + : await service.command(body.operation, body.args); + return JSON.parse( + JSON.stringify({ version: "v1", ok: true, payload: result }), + ); + }; + const connection: CloudHubConnection = { + command, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + const viewer = () => new HubCloudSessionClient(async () => connection); + return { + service, + cloudSessionOptions, + controllers, + connection, + viewer, + changeScope: (value: string) => { + scope = value; + }, + }; +} + +describe("hub-owned cloud sessions", () => { + it("shares a controller across viewers and retains work after viewer disposal", async () => { + const f = fixture(); + const first = f.viewer(); + const second = f.viewer(); + await first.attach("ses-one"); + await second.connect(); + expect(second.getSnapshot("ses-one")?.status).toBe("running"); + await first.dispose(); + expect(f.controllers).toHaveLength(1); + expect(f.controllers[0]!.controller.dispose).not.toHaveBeenCalled(); + await second.send("ses-one", "Continue", undefined, undefined, ["image"]); + expect(f.controllers[0]!.controller.send).toHaveBeenCalledWith( + "ses-one", + "Continue", + undefined, + undefined, + ["image"], + ); + await f.service.dispose(); + expect(f.controllers[0]!.controller.dispose).toHaveBeenCalledOnce(); + }); + it("keeps an admitted send alive when its viewer disconnects", async () => { + const f = fixture(); + const viewer = f.viewer(); + await viewer.connect(); + const sending = deferred<{ sessionId: string; ok: boolean }>(); + f.controllers[0]!.controller.send.mockReturnValue(sending.promise); + const result = viewer + .send("ses-one", "Continue") + .catch((error: unknown) => error); + await vi.waitFor(() => expect(f.service.activeCommands).toBe(1)); + await viewer.dispose(); + expect(f.controllers[0]!.controller.dispose).not.toHaveBeenCalled(); + sending.resolve({ sessionId: "ses-one", ok: true }); + expect(await result).toBeInstanceOf(Error); + expect(f.service.activeCommands).toBe(0); + }); + it("retires old account state and ignores late events and replies", async () => { + const f = fixture(); + const viewer = f.viewer(); + await viewer.attach("ses-one"); + const old = f.controllers[0]!; + const pending = deferred<{ sessionId: string; ok: boolean }>(); + old.controller.send.mockReturnValue(pending.promise); + const result = viewer + .send("ses-one", "Continue") + .catch((error: unknown) => error); + await vi.waitFor(() => expect(old.controller.send).toHaveBeenCalledOnce()); + f.changeScope("account-two"); + await f.service.command("listForDiscovery", []); + expect(viewer.getSnapshot("ses-one")).toBeUndefined(); + expect(old.controller.dispose).toHaveBeenCalledOnce(); + old.emit({ + type: "snapshot", + sessionId: "ses-one", + snapshot: snapshot(), + replace: true, + }); + expect(viewer.getSnapshot("ses-one")).toBeUndefined(); + pending.resolve({ sessionId: "ses-one", ok: true }); + expect(String(await result)).toContain("account changed"); + }); + it("invalidates discovery-only caches on account change without another client command", async () => { + vi.useFakeTimers(); + const f = fixture(); + const viewer = f.viewer(); + await viewer.listForDiscovery(); + expect(viewer.getCachedDiscoveryRecord("ses-one")).toBeDefined(); + f.changeScope("account-two"); + await vi.advanceTimersByTimeAsync(1000); + expect(viewer.getCachedDiscoveryRecord("ses-one")).toBeUndefined(); + }); + it("validates operations and provisioning payloads before invoking the controller", async () => { + const f = fixture(); + await expect(f.service.command("dispose", [])).rejects.toThrow( + "Unknown cloud operation", + ); + await expect(f.service.command("__proto__", [])).rejects.toThrow( + "Unknown cloud operation", + ); + await expect( + f.service.command("create", [ + { modelId: "model", repoUrl: "repo", handoff: {} }, + ]), + ).rejects.toThrow(); + await expect( + f.service.command("respondApproval", [ + "ses-one", + "approval", + { approved: "yes" }, + ]), + ).rejects.toThrow(); + expect(f.controllers).toHaveLength(0); + await f.service.command("create", [{ modelId: "model", repoUrl: "repo" }]); + expect(f.controllers[0]!.controller.create).toHaveBeenCalledWith({ + modelId: "model", + repoUrl: "repo", + sandboxType: "resumable", + }); + }); + it("does not resend a dispatched action when transport recovery retries", async () => { + const f = fixture(); + const viewer = f.viewer(); + await viewer.connect(); + f.connection.command = async (_name, _payload, _session, options) => { + options?.beforeDispatch?.(); + options?.onDispatch?.("first"); + options?.beforeDispatch?.(); + throw new Error("This must not be reached"); + }; + await expect(viewer.send("ses-one", "Once")).rejects.toThrow( + "outcome is unknown", + ); + }); + it("ignores a hydration reply older than a live snapshot", async () => { + const f = fixture(); + await f.service.command("attach", ["ses-one"]); + const ready = deferred(); + const original = f.connection.command; + f.connection.command = async (name, ...args) => { + const reply = await original(name, ...args); + if (name === "cloud.watch") await ready.promise; + return reply; + }; + const viewer = f.viewer(); + const connecting = viewer.connect(); + await vi.waitFor(() => expect(f.controllers).toHaveLength(1)); + const current = { ...snapshot(), status: "completed", busy: false }; + f.controllers[0]!.emit({ + type: "snapshot", + sessionId: "ses-one", + snapshot: current, + replace: true, + }); + ready.resolve(undefined); + await connecting; + expect(viewer.getSnapshot("ses-one")?.status).toBe("completed"); + }); + it("hydrates untouched sessions when another session receives a newer live event", async () => { + const f = fixture(); + await f.service.command("attach", ["ses-one"]); + const ready = deferred(); + const original = f.connection.command; + let watching = false; + f.connection.command = async (name, ...args) => { + const reply = await original(name, ...args); + if (name === "cloud.watch") { + watching = true; + await ready.promise; + } + return reply; + }; + const viewer = f.viewer(); + const connecting = viewer.connect(); + await vi.waitFor(() => expect(watching).toBe(true)); + f.controllers[0]?.emit({ + type: "snapshot", + sessionId: "ses-two", + snapshot: snapshot("ses-two"), + replace: true, + }); + ready.resolve(undefined); + await connecting; + expect(viewer.getSnapshot("ses-one")).toBeDefined(); + expect(viewer.getSnapshot("ses-two")).toBeDefined(); + }); + it("does not dispatch queued work after the viewer is disposed during hydration", async () => { + const f = fixture(); + const ready = deferred(); + const original = f.connection.command; + let watching = false; + f.connection.command = async (name, ...args) => { + const reply = await original(name, ...args); + if (name === "cloud.watch") { + watching = true; + await ready.promise; + } + return reply; + }; + const viewer = f.viewer(); + const sending = viewer + .send("ses-one", "Never dispatch") + .catch((error: unknown) => error); + await vi.waitFor(() => expect(watching).toBe(true)); + await viewer.dispose(); + ready.resolve(undefined); + expect(String(await sending)).toContain("disposed"); + expect(f.controllers[0]?.controller.send).not.toHaveBeenCalled(); + }); +}); + +describe("cloud RPC over a shared hub socket", () => { + it("hydrates another desktop client, preserves ownership on disconnect, and refuses new work while draining", async () => { + const f = fixture(); + const root = mkdtempSync(join(tmpdir(), "cline-cloud-hub-")); + const server = await startHubWebSocketServer({ + owner: createInMemoryHubOwnerContext("cloud-wire"), + host: "127.0.0.1", + port: 0, + workspaceRoot: root, + sessionHost: { + subscribe: () => () => {}, + dispose: async () => {}, + listSessions: async () => [], + } as never, + runtimeHandlers: createLocalHubScheduleRuntimeHandlers(), + scheduleOptions: { dbPath: ":memory:" }, + taskOptions: { + dbPath: join(root, "tasks.db"), + globalSpecsDir: join(root, "specs"), + watchFiles: false, + }, + eventLog: { dbPath: ":memory:" }, + runQueue: false, + cloudSessionOptions: f.cloudSessionOptions, + }); + const first = new NodeHubClient({ + url: server.url, + authToken: server.authToken, + clientId: "cloud-first", + workspaceRoot: root, + }); + const second = new NodeHubClient({ + url: server.url, + authToken: server.authToken, + clientId: "cloud-second", + workspaceRoot: root, + }); + try { + const viewer = new HubCloudSessionClient(async () => { + await first.connect(); + return first; + }); + await viewer.attach("ses-one"); + expect(viewer.getSnapshot("ses-one")?.status).toBe("running"); + await viewer.dispose(); + await first.dispose(); + expect(f.controllers[0]?.controller.dispose).not.toHaveBeenCalled(); + const next = new HubCloudSessionClient(async () => { + await second.connect(); + return second; + }); + await next.connect(); + expect(next.getSnapshot("ses-one")?.status).toBe("running"); + const events: HubEventEnvelope[] = []; + second.subscribe((event) => events.push(event)); + f.controllers[0]?.emit({ + type: "snapshot", + sessionId: "ses-one", + snapshot: snapshot(), + replace: true, + }); + await vi.waitFor(() => + expect(events.some((event) => event.event === "cloud.event")).toBe( + true, + ), + ); + expect( + events + .filter((event) => event.event === "cloud.event") + .every((event) => event.sequence === undefined), + ).toBe(true); + await second.command("hub.drain"); + await expect(next.send("ses-one", "New work")).rejects.toThrow( + /draining/, + ); + await next.dispose(); + } finally { + await first.dispose(); + await second.dispose(); + await server.close(); + rmSync(root, { recursive: true, force: true }); + } + expect(f.controllers[0]?.controller.dispose).toHaveBeenCalledOnce(); + }); +}); + +describe("hub cloud credentials", () => { + it.each([ + "manual", + "refresh", + "transient", + "reauth", + ])("resolves %s credentials inside the daemon", async (mode) => { + vi.spyOn( + ProviderSettingsManager.prototype, + "getProviderSettings", + ).mockReturnValue({ provider: "cline", apiKey: "persisted-key" }); + const resolve = vi.spyOn( + RuntimeOAuthTokenManager.prototype, + "resolveProviderApiKey", + ); + if (mode === "refresh") + resolve.mockResolvedValue({ apiKey: "fresh-key" } as never); + else if (mode === "transient") + resolve.mockRejectedValue(new Error("temporarily unavailable")); + else if (mode === "reauth") + resolve.mockRejectedValue(new OAuthReauthRequiredError("cline")); + else resolve.mockResolvedValue(null); + const service = new HubCloudSessionService({ publish: () => {} }); + services.push(service); + service.watch(); + const controller = service["controller"]; + if (!controller) throw new Error("Controller missing"); + expect(await controller["options"].getAuthToken()).toBe( + mode === "reauth" + ? undefined + : mode === "refresh" + ? "fresh-key" + : "persisted-key", + ); + }); +}); diff --git a/sdk/packages/core/src/hub/server/cloud-session-service.ts b/sdk/packages/core/src/hub/server/cloud-session-service.ts new file mode 100644 index 0000000000..9ce195f4f0 --- /dev/null +++ b/sdk/packages/core/src/hub/server/cloud-session-service.ts @@ -0,0 +1,317 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { BasicLogger, ITelemetryService } from "@cline/shared"; +import { getClineEnvironmentConfig } from "@cline/shared"; +import { z } from "zod"; +import { ClineAccountService } from "../../account"; +import { getProviderAuthHandler } from "../../auth/provider-auth-registry"; +import { CloudSessionApi } from "../../cloud/api"; +import { CloudSessionController } from "../../cloud/controller"; +import type { CloudHubOperation } from "../../cloud/hub-protocol"; +import type { + CloudSessionEvent, + CloudSessionSnapshot, +} from "../../cloud/types"; +import { + OAuthReauthRequiredError, + RuntimeOAuthTokenManager, +} from "../../runtime/orchestration/runtime-oauth-token-manager"; +import { ProviderSettingsManager } from "../../services/storage/provider-settings-manager"; +import { captureAuthRefreshSoftFailure } from "../../services/telemetry/core-events"; + +const id = z.string().trim().min(1); +const optionalString = z.string().nullish(); +const creationOptions = z.object({ + autoApproveTools: z.boolean().optional(), + thinking: z.boolean().optional(), + reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(), +}); +const schemas = { + create: z.tuple([ + creationOptions + .extend({ + sandboxType: z.enum(["standard", "resumable"]).optional(), + requestId: id.optional(), + modelId: id, + repoUrl: id, + initialPrompt: z.string().optional(), + branch: id.optional(), + mode: z.enum(["act", "plan", "yolo"]).optional(), + workspaceRelativePath: id.optional(), + organizationId: id.nullable().optional(), + }) + .strict(), + ]), + attach: z.tuple([id, creationOptions.nullish()]), + send: z.tuple([ + id, + z.string(), + z.enum(["queue", "steer"]).nullish(), + optionalString, + z.array(z.string()).nullish(), + ]), + abort: z.tuple([id]), + pendingPrompts: z.tuple([id]), + updatePendingPrompt: z.tuple([ + id, + id, + z + .object({ + prompt: z.string().optional(), + delivery: z.enum(["queue", "steer"]).optional(), + }) + .strict(), + ]), + removePendingPrompt: z.tuple([id, id]), + readMessages: z.tuple([id]), + updateTitle: z.tuple([id, z.string()]), + delete: z.tuple([id]), + list: z.tuple([]), + listForDiscovery: z.tuple([ + z + .object({ timeoutMs: z.number().positive().max(30000).optional() }) + .nullish(), + ]), + getCrossScopeDiscoveryRecord: z.tuple([id]), + listModels: z.tuple([]), + listRepositories: z.tuple([]), + listBranches: z.tuple([ + z.number().int().positive(), + z + .object({ cursor: z.string().optional(), query: z.string().optional() }) + .nullish(), + ]), + respondApproval: z.tuple([ + id, + id, + z.object({ approved: z.boolean(), reason: z.string().optional() }).strict(), + ]), + restoreCreationOptions: z.tuple([id, creationOptions.strict()]), +} satisfies Record; + +export interface HubCloudSessionServiceOptions { + publish: ( + event: CloudSessionEvent | undefined, + version: { serviceId: string; revision: number; scopeGeneration: number }, + ) => void; + logger?: BasicLogger; + telemetry?: ITelemetryService; + /** Test/embedding seams; the daemon uses persisted account credentials. */ + getScope?: () => string | undefined; + createController?: () => CloudSessionController; +} + +/** One account-scoped controller per hub, independent of connected viewers. */ +export class HubCloudSessionService { + private readonly serviceId = randomUUID(); + private scopeGeneration = 0; + private revision = 0; + private controller?: CloudSessionController; + private scope?: string; + private closed = false; + private readonly snapshots = new Map(); + private readonly tokenManager: RuntimeOAuthTokenManager; + private readonly settings = new ProviderSettingsManager(); + private readonly scopeTimer: ReturnType; + private readonly retiring = new Set>(); + activeCommands = 0; + constructor(private readonly options: HubCloudSessionServiceOptions) { + this.tokenManager = new RuntimeOAuthTokenManager({ + providerSettingsManager: this.settings, + telemetry: options.telemetry, + }); + this.scopeTimer = setInterval(() => this.checkScope(), 1000); + this.scopeTimer.unref?.(); + } + private currentScope(): string | undefined { + if (this.options.getScope) return this.options.getScope(); + const settings = this.settings.getProviderSettings("cline"); + const identity = + settings?.auth?.accountId || + settings?.auth?.accessToken || + settings?.apiKey; + return identity + ? createHash("sha256") + .update(JSON.stringify([identity, settings?.baseUrl])) + .digest("hex") + : undefined; + } + private checkScope(): void { + if (!this.controller || this.scope === this.currentScope()) return; + const old = this.controller; + this.controller = undefined; + this.scope = undefined; + for (const sessionId of this.snapshots.keys()) + this.publish({ type: "removed", sessionId }); + this.snapshots.clear(); + this.scopeGeneration++; + this.options.publish(undefined, { + serviceId: this.serviceId, + revision: ++this.revision, + scopeGeneration: this.scopeGeneration, + }); + const disposing = old + .dispose() + .catch((error) => + this.options.logger?.error?.("Cloud controller teardown failed", { + error, + }), + ) + .finally(() => this.retiring.delete(disposing)); + this.retiring.add(disposing); + } + private getController(): CloudSessionController { + if (this.closed) throw new Error("Hub cloud service is closed"); + this.checkScope(); + if (this.controller) return this.controller; + this.scope = this.currentScope(); + const controller = + this.options.createController?.() ?? this.createController(); + this.controller = controller; + controller.subscribe((event) => { + this.checkScope(); + if (this.controller !== controller || this.closed) return; + if (event.type === "snapshot") + this.snapshots.set(event.sessionId, event.snapshot); + if (event.type === "removed") this.snapshots.delete(event.sessionId); + this.publish(event); + }); + return controller; + } + private createController(): CloudSessionController { + const environment = getClineEnvironmentConfig(); + const accountId = this.scope; + const apiBaseUrl = + this.settings.getProviderSettings("cline")?.baseUrl?.trim() || + environment.apiBaseUrl; + const getAuthToken = async () => { + if (accountId !== this.currentScope()) + throw new Error("Cline account changed"); + let token: string | undefined; + let refreshError: unknown; + try { + token = ( + await this.tokenManager.resolveProviderApiKey({ providerId: "cline" }) + )?.apiKey; + } catch (error) { + refreshError = error; + } + if (!token && !(refreshError instanceof OAuthReauthRequiredError)) { + token = getProviderAuthHandler("cline")?.getApiKey( + this.settings.getProviderSettings("cline"), + ); + } + if (!token && refreshError) { + this.options.logger?.error?.("Cloud account token refresh failed", { + error: refreshError, + }); + captureAuthRefreshSoftFailure(this.options.telemetry, "cline", { + errorName: + refreshError instanceof Error ? refreshError.name : "Error", + errorCode: "hub_cloud_refresh_failed_no_fallback_token", + }); + } + if (accountId !== this.currentScope()) + throw new Error("Cline account changed"); + return token; + }; + const account = new ClineAccountService({ apiBaseUrl, getAuthToken }); + let organization: { id: string | undefined; at: number } | undefined; + return new CloudSessionController({ + api: new CloudSessionApi({ + apiBaseUrl, + appBaseUrl: environment.appBaseUrl, + getAuthToken, + }), + apiBaseUrl, + getAuthToken, + logger: this.options.logger, + clientIdentity: { + prefix: "hub-cloud", + type: "hub-cloud", + displayName: "Cline Hub cloud session", + source: "desktop", + }, + lateCreateDisposition: "preserve", + getActiveOrganizationId: async (options) => { + if ( + !options?.fresh && + organization && + Date.now() - organization.at < 60000 + ) + return organization.id; + if (!(await getAuthToken())) return undefined; + const organizations = await account.fetchUserOrganizations(); + const id = organizations?.find((org) => org.active)?.organizationId; + organization = { id, at: Date.now() }; + return id; + }, + }); + } + private publish(event: CloudSessionEvent): void { + this.options.publish(event, { + serviceId: this.serviceId, + revision: ++this.revision, + scopeGeneration: this.scopeGeneration, + }); + } + snapshot(): { + serviceId: string; + revision: number; + scopeGeneration: number; + snapshots: CloudSessionSnapshot[]; + } { + this.checkScope(); + return { + serviceId: this.serviceId, + revision: this.revision, + scopeGeneration: this.scopeGeneration, + snapshots: [...this.snapshots.values()], + }; + } + watch(): ReturnType { + this.getController(); + return this.snapshot(); + } + async command( + operation: string, + rawArgs: unknown, + ): Promise> { + if (!Object.hasOwn(schemas, operation)) + throw new Error(`Unknown cloud operation: ${operation}`); + const name = operation as CloudHubOperation; + const args = schemas[name].parse(rawArgs) as unknown[]; + // JSON arrays encode omitted optional arguments as null. + const normalized = args.map((value) => + value === null ? undefined : value, + ); + if (name === "create") { + const input = normalized[0] as { sandboxType?: string }; + input.sandboxType ??= "resumable"; + } + const controller = this.getController(); + this.activeCommands++; + try { + const method = controller[name] as (...args: unknown[]) => unknown; + const result = await method.apply(controller, normalized); + this.checkScope(); + if (this.controller !== controller) + throw new Error("Cline account changed during cloud operation"); + return { + serviceId: this.serviceId, + revision: this.revision, + scopeGeneration: this.scopeGeneration, + result, + }; + } finally { + this.activeCommands--; + } + } + async dispose(): Promise { + this.closed = true; + clearInterval(this.scopeTimer); + const controller = this.controller; + this.controller = undefined; + this.snapshots.clear(); + await Promise.all([controller?.dispose(), ...this.retiring]); + } +} diff --git a/sdk/packages/core/src/hub/server/hub-server-options.ts b/sdk/packages/core/src/hub/server/hub-server-options.ts index 6f1c4ed86d..b3acd7ff6a 100644 --- a/sdk/packages/core/src/hub/server/hub-server-options.ts +++ b/sdk/packages/core/src/hub/server/hub-server-options.ts @@ -13,10 +13,13 @@ import type { SessionHistorySearchOptions } from "../../session/search"; import type { CoreSettingsService } from "../../settings"; import type { AgendaTaskManagerOptions } from "../../tasks"; import type { HubOwnerContext } from "../discovery"; +import type { HubCloudSessionServiceOptions } from "./cloud-session-service"; import type { HubEventLogOptions } from "./hub-event-log"; import type { HubRunQueueOptions } from "./hub-run-queue"; export interface HubWebSocketServerOptions { + /** Override cloud credentials/controller construction for embedded hubs. */ + cloudSessionOptions?: Omit; /** Hub-owned full-text session history index configuration. */ sessionSearchOptions?: SessionHistorySearchOptions; /** Workspace authority assigned by the Hub to authenticated clients. */ diff --git a/sdk/packages/core/src/hub/server/hub-server-transport.ts b/sdk/packages/core/src/hub/server/hub-server-transport.ts index 0f85fa73ca..66133c6e01 100644 --- a/sdk/packages/core/src/hub/server/hub-server-transport.ts +++ b/sdk/packages/core/src/hub/server/hub-server-transport.ts @@ -49,6 +49,7 @@ import { } from "../../tasks"; import { SessionSource } from "../../types/common"; import type { CoreSessionEvent } from "../../types/events"; +import { HubCloudSessionService } from "./cloud-session-service"; import type { HubConnectionAuthority } from "./command-transport"; import { handleApprovalRespond, @@ -294,7 +295,18 @@ export class HubServerTransport implements NativeHubTransport { private readonly plugins: PluginRegistry = getProcessPluginRegistry(); private unsubscribePluginStatus?: () => void; + private readonly cloud: HubCloudSessionService; + constructor(readonly options: HubWebSocketServerOptions) { + this.cloud = new HubCloudSessionService({ + ...options.cloudSessionOptions, + logger: options.logger, + telemetry: options.telemetry, + publish: (event, version) => + this.publish( + buildHubEvent("cloud.event", { event, ...version }, event?.sessionId), + ), + }); this.plugins.setLogger(hubPluginLogger); this.unsubscribePluginStatus = this.plugins.subscribe((status) => { this.publish( @@ -758,6 +770,7 @@ export class HubServerTransport implements NativeHubTransport { () => true, "Hub shutting down before capability request was resolved.", ); + await this.cloud.dispose(); await this.sessionSearch.dispose(); await this.tasks.dispose(); await this.sessionHost.dispose("hub_server_stop"); @@ -781,6 +794,16 @@ export class HubServerTransport implements NativeHubTransport { authority?: HubConnectionAuthority | null, ): Promise { try { + if ( + authority === null && + (envelope.command === "cloud.command" || + envelope.command === "cloud.watch") + ) + return errorReply( + envelope, + "client_not_registered", + "Register before accessing cloud sessions", + ); const clientId = envelope.clientId?.trim(); // Omitted authority is reserved for trusted in-process callers. A remote // transport passes null until registration so caller-controlled envelope @@ -823,6 +846,46 @@ export class HubServerTransport implements NativeHubTransport { if (isAgendaTaskCommand(envelope.command)) { return await this.taskCommands.handleCommand(envelope, authority); } + if ( + envelope.command === "cloud.command" || + envelope.command === "cloud.watch" + ) { + const clientId = envelope.clientId?.trim(); + if ( + !clientId || + !this.clients.has(clientId) || + (authority && authority.clientId !== clientId) + ) { + return errorReply( + envelope, + "client_not_registered", + "Register before accessing cloud sessions", + ); + } + if ( + this.draining && + envelope.command === "cloud.command" && + ["create", "attach", "send"].includes( + String(envelope.payload?.operation), + ) + ) + return drainingReply(envelope); + try { + if (envelope.command === "cloud.watch") + return okReply(envelope, this.cloud.watch()); + const result = await this.cloud.command( + String(envelope.payload?.operation ?? ""), + envelope.payload?.args, + ); + return okReply(envelope, result); + } catch (error) { + return errorReply( + envelope, + "cloud_command_failed", + error instanceof Error ? error.message : String(error), + ); + } + } switch (envelope.command) { case "client.register": { const reply = handleClientRegister(this.ctx, envelope); @@ -1146,12 +1209,16 @@ export class HubServerTransport implements NativeHubTransport { hubId: this.hubId, draining: this.draining, activeRpcTurns, + activeCloudCommands: this.cloud.activeCommands, pendingRuns: this.runQueue?.countPending() ?? 0, eventLog: this.eventLog ? { lastSequence: this.eventLog.lastSequence() } : undefined, // Idle = safe to stop: nothing executing and nothing accepted-but-unstarted. - idle: activeRpcTurns === 0 && (this.runQueue?.countPending() ?? 0) === 0, + idle: + activeRpcTurns === 0 && + this.cloud.activeCommands === 0 && + (this.runQueue?.countPending() ?? 0) === 0, }; } @@ -1190,6 +1257,43 @@ export class HubServerTransport implements NativeHubTransport { this.listeners.set(clientId, current); // Re-issue pending approvals so a (re)connecting client can answer a // request raised while it was away instead of leaving the turn parked. + const cloud = this.cloud.snapshot(); + queueMicrotask(() => { + if ( + !this.listeners.get(clientId)?.has(entry) || + (!cloud.snapshots.length && cloud.scopeGeneration === 0) + ) + return; + try { + const { snapshots, ...version } = cloud; + entry.listener(buildHubEvent("cloud.event", version)); + for (const snapshot of snapshots) { + if (entry.sessionId && entry.sessionId !== snapshot.sessionId) + continue; + entry.listener( + buildHubEvent( + "cloud.event", + { + ...cloud, + snapshots: undefined, + event: { + type: "snapshot", + sessionId: snapshot.sessionId, + snapshot, + replace: true, + }, + }, + snapshot.sessionId, + ), + ); + } + } catch (error) { + logHubBoundaryError( + "listener threw while recovering cloud state", + error, + ); + } + }); const pending = pendingApprovalEvents(this.ctx, options?.sessionId); if (pending.length > 0) { queueMicrotask(() => { @@ -1242,7 +1346,8 @@ export class HubServerTransport implements NativeHubTransport { // Durability before delivery: append to the event log and fan out the // sequence-stamped envelope, so live listeners and replaying clients // observe identical frames and the cursor is always meaningful. - if (this.eventLog) { + // Cloud snapshots are recovered with cloud.watch, never from an old account event log. + if (this.eventLog && event.event !== "cloud.event") { try { event = this.eventLog.append(event); } catch (error) { diff --git a/sdk/packages/shared/src/hub.ts b/sdk/packages/shared/src/hub.ts index 5f4a7dea3b..cd769c2ba6 100644 --- a/sdk/packages/shared/src/hub.ts +++ b/sdk/packages/shared/src/hub.ts @@ -621,6 +621,8 @@ export type HubCommandOutput = HubCommandOutputMap[TCommand]; export type HubCommandName = + | "cloud.command" + | "cloud.watch" | "client.register" | "client.update" | "client.unregister" @@ -754,6 +756,7 @@ export interface HubReplyEnvelope { } export type HubEventName = + | "cloud.event" | "hub.client.registered" | "hub.client.disconnected" | "session.created" From 0abc68fbfd95872eddc98a2d4de847ace5491557 Mon Sep 17 00:00:00 2001 From: abeatrix Date: Mon, 19 Oct 2026 16:09:47 -0700 Subject: [PATCH 2/5] fix(core): compare cloud account scope without hashing credentials --- .../hub/server/cloud-session-service.test.ts | 75 +++++++++++++++++++ .../src/hub/server/cloud-session-service.ts | 49 ++++++++---- 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/sdk/packages/core/src/hub/server/cloud-session-service.test.ts b/sdk/packages/core/src/hub/server/cloud-session-service.test.ts index 19557d4ec7..583b68675d 100644 --- a/sdk/packages/core/src/hub/server/cloud-session-service.test.ts +++ b/sdk/packages/core/src/hub/server/cloud-session-service.test.ts @@ -453,3 +453,78 @@ describe("hub cloud credentials", () => { ); }); }); + +describe("persisted cloud account scope", () => { + it.each([ + "key", + "account", + "endpoint", + "signout", + ])("retires its controller after a %s change", (change) => { + let settings: ReturnType = { + provider: "cline", + apiKey: "first-key", + }; + if (change === "account") + settings.auth = { accountId: "account-one", accessToken: "access-one" }; + vi.spyOn( + ProviderSettingsManager.prototype, + "getProviderSettings", + ).mockImplementation(() => settings); + const created: Array<{ + subscribe: ReturnType; + dispose: ReturnType; + }> = []; + const service = new HubCloudSessionService({ + publish: () => {}, + createController: () => { + const controller = { + subscribe: vi.fn(), + dispose: vi.fn(async () => {}), + }; + created.push(controller); + return controller as unknown as CloudSessionController; + }, + }); + services.push(service); + service.watch(); + service.watch(); + expect(created).toHaveLength(1); + if (change === "key") settings = { ...settings, apiKey: "second-key" }; + else if (change === "account") + settings = { + ...settings, + auth: { accountId: "account-two", accessToken: "access-two" }, + }; + else if (change === "endpoint") + settings = { ...settings, baseUrl: "https://other.example" }; + else settings = undefined; + service.watch(); + expect(created).toHaveLength(2); + expect(created[0]?.dispose).toHaveBeenCalledOnce(); + }); + it("keeps the same controller when a token refresh preserves account identity", () => { + let accessToken = "first-access"; + vi.spyOn( + ProviderSettingsManager.prototype, + "getProviderSettings", + ).mockImplementation(() => ({ + provider: "cline", + auth: { accountId: "same-account", accessToken }, + })); + const controller = { subscribe: vi.fn(), dispose: vi.fn(async () => {}) }; + const createController = vi.fn( + () => controller as unknown as CloudSessionController, + ); + const service = new HubCloudSessionService({ + publish: () => {}, + createController, + }); + services.push(service); + service.watch(); + accessToken = "refreshed-access"; + service.watch(); + expect(createController).toHaveBeenCalledOnce(); + expect(controller.dispose).not.toHaveBeenCalled(); + }); +}); diff --git a/sdk/packages/core/src/hub/server/cloud-session-service.ts b/sdk/packages/core/src/hub/server/cloud-session-service.ts index 9ce195f4f0..4d9fe7ba02 100644 --- a/sdk/packages/core/src/hub/server/cloud-session-service.ts +++ b/sdk/packages/core/src/hub/server/cloud-session-service.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import type { BasicLogger, ITelemetryService } from "@cline/shared"; import { getClineEnvironmentConfig } from "@cline/shared"; import { z } from "zod"; @@ -100,13 +100,20 @@ export interface HubCloudSessionServiceOptions { createController?: () => CloudSessionController; } +/** Private change-detection state; never serialized or logged. */ +type CloudAccountScope = { + kind: "account" | "credential" | "custom"; + identity: string; + apiBaseUrl?: string; +}; + /** One account-scoped controller per hub, independent of connected viewers. */ export class HubCloudSessionService { private readonly serviceId = randomUUID(); private scopeGeneration = 0; private revision = 0; private controller?: CloudSessionController; - private scope?: string; + private scope?: CloudAccountScope; private closed = false; private readonly snapshots = new Map(); private readonly tokenManager: RuntimeOAuthTokenManager; @@ -122,21 +129,33 @@ export class HubCloudSessionService { this.scopeTimer = setInterval(() => this.checkScope(), 1000); this.scopeTimer.unref?.(); } - private currentScope(): string | undefined { - if (this.options.getScope) return this.options.getScope(); + private currentScope(): CloudAccountScope | undefined { + if (this.options.getScope) { + const identity = this.options.getScope(); + return identity ? { kind: "custom", identity } : undefined; + } const settings = this.settings.getProviderSettings("cline"); + const accountId = settings?.auth?.accountId; const identity = - settings?.auth?.accountId || - settings?.auth?.accessToken || - settings?.apiKey; + accountId || settings?.auth?.accessToken || settings?.apiKey; return identity - ? createHash("sha256") - .update(JSON.stringify([identity, settings?.baseUrl])) - .digest("hex") + ? { + kind: accountId ? "account" : "credential", + identity, + apiBaseUrl: settings?.baseUrl, + } : undefined; } + private isCurrentScope(scope: CloudAccountScope | undefined): boolean { + const current = this.currentScope(); + return ( + scope?.kind === current?.kind && + scope?.identity === current?.identity && + scope?.apiBaseUrl === current?.apiBaseUrl + ); + } private checkScope(): void { - if (!this.controller || this.scope === this.currentScope()) return; + if (!this.controller || this.isCurrentScope(this.scope)) return; const old = this.controller; this.controller = undefined; this.scope = undefined; @@ -179,13 +198,12 @@ export class HubCloudSessionService { } private createController(): CloudSessionController { const environment = getClineEnvironmentConfig(); - const accountId = this.scope; + const scope = this.scope; const apiBaseUrl = this.settings.getProviderSettings("cline")?.baseUrl?.trim() || environment.apiBaseUrl; const getAuthToken = async () => { - if (accountId !== this.currentScope()) - throw new Error("Cline account changed"); + if (!this.isCurrentScope(scope)) throw new Error("Cline account changed"); let token: string | undefined; let refreshError: unknown; try { @@ -210,8 +228,7 @@ export class HubCloudSessionService { errorCode: "hub_cloud_refresh_failed_no_fallback_token", }); } - if (accountId !== this.currentScope()) - throw new Error("Cline account changed"); + if (!this.isCurrentScope(scope)) throw new Error("Cline account changed"); return token; }; const account = new ClineAccountService({ apiBaseUrl, getAuthToken }); From d12f57fe1ed9498316d978c58f8db044438fca78 Mon Sep 17 00:00:00 2001 From: abeatrix Date: Tue, 20 Oct 2026 18:54:16 -0700 Subject: [PATCH 3/5] fix(core): address hub cloud session review concerns --- .../sidecar/cloud-session-test-runtime.ts | 6 +- .../sidecar/cloud-sessions.test.ts | 46 +++- .../desktop-app/sidecar/cloud-sessions.ts | 1 + .../sidecar/commands-account.test.ts | 9 + apps/examples/desktop-app/sidecar/commands.ts | 1 + sdk/ARCHITECTURE.md | 2 +- sdk/packages/core/src/cloud/controller.ts | 4 + sdk/packages/core/src/cloud/hub-client.ts | 76 ++++-- sdk/packages/core/src/cloud/hub-protocol.ts | 3 + .../core/src/hub/server/boundary.test.ts | 30 ++- .../hub/server/cloud-session-service.test.ts | 222 +++++++++++++++++- .../src/hub/server/cloud-session-service.ts | 42 +++- .../src/hub/server/hub-server-transport.ts | 44 ++-- .../core/src/hub/server/hub-upgrades.test.ts | 8 +- .../runtime-oauth-token-manager.ts | 10 + 15 files changed, 434 insertions(+), 70 deletions(-) diff --git a/apps/examples/desktop-app/sidecar/cloud-session-test-runtime.ts b/apps/examples/desktop-app/sidecar/cloud-session-test-runtime.ts index 0ccef6c688..5332d0f588 100644 --- a/apps/examples/desktop-app/sidecar/cloud-session-test-runtime.ts +++ b/apps/examples/desktop-app/sidecar/cloud-session-test-runtime.ts @@ -76,7 +76,11 @@ export async function createCloudSessionTestRuntime( payload: command === "cloud.watch" ? service.watch() - : await service.command(String(payload?.operation), payload?.args), + : await service.command( + String(payload?.operation), + payload?.args, + payload?.scope, + ), }), })); ctx.cloudSessionManager = projection; diff --git a/apps/examples/desktop-app/sidecar/cloud-sessions.test.ts b/apps/examples/desktop-app/sidecar/cloud-sessions.test.ts index 8e41947a2b..8f2d09b80f 100644 --- a/apps/examples/desktop-app/sidecar/cloud-sessions.test.ts +++ b/apps/examples/desktop-app/sidecar/cloud-sessions.test.ts @@ -8,7 +8,7 @@ import { import { type CloudSessionApi, CloudSessionError, - type CloudSessionManager, + CloudSessionManager, type CloudSessionRecord, getCloudSessionManager, resetCloudSessionManager, @@ -162,6 +162,50 @@ afterAll(() => { }); describe("Cloud sessions sidecar wiring", () => { + it("clears resolved approvals when a replacement snapshot has an empty approval list", async () => { + const { ctx, events } = createContext(); + const viewer = new CloudSessionManager(ctx); + const approval = { + requestId: "request", + sessionId: "ses-one", + approvalId: "approval", + createdAt: "2026-10-20", + toolCallId: "tool", + toolName: "bash", + }; + const snapshot = { + sessionId: "ses-one", + config: {}, + messages: [], + promptsInQueue: [], + approvals: [approval], + busy: true, + status: "running", + startedAt: 1, + connectionState: "connected" as const, + transcriptKnown: true, + }; + viewer["project"]({ + type: "snapshot", + sessionId: "ses-one", + snapshot, + replace: true, + }); + expect(ctx.pendingApprovals.has("request")).toBe(true); + viewer["project"]({ + type: "snapshot", + sessionId: "ses-one", + snapshot: { ...snapshot, approvals: [] }, + replace: true, + }); + expect(ctx.pendingApprovals.size).toBe(0); + expect( + events.filter((event) => event.name === "tool_approval_state").at(-1) + ?.payload.items, + ).toEqual([]); + await viewer.dispose(); + }); + it.each([ "local", "ssh-remote", diff --git a/apps/examples/desktop-app/sidecar/cloud-sessions.ts b/apps/examples/desktop-app/sidecar/cloud-sessions.ts index df117b1e24..6ac37c55fc 100644 --- a/apps/examples/desktop-app/sidecar/cloud-sessions.ts +++ b/apps/examples/desktop-app/sidecar/cloud-sessions.ts @@ -109,6 +109,7 @@ export class CloudSessionManager extends HubCloudSessionClient { else this.ctx.liveSessions.set(id, state); const approvalsJson = JSON.stringify(snapshot.approvals); if ( + event.replace || event.cause === "approvals" || (this.approvalSnapshots.get(id) !== approvalsJson && snapshot.approvals.length > 0) diff --git a/apps/examples/desktop-app/sidecar/commands-account.test.ts b/apps/examples/desktop-app/sidecar/commands-account.test.ts index d287f63dfa..5293ab47fe 100644 --- a/apps/examples/desktop-app/sidecar/commands-account.test.ts +++ b/apps/examples/desktop-app/sidecar/commands-account.test.ts @@ -1,6 +1,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { HubCloudSessionClient } from "@cline/core/cloud"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state"; import type { SidecarContext } from "./types"; @@ -70,6 +71,10 @@ async function runClineAccountCommand(ctx: SidecarContext) { } beforeEach(() => { + vi.spyOn( + HubCloudSessionClient.prototype, + "refreshOrganization", + ).mockResolvedValue(undefined); // Account context is persisted across launches; never hydrate the developer's // signed-in identity when a test expects an anonymous device identity. testDataDir = mkdtempSync(join(tmpdir(), "commands-account-test-")); @@ -194,6 +199,7 @@ describe("provider settings cloud session lifecycle", () => { }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(testDataDir, { recursive: true, force: true }); }); @@ -426,6 +432,9 @@ describe("cline_account keeps feature-flag identity in sync", () => { }); await runOperation(ctx, "switchAccount"); + expect( + HubCloudSessionClient.prototype.refreshOrganization, + ).toHaveBeenCalledOnce(); expect(await currentFlagsUserId()).toBe("acct-1"); }); diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index 448c9f526a..cfd4caa99f 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -2812,6 +2812,7 @@ export async function handleCommand( accountService, ); if (operation === "switchAccount") { + await getCloudSessionManager(ctx).refreshOrganization(); await resetCloudSessionManager(ctx); // The sidebar must re-scope immediately (personal ⇄ org), not on // the next 12s poll. diff --git a/sdk/ARCHITECTURE.md b/sdk/ARCHITECTURE.md index 74b57df8dc..8744f1d607 100644 --- a/sdk/ARCHITECTURE.md +++ b/sdk/ARCHITECTURE.md @@ -138,7 +138,7 @@ Design rules: - `session/fork-metadata` owns fork ancestry and removal of inherited handoff markers; hosts retain title, transcript, and workspace-restore policies. - `@cline/core/cloud` owns remote cloud-session state and emits immutable snapshots and events. Viewers hydrating active runs with `readMessages` reconcile canonical history at completion even if they missed the run start. Desktop uses `HubCloudSessionClient` over its existing shared Hub connection. `HubCloudSessionService` in the daemon owns its account-scoped controller, refresh-aware credentials, organization lookup, provisioning, sandbox connections, queues, approvals, and transcript reconciliation. Desktop projects the snapshots into its UI; feature gating and host persistence remain in the app. Direct controller hosts (including handoff flows) can still supply authentication. Importing this subpath does not initialize a local agent. - `cloud/models` owns cloud model eligibility; `services/cloud-handoff` owns Git preflight, fingerprints, and transcript verification. Hosts own transfer orchestration, source locks, persistence, feature gating, and draft recovery. -- Cloud RPC validates an explicit operation allowlist. Handoff and prompt dispatch lifecycle callbacks stay with direct controller hosts and are excluded from the serializable RPC arguments. Viewer disconnects do not stop cloud work or close daemon-owned sandbox connections. Account changes retire the old controller and invalidate viewer caches; daemon shutdown disposes it. Cloud events carry a hub-instance id, account-scope generation, and revision; reconnecting subscriptions receive current snapshots. Cloud events are excluded from the durable local event log so old-account transcripts cannot be replayed after a scope change. Dispatched cloud mutations are never automatically resent after a lost reply. +- Cloud RPC validates an explicit operation allowlist and binds requests to the viewer’s observed hub instance and account-scope generation before dispatch. Handoff and prompt dispatch lifecycle callbacks stay with direct controller hosts and are excluded from the serializable RPC arguments. Viewer disconnects do not stop cloud work or close daemon-owned sandbox connections. Account changes retire the old controller and invalidate viewer caches; daemon shutdown disposes it. Cloud events carry a hub-instance id, account-scope generation, and revision; reconnecting subscriptions receive a complete snapshot set, including an empty set, and remove sessions and approvals absent from it. Scope checks run at command/auth boundaries and periodically, avoiding provider-settings reads per streamed event. Token refresh updates credential-based scope identity without retiring the same account’s controller. After an organization switch, desktop refreshes the daemon’s organization cache before rediscovery. Cloud events are excluded from the durable local event log so old-account transcripts cannot be replayed after a scope change. Dispatched cloud mutations are never automatically resent after a lost reply. - The cloud controller resumes suspended sessions and restores saved tasks. - The hub retains pending first-task creation options with its controller across desktop viewer disconnects and reconnects. The controller consumes that intent when an inner task exists or is created. Account changes dispose the old scope and its intent. - hub-related modules live under `packages/core/src/hub/`, grouped by service: diff --git a/sdk/packages/core/src/cloud/controller.ts b/sdk/packages/core/src/cloud/controller.ts index 3fc9acfe74..320d8f5d0d 100644 --- a/sdk/packages/core/src/cloud/controller.ts +++ b/sdk/packages/core/src/cloud/controller.ts @@ -838,6 +838,10 @@ export class CloudSessionController { }; } + async refreshOrganization(): Promise { + await this.resolveActiveOrganizationId({ fresh: true }); + } + private async resolveActiveOrganizationId(options?: { fresh?: boolean; }): Promise { diff --git a/sdk/packages/core/src/cloud/hub-client.ts b/sdk/packages/core/src/cloud/hub-client.ts index 8c55682b4c..3d8e77745e 100644 --- a/sdk/packages/core/src/cloud/hub-client.ts +++ b/sdk/packages/core/src/cloud/hub-client.ts @@ -35,31 +35,12 @@ export class HubCloudSessionClient { const connecting = this.getConnection().then(async (client) => { if (this.disposed) throw new Error("Cloud viewer is disposed"); this.unsubscribe = client.subscribe((envelope: HubEventEnvelope) => { - if ( - envelope.event === "cloud.event" && - this.acceptVersion(envelope.payload) - ) - this.receive( - envelope.payload?.event as CloudSessionEvent, - Number(envelope.payload?.revision), - ); + if (envelope.event === "cloud.event") this.recover(envelope.payload); }); try { const reply = await client.command("cloud.watch"); this.checkReply(reply); - if (!this.disposed && this.acceptVersion(reply.payload)) - for (const snapshot of (reply.payload?.snapshots ?? - []) as CloudSessionSnapshot[]) { - this.receive( - { - type: "snapshot", - sessionId: snapshot.sessionId, - snapshot, - replace: true, - }, - Number(reply.payload?.revision), - ); - } + this.recover(reply.payload); return client; } catch (error) { this.unsubscribe?.(); @@ -77,6 +58,31 @@ export class HubCloudSessionClient { async connect(): Promise { await this.ready(); } + private recover(payload: Record | undefined): void { + if (this.disposed || !this.acceptVersion(payload)) return; + const revision = Number(payload?.revision); + if (Array.isArray(payload?.snapshots)) { + const snapshots = payload.snapshots as CloudSessionSnapshot[]; + const present = new Set(snapshots.map((snapshot) => snapshot.sessionId)); + for (const sessionId of this.snapshots.keys()) { + if ( + (!payload.sessionId || payload.sessionId === sessionId) && + !present.has(sessionId) + ) + this.receive({ type: "removed", sessionId }, revision); + } + for (const snapshot of snapshots) + this.receive( + { + type: "snapshot", + sessionId: snapshot.sessionId, + snapshot, + replace: true, + }, + revision, + ); + } else this.receive(payload?.event as CloudSessionEvent, revision); + } private acceptVersion(payload: Record | undefined): boolean { if ( typeof payload?.serviceId !== "string" || @@ -128,12 +134,26 @@ export class HubCloudSessionClient { operation: K, args: CloudHubArgs, ): Promise> { + // Capture an established viewer's scope before yielding to connection recovery. + const preparedScope = this.serviceId + ? { + serviceId: this.serviceId, + scopeGeneration: this.scopeGeneration, + } + : undefined; const client = await this.ready(); if (this.disposed) throw new Error("Cloud viewer is disposed"); + const scope = preparedScope ?? { + serviceId: this.serviceId, + scopeGeneration: this.scopeGeneration, + }; + const isCurrentScope = () => + scope.serviceId === this.serviceId && + scope.scopeGeneration === this.scopeGeneration; let dispatched = false; const reply = await client.command( "cloud.command", - { operation, args }, + { operation, args, scope }, undefined, { timeoutMs: ["create", "send", "attach"].includes(operation) @@ -141,6 +161,8 @@ export class HubCloudSessionClient { : undefined, beforeDispatch: () => { if (this.disposed) throw new Error("Cloud viewer is disposed"); + if (!isCurrentScope()) + throw new Error("Cloud account changed before request"); if (dispatched) throw new Error( "Cloud request outcome is unknown; reconnect before retrying", @@ -152,7 +174,12 @@ export class HubCloudSessionClient { }, ); this.checkReply(reply); - if (!this.acceptVersion(reply.payload)) + if ( + !isCurrentScope() || + reply.payload?.serviceId !== scope.serviceId || + reply.payload?.scopeGeneration !== scope.scopeGeneration || + !this.acceptVersion(reply.payload) + ) throw new Error("Cloud account changed during request"); if (this.disposed) throw new Error("Cloud viewer is disposed"); return reply.payload?.result as CloudHubResult; @@ -166,6 +193,9 @@ export class HubCloudSessionClient { send(...args: CloudHubArgs<"send">): Promise> { return this.call("send", args); } + refreshOrganization(): Promise { + return this.call("refreshOrganization", []); + } abort(...args: CloudHubArgs<"abort">): Promise> { return this.call("abort", args); } diff --git a/sdk/packages/core/src/cloud/hub-protocol.ts b/sdk/packages/core/src/cloud/hub-protocol.ts index 20c8002eb4..22d9ae746e 100644 --- a/sdk/packages/core/src/cloud/hub-protocol.ts +++ b/sdk/packages/core/src/cloud/hub-protocol.ts @@ -21,7 +21,10 @@ export const CLOUD_HUB_OPERATIONS = [ "listBranches", "respondApproval", "restoreCreationOptions", + "refreshOrganization", ] as const; +export type CloudHubScope = { serviceId: string; scopeGeneration: number }; + export type CloudHubOperation = (typeof CLOUD_HUB_OPERATIONS)[number]; export type CloudHubMethods = Pick; export type CloudHubArgs = K extends "create" diff --git a/sdk/packages/core/src/hub/server/boundary.test.ts b/sdk/packages/core/src/hub/server/boundary.test.ts index dfb95cdaf4..5924f2329b 100644 --- a/sdk/packages/core/src/hub/server/boundary.test.ts +++ b/sdk/packages/core/src/hub/server/boundary.test.ts @@ -60,6 +60,28 @@ describe("HubServerTransport boundaries", () => { return (transport as unknown as { ctx: HubTransportContext }).ctx; } + it("recovers an empty cloud snapshot set from a fresh daemon", async () => { + const transport = createTransport({ eventLog: false, runQueue: false }); + const listener = vi.fn(); + try { + const unsubscribe = transport.subscribe("viewer", listener); + await Promise.resolve(); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + event: "cloud.event", + payload: expect.objectContaining({ + serviceId: expect.any(String), + scopeGeneration: 0, + snapshots: [], + }), + }), + ); + unsubscribe(); + } finally { + await transport.stop(); + } + }); + it("requires connection registration before cloud access even when another client id is known", async () => { const transport = createTransport({ eventLog: false, runQueue: false }); try { @@ -1982,7 +2004,7 @@ describe("HubServerTransport boundaries", () => { const transport = createTransport(); const published: string[] = []; transport.subscribe("test", (event) => { - published.push(event.event); + if (event.event.startsWith("iteration.")) published.push(event.event); }); const ctx = getContext(transport); @@ -2209,7 +2231,7 @@ describe("HubServerTransport boundaries", () => { }); const events: HubEventEnvelope[] = []; transport.subscribe("test", (event) => { - events.push(event); + if (event.event === "usage.updated") events.push(event); }); const ctx = getContext(transport); @@ -2276,7 +2298,9 @@ describe("HubServerTransport boundaries", () => { it("projects notices with teammate provenance intact", async () => { const transport = createTransport(); const events: HubEventEnvelope[] = []; - transport.subscribe("test", (event) => events.push(event)); + transport.subscribe("test", (event) => { + if (event.event === "session.notice") events.push(event); + }); const ctx = getContext(transport); await projectSessionEvent(ctx, { diff --git a/sdk/packages/core/src/hub/server/cloud-session-service.test.ts b/sdk/packages/core/src/hub/server/cloud-session-service.test.ts index 583b68675d..3c6bcb1986 100644 --- a/sdk/packages/core/src/hub/server/cloud-session-service.test.ts +++ b/sdk/packages/core/src/hub/server/cloud-session-service.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { HubEventEnvelope } from "@cline/shared"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { ClineAccountService } from "../../account"; +import { getProviderAuthHandler } from "../../auth/provider-auth-registry"; import type { CloudSessionController } from "../../cloud/controller"; import { type CloudHubConnection, @@ -17,6 +19,10 @@ import { RuntimeOAuthTokenManager, } from "../../runtime/orchestration/runtime-oauth-token-manager"; import { ProviderSettingsManager } from "../../services/storage/provider-settings-manager"; +import { + emptyStoredProviderSettings, + ProviderSettingsSchemaTyped, +} from "../../types/provider-settings"; import { NodeHubClient } from "../client"; import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers"; import { createInMemoryHubOwnerContext } from "../discovery"; @@ -29,6 +35,13 @@ afterEach(async () => { vi.restoreAllMocks(); await Promise.all(services.splice(0).map((service) => service.dispose())); }); +function command( + service: HubCloudSessionService, + operation: string, + args: unknown, +) { + return service.command(operation, args, service.snapshot()); +} function deferred() { let resolve: (value: T | PromiseLike) => void = () => { throw new Error("Deferred not initialized"); @@ -74,6 +87,7 @@ function fixture() { return { sessionId, status: "running" }; }), send: vi.fn(async () => ({ sessionId: "ses-one", ok: true })), + restoreCreationOptions: vi.fn(async () => {}), listForDiscovery: vi.fn(async () => [{ sessionId: "ses-one" }]), dispose: vi.fn(async () => {}), }; @@ -112,7 +126,7 @@ function fixture() { const result = name === "cloud.watch" ? service.watch() - : await service.command(body.operation, body.args); + : await service.command(body.operation, body.args, body.scope); return JSON.parse( JSON.stringify({ version: "v1", ok: true, payload: result }), ); @@ -133,6 +147,10 @@ function fixture() { controllers, connection, viewer, + emitRecovery: (payload: Record) => { + for (const listener of listeners) + listener({ version: "v1", event: "cloud.event", payload }); + }, changeScope: (value: string) => { scope = value; }, @@ -140,6 +158,92 @@ function fixture() { } describe("hub-owned cloud sessions", () => { + it("does not rebind a prepared request when account recovery runs before dispatch", async () => { + const f = fixture(); + const viewer = f.viewer(); + await viewer.connect(); + const creating = viewer.create({ modelId: "model", repoUrl: "repo" }); + f.changeScope("account-two"); + f.service.watch(); + await expect(creating).rejects.toThrow("account changed"); + for (const { controller } of f.controllers) + expect(controller.create).not.toHaveBeenCalled(); + }); + + it.each([ + "account", + "daemon", + ])("rejects a delayed create after the %s changes before admission", async (change) => { + const f = fixture(); + const viewer = f.viewer(); + await viewer.connect(); + const original = f.connection.command; + f.connection.command = async (name, payload, ...rest) => { + if (name === "cloud.command") { + if (change === "account") f.changeScope("account-two"); + else + payload = { + ...payload, + scope: { ...(payload?.scope as object), serviceId: "old-daemon" }, + }; + } + return original(name, payload, ...rest); + }; + await expect( + viewer.create({ modelId: "model", repoUrl: "repo" }), + ).rejects.toThrow("account changed"); + expect(f.controllers).toHaveLength(1); + expect(f.controllers[0]?.controller.create).not.toHaveBeenCalled(); + }); + it.each([ + "plan", + "zen", + ] as const)("preserves %s mode and workspace paths at the RPC boundary", async (mode) => { + const f = fixture(); + const viewer = f.viewer(); + const options = { + mode, + workspaceRelativePath: "packages/app", + thinking: true, + autoApproveTools: false, + reasoningEffort: "high" as const, + }; + await viewer.create({ modelId: "model", repoUrl: "repo", ...options }); + await viewer.attach("ses-one", options); + await viewer.restoreCreationOptions("ses-one", options); + expect(f.controllers[0]?.controller.create).toHaveBeenCalledWith( + expect.objectContaining(options), + ); + expect(f.controllers[0]?.controller.attach).toHaveBeenCalledWith( + "ses-one", + options, + ); + expect( + f.controllers[0]?.controller.restoreCreationOptions, + ).toHaveBeenCalledWith("ses-one", options); + }); + it.each([ + "same", + "new", + ])("clears missed deletions on recovery from the %s daemon, including an empty set", async (daemon) => { + const f = fixture(); + const viewer = f.viewer(); + await viewer.attach("ses-one"); + const removed = vi.fn(); + viewer.subscribe(removed); + const state = f.service.snapshot(); + f.emitRecovery({ + ...state, + serviceId: daemon === "new" ? "new-daemon" : state.serviceId, + snapshots: [], + }); + expect(viewer.getSnapshot("ses-one")).toBeUndefined(); + expect(removed).toHaveBeenCalledWith({ + type: "removed", + sessionId: "ses-one", + }); + }); + it("shares a controller across viewers and retains work after viewer disposal", async () => { const f = fixture(); const first = f.viewer(); @@ -189,7 +293,7 @@ describe("hub-owned cloud sessions", () => { .catch((error: unknown) => error); await vi.waitFor(() => expect(old.controller.send).toHaveBeenCalledOnce()); f.changeScope("account-two"); - await f.service.command("listForDiscovery", []); + await command(f.service, "listForDiscovery", []); expect(viewer.getSnapshot("ses-one")).toBeUndefined(); expect(old.controller.dispose).toHaveBeenCalledOnce(); old.emit({ @@ -214,26 +318,26 @@ describe("hub-owned cloud sessions", () => { }); it("validates operations and provisioning payloads before invoking the controller", async () => { const f = fixture(); - await expect(f.service.command("dispose", [])).rejects.toThrow( + await expect(command(f.service, "dispose", [])).rejects.toThrow( "Unknown cloud operation", ); - await expect(f.service.command("__proto__", [])).rejects.toThrow( + await expect(command(f.service, "__proto__", [])).rejects.toThrow( "Unknown cloud operation", ); await expect( - f.service.command("create", [ + command(f.service, "create", [ { modelId: "model", repoUrl: "repo", handoff: {} }, ]), ).rejects.toThrow(); await expect( - f.service.command("respondApproval", [ + command(f.service, "respondApproval", [ "ses-one", "approval", { approved: "yes" }, ]), ).rejects.toThrow(); expect(f.controllers).toHaveLength(0); - await f.service.command("create", [{ modelId: "model", repoUrl: "repo" }]); + await command(f.service, "create", [{ modelId: "model", repoUrl: "repo" }]); expect(f.controllers[0]!.controller.create).toHaveBeenCalledWith({ modelId: "model", repoUrl: "repo", @@ -256,7 +360,7 @@ describe("hub-owned cloud sessions", () => { }); it("ignores a hydration reply older than a live snapshot", async () => { const f = fixture(); - await f.service.command("attach", ["ses-one"]); + await command(f.service, "attach", ["ses-one"]); const ready = deferred(); const original = f.connection.command; f.connection.command = async (name, ...args) => { @@ -280,7 +384,7 @@ describe("hub-owned cloud sessions", () => { }); it("hydrates untouched sessions when another session receives a newer live event", async () => { const f = fixture(); - await f.service.command("attach", ["ses-one"]); + await command(f.service, "attach", ["ses-one"]); const ready = deferred(); const original = f.connection.command; let watching = false; @@ -418,6 +522,106 @@ describe("cloud RPC over a shared hub socket", () => { }); describe("hub cloud credentials", () => { + it("refreshes the daemon organization cache through RPC without disposing cloud work", async () => { + vi.spyOn( + ProviderSettingsManager.prototype, + "getProviderSettings", + ).mockReturnValue({ provider: "cline", apiKey: "key" }); + vi.spyOn( + RuntimeOAuthTokenManager.prototype, + "resolveProviderApiKey", + ).mockResolvedValue(null); + const fetch = vi + .spyOn(ClineAccountService.prototype, "fetchUserOrganizations") + .mockResolvedValueOnce([ + { organizationId: "org-one", active: true }, + ] as never) + .mockResolvedValueOnce([ + { organizationId: "org-two", active: true }, + ] as never); + const service = new HubCloudSessionService({ publish: () => {} }); + services.push(service); + service.watch(); + const controller = service["controller"]!; + const getOrganization = controller["options"].getActiveOrganizationId!; + expect(await getOrganization()).toBe("org-one"); + expect(await getOrganization()).toBe("org-one"); + expect(fetch).toHaveBeenCalledOnce(); + await command(service, "refreshOrganization", []); + expect(await getOrganization()).toBe("org-two"); + expect(service["controller"]).toBe(controller); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("retains the controller when its OAuth refresh rotates tokens without an account id", async () => { + let settings: NonNullable< + ReturnType + > = { + provider: "cline", + auth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: 1, + }, + }; + vi.spyOn( + ProviderSettingsManager.prototype, + "getProviderSettings", + ).mockImplementation(() => settings); + vi.spyOn( + ProviderSettingsManager.prototype, + "saveProviderSettings", + ).mockImplementation((next) => { + settings = ProviderSettingsSchemaTyped.parse(next); + return emptyStoredProviderSettings(); + }); + const handler = getProviderAuthHandler("cline"); + if (!handler) throw new Error("Missing Cline auth handler"); + vi.spyOn(handler, "refresh").mockResolvedValue({ + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 3600000, + }); + const service = new HubCloudSessionService({ publish: () => {} }); + services.push(service); + service.watch(); + const controller = service["controller"]!; + const dispose = vi.spyOn(controller, "dispose"); + expect(await controller["options"].getAuthToken()).toContain("new-access"); + service.watch(); + expect(service["controller"]).toBe(controller); + expect(dispose).not.toHaveBeenCalled(); + }); + it("does not read provider settings for every streamed event", () => { + const reads = vi + .spyOn(ProviderSettingsManager.prototype, "getProviderSettings") + .mockReturnValue({ provider: "cline", apiKey: "key" }); + let emit: (event: CloudSessionEvent) => void = () => {}; + const publish = vi.fn(); + const service = new HubCloudSessionService({ + publish, + createController: () => + ({ + subscribe: (listener: typeof emit) => { + emit = listener; + }, + dispose: async () => {}, + }) as unknown as CloudSessionController, + }); + services.push(service); + service.watch(); + reads.mockClear(); + for (let i = 0; i < 100; i++) + emit({ + type: "snapshot", + sessionId: "ses-one", + snapshot: snapshot(), + replace: false, + }); + expect(reads).not.toHaveBeenCalled(); + expect(publish).toHaveBeenCalledTimes(100); + }); + it.each([ "manual", "refresh", diff --git a/sdk/packages/core/src/hub/server/cloud-session-service.ts b/sdk/packages/core/src/hub/server/cloud-session-service.ts index 4d9fe7ba02..3601eeec0a 100644 --- a/sdk/packages/core/src/hub/server/cloud-session-service.ts +++ b/sdk/packages/core/src/hub/server/cloud-session-service.ts @@ -6,7 +6,10 @@ import { ClineAccountService } from "../../account"; import { getProviderAuthHandler } from "../../auth/provider-auth-registry"; import { CloudSessionApi } from "../../cloud/api"; import { CloudSessionController } from "../../cloud/controller"; -import type { CloudHubOperation } from "../../cloud/hub-protocol"; +import type { + CloudHubOperation, + CloudHubScope, +} from "../../cloud/hub-protocol"; import type { CloudSessionEvent, CloudSessionSnapshot, @@ -21,6 +24,8 @@ import { captureAuthRefreshSoftFailure } from "../../services/telemetry/core-eve const id = z.string().trim().min(1); const optionalString = z.string().nullish(); const creationOptions = z.object({ + mode: z.enum(["act", "plan", "yolo", "zen"]).optional(), + workspaceRelativePath: z.string().optional(), autoApproveTools: z.boolean().optional(), thinking: z.boolean().optional(), reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(), @@ -35,8 +40,6 @@ const schemas = { repoUrl: id, initialPrompt: z.string().optional(), branch: id.optional(), - mode: z.enum(["act", "plan", "yolo"]).optional(), - workspaceRelativePath: id.optional(), organizationId: id.nullable().optional(), }) .strict(), @@ -85,6 +88,7 @@ const schemas = { id, z.object({ approved: z.boolean(), reason: z.string().optional() }).strict(), ]), + refreshOrganization: z.tuple([]), restoreCreationOptions: z.tuple([id, creationOptions.strict()]), } satisfies Record; @@ -125,6 +129,26 @@ export class HubCloudSessionService { this.tokenManager = new RuntimeOAuthTokenManager({ providerSettingsManager: this.settings, telemetry: options.telemetry, + onCredentialsRefreshed: (previous, next) => { + const scope = this.scope; + // A refresh rotates credentials without changing this controller's account. + if ( + previous.provider !== "cline" || + !scope || + scope.kind !== "credential" || + scope.identity !== (previous.auth?.accessToken || previous.apiKey) || + scope.apiBaseUrl !== previous.baseUrl + ) + return; + const identity = + next.auth?.accountId || next.auth?.accessToken || next.apiKey; + if (identity) + Object.assign(scope, { + kind: next.auth?.accountId ? "account" : "credential", + identity, + apiBaseUrl: next.baseUrl, + }); + }, }); this.scopeTimer = setInterval(() => this.checkScope(), 1000); this.scopeTimer.unref?.(); @@ -187,7 +211,7 @@ export class HubCloudSessionService { this.options.createController?.() ?? this.createController(); this.controller = controller; controller.subscribe((event) => { - this.checkScope(); + // Scope changes are checked periodically and at command/auth boundaries. if (this.controller !== controller || this.closed) return; if (event.type === "snapshot") this.snapshots.set(event.sessionId, event.snapshot); @@ -292,6 +316,7 @@ export class HubCloudSessionService { async command( operation: string, rawArgs: unknown, + expectedScope: unknown, ): Promise> { if (!Object.hasOwn(schemas, operation)) throw new Error(`Unknown cloud operation: ${operation}`); @@ -305,6 +330,15 @@ export class HubCloudSessionService { const input = normalized[0] as { sandboxType?: string }; input.sandboxType ??= "resumable"; } + this.checkScope(); + const expected = z + .object({ serviceId: z.string(), scopeGeneration: z.number().int() }) + .parse(expectedScope) satisfies CloudHubScope; + if ( + expected.serviceId !== this.serviceId || + expected.scopeGeneration !== this.scopeGeneration + ) + throw new Error("Cloud account changed before request"); const controller = this.getController(); this.activeCommands++; try { diff --git a/sdk/packages/core/src/hub/server/hub-server-transport.ts b/sdk/packages/core/src/hub/server/hub-server-transport.ts index 05ff34fdb7..223d44b342 100644 --- a/sdk/packages/core/src/hub/server/hub-server-transport.ts +++ b/sdk/packages/core/src/hub/server/hub-server-transport.ts @@ -880,6 +880,7 @@ export class HubServerTransport implements NativeHubTransport { const result = await this.cloud.command( String(envelope.payload?.operation ?? ""), envelope.payload?.args, + envelope.payload?.scope, ); return okReply(envelope, result); } catch (error) { @@ -1330,34 +1331,23 @@ export class HubServerTransport implements NativeHubTransport { // request raised while it was away instead of leaving the turn parked. const cloud = this.cloud.snapshot(); queueMicrotask(() => { - if ( - !this.listeners.get(clientId)?.has(entry) || - (!cloud.snapshots.length && cloud.scopeGeneration === 0) - ) - return; + if (!this.listeners.get(clientId)?.has(entry)) return; try { - const { snapshots, ...version } = cloud; - entry.listener(buildHubEvent("cloud.event", version)); - for (const snapshot of snapshots) { - if (entry.sessionId && entry.sessionId !== snapshot.sessionId) - continue; - entry.listener( - buildHubEvent( - "cloud.event", - { - ...cloud, - snapshots: undefined, - event: { - type: "snapshot", - sessionId: snapshot.sessionId, - snapshot, - replace: true, - }, - }, - snapshot.sessionId, - ), - ); - } + entry.listener( + buildHubEvent( + "cloud.event", + { + ...cloud, + snapshots: entry.sessionId + ? cloud.snapshots.filter( + (snapshot) => snapshot.sessionId === entry.sessionId, + ) + : cloud.snapshots, + ...(entry.sessionId ? { sessionId: entry.sessionId } : {}), + }, + entry.sessionId, + ), + ); } catch (error) { logHubBoundaryError( "listener threw while recovering cloud state", diff --git a/sdk/packages/core/src/hub/server/hub-upgrades.test.ts b/sdk/packages/core/src/hub/server/hub-upgrades.test.ts index 4d2bff6f7a..8401237ce3 100644 --- a/sdk/packages/core/src/hub/server/hub-upgrades.test.ts +++ b/sdk/packages/core/src/hub/server/hub-upgrades.test.ts @@ -134,7 +134,13 @@ describe("Hub app-server upgrades", () => { await transport.start(); try { const seen: HubEventEnvelope[] = []; - transport.subscribe("observer", (event) => seen.push(event)); + transport.subscribe("observer", (event) => { + if (event.event === "cloud.event") { + expect(event.sequence).toBeUndefined(); + return; + } + seen.push(event); + }); await transport.handleCommand({ version: "v1", command: "session.create", diff --git a/sdk/packages/core/src/runtime/orchestration/runtime-oauth-token-manager.ts b/sdk/packages/core/src/runtime/orchestration/runtime-oauth-token-manager.ts index e17c424db4..b745b9f09d 100644 --- a/sdk/packages/core/src/runtime/orchestration/runtime-oauth-token-manager.ts +++ b/sdk/packages/core/src/runtime/orchestration/runtime-oauth-token-manager.ts @@ -50,6 +50,10 @@ export type RuntimeOAuthResolution = { export class RuntimeOAuthTokenManager { private readonly providerSettingsManager: ProviderSettingsManager; private readonly telemetry?: ITelemetryService; + private readonly onCredentialsRefreshed?: ( + previous: ProviderSettings, + next: ProviderSettings, + ) => void; private readonly refreshInFlight = new Map< ManagedOAuthProviderId, Promise @@ -58,10 +62,15 @@ export class RuntimeOAuthTokenManager { constructor(options?: { providerSettingsManager?: ProviderSettingsManager; telemetry?: ITelemetryService; + onCredentialsRefreshed?: ( + previous: ProviderSettings, + next: ProviderSettings, + ) => void; }) { this.providerSettingsManager = options?.providerSettingsManager ?? new ProviderSettingsManager(); this.telemetry = options?.telemetry; + this.onCredentialsRefreshed = options?.onCredentialsRefreshed; } public async resolveProviderApiKey(input: { @@ -191,6 +200,7 @@ export class RuntimeOAuthTokenManager { setLastUsed: false, tokenSource: "oauth", }); + this.onCredentialsRefreshed?.(settings, nextSettings); } else { sdkDebug(`oauth.resolve providerId=${providerId} outcome=not_refreshed`); } From 234cb7b0f6ddbd6755b2af717fb2b6d048241d87 Mon Sep 17 00:00:00 2001 From: abeatrix Date: Tue, 20 Oct 2026 19:26:39 -0700 Subject: [PATCH 4/5] fix(desktop): preserve successful account switches when cloud refresh fails --- .../sidecar/commands-account.test.ts | 39 ++++++++++++++ apps/examples/desktop-app/sidecar/commands.ts | 11 +++- sdk/ARCHITECTURE.md | 2 +- sdk/DOC.md | 52 +++++++++++++++++++ .../hub/server/cloud-session-service.test.ts | 36 +++++++++++++ .../src/hub/server/cloud-session-service.ts | 16 +++++- 6 files changed, 152 insertions(+), 4 deletions(-) diff --git a/apps/examples/desktop-app/sidecar/commands-account.test.ts b/apps/examples/desktop-app/sidecar/commands-account.test.ts index 5293ab47fe..f80ad05d70 100644 --- a/apps/examples/desktop-app/sidecar/commands-account.test.ts +++ b/apps/examples/desktop-app/sidecar/commands-account.test.ts @@ -328,6 +328,44 @@ describe("cline_account command auth states", () => { * against a stale account (or the device). */ describe("cline_account keeps feature-flag identity in sync", () => { + it.each([ + "Hub unavailable", + "organization request failed", + "cloud disabled", + ])("keeps a successful account switch and desktop cleanup when %s", async (failure) => { + vi.stubEnv( + "CLINE_CODE_CLOUD_AGENTS", + failure === "cloud disabled" ? "0" : "1", + ); + const { getCloudSessionManager } = await import("./cloud-sessions"); + const { ctx } = createContext(); + const viewer = getCloudSessionManager(ctx); + const dispose = vi.spyOn(viewer, "dispose"); + const send = vi.fn(); + ctx.wsClients.add({ send } as never); + resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" }); + getProviderSettingsMock.mockReturnValue({}); + const result = { switched: true }; + executeClineAccountActionMock.mockResolvedValue(result); + vi.mocked( + HubCloudSessionClient.prototype.refreshOrganization, + ).mockRejectedValue(new Error(failure)); + await expect(runOperation(ctx, "switchAccount")).resolves.toBe(result); + expect(dispose).toHaveBeenCalledOnce(); + expect(ctx.cloudSessionManager).toBeNull(); + expect( + send.mock.calls.map(([message]) => JSON.parse(message).event.name), + ).toContain("cloud_sessions_changed"); + if (failure === "cloud disabled") + expect( + HubCloudSessionClient.prototype.refreshOrganization, + ).not.toHaveBeenCalled(); + else + expect( + HubCloudSessionClient.prototype.refreshOrganization, + ).toHaveBeenCalledOnce(); + }); + async function currentFlagsUserId(): Promise { const { getDesktopFeatureFlagsContext } = await import("./feature-flags"); return getDesktopFeatureFlagsContext().userId ?? undefined; @@ -419,6 +457,7 @@ describe("cline_account keeps feature-flag identity in sync", () => { }); it("leaves the signed-in identity intact across an organization switch", async () => { + vi.stubEnv("CLINE_CODE_CLOUD_AGENTS", "1"); const { ctx } = createContext(); resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" }); getProviderSettingsMock.mockReturnValue({}); diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index cfd4caa99f..0bae266c3c 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -2812,7 +2812,16 @@ export async function handleCommand( accountService, ); if (operation === "switchAccount") { - await getCloudSessionManager(ctx).refreshOrganization(); + if (isCloudAgentsEnabled()) { + try { + await getCloudSessionManager(ctx).refreshOrganization(); + } catch (error) { + ctx.logger?.error?.( + "Failed to refresh cloud organization after account switch", + { error }, + ); + } + } await resetCloudSessionManager(ctx); // The sidebar must re-scope immediately (personal ⇄ org), not on // the next 12s poll. diff --git a/sdk/ARCHITECTURE.md b/sdk/ARCHITECTURE.md index 8744f1d607..d750b7b997 100644 --- a/sdk/ARCHITECTURE.md +++ b/sdk/ARCHITECTURE.md @@ -138,7 +138,7 @@ Design rules: - `session/fork-metadata` owns fork ancestry and removal of inherited handoff markers; hosts retain title, transcript, and workspace-restore policies. - `@cline/core/cloud` owns remote cloud-session state and emits immutable snapshots and events. Viewers hydrating active runs with `readMessages` reconcile canonical history at completion even if they missed the run start. Desktop uses `HubCloudSessionClient` over its existing shared Hub connection. `HubCloudSessionService` in the daemon owns its account-scoped controller, refresh-aware credentials, organization lookup, provisioning, sandbox connections, queues, approvals, and transcript reconciliation. Desktop projects the snapshots into its UI; feature gating and host persistence remain in the app. Direct controller hosts (including handoff flows) can still supply authentication. Importing this subpath does not initialize a local agent. - `cloud/models` owns cloud model eligibility; `services/cloud-handoff` owns Git preflight, fingerprints, and transcript verification. Hosts own transfer orchestration, source locks, persistence, feature gating, and draft recovery. -- Cloud RPC validates an explicit operation allowlist and binds requests to the viewer’s observed hub instance and account-scope generation before dispatch. Handoff and prompt dispatch lifecycle callbacks stay with direct controller hosts and are excluded from the serializable RPC arguments. Viewer disconnects do not stop cloud work or close daemon-owned sandbox connections. Account changes retire the old controller and invalidate viewer caches; daemon shutdown disposes it. Cloud events carry a hub-instance id, account-scope generation, and revision; reconnecting subscriptions receive a complete snapshot set, including an empty set, and remove sessions and approvals absent from it. Scope checks run at command/auth boundaries and periodically, avoiding provider-settings reads per streamed event. Token refresh updates credential-based scope identity without retiring the same account’s controller. After an organization switch, desktop refreshes the daemon’s organization cache before rediscovery. Cloud events are excluded from the durable local event log so old-account transcripts cannot be replayed after a scope change. Dispatched cloud mutations are never automatically resent after a lost reply. +- Cloud RPC validates an explicit operation allowlist and binds requests to the viewer’s observed hub instance and account-scope generation before dispatch. Handoff and prompt dispatch lifecycle callbacks stay with direct controller hosts and are excluded from the serializable RPC arguments. Viewer disconnects do not stop cloud work or close daemon-owned sandbox connections. Account changes retire the old controller and invalidate viewer caches; daemon shutdown disposes it. Cloud events carry a hub-instance id, account-scope generation, and revision; reconnecting subscriptions receive a complete snapshot set, including an empty set, and remove sessions and approvals absent from it. Scope checks run at command/auth boundaries and periodically, avoiding provider-settings reads per streamed event. Token refresh updates credential-based scope identity without retiring the same account’s controller. After an organization switch, desktop attempts a cloud-enabled organization refresh without making switch success or viewer cleanup depend on it. Fresh requests invalidate the old cache before fetching; a returning viewer also invalidates it to cover switches made while the Hub was unreachable. Cloud events are excluded from the durable local event log so old-account transcripts cannot be replayed after a scope change. Dispatched cloud mutations are never automatically resent after a lost reply. - The cloud controller resumes suspended sessions and restores saved tasks. - The hub retains pending first-task creation options with its controller across desktop viewer disconnects and reconnects. The controller consumes that intent when an inner task exists or is created. Account changes dispose the old scope and its intent. - hub-related modules live under `packages/core/src/hub/`, grouped by service: diff --git a/sdk/DOC.md b/sdk/DOC.md index 1d174b73a9..7af5eb4272 100644 --- a/sdk/DOC.md +++ b/sdk/DOC.md @@ -135,6 +135,58 @@ the outer session is deleted). `dispose` preserves an injected map; without one, the controller owns and clears its pending state. Restoring options alone never authorizes recreation of a missing established task. +### Hub-owned cloud viewers + +`HubCloudSessionClient` from `@cline/core/cloud` uses an authenticated, +registered `NodeHubClient` from `@cline/core/hub`. Reuse the host's existing +Hub connection, or create and connect a `NodeHubClient` with the Hub URL, +auth token, client ID, and workspace root. The cloud client accepts an async +connection factory and connects lazily; `connect()` explicitly hydrates it. + +```ts +import { HubCloudSessionClient } from "@cline/core/cloud"; +import type { NodeHubClient } from "@cline/core/hub"; + +async function openCloudSession(hub: NodeHubClient, sessionId: string) { + const viewer = new HubCloudSessionClient(async () => { + await hub.connect(); + return hub; + }); + const unsubscribe = viewer.subscribe((event) => console.log(event)); + try { + await viewer.attach(sessionId); + return await viewer.readMessages(sessionId); + } finally { + unsubscribe(); + await viewer.dispose(); + } +} +``` + +The Hub owns the account-scoped controller, credentials, organization lookup, +provisioning, and sandbox connections. Disposing a viewer releases its subscription +and projected state; admitted cloud work and sandbox connections continue. The +host retains ownership of its shared `NodeHubClient`. Account changes retire the +old controller; Hub shutdown disposes it. Direct controller hosts instead own +and dispose their controller and its connections. + +The viewer supports the serializable operations in `CLOUD_HUB_OPERATIONS`, +with arguments/results described by `CloudHubArgs` and `CloudHubResult`. +`CloudHubScope` binds commands to the observed Hub instance and account generation; +callers use the viewer methods rather than constructing RPC envelopes. Snapshot +recovery includes empty sets, removes deleted sessions, and rejects stale revisions. +Dispatched actions are not automatically resent after an uncertain reply. +Handoff callbacks and the sixth `send` lifecycle argument are direct-controller +features and cannot cross this RPC boundary. Hub-backed creation defaults to +`"resumable"` sandboxes. + +After a successful organization switch, `refreshOrganization()` invalidates the +Hub's cached organization before fetching the current one. A fetch failure leaves +the old cache invalidated. Account-switch success and desktop cleanup must not +depend on this optional refresh; cloud-disabled hosts skip it. A new viewer's +watch also invalidates the cache, covering switches made while the Hub was +unreachable without interrupting existing cloud work. + ## Experimental cloud handoff `loadCloudModels` and `CloudSessionController.listModels()` provide eligible cloud diff --git a/sdk/packages/core/src/hub/server/cloud-session-service.test.ts b/sdk/packages/core/src/hub/server/cloud-session-service.test.ts index 3c6bcb1986..e41b0c9f6c 100644 --- a/sdk/packages/core/src/hub/server/cloud-session-service.test.ts +++ b/sdk/packages/core/src/hub/server/cloud-session-service.test.ts @@ -522,6 +522,42 @@ describe("cloud RPC over a shared hub socket", () => { }); describe("hub cloud credentials", () => { + it.each([ + "failed refresh", + "returning viewer", + ])("discards the previous organization after a %s", async (reason) => { + vi.spyOn( + ProviderSettingsManager.prototype, + "getProviderSettings", + ).mockReturnValue({ provider: "cline", apiKey: "key" }); + vi.spyOn( + RuntimeOAuthTokenManager.prototype, + "resolveProviderApiKey", + ).mockResolvedValue(null); + const fetch = vi + .spyOn(ClineAccountService.prototype, "fetchUserOrganizations") + .mockResolvedValueOnce([ + { organizationId: "org-one", active: true }, + ] as never); + const service = new HubCloudSessionService({ publish: () => {} }); + services.push(service); + service.watch(); + const controller = service["controller"]!; + const getOrganization = controller["options"].getActiveOrganizationId!; + expect(await getOrganization()).toBe("org-one"); + if (reason === "failed refresh") { + fetch.mockRejectedValueOnce(new Error("organization request failed")); + await expect(command(service, "refreshOrganization", [])).rejects.toThrow( + "organization request failed", + ); + } else service.watch(); + fetch.mockResolvedValueOnce([ + { organizationId: "org-two", active: true }, + ] as never); + expect(await getOrganization()).toBe("org-two"); + expect(service["controller"]).toBe(controller); + }); + it("refreshes the daemon organization cache through RPC without disposing cloud work", async () => { vi.spyOn( ProviderSettingsManager.prototype, diff --git a/sdk/packages/core/src/hub/server/cloud-session-service.ts b/sdk/packages/core/src/hub/server/cloud-session-service.ts index 3601eeec0a..2c40b3dbfe 100644 --- a/sdk/packages/core/src/hub/server/cloud-session-service.ts +++ b/sdk/packages/core/src/hub/server/cloud-session-service.ts @@ -116,6 +116,8 @@ export class HubCloudSessionService { private readonly serviceId = randomUUID(); private scopeGeneration = 0; private revision = 0; + private organization?: { id: string | undefined; at: number }; + private organizationGeneration = 0; private controller?: CloudSessionController; private scope?: CloudAccountScope; private closed = false; @@ -206,6 +208,7 @@ export class HubCloudSessionService { if (this.closed) throw new Error("Hub cloud service is closed"); this.checkScope(); if (this.controller) return this.controller; + this.invalidateOrganization(); this.scope = this.currentScope(); const controller = this.options.createController?.() ?? this.createController(); @@ -256,7 +259,6 @@ export class HubCloudSessionService { return token; }; const account = new ClineAccountService({ apiBaseUrl, getAuthToken }); - let organization: { id: string | undefined; at: number } | undefined; return new CloudSessionController({ api: new CloudSessionApi({ apiBaseUrl, @@ -274,16 +276,20 @@ export class HubCloudSessionService { }, lateCreateDisposition: "preserve", getActiveOrganizationId: async (options) => { + if (options?.fresh) this.invalidateOrganization(); + const organization = this.organization; if ( !options?.fresh && organization && Date.now() - organization.at < 60000 ) return organization.id; + const generation = this.organizationGeneration; if (!(await getAuthToken())) return undefined; const organizations = await account.fetchUserOrganizations(); const id = organizations?.find((org) => org.active)?.organizationId; - organization = { id, at: Date.now() }; + if (generation === this.organizationGeneration) + this.organization = { id, at: Date.now() }; return id; }, }); @@ -309,7 +315,13 @@ export class HubCloudSessionService { snapshots: [...this.snapshots.values()], }; } + private invalidateOrganization(): void { + this.organization = undefined; + this.organizationGeneration++; + } watch(): ReturnType { + // A returning viewer may have switched organization while the Hub was unreachable. + this.invalidateOrganization(); this.getController(); return this.snapshot(); } From f00ea3ec3251848286d16118cfe9cf0599048526 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 21:34:26 +0000 Subject: [PATCH 5/5] fix(core): treat in-flight cloud commands as busy when replacing the hub Hub replacement decided busy-ness from session.list, which never reported cloud work, so a newer install could retire the hub mid-provisioning and fail the pending create. session.list now reports activeCloudCommands, and the automatic retirement, explicit upgrade, and recovery checks count it. Co-authored-by: Saoud Rizwan --- .../core/src/hub/client/index.test.ts | 12 ++++ sdk/packages/core/src/hub/client/index.ts | 30 +++++--- .../core/src/hub/daemon/index.test.ts | 72 +++++++++++++++++-- sdk/packages/core/src/hub/daemon/index.ts | 13 ++-- .../hub/server/cloud-session-service.test.ts | 54 +++++++++++++- .../src/hub/server/hub-server-transport.ts | 1 + 6 files changed, 160 insertions(+), 22 deletions(-) diff --git a/sdk/packages/core/src/hub/client/index.test.ts b/sdk/packages/core/src/hub/client/index.test.ts index dda6320184..1fefd3222e 100644 --- a/sdk/packages/core/src/hub/client/index.test.ts +++ b/sdk/packages/core/src/hub/client/index.test.ts @@ -1556,6 +1556,18 @@ describe("hasActiveHubSessions", () => { ).toBe(false); }); + it("counts active cloud commands without attached sessions", async () => { + const { hasActiveHubSessions, summarizeHubSessionActivity } = await import( + "." + ); + expect(hasActiveHubSessions({ sessions: [], activeCloudCommands: 1 })).toBe( + true, + ); + expect( + summarizeHubSessionActivity({ sessions: [], activeCloudCommands: 2 }), + ).toMatchObject({ activeSessionCount: 0, activeCloudCommands: 2 }); + }); + const payload = (sessions: unknown[]) => ({ sessions }); it("is idle for an empty or malformed list", async () => { diff --git a/sdk/packages/core/src/hub/client/index.ts b/sdk/packages/core/src/hub/client/index.ts index b6ca1c5f52..baeda45bf3 100644 --- a/sdk/packages/core/src/hub/client/index.ts +++ b/sdk/packages/core/src/hub/client/index.ts @@ -1221,6 +1221,8 @@ export interface HubSessionActivity { participantClientCount: number; /** Plugin command requests executing even without an attached session. */ activePluginRequests: number; + /** Cloud session commands, such as provisioning, executing in the hub. */ + activeCloudCommands: number; } /** @@ -1267,24 +1269,30 @@ export function summarizeHubSessionActivity( } } } - const requests = - payload && typeof payload === "object" - ? (payload as { activePluginRequests?: unknown }).activePluginRequests - : undefined; - const activePluginRequests = - typeof requests === "number" && Number.isInteger(requests) && requests > 0 - ? requests + const count = (key: "activePluginRequests" | "activeCloudCommands") => { + const value = + payload && typeof payload === "object" + ? (payload as Record)[key] + : undefined; + return typeof value === "number" && Number.isInteger(value) && value > 0 + ? value : 0; + }; return { activeSessionCount, participantClientCount: clientIds.size, - activePluginRequests, + activePluginRequests: count("activePluginRequests"), + activeCloudCommands: count("activeCloudCommands"), }; } export function hasActiveHubSessions(payload: unknown): boolean { const activity = summarizeHubSessionActivity(payload); - return activity.activeSessionCount > 0 || activity.activePluginRequests > 0; + return ( + activity.activeSessionCount > 0 || + activity.activePluginRequests > 0 || + activity.activeCloudCommands > 0 + ); } /** @@ -1329,7 +1337,9 @@ export async function localHubHasNoActiveSessions( try { const activity = await queryHubSessionActivity(url, authToken, options); return ( - activity.activeSessionCount === 0 && activity.activePluginRequests === 0 + activity.activeSessionCount === 0 && + activity.activePluginRequests === 0 && + activity.activeCloudCommands === 0 ); } catch { // Recovery callers must not treat a hub they cannot positively observe diff --git a/sdk/packages/core/src/hub/daemon/index.test.ts b/sdk/packages/core/src/hub/daemon/index.test.ts index f87b7646f5..4c113f72ed 100644 --- a/sdk/packages/core/src/hub/daemon/index.test.ts +++ b/sdk/packages/core/src/hub/daemon/index.test.ts @@ -33,11 +33,18 @@ const { rememberRecoverableLocalHubUrl: vi.fn((url: string) => url), verifyHubConnection: vi.fn(), // Idle by default, so existing replacement cases are unaffected. - queryHubSessionActivity: vi.fn(async () => ({ - activeSessionCount: 0, - participantClientCount: 0, - activePluginRequests: 0, - })), + queryHubSessionActivity: vi.fn( + async (): Promise<{ + activeSessionCount: number; + participantClientCount: number; + activePluginRequests: number; + activeCloudCommands?: number; + }> => ({ + activeSessionCount: 0, + participantClientCount: 0, + activePluginRequests: 0, + }), + ), requestHubDrain: vi.fn(async () => true), resolveProductionHubOwnerContext: vi.fn(() => ({ discoveryPath: "/tmp/hub-discovery.json", @@ -554,6 +561,35 @@ describe("ensureDetachedHubServer", () => { } }); + it("attaches to an older hub that is provisioning a cloud session instead of retiring it", async () => { + queryHubSessionActivity.mockResolvedValue({ + activeSessionCount: 0, + participantClientCount: 0, + activePluginRequests: 0, + activeCloudCommands: 1, + }); + readHubDiscovery.mockResolvedValueOnce({ + url: "ws://127.0.0.1:25463/hub", + authToken: "busy-token", + }); + probeHubServer.mockResolvedValueOnce({ + url: "ws://127.0.0.1:25463/hub", + protocolVersion: "v1", + buildId: "old-build", + pid: 12345, + }); + verifyHubConnection.mockResolvedValue(true); + + const { ensureDetachedHubServer } = await import("."); + + await expect(ensureDetachedHubServer("/workspace")).resolves.toEqual({ + url: "ws://127.0.0.1:25463/hub", + authToken: "busy-token", + }); + expect(requestHubShutdown).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + }); + it("reuses a healthy hub from a newer build without retiring it", async () => { const kill = vi.spyOn(process, "kill").mockImplementation(() => true); try { @@ -1063,6 +1099,32 @@ describe("upgradeManagedHub", () => { expect(spawn).not.toHaveBeenCalled(); }); + it("leaves a hub provisioning a cloud session running when not forced", async () => { + queryHubSessionActivity.mockResolvedValue({ + activeSessionCount: 0, + participantClientCount: 0, + activePluginRequests: 0, + activeCloudCommands: 1, + }); + readHubDiscovery.mockResolvedValueOnce({ + url: "ws://127.0.0.1:25463/hub", + authToken: "old-token", + }); + probeHubServer.mockResolvedValueOnce({ + url: "ws://127.0.0.1:25463/hub", + protocolVersion: "v1", + buildId: "old-build", + buildEpochMs: 500, + }); + + const { upgradeManagedHub } = await import("."); + const result = await upgradeManagedHub({ waitForIdleMs: 0 }); + + expect(result).toMatchObject({ outcome: "still_busy" }); + expect(requestHubShutdown).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + }); + it("refuses to replace a hub running a newer build", async () => { readHubDiscovery.mockResolvedValueOnce({ url: "ws://127.0.0.1:25463/hub", diff --git a/sdk/packages/core/src/hub/daemon/index.ts b/sdk/packages/core/src/hub/daemon/index.ts index 7a2f2f35bf..ebf5797605 100644 --- a/sdk/packages/core/src/hub/daemon/index.ts +++ b/sdk/packages/core/src/hub/daemon/index.ts @@ -301,7 +301,8 @@ export async function hubHasLiveSessions( ); return ( activity.activeSessionCount > 0 || - (activity.activePluginRequests ?? 0) > 0 + (activity.activePluginRequests ?? 0) > 0 || + (activity.activeCloudCommands ?? 0) > 0 ); } catch { return false; @@ -968,7 +969,7 @@ export async function upgradeManagedHub( // Only an observed-zero reading ends the window before the deadline. // (Checks at least once so waitForIdleMs: 0 still observes an idle hub.) let observedSessionCount: number | undefined; - let observedPluginRequests: number | undefined; + let observedBackgroundRequests: number | undefined; for (;;) { try { const activity = await queryHubSessionActivity( @@ -976,8 +977,10 @@ export async function upgradeManagedHub( record.authToken, ); observedSessionCount = activity.activeSessionCount; - observedPluginRequests = activity.activePluginRequests ?? 0; - if (observedSessionCount === 0 && observedPluginRequests === 0) { + observedBackgroundRequests = + (activity.activePluginRequests ?? 0) + + (activity.activeCloudCommands ?? 0); + if (observedSessionCount === 0 && observedBackgroundRequests === 0) { break; } } catch { @@ -991,7 +994,7 @@ export async function upgradeManagedHub( ); } const confirmedIdle = - observedSessionCount === 0 && observedPluginRequests === 0; + observedSessionCount === 0 && observedBackgroundRequests === 0; // Without force, only a positively idle hub may be replaced: a busy or // unanswerable hub is handed back un-drained. With force, the user has // already consented to interrupting the sessions the prompt showed them, diff --git a/sdk/packages/core/src/hub/server/cloud-session-service.test.ts b/sdk/packages/core/src/hub/server/cloud-session-service.test.ts index e41b0c9f6c..a07f4f551a 100644 --- a/sdk/packages/core/src/hub/server/cloud-session-service.test.ts +++ b/sdk/packages/core/src/hub/server/cloud-session-service.test.ts @@ -436,8 +436,7 @@ describe("hub-owned cloud sessions", () => { }); describe("cloud RPC over a shared hub socket", () => { - it("hydrates another desktop client, preserves ownership on disconnect, and refuses new work while draining", async () => { - const f = fixture(); + async function startCloudHub(f: ReturnType) { const root = mkdtempSync(join(tmpdir(), "cline-cloud-hub-")); const server = await startHubWebSocketServer({ owner: createInMemoryHubOwnerContext("cloud-wire"), @@ -460,6 +459,57 @@ describe("cloud RPC over a shared hub socket", () => { runQueue: false, cloudSessionOptions: f.cloudSessionOptions, }); + return { root, server }; + } + + it("reports in-flight cloud commands as hub activity", async () => { + const f = fixture(); + const { root, server } = await startCloudHub(f); + const client = new NodeHubClient({ + url: server.url, + authToken: server.authToken, + clientId: "cloud-activity", + workspaceRoot: root, + }); + const viewer = new HubCloudSessionClient(async () => { + await client.connect(); + return client; + }); + try { + await viewer.connect(); + const provisioning = deferred<{ sessionId: string; status: string }>(); + f.controllers[0]!.controller.create.mockReturnValueOnce( + provisioning.promise, + ); + const created = viewer.create({ + modelId: "model", + repoUrl: "https://github.com/cline/cline", + }); + await vi.waitFor(() => + expect(f.controllers[0]!.controller.create).toHaveBeenCalled(), + ); + expect((await client.command("session.list")).payload).toMatchObject({ + activeCloudCommands: 1, + }); + expect((await client.command("hub.status")).payload).toMatchObject({ + idle: false, + }); + provisioning.resolve({ sessionId: "ses-one", status: "ready" }); + await created; + expect((await client.command("session.list")).payload).toMatchObject({ + activeCloudCommands: 0, + }); + } finally { + await viewer.dispose(); + await client.dispose(); + await server.close(); + rmSync(root, { recursive: true, force: true }); + } + }); + + it("hydrates another desktop client, preserves ownership on disconnect, and refuses new work while draining", async () => { + const f = fixture(); + const { root, server } = await startCloudHub(f); const first = new NodeHubClient({ url: server.url, authToken: server.authToken, diff --git a/sdk/packages/core/src/hub/server/hub-server-transport.ts b/sdk/packages/core/src/hub/server/hub-server-transport.ts index 223d44b342..4bb84da25e 100644 --- a/sdk/packages/core/src/hub/server/hub-server-transport.ts +++ b/sdk/packages/core/src/hub/server/hub-server-transport.ts @@ -943,6 +943,7 @@ export class HubServerTransport implements NativeHubTransport { payload: { ...reply.payload, activePluginRequests: this.pendingPluginCommands.size, + activeCloudCommands: this.cloud.activeCommands, }, }; }