fix(core): compare cloud account scope without hashing credentials

This commit is contained in:
abeatrix
2026-10-19 16:09:47 -07:00
parent 5fcc892f6d
commit 0abc68fbfd
2 changed files with 108 additions and 16 deletions
@@ -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<ProviderSettingsManager["getProviderSettings"]> = {
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<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
}> = [];
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();
});
});
@@ -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<string, CloudSessionSnapshot>();
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 });