Compare commits

...
Author SHA1 Message Date
Saoud Rizwan e1bdeeff68 docs(changelog): note Vertex SDK companion bump in 3.89.2 (#11459) 2026-06-11 04:10:30 -07:00
Saoud Rizwan 49897830bb fix(vscode): align Anthropic Vertex SDK with runtime SDK (#11458) 2026-06-11 04:07:51 -07:00
Saoud Rizwan 1f316a2734 fix(vscode): remove unused ClineStorageMessage import in openai-format (#11457)
The SDK 0.50.1 upgrade widened convertToOpenAiMessages to take
Anthropic.Messages.MessageParam[], which left the ClineStorageMessage
import referenced only in comments. tsc does not flag unused imports in
this config, but biome lint does, and it blocked the 3.89.2 publish.
2026-06-11 03:52:55 -07:00
Saoud Rizwan 9c1f9133c7 v3.89.2 Release Notes (#11455) 2026-06-11 03:47:24 -07:00
Saoud Rizwan 2faef2b40d fix(vscode): upgrade @anthropic-ai/sdk to 0.50.1 for Node 24 compatibility (#11454)
VS Code 1.123.0 bumped its bundled runtime from Node 22 to Node 24
(Electron 39 to 42). The Anthropic provider broke on the updated editor
because the old SDK (<=0.41.x) shipped a legacy runtime built on
node-fetch and an internal _shims layer that does not work under Node 24.

0.50.1 is the first SDK release rewritten on top of the platform's native
fetch: it has zero runtime dependencies (no node-fetch, no _shims), which
removes the incompatibility. This is the actual fix; the earlier 0.40.1
bump did not change the runtime architecture.

The 0.50.1 type changes are minimal:
- Usage gained a required server_tool_use field, so fabricated Usage
  objects in the gemini/o1/openai/vscode-lm transforms set it to null.
- ContentBlockParam widened, so Anthropic.MessageParam is no longer
  structurally assignable to ClineStorageMessage. Handled by narrowing
  the two transform helpers that only ever receive Cline history
  (sanitizeAnthropicMessages, convertAnthropicMessageToGemini), typing
  getSavedApiConversationHistory as the Cline history it reads, and
  narrowing ContextManager's loosely-typed truncated output back to
  ClineStorageMessage at the two provider/hook boundaries.
2026-06-11 03:43:33 -07:00
Saoud Rizwan 64829bca8c chore(vscode): release v3.89.1 (#11451) 2026-06-11 02:49:39 -07:00
Saoud Rizwan 4c9ba6b091 fix(vscode): restore Anthropic provider on Node 24 by bumping SDK (#11449)
VS Code 1.123.0 bumped its bundled runtime from Node 22 to Node 24
(Electron 39 to 42). The extension passes VS Code's globalThis.fetch to
every provider SDK, but @anthropic-ai/sdk was pinned at 0.37.0, which
predates the SDK's native-fetch rewrite and relies on legacy _shims
runtime detection that breaks under Node 24. The modern OpenAI and Gemini
SDKs are unaffected, which is why only the Anthropic provider broke after
users updated VS Code.

Bump to ^0.40.1, the first release with the native-fetch rewrite that
restores Node 24 compatibility, while staying short of the latest line's
larger breaking surface.

The only code change the bump requires is narrowing the image source type:
ImageBlockParam.source widened from a base64-only type to
Base64ImageSource | URLImageSource. Add a getBase64ImageSource/
getImageDataUrl helper in shared/messages/content.ts and route the
provider transforms through it. Cline only ever produces base64 image
sources, so behavior is unchanged; the helper emits the same data URL the
inline code did.
2026-06-11 02:44:25 -07:00
BeeandSaoud Rizwan 6138bdfe40 feat: Enforce a production singleton Cline Hub (#11372)
* feat: Enforce a production singleton Cline Hub

This PR changes local Hub startup/discovery so production uses one stable daemon per user machine instead of silently creating additional hubs on random ports.

Replace resolveSharedHubOwnerContext with resolveProductionHubOwnerContext
across doctor and hub server lifecycle management to scope hub discovery
to the production owner.

Additionally:
- Preserve and propagate auth tokens when retiring incompatible hubs
- Throw a clear error when a compatible hub is already running but its
  discovery record is missing, guiding users to run 'cline doctor fix'
- Gate port fallback behind an explicit allowPortFallback override
- Update tests to mock the new production hub owner context

* patches

* fix

* hasExplicitPort

* Restored daemon cron startup, made discovery auth tokens required again, and fixed graceful hub stop/restart paths to use the selected production/shared owner context.

* clean up

* patches

* fix Polynomial regular expression

* test

* fix: require explicit hub port fallback in production

* fix(cli): stop pgrep from parsing the hub daemon marker as an option

pgrep treats the "--cline-hub-daemon" pattern as an unknown long option
and exits 2, so doctor never found stale daemons from compiled-binary
installs, which are exactly the processes 'cline doctor fix' is told to
clean up. Pass "--" before the pattern to end option parsing.

* fix(hub): retire legacy shared-owner hubs on production startup

Pre-singleton production builds tracked the local hub under the shared
owner discovery path and spawned daemons on random fallback ports. The
production owner context never reads that path, so upgrades would leave
those daemons running indefinitely with no way to reuse or stop them.
Retire the recorded legacy hub (its record carries the auth token and
pid needed for a graceful stop) and clear the legacy record before
resolving the production hub.

* refactor(hub): simplify stale discovery clearing, share capability list

shouldClearStaleHubDiscovery was only ever called with
discoveredVerified=false (the true assignment sits on a return path),
so the expected-hub probe and compatibility check had no effect and the
condition reduced to "a discovery record exists and was not reused".
Replace it with a plain conditional and drop the tests that exercised
unreachable states.

Also move the hub capability list into a typed HUB_CAPABILITIES
constant in @cline/shared next to HubCapabilityName so the server
cannot drift from the type.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-10 16:59:13 -07:00
43 changed files with 1696 additions and 257 deletions
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [3.89.2]
### Fixed
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
## [3.89.1]
### Fixed
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
- Handle the DeepSeek V4 reasoning format.
## [3.89.0]
### Added
+24 -2
View File
@@ -14,6 +14,7 @@ import { getCliBuildInfo } from "../utils/common";
const {
mockSpawnSync,
mockResolveClineDataDir,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockReadHubDiscovery,
mockProbeHubServer,
@@ -24,6 +25,15 @@ const {
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"production.json",
),
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: path.join(
@@ -52,6 +62,7 @@ vi.mock("node:child_process", () => ({
vi.mock("@cline/core", () => ({
resolveClineDataDir: mockResolveClineDataDir,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
clearHubDiscovery: mockClearHubDiscovery,
probeHubServer: mockProbeHubServer,
@@ -76,6 +87,15 @@ describe("runDoctorCommand", () => {
afterEach(() => {
vi.clearAllMocks();
mockResolveClineDataDir.mockReturnValue("/tmp/cline-data");
mockResolveProductionHubOwnerContext.mockReturnValue({
ownerId: "hub-production",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"production.json",
),
});
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
@@ -110,7 +130,8 @@ describe("runDoctorCommand", () => {
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "/apps/cli/src/index.ts"
args[1] === "--" &&
args[2] === "/apps/cli/src/index.ts"
) {
return {
status: 0,
@@ -261,7 +282,8 @@ describe("runDoctorCommand", () => {
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "/src-tauri/bin/code-sidecar"
args[1] === "--" &&
args[2] === "/src-tauri/bin/code-sidecar"
) {
return {
status: 0,
+60 -9
View File
@@ -7,10 +7,11 @@ import {
probeHubServer,
readHubDiscovery,
resolveClineDataDir,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime } from "@cline/shared";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { isProcessRunning } from "../connectors/common";
@@ -54,6 +55,7 @@ type DoctorStatus = {
hubStartedAt?: string;
hubUptime?: string;
listeningPids: number[];
staleHubPids: number[];
hubStartupLocks: StartupArtifact[];
staleCliPids: number[];
staleSidecarPids: number[];
@@ -77,7 +79,11 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
if (process.platform === "win32") {
return [];
}
const result = spawnSync("pgrep", ["-fal", pattern], { encoding: "utf8" });
// "--" stops pgrep's option parsing so patterns that start with dashes
// (e.g. the "--cline-hub-daemon" marker) are treated as patterns.
const result = spawnSync("pgrep", ["-fal", "--", pattern], {
encoding: "utf8",
});
if (result.status !== 0 && result.status !== 1) {
return [];
}
@@ -148,6 +154,25 @@ function listStaleCliPids(): number[] {
.map((record) => record.pid);
}
function listStaleHubPids(currentHubPids: number[]): number[] {
const current = new Set(currentHubPids.filter((pid) => pid > 0));
const patterns = [
"/sdk/packages/core/src/hub/daemon/entry.ts",
"/sdk/packages/core/dist/hub/daemon/entry.js",
"--cline-hub-daemon",
];
const records = new Map<number, ProcessRecord>();
for (const pattern of patterns) {
for (const record of listMatchingProcesses(pattern)) {
if (current.has(record.pid) || /\bpgrep\s+-fal\b/.test(record.command)) {
continue;
}
records.set(record.pid, record);
}
}
return [...records.values()].map((record) => record.pid);
}
function listStaleSidecarPids(): number[] {
const patterns = [
"/apps/examples/desktop-app/sidecar/index.ts",
@@ -235,7 +260,7 @@ function readStartupArtifact(path: string): StartupArtifact | undefined {
}
function listHubStartupLocks(_cwd: string): StartupArtifact[] {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const ownerPath = join(`${owner.discoveryPath}.lock`, "owner.json");
if (!existsSync(ownerPath)) {
return [];
@@ -259,7 +284,7 @@ async function clearHubStartupArtifacts(
_cwd: string,
options?: { clearDiscovery?: boolean },
): Promise<{ startupLocks: number; discovery: number }> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const startupLocks = listHubStartupLocks(_cwd);
let clearedStartupLocks = 0;
for (const artifact of startupLocks) {
@@ -291,14 +316,25 @@ function formatHubUptimeFromStartedAt(
return formatUptime(Date.now() - timestamp);
}
function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url)
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
: undefined;
const current = health ?? discovery;
const hubUptime = formatHubUptimeFromStartedAt(health?.startedAt);
const listeningPids = listListeningPids(current?.port);
const currentHubPids = [
...(current?.pid ? [current.pid] : []),
...listeningPids,
];
return {
cwd,
hubUrl: current?.url,
@@ -306,7 +342,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
hubPid: current?.pid,
hubStartedAt: health?.startedAt,
hubUptime,
listeningPids: listListeningPids(current?.port),
listeningPids,
staleHubPids: listStaleHubPids(currentHubPids),
hubStartupLocks: listHubStartupLocks(cwd),
staleCliPids: listStaleCliPids(),
staleSidecarPids: listStaleSidecarPids(),
@@ -388,6 +425,7 @@ export async function runDoctorCommand(
);
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
writeln(formatPidList("hub listeners", before.listeningPids));
writeln(formatPidList("stale hub daemons", before.staleHubPids));
writeln(
formatPidList(
"hub startup locks",
@@ -412,6 +450,7 @@ export async function runDoctorCommand(
}
if (
before.listeningPids.length > 0 ||
before.staleHubPids.length > 0 ||
before.staleCliPids.length > 0 ||
before.staleSidecarPids.length > 0
) {
@@ -423,7 +462,9 @@ export async function runDoctorCommand(
}
const gracefullyStoppedHub = before.hubHealthy
? await stopLocalHubServerGracefully().catch(() => false)
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
() => false,
)
: false;
const refreshedAfterGracefulStop = gracefullyStoppedHub
? await collectDoctorStatus(opts.cwd)
@@ -431,13 +472,20 @@ export async function runDoctorCommand(
const killedHub = gracefullyStoppedHub
? 0
: killPids(refreshedAfterGracefulStop.listeningPids);
const staleCliTargets = before.staleCliPids.filter(
const staleHubTargets = before.staleHubPids.filter(
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
);
const killedStaleHubs = killPids(staleHubTargets);
const staleCliTargets = before.staleCliPids.filter(
(pid) =>
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
!staleHubTargets.includes(pid),
);
const killedCli = killPids(staleCliTargets);
const staleSidecarTargets = before.staleSidecarPids.filter(
(pid) =>
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
!staleHubTargets.includes(pid) &&
!staleCliTargets.includes(pid),
);
const killedSidecars = killPids(staleSidecarTargets);
@@ -459,6 +507,7 @@ export async function runDoctorCommand(
after,
killed: {
hubListeners: killedHub,
staleHubDaemons: killedStaleHubs,
cliProcesses: killedCli,
sidecarProcesses: killedSidecars,
connectorProcesses: stoppedConnectors.stoppedProcesses,
@@ -471,6 +520,7 @@ export async function runDoctorCommand(
return 0;
}
writeln(`killed hub listeners ${c.dim}${killedHub}${c.reset}`);
writeln(`killed stale hub daemons ${c.dim}${killedStaleHubs}${c.reset}`);
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
writeln(
@@ -487,6 +537,7 @@ export async function runDoctorCommand(
);
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
writeln(formatPidList("remaining hub listeners", after.listeningPids));
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
writeln(
formatPidList(
"remaining hub startup locks",
+51 -1
View File
@@ -1,10 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
const {
mockClearHubDiscovery,
mockEnsureDetachedHubServer,
mockProbeHubServer,
mockReadHubDiscovery,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStopLocalHubServerGracefully,
} = vi.hoisted(() => ({
@@ -12,6 +13,10 @@ const {
mockEnsureDetachedHubServer: vi.fn(),
mockProbeHubServer: vi.fn(),
mockReadHubDiscovery: vi.fn(),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
@@ -24,13 +29,25 @@ vi.mock("@cline/core", () => ({
ensureDetachedHubServer: mockEnsureDetachedHubServer,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
describe("createHubCommand", () => {
afterEach(() => {
vi.clearAllMocks();
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
});
it("includes uptime in hub status output", async () => {
vi.spyOn(Date, "now").mockReturnValue(
new Date("2026-01-01T00:01:05.000Z").getTime(),
@@ -73,4 +90,37 @@ describe("createHubCommand", () => {
uptime: "1m 5s",
});
});
it("passes the selected owner to graceful stop", async () => {
process.env.CLINE_BUILD_ENV = "development";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25466/hub",
port: 25466,
pid: 50174,
});
mockStopLocalHubServerGracefully.mockResolvedValue(true);
const output: string[] = [];
let exitCode = 0;
const cmd = createHubCommand(
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
(code) => {
exitCode = code;
},
);
await cmd.parseAsync(["stop"], { from: "user" });
expect(exitCode).toBe(0);
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
});
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
});
});
+14 -5
View File
@@ -3,10 +3,11 @@ import {
ensureDetachedHubServer,
probeHubServer,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime } from "@cline/shared";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
interface HubCommandIo {
@@ -15,9 +16,9 @@ interface HubCommandIo {
}
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (await stopLocalHubServerGracefully()) {
if (await stopLocalHubServerGracefully(owner)) {
await clearHubDiscovery(owner.discoveryPath);
return true;
}
@@ -46,6 +47,12 @@ function formatHubUptimeFromStartedAt(
return formatUptime(Date.now() - timestamp);
}
function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
export function createHubCommand(
io: HubCommandIo,
setExitCode: (code: number) => void,
@@ -112,10 +119,12 @@ export function createHubCommand(
hub.command("status").action(
action(async () => {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url)
? await probeHubServer(discovery.url, {
authToken: discovery.authToken,
})
: undefined;
const uptime = formatHubUptimeFromStartedAt(health?.startedAt);
io.writeln(
+67
View File
@@ -7,10 +7,14 @@ import {
checkForUpdates,
getInstallationInfo,
PackageManager,
resolveCliHubOwnerContext,
withMinimumReleaseAgeBypass,
} from "./update";
const originalArgv = [...process.argv];
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
const originalDataDir = process.env.CLINE_DATA_DIR;
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
const originalIsDev = process.env.IS_DEV;
@@ -32,6 +36,21 @@ function createTempFile(pathSuffix: string): string {
describe("getInstallationInfo", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
@@ -96,6 +115,21 @@ describe("getInstallationInfo", () => {
describe("auto update settings", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
@@ -153,6 +187,39 @@ describe("auto update settings", () => {
});
});
describe("hub restart owner selection", () => {
afterEach(() => {
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
});
it("uses the shared hub owner outside production builds", () => {
process.env.CLINE_BUILD_ENV = "development";
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
delete process.env.CLINE_HUB_DISCOVERY_PATH;
const owner = resolveCliHubOwnerContext();
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
expect(owner.discoveryPath).not.toBe(
"/tmp/cline-update-test-data/locks/hub/production.json",
);
});
});
describe("withMinimumReleaseAgeBypass", () => {
it("adds the package-manager-specific cooldown bypass", () => {
expect(
+20 -7
View File
@@ -5,9 +5,11 @@ import {
isAutoUpdateEnabledGlobally,
probeHubServer,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { resolveClineBuildEnv } from "@cline/shared";
import { version } from "../../package.json";
import { ensureCliHubServer } from "../utils/hub-runtime";
import { c, writeErr, writeln } from "../utils/output";
@@ -269,13 +271,22 @@ export function getPreferredKanbanInstaller(
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function waitForHubToStop(
url: string,
authToken: string | undefined,
timeoutMs: number,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const check = await probeHubServer(url).catch(() => undefined);
const check = await probeHubServer(url, { authToken }).catch(
() => undefined,
);
if (!check?.url) return true;
await sleep(100);
}
@@ -288,20 +299,22 @@ async function waitForHubToStop(
* clears stale discovery, then re-ensures a fresh instance is spawned.
*/
async function restartHubServerIfRunning(): Promise<void> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
() => undefined,
);
const health = discovery?.url
? await probeHubServer(discovery.url).catch(() => undefined)
? await probeHubServer(discovery.url, {
authToken: discovery.authToken,
}).catch(() => undefined)
: undefined;
if (!health?.url) return;
if (!discovery || !health?.url) return;
const pid = discovery?.pid;
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
let stopped = await stopLocalHubServerGracefully().catch(() => false);
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
if (!stopped && pid) {
try {
process.kill(pid, "SIGTERM");
@@ -310,14 +323,14 @@ async function restartHubServerIfRunning(): Promise<void> {
}
}
stopped = await waitForHubToStop(health.url, 3_000);
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
if (!stopped && pid) {
try {
process.kill(pid, "SIGKILL");
} catch {
// best-effort
}
stopped = await waitForHubToStop(health.url, 2_000);
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
}
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
+6 -2
View File
@@ -8,7 +8,7 @@ import {
Llms,
ProviderSettingsManager,
stopLocalHubServerGracefully,
toHubHealthUrl,
toHubStatusUrl,
} from "@cline/core";
import type { HubUINotifyPayload, SessionRecord } from "@cline/shared";
@@ -460,7 +460,11 @@ async function main(): Promise<void> {
const syncHealthState = async (): Promise<void> => {
try {
const response = await fetch(toHubHealthUrl(hubUrl));
const response = await fetch(toHubStatusUrl(hubUrl), {
headers: hubAuthToken
? { authorization: `Bearer ${hubAuthToken}` }
: undefined,
});
if (!response.ok) {
return;
}
+6 -2
View File
@@ -701,7 +701,9 @@ class CoreChatWebviewController implements vscode.Disposable {
const owner = resolveSharedHubOwnerContext();
if (this.hubUrl) {
const healthy = await probeHubServer(this.hubUrl);
const healthy = await probeHubServer(this.hubUrl, {
authToken: this.hubAuthToken,
});
if (healthy?.url) {
return {
url: rememberRecoverableLocalHubUrl(healthy.url, this.hubAuthToken),
@@ -733,7 +735,9 @@ class CoreChatWebviewController implements vscode.Disposable {
): Promise<HubResolution | undefined> {
const discovery = await readHubDiscovery(discoveryPath);
if (!discovery?.url) return undefined;
const healthy = await probeHubServer(discovery.url);
const healthy = await probeHubServer(discovery.url, {
authToken: discovery.authToken,
});
return healthy?.url
? {
url: rememberRecoverableLocalHubUrl(healthy.url, discovery.authToken),
+16 -34
View File
@@ -1,16 +1,16 @@
{
"name": "claude-dev",
"version": "3.89.0",
"version": "3.89.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.89.0",
"version": "3.89.2",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@anthropic-ai/sdk": "^0.50.4",
"@anthropic-ai/vertex-sdk": "^0.11.5",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
@@ -156,42 +156,24 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
"version": "0.50.4",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.50.4.tgz",
"integrity": "sha512-zZOWyIuznx2uqiRcCNuidkAsLC8IBHgS9lTwSVEB29sUCwEcqL95MWpWP1nccHSKfiZga+hbZaI0btR2zjuMmw==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/@anthropic-ai/vertex-sdk": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.6.4.tgz",
"integrity": "sha512-rMBlO2jF53TfMRmsQMm1bPO2JRUh4jYddjq/OJLj8DSAkfbCrNWhc0yhDed6oLYJg5s+VpDbvlPzMggqHhTfMw==",
"version": "0.11.5",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.11.5.tgz",
"integrity": "sha512-V7sB5nY80unEQu8lSQaEzh1WhYwpIdpC3iXNRHUskghkuQDhS6dQu2ASZBgA5MNuJ1Yv5PhY61NM15dLaQhPQw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": ">=0.35 <1",
"@anthropic-ai/sdk": ">=0.50.3 <1",
"google-auth-library": "^9.4.2"
}
},
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.89.0",
"version": "3.89.2",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -486,8 +486,8 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@anthropic-ai/sdk": "^0.50.4",
"@anthropic-ai/vertex-sdk": "^0.11.5",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
@@ -11,7 +11,7 @@ import axios from "axios"
import JSON5 from "json5"
import OpenAI from "openai"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { ClineStorageMessage } from "@/shared/messages/content"
import { ClineStorageMessage, getBase64ImageSource } from "@/shared/messages/content"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from "../"
@@ -302,10 +302,11 @@ namespace Gemini {
if (block.type === "text") {
parts.push({ text: block.text })
} else if (block.type === "image") {
const { mediaType, data } = getBase64ImageSource(block.source)
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data,
mimeType: mediaType,
data,
},
})
}
@@ -12,7 +12,7 @@ import { ClineStorageMessage, convertClineStorageToAnthropicMessage } from "@/sh
* @returns Array of Anthropic-compatible messages with cache control applied
*/
export function sanitizeAnthropicMessages(
clineMessages: Array<ClineStorageMessage | Anthropic.MessageParam>,
clineMessages: ClineStorageMessage[],
supportCache: boolean,
): Array<Anthropic.MessageParam> {
// The latest message will be the new user message, one before will be the assistant message from a previous request,
@@ -60,7 +60,7 @@ export function convertAnthropicContentToGemini(content: string | ClineStorageMe
.filter((part): part is Part => part !== undefined) // Filter out unsupported blocks
}
export function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam): Content {
export function convertAnthropicMessageToGemini(message: ClineStorageMessage): Content {
return {
role: message.role === "assistant" ? "model" : "user",
parts: convertAnthropicContentToGemini(message.content),
@@ -113,6 +113,7 @@ export function convertGeminiResponseToAnthropic(response: GenerateContentRespon
output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
}
}
@@ -3,6 +3,7 @@ import { AssistantMessage } from "@mistralai/mistralai/models/components/assista
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
import { UserMessage } from "@mistralai/mistralai/models/components/usermessage"
import { getImageDataUrl } from "@/shared/messages/content"
export type MistralMessage =
| (SystemMessage & { role: "system" })
@@ -33,7 +34,7 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
return {
type: "image_url",
imageUrl: {
url: `data:${part.source.media_type};base64,${part.source.data}`,
url: getImageDataUrl(part.source),
},
}
}
@@ -400,6 +400,7 @@ export function convertO1ResponseToAnthropicMessage(
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
}
@@ -5,6 +5,7 @@ import {
ClineStorageMessage,
ClineTextContentBlock,
ClineUserToolResultContentBlock,
getImageDataUrl,
} from "@/shared/messages/content"
export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[]): Message[] {
@@ -46,7 +47,7 @@ export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMess
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(`data:${part.source.media_type};base64,${part.source.data}`)
toolResultImages.push(getImageDataUrl(part.source))
return "(see following user message for image)"
}
return part.text
@@ -67,7 +68,7 @@ export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMess
content: nonToolMessages
.map((part) => {
if (part.type === "image") {
return `data:${part.source.media_type};base64,${part.source.data}`
return getImageDataUrl(part.source)
}
return part.text
})
@@ -6,9 +6,9 @@ import {
ClineAssistantThinkingBlock,
ClineAssistantToolUseBlock,
ClineImageContentBlock,
ClineStorageMessage,
ClineTextContentBlock,
ClineUserToolResultContentBlock,
getImageDataUrl,
} from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
@@ -65,7 +65,7 @@ function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider)
* @returns Array of OpenAI.Chat.ChatCompletionMessageParam objects
*/
export function convertToOpenAiMessages(
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
anthropicMessages: Anthropic.Messages.MessageParam[],
provider?: ApiProvider,
): OpenAI.Chat.ChatCompletionMessageParam[] {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
@@ -144,7 +144,7 @@ export function convertToOpenAiMessages(
role: "user",
content: toolResultImages.map((part) => ({
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
image_url: { url: getImageDataUrl(part.source) },
})),
})
}
@@ -158,7 +158,7 @@ export function convertToOpenAiMessages(
return {
type: "image_url",
image_url: {
url: `data:${part.source.media_type};base64,${part.source.data}`,
url: getImageDataUrl(part.source),
},
}
}
@@ -421,6 +421,7 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
}
try {
@@ -1,5 +1,5 @@
import { ResponseInput, ResponseInputMessageContentList, ResponseReasoningItem } from "openai/resources/responses/responses"
import { ClineStorageMessage } from "@/shared/messages/content"
import { ClineStorageMessage, getBase64ImageSource, getImageDataUrl } from "@/shared/messages/content"
/**
* Converts an array of ClineStorageMessage objects (extension of Anthropic format) to a ResponseInput array to use with OpenAI's Responses API.
@@ -177,7 +177,7 @@ export function convertToOpenAIResponsesInput(
const imageItem: any = {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: `[image:${part.source.media_type}]` }],
content: [{ type: "output_text", text: `[image:${getBase64ImageSource(part.source).mediaType}]` }],
}
// Set message-level id if available (though images typically don't have call_id)
if (part.call_id) {
@@ -218,7 +218,7 @@ export function convertToOpenAIResponsesInput(
messageContent.push({
type: "input_image",
detail: "auto",
image_url: `data:${part.source.media_type};base64,${part.source.data}`,
image_url: getImageDataUrl(part.source),
})
break
case "tool_result": {
@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ClineAssistantThinkingBlock, ClineStorageMessage } from "@/shared/messages/content"
import { ClineAssistantThinkingBlock, ClineStorageMessage, getImageDataUrl } from "@/shared/messages/content"
/**
* DeepSeek Reasoner message format with reasoning_content support.
@@ -87,7 +87,7 @@ export function convertToR1Format(messages: Anthropic.Messages.MessageParam[]):
hasImages = true
imageParts.push({
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
image_url: { url: getImageDataUrl(part.source) },
})
}
})
@@ -74,7 +74,7 @@ export function convertToVsCodeLmMessages(
: (toolMessage.content?.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
)
}
return new vscode.LanguageModelTextPart(part.text)
@@ -87,7 +87,7 @@ export function convertToVsCodeLmMessages(
...nonToolMessages.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
)
}
return new vscode.LanguageModelTextPart(part.text)
@@ -199,6 +199,7 @@ export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelC
output_tokens: 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
}
}
@@ -192,11 +192,13 @@ export async function executePreCompactHookWithCleanup(params: PreCompactHookPar
let contextRawPath: string | undefined
try {
// Get current active context (respects previous compactions)
// Get current active context (respects previous compactions).
// getTruncatedMessages types its output as Anthropic.MessageParam[], but it slices the Cline-stored
// conversation history (ClineStorageMessage[]) passed in, so narrow it back here.
const currentContext = params.contextManager.getTruncatedMessages(
params.apiConversationHistory,
params.conversationHistoryDeletedRange,
)
) as ClineStorageMessage[]
// Write context files for hook access
const contextFiles = await writePreCompactContextFiles(params.taskId, currentContext)
+2 -1
View File
@@ -13,6 +13,7 @@ import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
import { telemetryService } from "@/services/telemetry"
import { McpMarketplaceCatalog } from "@/shared/mcp"
import { ClineStorageMessage } from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
import { syncWorker } from "@/shared/services/worker/sync"
import { reconstructTaskHistory } from "../commands/reconstructTaskHistory"
@@ -233,7 +234,7 @@ export async function getMcpSettingsFilePath(settingsDirectoryPath: string): Pro
return mcpSettingsFilePath
}
export async function getSavedApiConversationHistory(taskId: string): Promise<Anthropic.MessageParam[]> {
export async function getSavedApiConversationHistory(taskId: string): Promise<ClineStorageMessage[]> {
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.apiConversationHistory)
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
+4 -1
View File
@@ -2018,7 +2018,10 @@ export class Task {
}
// Response API requires native tool calls to be enabled
const stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory, tools)
// ContextManager types its truncated output as Anthropic.MessageParam[], but the history it slices is the
// Cline-stored conversation history (ClineStorageMessage[]), so narrow it back for the provider boundary.
const truncatedConversationHistory = contextManagementMetadata.truncatedConversationHistory as ClineStorageMessage[]
const stream = this.api.createMessage(systemPrompt, truncatedConversationHistory, tools)
const iterator = stream[Symbol.asyncIterator]()
@@ -16,7 +16,7 @@ export function filterMessagesForClaudeCode(messages: Anthropic.Messages.Message
if (block.type === "image") {
// Replace image blocks with text placeholders
const sourceType = block.source?.type || "unknown"
const mediaType = block.source?.media_type || "unknown"
const mediaType = (block.source?.type === "base64" && block.source.media_type) || "unknown"
return {
type: "text" as const,
text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`,
@@ -131,6 +131,27 @@ export function convertClineStorageToAnthropicMessage(
return { role, content: cleanedContent }
}
/**
* Cline stores images as base64, so an image block's source is always a base64 source.
* The Anthropic SDK types the source as a Base64ImageSource | URLImageSource union, so this
* narrows to the base64 variant for the transform layer. URL sources are not produced by Cline,
* so they degrade to empty values rather than throwing.
*/
export function getBase64ImageSource(source: Anthropic.ImageBlockParam["source"]): { mediaType: string; data: string } {
if (source.type === "base64") {
return { mediaType: source.media_type, data: source.data }
}
return { mediaType: "", data: "" }
}
/**
* Builds a base64 data URL from an image block's source. See getBase64ImageSource.
*/
export function getImageDataUrl(source: Anthropic.ImageBlockParam["source"]): string {
const { mediaType, data } = getBase64ImageSource(source)
return `data:${mediaType};base64,${data}`
}
/**
* Clean a content block by removing Cline-specific fields and returning only Anthropic-compatible fields
*/
@@ -46,6 +46,35 @@ describe("resolveHubUrl", () => {
await expect(resolveHubUrl()).resolves.toBe("ws://127.0.0.1:25463/hub");
});
it("uses the shared discovery owner in development builds", async () => {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
process.env.CLINE_DATA_DIR = "/tmp/cline-connect-test-data";
process.env.CLINE_BUILD_ENV = "development";
const readHubDiscovery = vi
.spyOn(await import("../discovery"), "readHubDiscovery")
.mockResolvedValue({
hubId: "hub-test",
protocolVersion: "v1",
authToken: "test-token",
host: "127.0.0.1",
port: 25466,
url: "ws://127.0.0.1:25466/hub",
startedAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
});
await expect(resolveHubUrl()).resolves.toBe("ws://127.0.0.1:25466/hub");
const discoveryPath = readHubDiscovery.mock.calls[0]?.[0].replaceAll(
"\\",
"/",
);
expect(discoveryPath).toContain("/locks/hub/owners/");
expect(discoveryPath).not.toBe(
"/tmp/cline-connect-test-data/locks/hub/production.json",
);
});
it("falls back to the default endpoint when no discovery file exists", async () => {
process.env.CLINE_HUB_DISCOVERY_PATH = "/tmp/missing-hub-discovery.json";
vi.spyOn(
+13 -3
View File
@@ -3,12 +3,16 @@ import type {
HubReplyEnvelope,
HubTransportFrame,
} from "@cline/shared";
import { resolveClineBuildEnv } from "@cline/shared";
import { createHubServerUrl, readHubDiscovery } from "../discovery";
import {
type HubEndpointOverrides,
resolveHubEndpointOptions,
} from "../discovery/defaults";
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
export interface HubConnection {
send(envelope: HubCommandEnvelope): Promise<HubReplyEnvelope>;
@@ -68,13 +72,19 @@ function sameHubEndpoint(left: string, right: string): boolean {
return leftUrl.toString() === rightUrl.toString();
}
function resolveDefaultHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function resolveHubUrlAuthToken(url: URL): Promise<string | undefined> {
const queryToken = url.searchParams.get("authToken")?.trim();
url.searchParams.delete("authToken");
if (queryToken) {
return queryToken;
}
const owner = resolveSharedHubOwnerContext();
const owner = resolveDefaultHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (discovery?.url && sameHubEndpoint(url.toString(), discovery.url)) {
return discovery.authToken;
@@ -87,7 +97,7 @@ export async function resolveHubUrl(
): Promise<string> {
const endpoint = resolveHubEndpointOptions(overrides);
if (!hasExplicitEndpoint(overrides)) {
const owner = resolveSharedHubOwnerContext();
const owner = resolveDefaultHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (discovery?.url) {
return discovery.url;
+151 -5
View File
@@ -546,6 +546,10 @@ describe("NodeHubClient", () => {
spawnDetachedHubServerWithRetry: vi.fn(async () => undefined),
}));
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery-recovery.json",
@@ -697,6 +701,10 @@ describe("NodeHubClient", () => {
spawnDetachedHubServerWithRetry: vi.fn(async () => undefined),
}));
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery-explicit.json",
@@ -764,6 +772,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
it("does not clear discovery on transient probe failure", async () => {
const clearHubDiscoveryMock = vi.fn();
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
@@ -798,9 +810,13 @@ describe("resolveCompatibleLocalHubUrl", () => {
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
});
it("clears discovery on build mismatch", async () => {
it("keeps discovery on build mismatch when protocol is compatible", async () => {
const clearHubDiscoveryMock = vi.fn();
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
@@ -840,15 +856,19 @@ describe("resolveCompatibleLocalHubUrl", () => {
const { resolveCompatibleLocalHubUrl } = await import(".");
await expect(resolveCompatibleLocalHubUrl()).resolves.toBeUndefined();
expect(clearHubDiscoveryMock).toHaveBeenCalledWith(
"/tmp/hub-discovery.json",
await expect(resolveCompatibleLocalHubUrl()).resolves.toBe(
"ws://127.0.0.1:59999/hub",
);
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
});
it("clears discovery when a hub omits build metadata", async () => {
it("keeps discovery when a hub omits build metadata but has compatible protocol", async () => {
const clearHubDiscoveryMock = vi.fn();
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
@@ -886,6 +906,57 @@ describe("resolveCompatibleLocalHubUrl", () => {
const { resolveCompatibleLocalHubUrl } = await import(".");
await expect(resolveCompatibleLocalHubUrl()).resolves.toBe(
"ws://127.0.0.1:59999/hub",
);
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
});
it("clears discovery on protocol mismatch", async () => {
const clearHubDiscoveryMock = vi.fn();
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
}));
vi.doMock("../discovery", async () => {
const actual =
await vi.importActual<typeof import("../discovery")>("../discovery");
return {
...actual,
readHubDiscovery: vi.fn(async () => ({
hubId: "hub-test",
protocolVersion: "v0",
buildId: "old-build",
host: "127.0.0.1",
port: 59999,
url: "ws://127.0.0.1:59999/hub",
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})),
clearHubDiscovery: vi.fn(async (...args: unknown[]) => {
clearHubDiscoveryMock(...args);
}),
probeHubServer: vi.fn(async () => ({
hubId: "hub-test",
protocolVersion: "v0",
buildId: "old-build",
host: "127.0.0.1",
port: 59999,
url: "ws://127.0.0.1:59999/hub",
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})),
};
});
const { resolveCompatibleLocalHubUrl } = await import(".");
await expect(resolveCompatibleLocalHubUrl()).resolves.toBeUndefined();
expect(clearHubDiscoveryMock).toHaveBeenCalledWith(
"/tmp/hub-discovery.json",
@@ -914,6 +985,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
spawnDetachedHubServerWithRetry: spawnDetachedHubServerWithRetryMock,
}));
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
@@ -950,6 +1025,73 @@ describe("resolveCompatibleLocalHubUrl", () => {
).toBeLessThan(readHubDiscoveryMock.mock.invocationCallOrder[1]);
});
it("waits on shared discovery after spawning in development builds", async () => {
vi.stubGlobal("WebSocket", MockWebSocket);
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
process.env.CLINE_BUILD_ENV = "development";
const spawnDetachedHubServerWithRetryMock = vi.fn(async () => undefined);
const record = {
hubId: "hub-test",
protocolVersion: "v1",
buildId: "test-build",
authToken: "token",
host: "127.0.0.1",
port: 25466,
url: "ws://127.0.0.1:25466/hub",
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const readHubDiscoveryMock = vi.fn(async (path: string) =>
path === "/tmp/shared-hub-discovery.json" ? record : undefined,
);
vi.doMock("../daemon", () => ({
spawnDetachedHubServerWithRetry: spawnDetachedHubServerWithRetryMock,
}));
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "production",
discoveryPath: "/tmp/production-hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "shared",
discoveryPath: "/tmp/shared-hub-discovery.json",
}),
}));
vi.doMock("../discovery", async () => {
const actual =
await vi.importActual<typeof import("../discovery")>("../discovery");
return {
...actual,
readHubDiscovery: readHubDiscoveryMock,
probeHubServer: vi.fn(async () => record),
clearHubDiscovery: vi.fn(async () => undefined),
};
});
try {
const { ensureCompatibleLocalHubUrl } = await import(".");
await expect(
ensureCompatibleLocalHubUrl({
workspaceRoot: "/tmp/project",
cwd: "/tmp/project",
}),
).resolves.toBe("ws://127.0.0.1:25466/hub");
expect(readHubDiscoveryMock).toHaveBeenCalledWith(
"/tmp/shared-hub-discovery.json",
);
expect(readHubDiscoveryMock).not.toHaveBeenCalledWith(
"/tmp/production-hub-discovery.json",
);
} finally {
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
}
});
it("does not restart explicit local endpoints after startup timeout", async () => {
const readHubDiscoveryMock = vi.fn(async () => ({
hubId: "hub-test",
@@ -963,6 +1105,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
updatedAt: new Date().toISOString(),
}));
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
+28 -15
View File
@@ -5,6 +5,8 @@ import {
type HubEventEnvelope,
type HubReplyEnvelope,
type HubTransportFrame,
isHubProtocolCompatible,
resolveClineBuildEnv,
resolveHubCommandTimeoutMs,
} from "@cline/shared";
import {
@@ -17,9 +19,11 @@ import {
type HubOwnerContext,
probeHubServer,
readHubDiscovery,
resolveHubBuildId,
} from "../discovery";
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
type PendingReply = {
resolve: (reply: HubReplyEnvelope) => void;
@@ -31,6 +35,12 @@ type SubscriptionEntry = {
sessionId?: string;
};
function resolveDefaultHubOwnerContext(): HubOwnerContext {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
type WebSocketLike = {
readyState: number;
send(data: string): void;
@@ -821,7 +831,7 @@ type HubProbeResult =
url: string;
}
| {
status: "unreachable" | "build_mismatch";
status: "unreachable" | "protocol_mismatch";
url: string;
};
@@ -835,18 +845,18 @@ async function probeCompatibleHubUrl(
},
): Promise<HubProbeResult> {
const normalized = normalizeHubWebSocketUrl(url);
const record = await probeHubServer(normalized);
const record = await probeHubServer(normalized, {
authToken: options?.authToken,
});
if (!record) {
return {
status: "unreachable",
url: normalized,
};
}
const buildId = resolveHubBuildId();
const recordBuildId = record.buildId?.trim();
if (!recordBuildId || recordBuildId !== buildId) {
if (!isHubProtocolCompatible(record).compatible) {
return {
status: "build_mismatch",
status: "protocol_mismatch",
url: normalized,
};
}
@@ -973,16 +983,18 @@ export async function resolveCompatibleLocalHubUrl(
return compatible.status === "compatible" ? compatible.url : undefined;
}
const owner = resolveSharedHubOwnerContext();
const owner = resolveDefaultHubOwnerContext();
const record = await readHubDiscovery(owner.discoveryPath);
if (!record?.url) {
return undefined;
}
const compatible = await probeCompatibleHubUrl(record.url);
const compatible = await probeCompatibleHubUrl(record.url, {
authToken: record.authToken,
});
if (compatible.status === "compatible") {
return rememberRecoverableLocalHubUrl(compatible.url, record.authToken);
}
if (compatible.status === "build_mismatch") {
if (compatible.status === "protocol_mismatch") {
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
}
return undefined;
@@ -1004,7 +1016,7 @@ export async function ensureCompatibleLocalHubUrl(
if (options.endpoint?.trim()) {
return undefined;
}
const owner = resolveSharedHubOwnerContext();
const owner = resolveDefaultHubOwnerContext();
await spawnDetachedHubServerWithRetry(options.workspaceRoot ?? process.cwd());
return await waitForCompatibleHubUrl(owner);
}
@@ -1032,8 +1044,9 @@ export async function requestHubShutdown(
return response.ok;
}
export async function stopLocalHubServerGracefully(): Promise<boolean> {
const owner = resolveSharedHubOwnerContext();
export async function stopLocalHubServerGracefully(
owner: HubOwnerContext = resolveDefaultHubOwnerContext(),
): Promise<boolean> {
const discovery = await readHubDiscovery(owner.discoveryPath);
if (!discovery?.url) {
return false;
@@ -1060,7 +1073,7 @@ export async function restartLocalHubIfIdleAfterStartupTimeout(options: {
if (!isRecoverableLocalHubUrl(options.url)) {
return undefined;
}
const owner = resolveSharedHubOwnerContext();
const owner = resolveDefaultHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (!discovery?.url || !sameNormalizedHubUrl(discovery.url, options.url)) {
return undefined;
@@ -0,0 +1,119 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const {
mockCreateLocalHubScheduleRuntimeHandlers,
mockInitVcr,
mockResolveHubEndpointOptions,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStartHubWebSocketServer,
} = vi.hoisted(() => ({
mockCreateLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
startSession: vi.fn(),
sendSession: vi.fn(),
stopSession: vi.fn(),
abortSession: vi.fn(),
})),
mockInitVcr: vi.fn(),
mockResolveHubEndpointOptions: vi.fn(
(options: { host?: string; port?: number; pathname?: string }) => ({
host: options.host ?? "127.0.0.1",
port: options.port ?? 25463,
pathname: options.pathname ?? "/hub",
}),
),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "shared",
discoveryPath: "/tmp/cline-data/locks/hub/owners/shared.json",
})),
mockStartHubWebSocketServer: vi.fn(async () => ({
close: vi.fn(async () => undefined),
})),
}));
vi.mock("@cline/shared", () => ({
initVcr: mockInitVcr,
resolveClineBuildEnv: () => "production",
}));
vi.mock("../daemon/runtime-handlers", () => ({
createLocalHubScheduleRuntimeHandlers:
mockCreateLocalHubScheduleRuntimeHandlers,
}));
vi.mock("../discovery/defaults", () => ({
resolveHubEndpointOptions: mockResolveHubEndpointOptions,
}));
vi.mock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
}));
vi.mock("../server", () => ({
startHubWebSocketServer: mockStartHubWebSocketServer,
}));
const originalArgv = [...process.argv];
const originalCwd = process.cwd();
describe("hub daemon entry", () => {
const tempDirs: string[] = [];
afterEach(() => {
process.argv = [...originalArgv];
process.chdir(originalCwd);
vi.restoreAllMocks();
vi.resetModules();
mockCreateLocalHubScheduleRuntimeHandlers.mockClear();
mockInitVcr.mockClear();
mockResolveHubEndpointOptions.mockClear();
mockResolveProductionHubOwnerContext.mockClear();
mockResolveSharedHubOwnerContext.mockClear();
mockStartHubWebSocketServer.mockClear();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("starts the daemon with cron options for the daemon workspace root", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
tempDirs.push(cwd);
process.argv = [
"node",
"entry.js",
"--cwd",
cwd,
"--host",
"127.0.0.1",
"--port",
"30000",
"--pathname",
"/hub",
];
vi.spyOn(process, "on").mockImplementation(() => process);
await import("./entry");
await vi.waitFor(() => {
expect(mockStartHubWebSocketServer).toHaveBeenCalled();
});
expect(mockStartHubWebSocketServer).toHaveBeenCalledWith(
expect.objectContaining({
host: "127.0.0.1",
port: 30000,
pathname: "/hub",
owner: expect.objectContaining({ ownerId: "production" }),
cronOptions: { workspaceRoot: cwd },
}),
);
expect(mockCreateLocalHubScheduleRuntimeHandlers).toHaveBeenCalledOnce();
});
});
+9 -3
View File
@@ -1,8 +1,11 @@
import { AgentRuntimeAbortError } from "@cline/agents";
import { initVcr } from "@cline/shared";
import { initVcr, resolveClineBuildEnv } from "@cline/shared";
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
import { resolveHubEndpointOptions } from "../discovery/defaults";
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
import { startHubWebSocketServer } from "../server";
initVcr(process.env.CLINE_VCR);
@@ -62,7 +65,10 @@ async function main(): Promise<void> {
host: endpoint.host,
port: endpoint.port,
pathname: endpoint.pathname,
owner: resolveSharedHubOwnerContext(),
owner:
resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext(),
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
cronOptions: { workspaceRoot: options.cwd },
});
+279 -55
View File
@@ -7,6 +7,7 @@ const {
openSync,
rememberRecoverableLocalHubUrl,
verifyHubConnection,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
createHubServerUrl,
clearHubDiscovery,
@@ -24,6 +25,9 @@ const {
openSync: vi.fn(() => 17),
rememberRecoverableLocalHubUrl: vi.fn((url: string) => url),
verifyHubConnection: vi.fn(),
resolveProductionHubOwnerContext: vi.fn(() => ({
discoveryPath: "/tmp/hub-discovery.json",
})),
resolveSharedHubOwnerContext: vi.fn(() => ({
discoveryPath: "/tmp/hub-discovery.json",
})),
@@ -57,6 +61,9 @@ vi.mock("@cline/shared", () => ({
CLINE_RUN_AS_HUB_DAEMON_ENV,
CLINE_HUB_PORT: 25463,
CLINE_HUB_DEV_PORT: 25466,
isHubProtocolCompatible: (record: { protocolVersion?: string }) => ({
compatible: record.protocolVersion === "v1",
}),
isHubDaemonProcess: (env: NodeJS.ProcessEnv = process.env) =>
env[CLINE_RUN_AS_HUB_DAEMON_ENV] === "1",
resolveClineBuildEnv: () => "production",
@@ -70,6 +77,7 @@ vi.mock("../client", () => ({
}));
vi.mock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
}));
@@ -88,6 +96,21 @@ describe("ensureDetachedHubServer", () => {
beforeEach(() => {
delete process.env[CLINE_RUN_AS_HUB_DAEMON_ENV];
spawn.mockReset();
spawn.mockImplementation(() => ({ unref: vi.fn() }));
closeSync.mockReset();
mkdirSync.mockReset();
openSync.mockReset();
openSync.mockImplementation(() => 17);
rememberRecoverableLocalHubUrl.mockReset();
rememberRecoverableLocalHubUrl.mockImplementation((url: string) => url);
verifyHubConnection.mockReset();
clearHubDiscovery.mockReset();
clearHubDiscovery.mockResolvedValue(undefined);
probeHubServer.mockReset();
requestHubShutdown.mockReset();
requestHubShutdown.mockResolvedValue(true);
readHubDiscovery.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
@@ -101,20 +124,16 @@ describe("ensureDetachedHubServer", () => {
}
});
it("lets the daemon bind port 0 when the configured endpoint is occupied", async () => {
it("does not use port 0 for default production startup", async () => {
readHubDiscovery.mockResolvedValue(undefined);
probeHubServer
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
buildId: "current-build",
})
.mockResolvedValueOnce({
url: "ws://127.0.0.1:5555/hub",
buildId: "current-build",
});
probeHubServer.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v1",
buildId: "current-build",
});
verifyHubConnection.mockResolvedValueOnce(true);
readHubDiscovery.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
url: "ws://127.0.0.1:5555/hub",
url: "ws://127.0.0.1:25463/hub",
buildId: "current-build",
authToken: "new-token",
});
@@ -129,12 +148,13 @@ describe("ensureDetachedHubServer", () => {
| undefined;
expect(result).toEqual({
url: "ws://127.0.0.1:5555/hub",
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
expect(spawn).toHaveBeenCalledOnce();
expect(spawnArgs).toContain("--port");
expect(spawnArgs).toContain("0");
expect(spawnArgs).toContain("25463");
expect(spawnArgs).not.toContain("0");
expect(spawnOptions?.env?.[CLINE_RUN_AS_HUB_DAEMON_ENV]).toBe("1");
});
@@ -153,11 +173,12 @@ describe("ensureDetachedHubServer", () => {
})
.mockImplementationOnce(() => ({ unref: vi.fn() }));
readHubDiscovery.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
url: "ws://127.0.0.1:5555/hub",
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
probeHubServer.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
url: "ws://127.0.0.1:5555/hub",
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v1",
buildId: "current-build",
});
verifyHubConnection.mockResolvedValueOnce(true);
@@ -168,7 +189,7 @@ describe("ensureDetachedHubServer", () => {
const result = await pending;
expect(result).toEqual({
url: "ws://127.0.0.1:5555/hub",
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
expect(spawn).toHaveBeenCalledTimes(2);
@@ -247,7 +268,42 @@ describe("ensureDetachedHubServer", () => {
}
});
it("does not reuse a healthy hub from a different build", async () => {
it("prewarms on a fallback port when an empty-token hub cannot be retired", async () => {
vi.useFakeTimers();
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
try {
readHubDiscovery.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
authToken: "",
pid: 12345,
});
probeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v1",
buildId: "old-build",
});
const { prewarmDetachedHubServer } = await import(".");
prewarmDetachedHubServer("/workspace", { allowPortFallback: true });
await vi.runAllTimersAsync();
const spawnArgs = ((spawn as unknown as { mock: { calls: unknown[][] } })
.mock.calls[0]?.[1] ?? []) as string[];
expect(requestHubShutdown).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"",
);
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
expect(spawn).toHaveBeenCalledOnce();
expect(spawnArgs).toContain("--port");
expect(spawnArgs).toContain("0");
} finally {
kill.mockRestore();
vi.useRealTimers();
}
});
it("reuses a protocol-compatible healthy hub from a different build", async () => {
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
try {
readHubDiscovery
@@ -255,54 +311,215 @@ describe("ensureDetachedHubServer", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "old-token",
})
.mockResolvedValueOnce({
url: "ws://127.0.0.1:5555/hub",
buildId: "current-build",
authToken: "new-token",
});
probeHubServer
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
buildId: "old-build",
pid: 12345,
})
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
buildId: "old-build",
pid: 12345,
})
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
url: "ws://127.0.0.1:5555/hub",
buildId: "current-build",
});
.mockResolvedValueOnce(undefined);
probeHubServer.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v1",
buildId: "old-build",
pid: 12345,
});
verifyHubConnection.mockResolvedValueOnce(true);
const { ensureDetachedHubServer } = await import(".");
const result = await ensureDetachedHubServer("/workspace");
expect(result).toEqual({
url: "ws://127.0.0.1:5555/hub",
authToken: "new-token",
url: "ws://127.0.0.1:25463/hub",
authToken: "old-token",
});
expect(requestHubShutdown).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"old-token",
);
expect(clearHubDiscovery).toHaveBeenCalledWith("/tmp/hub-discovery.json");
expect(clearHubDiscovery.mock.invocationCallOrder[0]).toBeGreaterThan(
probeHubServer.mock.invocationCallOrder[2],
);
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
expect(spawn).toHaveBeenCalledOnce();
expect(requestHubShutdown).not.toHaveBeenCalled();
expect(clearHubDiscovery).not.toHaveBeenCalled();
expect(kill).not.toHaveBeenCalled();
expect(spawn).not.toHaveBeenCalled();
expect(verifyHubConnection).toHaveBeenCalledOnce();
} finally {
kill.mockRestore();
}
});
it("does not reuse a healthy hub without build metadata", async () => {
it("retires an existing hub with an empty discovery auth token before starting a replacement", async () => {
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
try {
readHubDiscovery
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
authToken: "",
pid: 12345,
})
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
probeHubServer
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v1",
buildId: "current-build",
});
verifyHubConnection.mockResolvedValueOnce(true);
const { ensureDetachedHubServer } = await import(".");
const result = await ensureDetachedHubServer("/workspace");
expect(result).toEqual({
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
expect(requestHubShutdown).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"",
);
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
expect(clearHubDiscovery).toHaveBeenCalledWith("/tmp/hub-discovery.json");
expect(spawn).toHaveBeenCalledOnce();
} finally {
kill.mockRestore();
}
});
it("throws a targeted error when an incompatible hub cannot be retired", async () => {
vi.useFakeTimers();
try {
readHubDiscovery.mockResolvedValue(undefined);
probeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v2",
buildId: "future-build",
});
const { ensureDetachedHubServer } = await import(".");
const pending = expect(
ensureDetachedHubServer("/workspace"),
).rejects.toThrow(
"An incompatible Cline Hub is already running at ws://127.0.0.1:25463/hub and could not be retired automatically.",
);
await vi.runAllTimersAsync();
await pending;
expect(spawn).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("retires a legacy shared production hub before resolving the production hub", async () => {
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
try {
resolveSharedHubOwnerContext.mockReturnValueOnce({
discoveryPath: "/tmp/legacy-hub-discovery.json",
});
readHubDiscovery
.mockResolvedValueOnce({
url: "ws://127.0.0.1:39121/hub",
authToken: "legacy-token",
pid: 222,
})
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
probeHubServer
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v1",
buildId: "current-build",
});
verifyHubConnection.mockResolvedValueOnce(true);
const { ensureDetachedHubServer } = await import(".");
const result = await ensureDetachedHubServer("/workspace");
expect(result).toEqual({
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
expect(requestHubShutdown).toHaveBeenCalledWith(
"ws://127.0.0.1:39121/hub",
"legacy-token",
);
expect(kill).toHaveBeenCalledWith(222, "SIGTERM");
expect(clearHubDiscovery).toHaveBeenCalledWith(
"/tmp/legacy-hub-discovery.json",
);
expect(spawn).toHaveBeenCalledOnce();
} finally {
kill.mockRestore();
}
});
it("throws when a compatible expected hub has no discovery record", async () => {
readHubDiscovery.mockResolvedValue(undefined);
probeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v1",
buildId: "current-build",
});
const { ensureDetachedHubServer } = await import(".");
await expect(ensureDetachedHubServer("/workspace")).rejects.toThrow(
"A compatible Cline Hub is already running at ws://127.0.0.1:25463/hub, but its discovery record is missing or unreadable.",
);
expect(spawn).not.toHaveBeenCalled();
});
it("uses matching discovery pid and token when retiring an incompatible expected-url hub", async () => {
const kill = vi
.spyOn(process, "kill")
.mockImplementation((_pid, signal) => {
if (signal === 0) {
throw Object.assign(new Error("missing"), { code: "ESRCH" });
}
return true;
});
try {
readHubDiscovery
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
authToken: "old-token",
pid: 12345,
})
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
probeHubServer
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v2",
buildId: "future-build",
})
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v1",
buildId: "current-build",
});
verifyHubConnection.mockResolvedValueOnce(true);
const { ensureDetachedHubServer } = await import(".");
const result = await ensureDetachedHubServer("/workspace");
expect(result).toEqual({
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
expect(requestHubShutdown).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"old-token",
);
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
} finally {
kill.mockRestore();
}
});
it("does not reuse a healthy hub without protocol metadata", async () => {
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
try {
readHubDiscovery
@@ -311,7 +528,8 @@ describe("ensureDetachedHubServer", () => {
authToken: "old-token",
})
.mockResolvedValueOnce({
url: "ws://127.0.0.1:5555/hub",
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v1",
buildId: "current-build",
authToken: "new-token",
});
@@ -327,7 +545,13 @@ describe("ensureDetachedHubServer", () => {
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
url: "ws://127.0.0.1:5555/hub",
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v1",
buildId: "current-build",
})
.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
protocolVersion: "v1",
buildId: "current-build",
});
verifyHubConnection.mockResolvedValueOnce(true);
@@ -336,7 +560,7 @@ describe("ensureDetachedHubServer", () => {
const result = await ensureDetachedHubServer("/workspace");
expect(result).toEqual({
url: "ws://127.0.0.1:5555/hub",
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
expect(requestHubShutdown).toHaveBeenCalledWith(
+207 -66
View File
@@ -5,6 +5,8 @@ import { fileURLToPath } from "node:url";
import {
CLINE_RUN_AS_HUB_DAEMON_ENV,
isHubDaemonProcess,
isHubProtocolCompatible,
resolveClineBuildEnv,
withResolvedClineBuildEnv,
} from "@cline/shared";
import {
@@ -15,17 +17,20 @@ import {
import {
clearHubDiscovery,
createHubServerUrl,
type HubServerDiscoveryRecord,
type HubOwnerContext,
type HubServerProbeRecord,
probeHubServer,
readHubDiscovery,
resolveClineDataDir,
resolveHubBuildId,
} from "../discovery";
import {
type HubEndpointOverrides,
resolveHubEndpointOptions,
} from "../discovery/defaults";
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
const HUB_STARTUP_TIMEOUT_MS = 8_000;
const HUB_STARTUP_POLL_MS = 200;
@@ -54,16 +59,37 @@ function openDetachedHubLogFile(): { fd: number; logPath: string } | undefined {
}
}
function isCompatibleHubRecord(record: HubServerDiscoveryRecord): boolean {
const recordBuildId = record.buildId?.trim();
return !!recordBuildId && recordBuildId === resolveHubBuildId();
function resolveDefaultHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
function isCompatibleHubRecord(record: HubServerProbeRecord): boolean {
return isHubProtocolCompatible(record).compatible;
}
function withMatchingDiscoveryRetirementMetadata(
probe: HubServerProbeRecord,
discovered: { url?: string; authToken?: string; pid?: number } | undefined,
expectedUrl: string,
): HubServerProbeRecord {
if (!discovered || discovered.url !== expectedUrl) {
return probe;
}
return {
...probe,
authToken: probe.authToken ?? discovered.authToken,
pid: probe.pid ?? discovered.pid,
};
}
async function safeProbeHubServer(
url: string,
): Promise<HubServerDiscoveryRecord | undefined> {
authToken?: string,
): Promise<HubServerProbeRecord | undefined> {
try {
return await probeHubServer(url);
return await probeHubServer(url, { authToken });
} catch {
return undefined;
}
@@ -84,13 +110,10 @@ async function waitForHubToRetire(
return false;
}
async function retireIncompatibleHub(
record: HubServerDiscoveryRecord,
async function retireDiscoveredHub(
record: { url: string; authToken?: string; pid?: number },
discoveryPath: string,
): Promise<void> {
if (isCompatibleHubRecord(record)) {
return;
}
): Promise<boolean> {
await requestHubShutdown(record.url, record.authToken).catch(() => false);
if (record.pid) {
try {
@@ -99,8 +122,43 @@ async function retireIncompatibleHub(
// Best-effort cleanup only. A compatible hub may still start on a fallback port.
}
}
await waitForHubToRetire(record.url, HUB_RETIRE_TIMEOUT_MS);
const retired = await waitForHubToRetire(record.url, HUB_RETIRE_TIMEOUT_MS);
await clearHubDiscovery(discoveryPath).catch(() => undefined);
return retired;
}
async function retireIncompatibleHub(
record: HubServerProbeRecord,
discoveryPath: string,
): Promise<boolean> {
if (isCompatibleHubRecord(record)) {
return true;
}
return retireDiscoveredHub(record, discoveryPath);
}
/**
* Pre-singleton production builds tracked the local hub under the shared
* owner discovery path and spawned daemons on random fallback ports. Those
* daemons are invisible to the production owner context, so nothing would
* ever reuse or stop them. Retire the recorded legacy hub (its record carries
* the auth token and pid needed for a graceful stop) and clear the legacy
* record so upgrades do not leave orphaned daemons running stale code.
*/
async function retireLegacySharedHub(owner: HubOwnerContext): Promise<void> {
if (resolveClineBuildEnv() !== "production") {
return;
}
const legacy = resolveSharedHubOwnerContext();
if (legacy.discoveryPath === owner.discoveryPath) {
return;
}
const record = await readHubDiscovery(legacy.discoveryPath);
if (record?.url) {
await retireDiscoveredHub(record, legacy.discoveryPath);
} else {
await clearHubDiscovery(legacy.discoveryPath).catch(() => undefined);
}
}
function resolveDaemonEntryPath(): string {
@@ -203,48 +261,75 @@ export async function spawnDetachedHubServerWithRetry(
export function prewarmDetachedHubServer(
workspaceRoot: string,
endpoint: HubEndpointOverrides = {},
endpoint: HubEndpointOverrides & { allowPortFallback?: boolean } = {},
): void {
if (isHubDaemonProcess()) {
return;
}
const owner = resolveSharedHubOwnerContext();
const hasExplicitPort =
endpoint.port !== undefined || !!process.env.CLINE_HUB_PORT?.trim();
const owner = resolveDefaultHubOwnerContext();
const resolvedEndpoint = resolveHubEndpointOptions(endpoint);
const expectedUrl = createHubServerUrl(
resolvedEndpoint.host,
resolvedEndpoint.port,
resolvedEndpoint.pathname,
);
void readHubDiscovery(owner.discoveryPath)
const shouldUseFallbackPort =
endpoint.allowPortFallback === true && resolvedEndpoint.port !== 0;
void retireLegacySharedHub(owner)
.catch(() => undefined)
.then(() => readHubDiscovery(owner.discoveryPath))
.then(async (discovered) => {
let retiredUnusableDiscovery = false;
if (discovered?.url) {
const healthy = await safeProbeHubServer(discovered.url);
if (
healthy?.url &&
isCompatibleHubRecord(healthy) &&
(await verifyHubConnection(healthy.url, {
authToken: discovered.authToken,
}))
) {
return;
}
if (healthy?.url) {
await retireIncompatibleHub(
{ ...healthy, authToken: discovered.authToken },
if (!discovered.authToken) {
retiredUnusableDiscovery = true;
const retired = await retireDiscoveredHub(
discovered,
owner.discoveryPath,
);
if (!retired && !shouldUseFallbackPort) {
return;
}
} else {
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
const healthy = await safeProbeHubServer(
discovered.url,
discovered.authToken,
);
if (
healthy?.url &&
isCompatibleHubRecord(healthy) &&
(await verifyHubConnection(healthy.url, {
authToken: discovered.authToken,
}))
) {
return;
}
if (healthy?.url) {
await retireIncompatibleHub(
{ ...healthy, authToken: discovered.authToken },
owner.discoveryPath,
);
} else {
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
}
}
}
const expected = await safeProbeHubServer(expectedUrl);
if (expected?.url) {
await retireIncompatibleHub(expected, owner.discoveryPath);
if (isCompatibleHubRecord(expected)) {
if (!shouldUseFallbackPort || !retiredUnusableDiscovery) {
return;
}
} else {
const retiredExpected = await retireIncompatibleHub(
{ ...expected, authToken: undefined },
owner.discoveryPath,
);
if (!retiredExpected && !shouldUseFallbackPort) {
return;
}
}
}
const shouldUseFallbackPort =
!hasExplicitPort && resolvedEndpoint.port !== 0;
const spawnEndpoint = shouldUseFallbackPort
? { ...resolvedEndpoint, port: 0 }
: resolvedEndpoint;
@@ -262,17 +347,16 @@ export interface DetachedHubResolution {
export async function ensureDetachedHubServer(
workspaceRoot: string,
endpointOverrides: HubEndpointOverrides = {},
endpointOverrides: HubEndpointOverrides & {
allowPortFallback?: boolean;
} = {},
): Promise<DetachedHubResolution> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveDefaultHubOwnerContext();
const hasExplicitEndpoint =
endpointOverrides.host !== undefined ||
endpointOverrides.port !== undefined ||
endpointOverrides.pathname !== undefined ||
!!process.env.CLINE_HUB_PORT?.trim();
const hasExplicitPort =
endpointOverrides.port !== undefined ||
!!process.env.CLINE_HUB_PORT?.trim();
const endpoint = resolveHubEndpointOptions(endpointOverrides);
const expectedUrl = createHubServerUrl(
endpoint.host,
@@ -287,35 +371,72 @@ export async function ensureDetachedHubServer(
}
return result;
};
await retireLegacySharedHub(owner).catch(() => undefined);
const discovered = await readHubDiscovery(owner.discoveryPath);
let retiredUnusableDiscovery = false;
if (discovered?.url) {
const healthy = await safeProbeHubServer(discovered.url);
if (
healthy?.url &&
isCompatibleHubRecord(healthy) &&
(await verifyHubConnection(healthy.url, {
authToken: discovered.authToken,
}))
) {
return rememberIfManaged({
url: healthy.url,
authToken: discovered.authToken,
});
}
if (healthy?.url) {
await retireIncompatibleHub(
{ ...healthy, authToken: discovered.authToken },
owner.discoveryPath,
);
const discoveredAuthToken = discovered.authToken;
if (!discoveredAuthToken) {
retiredUnusableDiscovery = true;
await retireDiscoveredHub(discovered, owner.discoveryPath);
} else {
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
const healthy = await safeProbeHubServer(
discovered.url,
discoveredAuthToken,
);
if (
healthy?.url &&
isCompatibleHubRecord(healthy) &&
(await verifyHubConnection(healthy.url, {
authToken: discoveredAuthToken,
}))
) {
return rememberIfManaged({
url: healthy.url,
authToken: discoveredAuthToken,
});
}
if (healthy?.url) {
await retireIncompatibleHub(
{ ...healthy, authToken: discoveredAuthToken },
owner.discoveryPath,
);
} else {
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
}
}
}
const expected = await safeProbeHubServer(expectedUrl);
if (expected?.url) {
await retireIncompatibleHub(expected, owner.discoveryPath);
const expectedForRetirement = withMatchingDiscoveryRetirementMetadata(
expected,
discovered,
expectedUrl,
);
if (isCompatibleHubRecord(expected)) {
const upgradeHint = retiredUnusableDiscovery
? " This can happen immediately after upgrading from a build that wrote an empty hub auth token; run 'cline doctor fix' to stop the old daemon and repair local hub discovery."
: "";
throw new Error(
`A compatible Cline Hub is already running at ${expectedUrl}, but its discovery record is missing or unreadable. Run 'cline doctor fix' to repair local hub discovery.${upgradeHint}`,
);
}
const retiredExpected = await retireIncompatibleHub(
expectedForRetirement,
owner.discoveryPath,
);
if (
!retiredExpected &&
endpointOverrides.allowPortFallback !== true &&
endpoint.port !== 0
) {
throw new Error(
`An incompatible Cline Hub is already running at ${expectedUrl} and could not be retired automatically. Run 'cline doctor fix' to stop stale hub daemons before starting a new hub.`,
);
}
}
const shouldUseFallbackPort = !hasExplicitPort && endpoint.port !== 0;
const shouldUseFallbackPort =
endpointOverrides.allowPortFallback === true && endpoint.port !== 0;
const spawnEndpoint = shouldUseFallbackPort
? { ...endpoint, port: 0 }
: endpoint;
@@ -323,8 +444,11 @@ export async function ensureDetachedHubServer(
const deadline = Date.now() + HUB_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
const nextDiscovery = await readHubDiscovery(owner.discoveryPath);
if (nextDiscovery?.url) {
const healthy = await safeProbeHubServer(nextDiscovery.url);
if (nextDiscovery?.url && nextDiscovery.authToken) {
const healthy = await safeProbeHubServer(
nextDiscovery.url,
nextDiscovery.authToken,
);
if (
healthy?.url &&
isCompatibleHubRecord(healthy) &&
@@ -340,7 +464,24 @@ export async function ensureDetachedHubServer(
}
const nextExpected = await safeProbeHubServer(expectedUrl);
if (nextExpected?.url && !isCompatibleHubRecord(nextExpected)) {
await retireIncompatibleHub(nextExpected, owner.discoveryPath);
const expectedForRetirement = withMatchingDiscoveryRetirementMetadata(
nextExpected,
nextDiscovery,
expectedUrl,
);
const retiredExpected = await retireIncompatibleHub(
expectedForRetirement,
owner.discoveryPath,
);
if (
!retiredExpected &&
endpointOverrides.allowPortFallback !== true &&
endpoint.port !== 0
) {
throw new Error(
`An incompatible Cline Hub is still running at ${expectedUrl} and could not be retired automatically. Run 'cline doctor fix' to stop stale hub daemons before starting a new hub.`,
);
}
}
await new Promise((resolve) => setTimeout(resolve, HUB_STARTUP_POLL_MS));
}
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { EnsureHubServerOptions } from "./start-shared-server";
const {
mockEnsureHubWebSocketServer,
mockResolveHubEndpointOptions,
mockResolveClineBuildEnv,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStartHubWebSocketServer,
} = vi.hoisted(() => ({
mockEnsureHubWebSocketServer: vi.fn(async () => ({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
action: "started",
})),
mockResolveHubEndpointOptions: vi.fn(
(options: { host?: string; port?: number; pathname?: string }) => ({
host: options.host ?? "127.0.0.1",
port: options.port ?? 25463,
pathname: options.pathname ?? "/hub",
}),
),
mockResolveClineBuildEnv: vi.fn(() => "production"),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "shared",
discoveryPath: "/tmp/cline-data/locks/hub/owners/shared.json",
})),
mockStartHubWebSocketServer: vi.fn(),
}));
vi.mock("@cline/shared", () => ({
resolveClineBuildEnv: mockResolveClineBuildEnv,
}));
vi.mock("../discovery/defaults", () => ({
resolveHubEndpointOptions: mockResolveHubEndpointOptions,
}));
vi.mock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
}));
vi.mock("../server", () => ({
ensureHubWebSocketServer: mockEnsureHubWebSocketServer,
startHubWebSocketServer: mockStartHubWebSocketServer,
}));
const originalHubPort = process.env.CLINE_HUB_PORT;
const runtimeHandlers =
{} as unknown as EnsureHubServerOptions["runtimeHandlers"];
describe("ensureHubServer", () => {
afterEach(() => {
mockEnsureHubWebSocketServer.mockClear();
mockResolveHubEndpointOptions.mockClear();
mockResolveClineBuildEnv.mockClear();
mockResolveClineBuildEnv.mockReturnValue("production");
mockResolveProductionHubOwnerContext.mockClear();
mockResolveSharedHubOwnerContext.mockClear();
mockStartHubWebSocketServer.mockClear();
if (originalHubPort === undefined) {
delete process.env.CLINE_HUB_PORT;
} else {
process.env.CLINE_HUB_PORT = originalHubPort;
}
});
it("does not allow port fallback by default in production", async () => {
delete process.env.CLINE_HUB_PORT;
const { ensureHubServer } = await import("./start-shared-server");
await ensureHubServer({ runtimeHandlers });
expect(mockEnsureHubWebSocketServer).toHaveBeenCalledWith(
expect.objectContaining({
port: 25463,
allowPortFallback: false,
owner: expect.objectContaining({
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
}),
}),
);
});
it("allows port fallback by default in development when no port is explicit", async () => {
delete process.env.CLINE_HUB_PORT;
mockResolveClineBuildEnv.mockReturnValue("development");
const { ensureHubServer } = await import("./start-shared-server");
await ensureHubServer({ runtimeHandlers });
expect(mockEnsureHubWebSocketServer).toHaveBeenCalledWith(
expect.objectContaining({
port: 25463,
allowPortFallback: true,
owner: expect.objectContaining({
discoveryPath: "/tmp/cline-data/locks/hub/owners/shared.json",
}),
}),
);
});
it("does not default port fallback when a port option is explicit", async () => {
delete process.env.CLINE_HUB_PORT;
const { ensureHubServer } = await import("./start-shared-server");
await ensureHubServer({ port: 30000, runtimeHandlers });
expect(mockEnsureHubWebSocketServer).toHaveBeenCalledWith(
expect.objectContaining({
port: 30000,
allowPortFallback: false,
}),
);
});
it("does not default port fallback when CLINE_HUB_PORT is explicit", async () => {
process.env.CLINE_HUB_PORT = "30001";
const { ensureHubServer } = await import("./start-shared-server");
await ensureHubServer({ runtimeHandlers });
expect(mockEnsureHubWebSocketServer).toHaveBeenCalledWith(
expect.objectContaining({
allowPortFallback: false,
}),
);
});
});
@@ -1,5 +1,9 @@
import { resolveClineBuildEnv } from "@cline/shared";
import { resolveHubEndpointOptions } from "../discovery/defaults";
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
import {
type EnsuredHubWebSocketServerResult,
type EnsureHubWebSocketServerOptions,
@@ -18,9 +22,19 @@ export interface StartHubServerOptions
export interface EnsureHubServerOptions
extends Omit<EnsureHubWebSocketServerOptions, "owner"> {}
function resolveDefaultHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
function shouldAllowDefaultPortFallback(hasExplicitPort: boolean): boolean {
return resolveClineBuildEnv() !== "production" && !hasExplicitPort;
}
/**
* Start a hub WebSocket server bound to the process-local shared owner
* context. Callers that need a custom owner should invoke
* Start a hub WebSocket server bound to the default owner context for the
* current build environment. Callers that need a custom owner should invoke
* {@link startHubWebSocketServer} directly.
*/
export async function startHubServer(
@@ -34,13 +48,13 @@ export async function startHubServer(
return await startHubWebSocketServer({
...options,
...endpoint,
owner: resolveSharedHubOwnerContext(),
owner: resolveDefaultHubOwnerContext(),
});
}
/**
* Ensure a hub WebSocket server is running in the process-local shared owner
* context, reusing a compatible in-process instance when available.
* Ensure a hub WebSocket server is running in the default owner context for the
* current build environment, reusing a compatible in-process instance when available.
*/
export async function ensureHubServer(
options: EnsureHubServerOptions,
@@ -55,7 +69,9 @@ export async function ensureHubServer(
return await ensureHubWebSocketServer({
...options,
...endpoint,
allowPortFallback: options.allowPortFallback ?? !hasExplicitPort,
owner: resolveSharedHubOwnerContext(),
allowPortFallback:
options.allowPortFallback ??
shouldAllowDefaultPortFallback(hasExplicitPort),
owner: resolveDefaultHubOwnerContext(),
});
}
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
clearHubDiscovery,
probeHubServer,
readHubDiscovery,
resolveHubOwnerContext,
writeHubDiscovery,
@@ -88,4 +89,62 @@ describe("hub discovery", () => {
await clearHubDiscovery(discoveryPath);
await expect(readHubDiscovery(discoveryPath)).resolves.toBeUndefined();
});
it("rejects discovery records without an auth token", async () => {
snapshot = captureEnv();
delete process.env.CLINE_HUB_DISCOVERY_PATH;
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
const discoveryPath = resolveHubOwnerContext("missing-auth").discoveryPath;
await mkdir(dirname(discoveryPath), { recursive: true });
await writeFile(
discoveryPath,
`${JSON.stringify({
hubId: "hub_123",
protocolVersion: "v1",
host: "127.0.0.1",
port: 25463,
url: "ws://127.0.0.1:25463/hub",
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})}\n`,
"utf8",
);
await expect(readHubDiscovery(discoveryPath)).resolves.toBeUndefined();
});
it("returns only public health fields for unauthenticated probes", async () => {
const fetchMock = async () =>
({
ok: true,
json: async () => ({
ok: true,
protocolVersion: "v1",
minClientProtocolVersion: "v1",
maxClientProtocolVersion: "v1",
coreVersion: "1.0.0",
host: "127.0.0.1",
port: 25463,
url: "ws://127.0.0.1:25463/hub",
}),
}) as Response;
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchMock as typeof fetch;
try {
const record = await probeHubServer("ws://127.0.0.1:25463/hub");
expect(record).toMatchObject({
protocolVersion: "v1",
host: "127.0.0.1",
port: 25463,
url: "ws://127.0.0.1:25463/hub",
});
expect(record?.hubId).toBeUndefined();
expect(record?.startedAt).toBeUndefined();
expect(record?.updatedAt).toBeUndefined();
} finally {
globalThis.fetch = originalFetch;
}
});
});
+90 -3
View File
@@ -14,6 +14,9 @@ const HUB_STARTUP_LOCK_POLL_MS = 100;
export interface HubServerDiscoveryRecord {
hubId: string;
protocolVersion: string;
minClientProtocolVersion?: string;
maxClientProtocolVersion?: string;
capabilities?: readonly string[];
coreVersion?: string;
buildId?: string;
authToken: string;
@@ -25,6 +28,23 @@ export interface HubServerDiscoveryRecord {
updatedAt: string;
}
export type HubServerProbeRecord = {
protocolVersion: string;
minClientProtocolVersion?: string;
maxClientProtocolVersion?: string;
capabilities?: readonly string[];
coreVersion?: string;
buildId?: string;
host: string;
port: number;
url: string;
hubId?: string;
authToken?: string;
pid?: number;
startedAt?: string;
updatedAt?: string;
};
export interface HubOwnerContext {
ownerId: string;
discoveryPath: string;
@@ -135,6 +155,20 @@ export async function readHubDiscovery(
return {
hubId: parsed.hubId,
protocolVersion: parsed.protocolVersion,
minClientProtocolVersion:
typeof parsed.minClientProtocolVersion === "string"
? parsed.minClientProtocolVersion
: undefined,
maxClientProtocolVersion:
typeof parsed.maxClientProtocolVersion === "string"
? parsed.maxClientProtocolVersion
: undefined,
capabilities: Array.isArray(parsed.capabilities)
? parsed.capabilities.filter(
(capability): capability is string =>
typeof capability === "string",
)
: undefined,
coreVersion:
typeof parsed.coreVersion === "string" ? parsed.coreVersion : undefined,
buildId: typeof parsed.buildId === "string" ? parsed.buildId : undefined,
@@ -225,13 +259,60 @@ export async function withHubStartupLock<T>(
export async function probeHubServer(
url: string,
): Promise<HubServerDiscoveryRecord | undefined> {
options?: { authToken?: string },
): Promise<HubServerProbeRecord | undefined> {
try {
const response = await fetch(toHubHealthUrl(url));
const response = await fetch(
options?.authToken ? toHubStatusUrl(url) : toHubHealthUrl(url),
{
headers: options?.authToken
? { authorization: `Bearer ${options.authToken}` }
: undefined,
},
);
if (!response.ok) {
return undefined;
}
return (await response.json()) as HubServerDiscoveryRecord;
const parsed = (await response.json()) as Partial<HubServerProbeRecord>;
if (
typeof parsed.protocolVersion !== "string" ||
typeof parsed.host !== "string" ||
typeof parsed.port !== "number" ||
typeof parsed.url !== "string"
) {
return undefined;
}
return {
protocolVersion: parsed.protocolVersion,
minClientProtocolVersion:
typeof parsed.minClientProtocolVersion === "string"
? parsed.minClientProtocolVersion
: undefined,
maxClientProtocolVersion:
typeof parsed.maxClientProtocolVersion === "string"
? parsed.maxClientProtocolVersion
: undefined,
capabilities: Array.isArray(parsed.capabilities)
? parsed.capabilities.filter(
(capability): capability is string =>
typeof capability === "string",
)
: undefined,
coreVersion:
typeof parsed.coreVersion === "string" ? parsed.coreVersion : undefined,
buildId: typeof parsed.buildId === "string" ? parsed.buildId : undefined,
host: parsed.host,
port: parsed.port,
url: parsed.url,
hubId: typeof parsed.hubId === "string" ? parsed.hubId : undefined,
authToken:
typeof parsed.authToken === "string" ? parsed.authToken : undefined,
pid: typeof parsed.pid === "number" ? parsed.pid : undefined,
startedAt:
typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
updatedAt:
typeof parsed.updatedAt === "string" ? parsed.updatedAt : undefined,
};
} catch {
return undefined;
}
@@ -253,6 +334,12 @@ export function toHubHealthUrl(wsUrl: string): string {
return parsed.toString();
}
export function toHubStatusUrl(wsUrl: string): string {
const parsed = new URL(toHubHealthUrl(wsUrl));
parsed.pathname = "/status";
return parsed.toString();
}
export function isDiscoveryFilePresent(pathname: string): boolean {
return existsSync(pathname);
}
@@ -1,7 +1,14 @@
import { join } from "node:path";
import { normalizeWorkspacePath } from "../../services/workspace/workspace-manifest";
import { type HubOwnerContext, resolveHubOwnerContext } from ".";
import {
type HubOwnerContext,
resolveClineDataDir,
resolveHubOwnerContext,
} from ".";
const DEFAULT_SHARED_HUB_OWNER_LABEL = "shared:cline";
const HUB_DISCOVERY_ENV = "CLINE_HUB_DISCOVERY_PATH";
const PRODUCTION_HUB_OWNER_ID = "hub-production";
export function resolveWorkspaceHubOwnerContext(
workspaceRoot: string,
@@ -17,3 +24,12 @@ export function resolveSharedHubOwnerContext(
): HubOwnerContext {
return resolveHubOwnerContext(label);
}
export function resolveProductionHubOwnerContext(): HubOwnerContext {
return {
ownerId: PRODUCTION_HUB_OWNER_ID,
discoveryPath:
process.env[HUB_DISCOVERY_ENV]?.trim() ||
join(resolveClineDataDir(), "locks", "hub", "production.json"),
};
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { readBearerToken } from "./hub-websocket-server";
describe("readBearerToken", () => {
it("reads a bearer token with case-insensitive scheme", () => {
expect(readBearerToken("Bearer token")).toBe("token");
expect(readBearerToken("bearer token")).toBe("token");
});
it("reads a bearer token separated by tabs without regex backtracking", () => {
expect(readBearerToken(`bearer\t\t${"token"}`)).toBe("token");
expect(readBearerToken(`bearer${"\t".repeat(10_000)}token`)).toBe("token");
});
it("rejects missing and malformed bearer tokens", () => {
expect(readBearerToken(undefined)).toBeNull();
expect(readBearerToken("Bearer")).toBeNull();
expect(readBearerToken("BearerToken")).toBeNull();
expect(readBearerToken("Basic token")).toBeNull();
});
});
@@ -2,6 +2,13 @@ import { timingSafeEqual } from "node:crypto";
import http from "node:http";
import net from "node:net";
import { URL } from "node:url";
import {
CURRENT_HUB_PROTOCOL_VERSION,
HUB_CAPABILITIES,
isHubProtocolCompatible,
MAX_CLIENT_HUB_PROTOCOL_VERSION,
MIN_CLIENT_HUB_PROTOCOL_VERSION,
} from "@cline/shared";
import { WebSocketServer } from "ws";
import corePackage from "../../../package.json";
import { rememberRecoverableLocalHubUrl, verifyHubConnection } from "../client";
@@ -204,10 +211,32 @@ function parseHeaderValue(value: string | string[] | undefined): string {
return Array.isArray(value) ? value.join(",") : (value ?? "");
}
function readBearerToken(value: string | string[] | undefined): string | null {
function isAuthHeaderWhitespace(code: number): boolean {
return code === 0x20 || code === 0x09;
}
export function readBearerToken(
value: string | string[] | undefined,
): string | null {
const header = parseHeaderValue(value).trim();
const match = /^Bearer\s+(.+)$/i.exec(header);
return match?.[1]?.trim() || null;
const bearerScheme = "bearer";
if (
header.length <= bearerScheme.length ||
header.slice(0, bearerScheme.length).toLowerCase() !== bearerScheme ||
!isAuthHeaderWhitespace(header.charCodeAt(bearerScheme.length))
) {
return null;
}
let tokenStart = bearerScheme.length + 1;
while (
tokenStart < header.length &&
isAuthHeaderWhitespace(header.charCodeAt(tokenStart))
) {
tokenStart += 1;
}
return header.slice(tokenStart).trim() || null;
}
function readWebSocketAuthToken(
@@ -244,7 +273,10 @@ export async function startHubWebSocketServer(
const cleanup = new Set<() => void>();
const startedAt = new Date().toISOString();
const versionPayload = {
protocolVersion: "v1",
protocolVersion: CURRENT_HUB_PROTOCOL_VERSION,
minClientProtocolVersion: MIN_CLIENT_HUB_PROTOCOL_VERSION,
maxClientProtocolVersion: MAX_CLIENT_HUB_PROTOCOL_VERSION,
capabilities: HUB_CAPABILITIES,
coreVersion: corePackage.version,
buildId,
pid: process.pid,
@@ -300,10 +332,36 @@ export async function startHubWebSocketServer(
const server = http.createServer((req, res) => {
if ((req.url ?? "/") === "/health") {
const body = JSON.stringify({
ok: true,
protocolVersion: versionPayload.protocolVersion,
minClientProtocolVersion: versionPayload.minClientProtocolVersion,
maxClientProtocolVersion: versionPayload.maxClientProtocolVersion,
coreVersion: versionPayload.coreVersion,
host,
port,
url,
});
res.statusCode = 200;
res.setHeader("content-type", "application/json");
res.end(body);
return;
}
if ((req.url ?? "/") === "/status") {
if (
!isValidHubAuthToken(
readBearerToken(req.headers.authorization),
authToken,
)
) {
res.statusCode = 401;
res.end("Unauthorized");
return;
}
const body = JSON.stringify({
hubId: transport.getHubId(),
...versionPayload,
authToken: "",
authToken,
host,
port,
url,
@@ -449,7 +507,10 @@ export async function startHubWebSocketServer(
await writeHubDiscovery(owner.discoveryPath, {
hubId: transport.getHubId(),
protocolVersion: "v1",
protocolVersion: CURRENT_HUB_PROTOCOL_VERSION,
minClientProtocolVersion: MIN_CLIENT_HUB_PROTOCOL_VERSION,
maxClientProtocolVersion: MAX_CLIENT_HUB_PROTOCOL_VERSION,
capabilities: [...versionPayload.capabilities],
coreVersion: corePackage.version,
buildId,
authToken,
@@ -511,9 +572,12 @@ export async function ensureHubWebSocketServer(
discovered?.url &&
(discovered.url === expectedUrl || options.allowPortFallback === true);
if (canReuseDiscovered) {
const healthy = await probeHubServer(discovered.url);
const healthy = await probeHubServer(discovered.url, {
authToken: discovered.authToken,
});
if (
healthy?.url &&
isHubProtocolCompatible(healthy).compatible &&
(await verifyHubConnection(healthy.url, {
authToken: discovered.authToken,
}))
@@ -526,8 +590,9 @@ export async function ensureHubWebSocketServer(
}
}
const expected = await probeHubServer(expectedUrl);
if (expected?.url || discovered?.url) {
// The discovered hub was not reusable (missing, mismatched, or failed
// verification), so its record is stale either way.
if (discovered?.url) {
await clearHubDiscovery(owner.discoveryPath);
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { isHubProtocolCompatible } from "./hub";
describe("isHubProtocolCompatible", () => {
it("accepts a hub whose supported client range includes the client protocol", () => {
expect(
isHubProtocolCompatible({
protocolVersion: "v2",
minClientProtocolVersion: "v1",
maxClientProtocolVersion: "v2",
}),
).toEqual({ compatible: true });
});
it("rejects a hub whose supported client range excludes the client protocol", () => {
expect(
isHubProtocolCompatible({
protocolVersion: "v2",
minClientProtocolVersion: "v2",
maxClientProtocolVersion: "v3",
}),
).toEqual({ compatible: false, reason: "unsupported_protocol" });
});
it("rejects missing or malformed protocol versions", () => {
expect(isHubProtocolCompatible({ protocolVersion: "" })).toEqual({
compatible: false,
reason: "missing_protocol",
});
});
});
+71
View File
@@ -5,6 +5,77 @@ import type { RuntimeConfigExtensionKind } from "./session/runtime-config";
export type HubProtocolVersion = "v1";
export const CURRENT_HUB_PROTOCOL_VERSION: HubProtocolVersion = "v1";
export const MIN_CLIENT_HUB_PROTOCOL_VERSION: HubProtocolVersion = "v1";
export const MAX_CLIENT_HUB_PROTOCOL_VERSION: HubProtocolVersion = "v1";
export type HubCapabilityName =
| "client.register"
| "client.list"
| "session.create"
| "session.list"
| "session.get"
| "session.run"
| "session.abort"
| "schedule.create"
| "schedule.list"
| "settings.get"
| "settings.set";
export const HUB_CAPABILITIES: readonly HubCapabilityName[] = [
"client.register",
"client.list",
"session.create",
"session.list",
"session.get",
"session.run",
"session.abort",
"schedule.create",
"schedule.list",
"settings.get",
"settings.set",
];
export interface HubProtocolMetadata {
protocolVersion: string;
minClientProtocolVersion?: string;
maxClientProtocolVersion?: string;
capabilities?: readonly string[];
}
export type HubCompatibilityResult =
| { compatible: true }
| { compatible: false; reason: "missing_protocol" | "unsupported_protocol" };
function parseHubProtocolNumber(
version: string | undefined,
): number | undefined {
const match = /^v(\d+)$/.exec(version?.trim() ?? "");
if (!match) {
return undefined;
}
return Number.parseInt(match[1] ?? "", 10);
}
export function isHubProtocolCompatible(
hub: HubProtocolMetadata,
clientProtocolVersion: HubProtocolVersion = CURRENT_HUB_PROTOCOL_VERSION,
): HubCompatibilityResult {
const hubProtocol = parseHubProtocolNumber(hub.protocolVersion);
const clientProtocol = parseHubProtocolNumber(clientProtocolVersion);
if (hubProtocol === undefined || clientProtocol === undefined) {
return { compatible: false, reason: "missing_protocol" };
}
const minClientProtocol =
parseHubProtocolNumber(hub.minClientProtocolVersion) ?? hubProtocol;
const maxClientProtocol =
parseHubProtocolNumber(hub.maxClientProtocolVersion) ?? hubProtocol;
return clientProtocol >= minClientProtocol &&
clientProtocol <= maxClientProtocol
? { compatible: true }
: { compatible: false, reason: "unsupported_protocol" };
}
export type HubActorKind = "client" | "peerHub";
export type HubTransportKind =