mirror of
https://github.com/cline/cline.git
synced 2026-09-12 17:19:32 +08:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36d1a6a8b3 | ||
|
|
7835260289 | ||
|
|
09f714be3f | ||
|
|
e56f8e2180 | ||
|
|
34040f4dfc | ||
|
|
d3e52c9265 | ||
|
|
f0023509b7 | ||
|
|
61d3f9c46e | ||
|
|
3ecf6b2264 | ||
|
|
98439ff250 | ||
|
|
de987a5246 | ||
|
|
90050426df | ||
|
|
205c5676ff | ||
|
|
1c13edd395 | ||
|
|
a2a1936709 | ||
|
|
35ce6a3f26 | ||
|
|
cf51936151 | ||
|
|
2c4aeae4f3 | ||
|
|
4077bb4693 | ||
|
|
0c027d2731 | ||
|
|
d430b24237 | ||
|
|
6cc93c124e | ||
|
|
fa3893e2a8 | ||
|
|
7e5b8be28c | ||
|
|
162734782d | ||
|
|
1686e291a2 | ||
|
|
b1bf80a961 | ||
|
|
c3a7a7097c | ||
|
|
36de5e4d8f | ||
|
|
696b72b708 | ||
|
|
92c039e202 | ||
|
|
933ead9d80 | ||
|
|
f43f4fdd01 | ||
|
|
d38a98d3e5 | ||
|
|
0012aef210 | ||
|
|
c623875b85 | ||
|
|
fbe6e3212a |
+3
-1
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"root": false,
|
||||
"extends": ["../sdk/biome.json"],
|
||||
"extends": [
|
||||
"../sdk/biome.json"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": {
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.23
|
||||
|
||||
- Fixed Vertex AI GCP settings configuration
|
||||
- Fixed the Azure Foundry API version
|
||||
- Added support for configured agents as subagent tools
|
||||
- Centralized OAuth management into the SDK
|
||||
- Fixed an error caused by disabled reasoning on Fable 5
|
||||
|
||||
## 3.0.22
|
||||
|
||||
- Added support for the Claude Fable 5 model
|
||||
- Fixed MiniMax M3 thinking controls so they route correctly across gateways
|
||||
|
||||
## 3.0.21
|
||||
|
||||
- Added a global auto-update setting that controls automatic updates on CLI startup
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.21",
|
||||
"version": "3.0.23",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
+24
-59
@@ -1,11 +1,6 @@
|
||||
import type { ProviderSettings, ProviderSettingsManager } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { OAuthCredentials } from "../commands/auth";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
saveOAuthProviderSettings,
|
||||
toProviderApiKey,
|
||||
} from "../commands/auth";
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { loginAndSaveProviderOAuthCredentials } from "@cline/core";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
/**
|
||||
@@ -30,37 +25,13 @@ export function isAcpAuthMethodId(id: string): id is AcpAuthMethodId {
|
||||
* If the OAuth flow requires interactive prompts (rare), defaults are used
|
||||
* when available; otherwise an error is thrown.
|
||||
*/
|
||||
async function performOAuthLogin(
|
||||
providerId: AcpAuthMethodId,
|
||||
existingSettings: ProviderSettings | undefined,
|
||||
): Promise<OAuthCredentials> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }, coreOAuth] =
|
||||
await Promise.all([
|
||||
import("@cline/core"),
|
||||
import("open"),
|
||||
import("@cline/core").then((m) => ({
|
||||
loginClineOAuth: m.loginClineOAuth as (input: {
|
||||
useWorkOSDeviceAuth?: boolean;
|
||||
apiBaseUrl: string;
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>,
|
||||
loginOpenAICodex: m.loginOpenAICodex as (input: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => Promise<OAuthCredentials>,
|
||||
})),
|
||||
]);
|
||||
async function performOAuthLogin(input: {
|
||||
providerId: AcpAuthMethodId;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
}): Promise<string> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
|
||||
[import("@cline/core"), import("open")],
|
||||
);
|
||||
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: ({ defaultValue }) => {
|
||||
@@ -82,18 +53,18 @@ async function performOAuthLogin(
|
||||
},
|
||||
});
|
||||
|
||||
if (providerId === "cline") {
|
||||
return coreOAuth.loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existingSettings?.baseUrl?.trim() ||
|
||||
getClineEnvironmentConfig().apiBaseUrl,
|
||||
callbacks,
|
||||
useWorkOSDeviceAuth: true,
|
||||
});
|
||||
const settings = await loginAndSaveProviderOAuthCredentials(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
{ callbacks },
|
||||
);
|
||||
const apiKey = getPersistedProviderApiKey(input.providerId, settings);
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
`OAuth login did not persist credentials for ${input.providerId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// openai-codex
|
||||
return coreOAuth.loginOpenAICodex(callbacks);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export interface AcpAuthResult {
|
||||
@@ -122,16 +93,10 @@ export async function authenticateAcpProvider(
|
||||
|
||||
// Perform a fresh OAuth login.
|
||||
writeDiagnostic(`[acp/auth] Starting OAuth login for ${methodId}…`);
|
||||
const credentials = await performOAuthLogin(methodId, existing);
|
||||
|
||||
saveOAuthProviderSettings(
|
||||
const apiKey = await performOAuthLogin({
|
||||
providerId: methodId,
|
||||
providerSettingsManager,
|
||||
methodId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
|
||||
const apiKey = toProviderApiKey(methodId, credentials);
|
||||
});
|
||||
writeDiagnostic(`[acp/auth] Successfully authenticated with ${methodId}`);
|
||||
return { providerId: methodId, apiKey };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,37 @@ import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getPersistedProviderApiKey, saveOAuthProviderSettings } from "./auth";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
normalizeAuthProviderId,
|
||||
parseAuthCommandArgs,
|
||||
saveOAuthProviderSettings,
|
||||
} from "./auth";
|
||||
|
||||
describe("parseAuthCommandArgs", () => {
|
||||
it("parses Azure API version quick setup option", () => {
|
||||
expect(
|
||||
parseAuthCommandArgs([
|
||||
"--provider",
|
||||
"openai-compatible",
|
||||
"--apikey",
|
||||
"key",
|
||||
"--modelid",
|
||||
"gpt-4.1",
|
||||
"--baseurl",
|
||||
"https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
"--azure-api-version",
|
||||
"2025-01-01-preview",
|
||||
]),
|
||||
).toMatchObject({
|
||||
explicitProvider: "openai-compatible",
|
||||
apikey: "key",
|
||||
modelid: "gpt-4.1",
|
||||
baseurl: "https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveOAuthProviderSettings", () => {
|
||||
it("preserves existing manual apiKey while updating OAuth tokens", () => {
|
||||
@@ -67,6 +97,12 @@ describe("getPersistedProviderApiKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAuthProviderId", () => {
|
||||
it("keeps CLI-only codex shorthand in CLI parsing", () => {
|
||||
expect(normalizeAuthProviderId("codex")).toBe("openai-codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAuthTuiRuntime", () => {
|
||||
it("loads OpenTUI React after provider catalog initialization", async () => {
|
||||
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
+40
-124
@@ -3,11 +3,13 @@ import {
|
||||
BUILT_IN_PROVIDER,
|
||||
createOAuthClientCallbacks,
|
||||
ensureCustomProvidersLoaded,
|
||||
getProviderAuthHandler,
|
||||
listLocalProviders,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import React from "react";
|
||||
@@ -37,40 +39,6 @@ const c = {
|
||||
green: "\x1b[32m",
|
||||
};
|
||||
|
||||
type CoreOAuthApi = {
|
||||
loginClineOAuth: (input: {
|
||||
apiBaseUrl: string;
|
||||
useWorkOSDeviceAuth?: boolean;
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>;
|
||||
loginOcaOAuth: (input: {
|
||||
mode?: "internal" | "external";
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>;
|
||||
loginOpenAICodex: (input: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => Promise<OAuthCredentials>;
|
||||
};
|
||||
|
||||
type AuthIo = {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
@@ -81,6 +49,7 @@ type AuthQuickSetupInput = {
|
||||
apikey: string;
|
||||
modelid: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
};
|
||||
|
||||
type AuthCommandInput = {
|
||||
@@ -90,6 +59,7 @@ type AuthCommandInput = {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
};
|
||||
|
||||
type ParsedAuthCommandArgs = {
|
||||
@@ -97,30 +67,10 @@ type ParsedAuthCommandArgs = {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
parseError?: string;
|
||||
};
|
||||
|
||||
let cachedCoreOAuthApi: Promise<CoreOAuthApi> | undefined;
|
||||
|
||||
async function getCoreOAuthApi(): Promise<CoreOAuthApi> {
|
||||
if (!cachedCoreOAuthApi) {
|
||||
cachedCoreOAuthApi = import("@cline/core").then((module) => {
|
||||
const runtimeApi = module as Partial<CoreOAuthApi>;
|
||||
if (
|
||||
typeof runtimeApi.loginClineOAuth !== "function" ||
|
||||
typeof runtimeApi.loginOcaOAuth !== "function" ||
|
||||
typeof runtimeApi.loginOpenAICodex !== "function"
|
||||
) {
|
||||
throw new Error(
|
||||
"Installed @cline/core does not expose OAuth login helpers required by the CLI",
|
||||
);
|
||||
}
|
||||
return runtimeApi as CoreOAuthApi;
|
||||
});
|
||||
}
|
||||
return cachedCoreOAuthApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `auth` subcommand for Commander.
|
||||
*
|
||||
@@ -137,7 +87,8 @@ export function createAuthCommand(): Command {
|
||||
.option("-p, --provider <id>", "provider id")
|
||||
.option("-k, --apikey <key>", "API key")
|
||||
.option("-m, --modelid <id>", "model id")
|
||||
.option("-b, --baseurl <url>", "base URL");
|
||||
.option("-b, --baseurl <url>", "base URL")
|
||||
.option("--azure-api-version <version>", "Azure API version");
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -154,6 +105,7 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
}>();
|
||||
const positionalProvider = cmd.args[0];
|
||||
return {
|
||||
@@ -161,6 +113,7 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
|
||||
apikey: opts.apikey,
|
||||
modelid: opts.modelid,
|
||||
baseurl: opts.baseurl,
|
||||
azureApiVersion: opts.azureApiVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +153,12 @@ async function ensureQuickSetupInputValid(
|
||||
) {
|
||||
return "base URL is only supported for OpenAI and OpenAI-compatible providers";
|
||||
}
|
||||
if (
|
||||
input.azureApiVersion?.trim() &&
|
||||
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_COMPATIBLE
|
||||
) {
|
||||
return "Azure API version is only supported for OpenAI-compatible providers";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -209,6 +168,7 @@ function saveQuickAuthProviderSettings(input: {
|
||||
apikey: string;
|
||||
modelid: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
}): void {
|
||||
const existing = input.providerSettingsManager.getProviderSettings(
|
||||
input.providerId,
|
||||
@@ -224,6 +184,12 @@ function saveQuickAuthProviderSettings(input: {
|
||||
if (input.baseurl?.trim()) {
|
||||
nextSettings.baseUrl = input.baseurl.trim();
|
||||
}
|
||||
if (input.azureApiVersion?.trim()) {
|
||||
nextSettings.azure = {
|
||||
...(nextSettings.azure ?? {}),
|
||||
apiVersion: input.azureApiVersion.trim(),
|
||||
};
|
||||
}
|
||||
input.providerSettingsManager.saveProviderSettings(nextSettings);
|
||||
}
|
||||
|
||||
@@ -272,64 +238,18 @@ function createOAuthCallbacks(io: AuthIo): {
|
||||
});
|
||||
}
|
||||
|
||||
async function loginWithOAuthProvider(
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
io: AuthIo,
|
||||
): Promise<OAuthCredentials> {
|
||||
const oauthApi = await getCoreOAuthApi();
|
||||
const callbacks = createOAuthCallbacks(io);
|
||||
|
||||
if (providerId === "cline") {
|
||||
return oauthApi.loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
useWorkOSDeviceAuth: true,
|
||||
callbacks,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "oca") {
|
||||
const mode = existing?.oca?.mode;
|
||||
return oauthApi.loginOcaOAuth({
|
||||
mode,
|
||||
callbacks,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "openai-codex") {
|
||||
return oauthApi.loginOpenAICodex(callbacks);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Provider "${providerId}" does not support CLI OAuth flow (supported: cline, openai-codex, oca)`,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveOAuthProviderSettings(
|
||||
providerSettingsManager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
credentials: OAuthCredentials,
|
||||
): ProviderSettings {
|
||||
const auth = {
|
||||
...(existing?.auth ?? {}),
|
||||
accessToken: toProviderApiKey(providerId, credentials),
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
} as ProviderSettings["auth"] & { expiresAt?: number };
|
||||
auth.expiresAt = credentials.expires;
|
||||
const merged: ProviderSettings = {
|
||||
...(existing ?? {
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
}),
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
auth,
|
||||
};
|
||||
providerSettingsManager.saveProviderSettings(merged, {
|
||||
tokenSource: "oauth",
|
||||
return saveProviderOAuthCredentials({
|
||||
manager: providerSettingsManager,
|
||||
providerId,
|
||||
settings: existing,
|
||||
credentials,
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function ensureOAuthProviderApiKey(input: {
|
||||
@@ -348,19 +268,14 @@ export async function ensureOAuthProviderApiKey(input: {
|
||||
selectedProviderSettings: input.existingSettings,
|
||||
};
|
||||
}
|
||||
const credentials = await loginWithOAuthProvider(
|
||||
input.providerId,
|
||||
input.existingSettings,
|
||||
input.io,
|
||||
);
|
||||
const selectedProviderSettings = saveOAuthProviderSettings(
|
||||
const selectedProviderSettings = await loginAndSaveProviderOAuthCredentials(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
input.existingSettings,
|
||||
credentials,
|
||||
{ callbacks: createOAuthCallbacks(input.io) },
|
||||
);
|
||||
const handler = getProviderAuthHandler(input.providerId);
|
||||
return {
|
||||
apiKey: toProviderApiKey(input.providerId, credentials),
|
||||
apiKey: handler?.getApiKey(selectedProviderSettings),
|
||||
selectedProviderSettings,
|
||||
};
|
||||
}
|
||||
@@ -370,12 +285,14 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
|
||||
const apikey = input.apikey?.trim() ?? "";
|
||||
const modelid = input.modelid?.trim() ?? "";
|
||||
const baseurl = input.baseurl?.trim();
|
||||
const azureApiVersion = input.azureApiVersion?.trim();
|
||||
const validationError = await ensureQuickSetupInputValid(
|
||||
{
|
||||
provider: providerId,
|
||||
apikey,
|
||||
modelid,
|
||||
baseurl,
|
||||
azureApiVersion,
|
||||
},
|
||||
input.providerSettingsManager,
|
||||
);
|
||||
@@ -389,6 +306,7 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
|
||||
apikey,
|
||||
modelid,
|
||||
baseurl,
|
||||
azureApiVersion,
|
||||
});
|
||||
input.io.writeln(
|
||||
`${c.green}Provider configured:${c.reset} ${c.cyan}${providerId}${c.reset} (${modelid})`,
|
||||
@@ -473,12 +391,13 @@ export async function runAuthCommand(input: AuthCommandInput): Promise<number> {
|
||||
const hasQuickSetupFlags =
|
||||
typeof input.apikey === "string" ||
|
||||
typeof input.modelid === "string" ||
|
||||
typeof input.baseurl === "string";
|
||||
typeof input.baseurl === "string" ||
|
||||
typeof input.azureApiVersion === "string";
|
||||
|
||||
if (hasQuickSetupFlags) {
|
||||
if (!input.explicitProvider?.trim()) {
|
||||
input.io.writeErr(
|
||||
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl",
|
||||
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl/--azure-api-version",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -515,13 +434,10 @@ export async function runAuthProviderCommand(
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
const credentials = await loginWithOAuthProvider(providerId, existing, io);
|
||||
saveOAuthProviderSettings(
|
||||
await loginAndSaveProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
{ callbacks: createOAuthCallbacks(io) },
|
||||
);
|
||||
io.writeln(
|
||||
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
|
||||
|
||||
@@ -14,16 +14,30 @@ import { getCliBuildInfo } from "../utils/common";
|
||||
const {
|
||||
mockSpawnSync,
|
||||
mockResolveClineDataDir,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockReadHubDiscovery,
|
||||
mockProbeHubServer,
|
||||
mockClearHubDiscovery,
|
||||
mockCreateHubServerUrl,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockStopLocalHubServerGracefully,
|
||||
mockStopConnectorsForHubs,
|
||||
mockEnsureFileExists,
|
||||
mockResolveHubEndpointOptions,
|
||||
mockStopAllConnectors,
|
||||
} = 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(
|
||||
@@ -37,8 +51,22 @@ const {
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockCreateHubServerUrl: vi.fn(
|
||||
(host: string, port: number, pathname: string) =>
|
||||
`ws://${host}:${port}${pathname}`,
|
||||
),
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockStopLocalHubServerGracefully: vi.fn(async () => false),
|
||||
mockStopConnectorsForHubs: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
queuedRestarts: 0,
|
||||
})),
|
||||
mockEnsureFileExists: vi.fn(),
|
||||
mockResolveHubEndpointOptions: vi.fn(() => ({
|
||||
host: "127.0.0.1",
|
||||
port: 25466,
|
||||
pathname: "/hub",
|
||||
})),
|
||||
mockStopAllConnectors: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
@@ -52,10 +80,14 @@ vi.mock("node:child_process", () => ({
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
createHubServerUrl: mockCreateHubServerUrl,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
resolveHubEndpointOptions: mockResolveHubEndpointOptions,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
ensureFileExists: mockEnsureFileExists,
|
||||
}));
|
||||
@@ -64,6 +96,10 @@ vi.mock("../connectors/common", () => ({
|
||||
isProcessRunning: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/restart", () => ({
|
||||
stopConnectorsForHubs: mockStopConnectorsForHubs,
|
||||
}));
|
||||
|
||||
vi.mock("./connect", () => ({
|
||||
stopAllConnectors: mockStopAllConnectors,
|
||||
}));
|
||||
@@ -76,7 +112,20 @@ 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);
|
||||
mockStopConnectorsForHubs.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
queuedRestarts: 0,
|
||||
});
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
@@ -110,7 +159,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,
|
||||
@@ -252,6 +302,115 @@ describe("runDoctorCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix queues active connectors for restart when killing hubs", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockStopConnectorsForHubs.mockResolvedValue({
|
||||
stoppedProcesses: 2,
|
||||
queuedRestarts: 1,
|
||||
});
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true, fix: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(mockStopConnectorsForHubs).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
{ targetHubUrl: "ws://127.0.0.1:25466/hub" },
|
||||
);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
killed: {
|
||||
connectorProcesses: 2,
|
||||
connectorProcessesQueuedForRestart: 1,
|
||||
connectorRestartsQueued: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix kills stale random-port hub daemons", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (command === "lsof") {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: "70001\n",
|
||||
};
|
||||
}
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "--" &&
|
||||
args[2] === "/sdk/packages/core/src/hub/daemon/entry.ts"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: [
|
||||
"70001 /Users/example/.bun/bin/bun /repo/sdk/packages/core/src/hub/daemon/entry.ts --cwd /workspace --host 127.0.0.1 --port 25466 --pathname /hub",
|
||||
"70002 /Users/example/.bun/bin/bun /repo/sdk/packages/core/src/hub/daemon/entry.ts --cwd /workspace --host 127.0.0.1 --port 0 --pathname /hub",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true, fix: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(killSpy).toHaveBeenCalledWith(70002, "SIGKILL");
|
||||
expect(killSpy).not.toHaveBeenCalledWith(70001, "SIGKILL");
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
before: {
|
||||
staleHubPids: [70002],
|
||||
},
|
||||
killed: {
|
||||
staleHubDaemons: 1,
|
||||
},
|
||||
});
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("doctor --fix kills stale code sidecar processes", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue(undefined);
|
||||
@@ -261,7 +420,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,
|
||||
|
||||
@@ -7,18 +7,21 @@ 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";
|
||||
import { stopConnectorsForHubs } from "../connectors/restart";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
listActiveConnectors,
|
||||
} from "../connectors/status";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
import { resolveDefaultCliHubUrl } from "../utils/hub-runtime";
|
||||
import { c, writeln } from "../utils/output";
|
||||
import { stopAllConnectors } from "./connect";
|
||||
|
||||
@@ -54,6 +57,7 @@ type DoctorStatus = {
|
||||
hubStartedAt?: string;
|
||||
hubUptime?: string;
|
||||
listeningPids: number[];
|
||||
staleHubPids: number[];
|
||||
hubStartupLocks: StartupArtifact[];
|
||||
staleCliPids: number[];
|
||||
staleSidecarPids: number[];
|
||||
@@ -77,7 +81,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 +156,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 +262,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 +286,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 +318,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 +344,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 +427,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 +452,7 @@ export async function runDoctorCommand(
|
||||
}
|
||||
if (
|
||||
before.listeningPids.length > 0 ||
|
||||
before.staleHubPids.length > 0 ||
|
||||
before.staleCliPids.length > 0 ||
|
||||
before.staleSidecarPids.length > 0
|
||||
) {
|
||||
@@ -423,7 +464,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 +474,25 @@ export async function runDoctorCommand(
|
||||
const killedHub = gracefullyStoppedHub
|
||||
? 0
|
||||
: killPids(refreshedAfterGracefulStop.listeningPids);
|
||||
const staleCliTargets = before.staleCliPids.filter(
|
||||
const restartAwareStoppedConnectors = await stopConnectorsForHubs(
|
||||
before.activeConnectors.map((record) => record.hubUrl),
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
{ targetHubUrl: resolveDefaultCliHubUrl() },
|
||||
);
|
||||
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,9 +514,15 @@ export async function runDoctorCommand(
|
||||
after,
|
||||
killed: {
|
||||
hubListeners: killedHub,
|
||||
staleHubDaemons: killedStaleHubs,
|
||||
cliProcesses: killedCli,
|
||||
sidecarProcesses: killedSidecars,
|
||||
connectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
connectorProcesses:
|
||||
stoppedConnectors.stoppedProcesses +
|
||||
restartAwareStoppedConnectors.stoppedProcesses,
|
||||
connectorProcessesQueuedForRestart:
|
||||
restartAwareStoppedConnectors.queuedRestarts,
|
||||
connectorRestartsQueued: restartAwareStoppedConnectors.queuedRestarts,
|
||||
connectorSessions: stoppedConnectors.stoppedSessions,
|
||||
hubStartupLocks: clearedArtifacts.startupLocks,
|
||||
hubDiscovery: clearedArtifacts.discovery,
|
||||
@@ -471,11 +532,20 @@ 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(
|
||||
`stopped connector processes ${c.dim}${stoppedConnectors.stoppedProcesses}${c.reset}`,
|
||||
`stopped connector processes ${c.dim}${stoppedConnectors.stoppedProcesses + restartAwareStoppedConnectors.stoppedProcesses}${c.reset}`,
|
||||
);
|
||||
writeln(
|
||||
`queued connector restarts ${c.dim}${restartAwareStoppedConnectors.queuedRestarts}${c.reset}`,
|
||||
);
|
||||
if (restartAwareStoppedConnectors.queuedRestarts > 0) {
|
||||
writeln(
|
||||
`${c.dim}queued connectors relaunch automatically the next time the hub starts (any cline command, or 'cline hub start')${c.reset}`,
|
||||
);
|
||||
}
|
||||
writeln(
|
||||
`stopped connector sessions ${c.dim}${stoppedConnectors.stoppedSessions}${c.reset}`,
|
||||
);
|
||||
@@ -487,6 +557,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",
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockClearHubDiscovery,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockRestartQueuedConnectorsForHub,
|
||||
mockStopLocalHubServerGracefully,
|
||||
mockStopConnectorsForHubs,
|
||||
} = vi.hoisted(() => ({
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
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",
|
||||
})),
|
||||
mockRestartQueuedConnectorsForHub: vi.fn(async () => ({
|
||||
restarted: 0,
|
||||
remaining: 0,
|
||||
})),
|
||||
mockStopLocalHubServerGracefully: vi.fn(),
|
||||
mockStopConnectorsForHubs: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
queuedRestarts: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
@@ -24,13 +39,34 @@ vi.mock("@cline/core", () => ({
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/restart", () => ({
|
||||
restartQueuedConnectorsForHub: mockRestartQueuedConnectorsForHub,
|
||||
stopConnectorsForHubs: mockStopConnectorsForHubs,
|
||||
}));
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
resolveDefaultCliHubUrl: () => "ws://127.0.0.1:25463/hub",
|
||||
}));
|
||||
|
||||
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 +109,108 @@ describe("createHubCommand", () => {
|
||||
uptime: "1m 5s",
|
||||
});
|
||||
});
|
||||
|
||||
it("queues associated connectors on stop", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockStopConnectorsForHubs.mockResolvedValue({
|
||||
stoppedProcesses: 2,
|
||||
queuedRestarts: 2,
|
||||
});
|
||||
|
||||
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(mockStopConnectorsForHubs).toHaveBeenCalledWith(
|
||||
["ws://127.0.0.1:25463/hub"],
|
||||
expect.any(Object),
|
||||
{ targetHubUrl: "ws://127.0.0.1:25463/hub" },
|
||||
);
|
||||
expect(JSON.parse(output.at(-1) || "")).toMatchObject({
|
||||
stopped: true,
|
||||
stoppedConnectorProcesses: 2,
|
||||
queuedConnectorRestarts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("restarts queued connectors on start", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
|
||||
await cmd.parseAsync(["start"], { from: "user" });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockRestartQueuedConnectorsForHub).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(output.at(-1)).toBe("ws://127.0.0.1:25463/hub");
|
||||
});
|
||||
|
||||
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] || "")).toMatchObject({ stopped: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,23 +3,45 @@ 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";
|
||||
import {
|
||||
restartQueuedConnectorsForHub,
|
||||
stopConnectorsForHubs,
|
||||
} from "../connectors/restart";
|
||||
import { resolveDefaultCliHubUrl } from "../utils/hub-runtime";
|
||||
|
||||
interface HubCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
async function stopHubServer(
|
||||
_workspaceRoot: string,
|
||||
io: HubCommandIo,
|
||||
): Promise<{
|
||||
stopped: boolean;
|
||||
stoppedConnectorProcesses: number;
|
||||
queuedConnectorRestarts: number;
|
||||
}> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (await stopLocalHubServerGracefully()) {
|
||||
const stoppedConnectors = discovery?.url
|
||||
? await stopConnectorsForHubs([discovery.url], io, {
|
||||
targetHubUrl: resolveDefaultCliHubUrl(),
|
||||
})
|
||||
: { stoppedProcesses: 0, queuedRestarts: 0 };
|
||||
if (await stopLocalHubServerGracefully(owner)) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return true;
|
||||
return {
|
||||
stopped: true,
|
||||
stoppedConnectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
queuedConnectorRestarts: stoppedConnectors.queuedRestarts,
|
||||
};
|
||||
}
|
||||
const pid = discovery?.pid;
|
||||
if (pid) {
|
||||
@@ -30,7 +52,11 @@ async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return !!pid;
|
||||
return {
|
||||
stopped: !!pid,
|
||||
stoppedConnectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
queuedConnectorRestarts: stoppedConnectors.queuedRestarts,
|
||||
};
|
||||
}
|
||||
|
||||
function formatHubUptimeFromStartedAt(
|
||||
@@ -46,6 +72,12 @@ function formatHubUptimeFromStartedAt(
|
||||
return formatUptime(Date.now() - timestamp);
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
io: HubCommandIo,
|
||||
setExitCode: (code: number) => void,
|
||||
@@ -89,6 +121,7 @@ export function createHubCommand(
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
await restartQueuedConnectorsForHub(url, io);
|
||||
io.writeln(url);
|
||||
}),
|
||||
);
|
||||
@@ -106,16 +139,19 @@ export function createHubCommand(
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
await restartQueuedConnectorsForHub(url, io);
|
||||
io.writeln(url);
|
||||
}),
|
||||
);
|
||||
|
||||
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(
|
||||
@@ -133,8 +169,7 @@ export function createHubCommand(
|
||||
hub.command("stop").action(
|
||||
action(async () => {
|
||||
const opts = hub.opts<{ cwd: string }>();
|
||||
const stopped = await stopHubServer(opts.cwd);
|
||||
io.writeln(JSON.stringify({ stopped }));
|
||||
io.writeln(JSON.stringify(await stopHubServer(opts.cwd, io)));
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -78,6 +78,74 @@ describe("saveLocalProviderSettings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("merges and clears Azure provider settings", () => {
|
||||
const save = vi.fn();
|
||||
const manager = {
|
||||
read: vi.fn().mockReturnValue({
|
||||
providers: {},
|
||||
}),
|
||||
write: vi.fn(),
|
||||
getFilePath: vi.fn().mockReturnValue("/tmp/providers.json"),
|
||||
getProviderSettings: vi.fn().mockReturnValue({
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2024-10-21",
|
||||
useIdentity: true,
|
||||
},
|
||||
}),
|
||||
saveProviderSettings: save,
|
||||
};
|
||||
|
||||
saveLocalProviderSettings(
|
||||
manager as unknown as ProviderSettingsManager,
|
||||
{
|
||||
action: "saveProviderSettings",
|
||||
providerId: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
},
|
||||
} as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useIdentity: true,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
|
||||
save.mockClear();
|
||||
manager.getProviderSettings.mockReturnValue({
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
},
|
||||
});
|
||||
|
||||
saveLocalProviderSettings(
|
||||
manager as unknown as ProviderSettingsManager,
|
||||
{
|
||||
action: "saveProviderSettings",
|
||||
providerId: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "",
|
||||
},
|
||||
} as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "openai-compatible",
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps OAuth auth fields when updating manual apiKey", () => {
|
||||
const save = vi.fn();
|
||||
const manager = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,11 +5,17 @@ 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 { stopConnectorsForHubs } from "../connectors/restart";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
resolveDefaultCliHubUrl,
|
||||
} from "../utils/hub-runtime";
|
||||
import { c, writeErr, writeln } from "../utils/output";
|
||||
import {
|
||||
getInstalledKanbanVersion,
|
||||
@@ -269,13 +275,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 +303,32 @@ 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}`);
|
||||
await stopConnectorsForHubs(
|
||||
[health.url],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
{
|
||||
targetHubUrl: resolveDefaultCliHubUrl(),
|
||||
},
|
||||
);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully().catch(() => false);
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
@@ -310,21 +337,22 @@ 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);
|
||||
|
||||
// Re-ensure a fresh hub instance is spawned.
|
||||
// Re-ensure a fresh hub instance is spawned. ensureCliHubServer also
|
||||
// drains the connector restart queue for the new hub.
|
||||
try {
|
||||
await ensureCliHubServer(process.cwd()); // return value intentionally unused here
|
||||
await ensureCliHubServer(process.cwd());
|
||||
writeln(`${c.green}✓${c.reset} ${c.dim}[hub] server restarted${c.reset}`);
|
||||
} catch (err) {
|
||||
writeErr(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
import { Command, CommanderError } from "commander";
|
||||
import {
|
||||
CLINE_CONNECTOR_RESTART_SPEC_ENV,
|
||||
isProcessRunning,
|
||||
readJsonFile,
|
||||
removeFile,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectorRestartSpec,
|
||||
ConnectStopResult,
|
||||
} from "./types";
|
||||
|
||||
@@ -113,6 +115,19 @@ export abstract class ConnectorBase<Options, State>
|
||||
}
|
||||
|
||||
protected writeStateFile(statePath: string, state: unknown): void {
|
||||
const restart = this.readRestartSpecFromEnv();
|
||||
if (
|
||||
restart &&
|
||||
state &&
|
||||
typeof state === "object" &&
|
||||
!Array.isArray(state)
|
||||
) {
|
||||
writeJsonFile(statePath, {
|
||||
...(state as Record<string, unknown>),
|
||||
restart,
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeJsonFile(statePath, state);
|
||||
}
|
||||
|
||||
@@ -120,6 +135,26 @@ export abstract class ConnectorBase<Options, State>
|
||||
removeFile(statePath);
|
||||
}
|
||||
|
||||
private readRestartSpecFromEnv(): ConnectorRestartSpec | undefined {
|
||||
const raw = process.env[CLINE_CONNECTOR_RESTART_SPEC_ENV]?.trim();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ConnectorRestartSpec>;
|
||||
if (
|
||||
parsed.connector === this.name &&
|
||||
Array.isArray(parsed.args) &&
|
||||
parsed.args.every((arg) => typeof arg === "string")
|
||||
) {
|
||||
return { connector: parsed.connector, args: parsed.args };
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed restart metadata from the environment.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected removeStaleState(
|
||||
statePath: string,
|
||||
readState: (path: string) => State | undefined,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
@@ -15,6 +16,8 @@ import { createCliLoggerAdapter } from "../logging/adapter";
|
||||
import { logSpawnedProcess } from "../logging/process";
|
||||
import { resolveCliLaunchSpec } from "../utils/internal-launch";
|
||||
|
||||
export const CLINE_CONNECTOR_RESTART_SPEC_ENV = "CLINE_CONNECTOR_RESTART_SPEC";
|
||||
|
||||
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
|
||||
return rawArgs.includes(flag);
|
||||
}
|
||||
@@ -183,6 +186,8 @@ export function spawnDetachedConnector(
|
||||
}
|
||||
const detachedLogFd = tryOpenDetachedLogFd(options?.logPath);
|
||||
try {
|
||||
const connectorName =
|
||||
commandPrefixArgs[0] === "connect" ? commandPrefixArgs[1] : undefined;
|
||||
const child = spawn(command.launcher, command.childArgs, {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
@@ -193,6 +198,14 @@ export function spawnDetachedConnector(
|
||||
env: {
|
||||
...withResolvedClineBuildEnv(process.env),
|
||||
[childEnvKey]: "1",
|
||||
...(connectorName
|
||||
? {
|
||||
[CLINE_CONNECTOR_RESTART_SPEC_ENV]: JSON.stringify({
|
||||
connector: connectorName,
|
||||
args: rawArgs,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
logSpawnedProcess({
|
||||
@@ -259,7 +272,20 @@ export function readJsonFile<T>(path: string, fallback: T): T {
|
||||
|
||||
export function writeJsonFile(path: string, value: unknown): void {
|
||||
ensureParentDir(path);
|
||||
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
|
||||
// Connector state and the restart queue persist raw CLI args, which can
|
||||
// include secrets like bot tokens. Recreate the file owner-only, matching
|
||||
// the discipline used for hub discovery records. The mode option only
|
||||
// applies on create, so remove any existing file first.
|
||||
rmSync(path, { force: true });
|
||||
writeFileSync(path, JSON.stringify(value, null, 2), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
try {
|
||||
chmodSync(path, 0o600);
|
||||
} catch {
|
||||
// Best-effort tightening on filesystems without chmod support.
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFile(path: string): void {
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockResolveClineDataDir, mockGetConnector } = vi.hoisted(() => ({
|
||||
mockResolveClineDataDir: vi.fn(),
|
||||
mockGetConnector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
ensureParentDir: (path: string) => {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./registry", () => ({
|
||||
getConnector: mockGetConnector,
|
||||
}));
|
||||
|
||||
import {
|
||||
restartQueuedConnectorsForHub,
|
||||
stopConnectorsForHubs,
|
||||
} from "./restart";
|
||||
|
||||
describe("connector restart queue", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockGetConnector.mockReset();
|
||||
mockResolveClineDataDir.mockReset();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("queues connector restart metadata when stopping connectors for a killed hub", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
botUsername: "bot",
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot", "--rpc-address", "ws://127.0.0.1:57648/hub"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const alive = new Set([12345]);
|
||||
const killSpy = vi
|
||||
.spyOn(process, "kill")
|
||||
.mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (alive.has(Number(pid))) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
alive.delete(Number(pid));
|
||||
return true;
|
||||
});
|
||||
|
||||
const stopped = await stopConnectorsForHubs(
|
||||
["ws://127.0.0.1:57648/hub"],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
{
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
},
|
||||
);
|
||||
|
||||
expect(stopped).toEqual({ stoppedProcesses: 1, queuedRestarts: 1 });
|
||||
expect(killSpy).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(existsSync(statePath)).toBe(false);
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{
|
||||
connector: "telegram",
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
pid: 12345,
|
||||
},
|
||||
]);
|
||||
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
["-m", "bot", "--rpc-address", "ws://127.0.0.1:25466/hub"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("rewrites equals-form rpc address args when restarting queued connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot", "--rpc-address=ws://127.0.0.1:57648/hub"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
["-m", "bot", "--rpc-address=ws://127.0.0.1:25466/hub"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("only restarts queue entries targeted at the started hub", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot-a"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot-a.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot-b"],
|
||||
hubUrl: "ws://127.0.0.1:57649/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25467/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot-b.json"),
|
||||
pid: 12346,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 1 });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
["-m", "bot-a", "--rpc-address", "ws://127.0.0.1:25466/hub"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{
|
||||
args: ["-m", "bot-b"],
|
||||
targetHubUrl: "ws://127.0.0.1:25467/hub",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"writes the restart queue owner-only",
|
||||
async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["--bot-token", "secret"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const alive = new Set([12345]);
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (alive.has(Number(pid))) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
alive.delete(Number(pid));
|
||||
return true;
|
||||
});
|
||||
|
||||
await stopConnectorsForHubs(["ws://127.0.0.1:57648/hub"], {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
|
||||
expect(statSync(queuePath).mode & 0o777).toBe(0o600);
|
||||
},
|
||||
);
|
||||
|
||||
it("claims queue entries before launching connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
let queueExistedDuringRun: boolean | undefined;
|
||||
const run = vi.fn(async () => {
|
||||
queueExistedDuringRun = existsSync(queuePath);
|
||||
return 0;
|
||||
});
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(queueExistedDuringRun).toBe(false);
|
||||
});
|
||||
|
||||
it("drops queue entries for unknown connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "renamed-connector",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
mockGetConnector.mockResolvedValue(undefined);
|
||||
const errors: string[] = [];
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 0, remaining: 0 });
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
expect(errors).toEqual([
|
||||
'[connect] dropping queued restart for unknown connector "renamed-connector"',
|
||||
]);
|
||||
});
|
||||
|
||||
it("requeues failed restarts with an attempt count and drops them at the cap", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
const entry = {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(queuePath, JSON.stringify([entry]), "utf8");
|
||||
const run = vi.fn(async () => 1);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
const io = { writeln: () => {}, writeErr: () => {} };
|
||||
|
||||
const first = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
io,
|
||||
);
|
||||
expect(first).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 1 },
|
||||
]);
|
||||
|
||||
const second = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
io,
|
||||
);
|
||||
expect(second).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 2 },
|
||||
]);
|
||||
|
||||
const errors: string[] = [];
|
||||
const third = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(third).toEqual({ restarted: 0, remaining: 0 });
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
expect(errors).toEqual([
|
||||
'[connect] dropping queued restart for connector "telegram" after 3 failed attempts',
|
||||
]);
|
||||
});
|
||||
|
||||
it("requeues entries when the connector run throws", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
const run = vi.fn(async () => {
|
||||
throw new Error("spawn failed");
|
||||
});
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps state and skips restart queue when connector termination fails", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
botUsername: "bot",
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (Number(pid) === 12345) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const errors: string[] = [];
|
||||
|
||||
const stopped = await stopConnectorsForHubs(["ws://127.0.0.1:57648/hub"], {
|
||||
writeln: () => {},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
});
|
||||
|
||||
expect(stopped).toEqual({ stoppedProcesses: 0, queuedRestarts: 0 });
|
||||
expect(existsSync(statePath)).toBe(true);
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
expect(errors).toEqual([
|
||||
"[connect] failed to stop connector pid=12345 hub=ws://127.0.0.1:57648/hub",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores non-directory entries while scanning connector state", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(queuePath, "[]", "utf8");
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
botUsername: "bot",
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const alive = new Set([12345]);
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (alive.has(Number(pid))) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
alive.delete(Number(pid));
|
||||
return true;
|
||||
});
|
||||
|
||||
const stopped = await stopConnectorsForHubs(["ws://127.0.0.1:57648/hub"], {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
|
||||
expect(stopped).toEqual({ stoppedProcesses: 1, queuedRestarts: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{
|
||||
connector: "telegram",
|
||||
targetHubUrl: "ws://127.0.0.1:57648/hub",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
import {
|
||||
isProcessRunning,
|
||||
readJsonFile,
|
||||
removeFile,
|
||||
terminateProcess,
|
||||
writeJsonFile,
|
||||
} from "./common";
|
||||
import { getConnector } from "./registry";
|
||||
import type { ConnectIo, ConnectorRestartSpec } from "./types";
|
||||
|
||||
type ConnectorStateForRestart = {
|
||||
statePath: string;
|
||||
pid: number;
|
||||
hubUrl: string;
|
||||
restart?: ConnectorRestartSpec;
|
||||
};
|
||||
|
||||
type QueuedConnectorRestart = ConnectorRestartSpec & {
|
||||
hubUrl: string;
|
||||
targetHubUrl: string;
|
||||
statePath: string;
|
||||
pid: number;
|
||||
stoppedAt: string;
|
||||
attempts?: number;
|
||||
};
|
||||
|
||||
const MAX_RESTART_ATTEMPTS = 3;
|
||||
|
||||
export type StopConnectorsForHubsOptions = {
|
||||
targetHubUrl?: string;
|
||||
};
|
||||
|
||||
export type StopConnectorsForHubsResult = {
|
||||
stoppedProcesses: number;
|
||||
queuedRestarts: number;
|
||||
};
|
||||
|
||||
export type RestartQueuedConnectorsResult = {
|
||||
restarted: number;
|
||||
remaining: number;
|
||||
};
|
||||
|
||||
function restartQueuePath(): string {
|
||||
return join(resolveClineDataDir(), "connectors", "restart-queue.json");
|
||||
}
|
||||
|
||||
function normalizeHubUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url.includes("://") ? url : `ws://${url}`);
|
||||
if (parsed.protocol === "http:") {
|
||||
parsed.protocol = "ws:";
|
||||
} else if (parsed.protocol === "https:") {
|
||||
parsed.protocol = "wss:";
|
||||
}
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function readQueue(): QueuedConnectorRestart[] {
|
||||
const parsed = readJsonFile<unknown>(restartQueuePath(), []);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter((entry): entry is QueuedConnectorRestart => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = entry as Partial<QueuedConnectorRestart>;
|
||||
return (
|
||||
typeof record.connector === "string" &&
|
||||
Array.isArray(record.args) &&
|
||||
record.args.every((arg) => typeof arg === "string") &&
|
||||
typeof record.hubUrl === "string" &&
|
||||
typeof record.targetHubUrl === "string" &&
|
||||
typeof record.statePath === "string" &&
|
||||
typeof record.pid === "number" &&
|
||||
typeof record.stoppedAt === "string" &&
|
||||
(record.attempts === undefined || typeof record.attempts === "number")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function writeQueue(queue: QueuedConnectorRestart[]): void {
|
||||
if (queue.length === 0) {
|
||||
removeFile(restartQueuePath());
|
||||
return;
|
||||
}
|
||||
writeJsonFile(restartQueuePath(), queue);
|
||||
}
|
||||
|
||||
function listConnectorStatePaths(): string[] {
|
||||
const root = join(resolveClineDataDir(), "connectors");
|
||||
if (!existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
const paths: string[] = [];
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const dir = join(root, entry.name);
|
||||
try {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name.endsWith(".json") && !name.endsWith(".threads.json")) {
|
||||
paths.push(join(dir, name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore connector directories that disappear while scanning.
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function readConnectorStateForRestart(
|
||||
statePath: string,
|
||||
): ConnectorStateForRestart | undefined {
|
||||
const parsed = readJsonFile<Record<string, unknown> | undefined>(
|
||||
statePath,
|
||||
undefined,
|
||||
);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
|
||||
const hubUrl =
|
||||
typeof parsed.hubUrl === "string"
|
||||
? parsed.hubUrl
|
||||
: typeof parsed.rpcAddress === "string"
|
||||
? parsed.rpcAddress
|
||||
: undefined;
|
||||
if (!pid || !hubUrl || !isProcessRunning(pid)) {
|
||||
return undefined;
|
||||
}
|
||||
const restart =
|
||||
parsed.restart &&
|
||||
typeof parsed.restart === "object" &&
|
||||
!Array.isArray(parsed.restart)
|
||||
? (parsed.restart as Partial<ConnectorRestartSpec>)
|
||||
: undefined;
|
||||
return {
|
||||
statePath,
|
||||
pid,
|
||||
hubUrl,
|
||||
restart:
|
||||
typeof restart?.connector === "string" &&
|
||||
Array.isArray(restart.args) &&
|
||||
restart.args.every((arg) => typeof arg === "string")
|
||||
? { connector: restart.connector, args: restart.args }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function queueConnectorRestart(
|
||||
state: ConnectorStateForRestart,
|
||||
targetHubUrl: string,
|
||||
): boolean {
|
||||
if (!state.restart) {
|
||||
return false;
|
||||
}
|
||||
const queue = readQueue().filter(
|
||||
(entry) =>
|
||||
entry.statePath !== state.statePath &&
|
||||
!(
|
||||
entry.connector === state.restart?.connector && entry.pid === state.pid
|
||||
),
|
||||
);
|
||||
queue.push({
|
||||
...state.restart,
|
||||
hubUrl: state.hubUrl,
|
||||
targetHubUrl,
|
||||
statePath: state.statePath,
|
||||
pid: state.pid,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
});
|
||||
writeQueue(queue);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function stopConnectorsForHubs(
|
||||
hubUrls: string[],
|
||||
io: ConnectIo,
|
||||
options: StopConnectorsForHubsOptions = {},
|
||||
): Promise<StopConnectorsForHubsResult> {
|
||||
const targetHubUrls = new Set(hubUrls.map(normalizeHubUrl));
|
||||
if (targetHubUrls.size === 0) {
|
||||
return { stoppedProcesses: 0, queuedRestarts: 0 };
|
||||
}
|
||||
const restartTargetHubUrl = options.targetHubUrl
|
||||
? normalizeHubUrl(options.targetHubUrl)
|
||||
: undefined;
|
||||
let stoppedProcesses = 0;
|
||||
let queuedRestarts = 0;
|
||||
for (const statePath of listConnectorStatePaths()) {
|
||||
const state = readConnectorStateForRestart(statePath);
|
||||
if (!state || !targetHubUrls.has(normalizeHubUrl(state.hubUrl))) {
|
||||
continue;
|
||||
}
|
||||
if (!(await terminateProcess(state.pid))) {
|
||||
io.writeErr(
|
||||
`[connect] failed to stop connector pid=${state.pid} hub=${state.hubUrl}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
stoppedProcesses += 1;
|
||||
io.writeln(
|
||||
`[connect] stopped connector pid=${state.pid} hub=${state.hubUrl}`,
|
||||
);
|
||||
if (
|
||||
queueConnectorRestart(
|
||||
state,
|
||||
restartTargetHubUrl ?? normalizeHubUrl(state.hubUrl),
|
||||
)
|
||||
) {
|
||||
queuedRestarts += 1;
|
||||
}
|
||||
removeFile(statePath);
|
||||
}
|
||||
return { stoppedProcesses, queuedRestarts };
|
||||
}
|
||||
|
||||
function withHubRpcAddress(args: string[], hubUrl: string): string[] {
|
||||
const next = args.filter((arg) => arg !== "-i" && arg !== "--interactive");
|
||||
for (let index = 0; index < next.length; index += 1) {
|
||||
if (next[index]?.startsWith("--rpc-address=")) {
|
||||
next[index] = `--rpc-address=${hubUrl}`;
|
||||
return next;
|
||||
}
|
||||
if (next[index] === "--rpc-address" && next[index + 1]) {
|
||||
next[index + 1] = hubUrl;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return [...next, "--rpc-address", hubUrl];
|
||||
}
|
||||
|
||||
// Restarting a connector re-runs its connect command, which ensures the hub
|
||||
// and drains this queue again. The guard turns those nested drains into
|
||||
// no-ops so a queue entry is never picked up twice within one process.
|
||||
let drainInProgress = false;
|
||||
|
||||
export async function restartQueuedConnectorsForHub(
|
||||
hubUrl: string,
|
||||
io: ConnectIo,
|
||||
): Promise<RestartQueuedConnectorsResult> {
|
||||
if (drainInProgress) {
|
||||
return { restarted: 0, remaining: readQueue().length };
|
||||
}
|
||||
const queue = readQueue();
|
||||
if (queue.length === 0) {
|
||||
return { restarted: 0, remaining: 0 };
|
||||
}
|
||||
const targetHubUrl = normalizeHubUrl(hubUrl);
|
||||
const matched: QueuedConnectorRestart[] = [];
|
||||
const remaining: QueuedConnectorRestart[] = [];
|
||||
for (const entry of queue) {
|
||||
if (normalizeHubUrl(entry.targetHubUrl) === targetHubUrl) {
|
||||
matched.push(entry);
|
||||
} else {
|
||||
remaining.push(entry);
|
||||
}
|
||||
}
|
||||
if (matched.length === 0) {
|
||||
return { restarted: 0, remaining: remaining.length };
|
||||
}
|
||||
// Claim matched entries before running them so a crash mid-restart (or a
|
||||
// concurrent drain in another process) cannot replay entries that already
|
||||
// launched a connector.
|
||||
writeQueue(remaining);
|
||||
let restarted = 0;
|
||||
const failed: QueuedConnectorRestart[] = [];
|
||||
drainInProgress = true;
|
||||
try {
|
||||
for (const entry of matched) {
|
||||
const connector = await getConnector(entry.connector);
|
||||
if (!connector) {
|
||||
io.writeErr(
|
||||
`[connect] dropping queued restart for unknown connector "${entry.connector}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const exitCode = await connector
|
||||
.run(withHubRpcAddress(entry.args, hubUrl), io)
|
||||
.catch(() => 1);
|
||||
if (exitCode === 0) {
|
||||
restarted += 1;
|
||||
continue;
|
||||
}
|
||||
const attempts = (entry.attempts ?? 0) + 1;
|
||||
if (attempts >= MAX_RESTART_ATTEMPTS) {
|
||||
io.writeErr(
|
||||
`[connect] dropping queued restart for connector "${entry.connector}" after ${attempts} failed attempts`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
failed.push({ ...entry, attempts });
|
||||
}
|
||||
} finally {
|
||||
drainInProgress = false;
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
// Re-read before appending so entries queued while restarting survive.
|
||||
writeQueue([...readQueue(), ...failed]);
|
||||
}
|
||||
return { restarted, remaining: readQueue().length };
|
||||
}
|
||||
@@ -8,6 +8,11 @@ export type ConnectStopResult = {
|
||||
stoppedSessions: number;
|
||||
};
|
||||
|
||||
export type ConnectorRestartSpec = {
|
||||
connector: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
export interface ConnectCommandDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
@@ -152,6 +152,7 @@ export async function runCli(): Promise<void> {
|
||||
.option("-k, --apikey <key>", "API key")
|
||||
.option("-m, --modelid <id>", "Model ID")
|
||||
.option("-b, --baseurl <url>", "Base URL")
|
||||
.option("--azure-api-version <version>", "Azure API version")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option(
|
||||
@@ -165,6 +166,7 @@ export async function runCli(): Promise<void> {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
config?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
@@ -195,6 +197,7 @@ export async function runCli(): Promise<void> {
|
||||
apikey: opts.apikey,
|
||||
modelid: opts.modelid,
|
||||
baseurl: opts.baseurl,
|
||||
azureApiVersion: opts.azureApiVersion,
|
||||
io,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const coreMocks = vi.hoisted(() => {
|
||||
@@ -9,13 +9,14 @@ const coreMocks = vi.hoisted(() => {
|
||||
return {
|
||||
getProviderSettings: vi.fn(),
|
||||
saveProviderSettings: vi.fn(),
|
||||
getValidClineCredentials: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@cline/core", () => {
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
ClineAccountService: class {
|
||||
constructor(options: {
|
||||
apiBaseUrl: string;
|
||||
@@ -32,7 +33,6 @@ vi.mock("@cline/core", () => {
|
||||
coreMocks.saveProviderSettings(settings, options);
|
||||
}
|
||||
},
|
||||
getValidClineCredentials: coreMocks.getValidClineCredentials,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -59,15 +59,51 @@ function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
function mockFetchJson(body: unknown, status = 200): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createClineAccountService", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.getValidClineCredentials.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("refreshes persisted Cline OAuth credentials before creating the account service", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: "new-access",
|
||||
refreshToken: "new-refresh",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: "2096-10-02T07:06:40.000Z",
|
||||
userInfo: {
|
||||
subject: "sub-new",
|
||||
email: "new@example.com",
|
||||
name: "New User",
|
||||
clineUserId: "acct-new",
|
||||
accounts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
@@ -77,26 +113,12 @@ describe("createClineAccountService", () => {
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
coreMocks.getValidClineCredentials.mockResolvedValue({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: 4_000_000_000_000,
|
||||
accountId: "acct-new",
|
||||
});
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
const service = await createClineAccountService({ config: makeConfig() });
|
||||
|
||||
expect(service).toBeDefined();
|
||||
expect(coreMocks.getValidClineCredentials).toHaveBeenCalledWith(
|
||||
{
|
||||
access: "old-access",
|
||||
refresh: "refresh-token",
|
||||
expires: 1,
|
||||
accountId: "acct-old",
|
||||
},
|
||||
{ apiBaseUrl: "https://api.cline.bot" },
|
||||
);
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
expect(coreMocks.saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "cline",
|
||||
@@ -115,6 +137,14 @@ describe("createClineAccountService", () => {
|
||||
});
|
||||
|
||||
it("asks the user to re-authenticate when Cline OAuth credentials cannot refresh", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson(
|
||||
{
|
||||
error: "invalid_grant",
|
||||
error_description: "refresh expired",
|
||||
},
|
||||
401,
|
||||
);
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
@@ -123,7 +153,6 @@ describe("createClineAccountService", () => {
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
coreMocks.getValidClineCredentials.mockResolvedValue(null);
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
|
||||
|
||||
@@ -4,16 +4,18 @@ import {
|
||||
type ClineAccountOrganizationBalance,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
getValidClineCredentials,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
import { toProviderApiKey } from "../utils/provider-auth";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const WORKOS_TOKEN_PREFIX = "workos:";
|
||||
export const CLINE_CREDITS_DASHBOARD_URL =
|
||||
"https://app.cline.bot/dashboard/account?tab=credits";
|
||||
|
||||
@@ -69,26 +71,13 @@ function resolveClineAccountAuthToken(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): string | undefined {
|
||||
const persistedAccessToken =
|
||||
input.clineProviderSettings?.auth?.accessToken?.trim() || "";
|
||||
const configApiKey =
|
||||
input.config.providerId === "cline" ? input.config.apiKey.trim() : "";
|
||||
const settingsApiKey =
|
||||
input.clineProviderSettings?.apiKey?.trim() ||
|
||||
input.clineProviderSettings?.auth?.apiKey?.trim() ||
|
||||
"";
|
||||
|
||||
let authToken = persistedAccessToken || configApiKey || settingsApiKey;
|
||||
if (authToken.toLowerCase().startsWith("workos:workos:")) {
|
||||
authToken = authToken.slice("workos:".length);
|
||||
}
|
||||
return authToken || undefined;
|
||||
}
|
||||
|
||||
function stripWorkosTokenPrefix(accessToken: string): string {
|
||||
return accessToken.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
? accessToken.slice(WORKOS_TOKEN_PREFIX.length)
|
||||
: accessToken;
|
||||
return (
|
||||
getPersistedProviderApiKey("cline", input.clineProviderSettings) ||
|
||||
configApiKey ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveValidClineAccountAuthToken(input: {
|
||||
@@ -98,43 +87,26 @@ async function resolveValidClineAccountAuthToken(input: {
|
||||
apiBaseUrl: string;
|
||||
}): Promise<string | undefined> {
|
||||
const settings = input.clineProviderSettings;
|
||||
const auth = settings?.auth;
|
||||
const accessToken = auth?.accessToken?.trim();
|
||||
const refreshToken = auth?.refreshToken?.trim();
|
||||
if (settings && auth && accessToken && refreshToken) {
|
||||
const credentials = await getValidClineCredentials(
|
||||
{
|
||||
access: stripWorkosTokenPrefix(accessToken),
|
||||
refresh: refreshToken,
|
||||
expires: auth.expiresAt ?? Date.now() - 1,
|
||||
accountId: auth.accountId,
|
||||
},
|
||||
{ apiBaseUrl: input.apiBaseUrl },
|
||||
);
|
||||
if (!credentials) {
|
||||
const credentials = settings
|
||||
? getProviderOAuthCredentialsFromSettings("cline", settings)
|
||||
: null;
|
||||
if (settings && credentials) {
|
||||
const nextCredentials = await getValidClineCredentials(credentials, {
|
||||
apiBaseUrl: input.apiBaseUrl,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new Error(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
}
|
||||
const nextAccessToken = toProviderApiKey("cline", credentials);
|
||||
if (
|
||||
nextAccessToken !== accessToken ||
|
||||
credentials.refresh !== refreshToken ||
|
||||
credentials.accountId !== auth.accountId ||
|
||||
credentials.expires !== auth.expiresAt
|
||||
) {
|
||||
input.manager.saveProviderSettings(
|
||||
{
|
||||
...settings,
|
||||
auth: {
|
||||
...(settings.auth ?? {}),
|
||||
accessToken: nextAccessToken,
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
expiresAt: credentials.expires,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false, tokenSource: "oauth" },
|
||||
const nextAccessToken = formatProviderOAuthApiKey("cline", nextCredentials);
|
||||
if (nextCredentials !== credentials) {
|
||||
saveLocalProviderOAuthCredentials(
|
||||
input.manager,
|
||||
"cline",
|
||||
settings,
|
||||
nextCredentials,
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
}
|
||||
return nextAccessToken;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
isOAuthProvider,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
@@ -21,12 +22,13 @@ import {
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { isOAuthProvider } from "../../../utils/provider-auth";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
type ProviderConfigValues,
|
||||
resolveProviderConfigAwsRegion,
|
||||
resolveProviderConfigAzure,
|
||||
resolveProviderConfigGcp,
|
||||
resolveProviderConfigSap,
|
||||
updateProviderConfigValue,
|
||||
} from "../../utils/provider-config-values";
|
||||
@@ -315,8 +317,11 @@ export function UseExistingOrReconfigureContent(
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
azureApiVersion: "Azure API Version",
|
||||
awsRegion: "AWS Region",
|
||||
awsProfile: "AWS Profile Name",
|
||||
gcpProjectId: "Google Cloud Project ID",
|
||||
gcpRegion: "Google Cloud Region",
|
||||
sapClientId: "Client ID",
|
||||
sapClientSecret: "Client Secret",
|
||||
sapTokenUrl: "Token URL",
|
||||
@@ -329,8 +334,11 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
> = {
|
||||
apiKey: "sk-...",
|
||||
baseUrl: "",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
awsRegion: "us-east-1",
|
||||
awsProfile: "default",
|
||||
gcpProjectId: "my-gcp-project",
|
||||
gcpRegion: "us-central1",
|
||||
sapClientId: "sb-...|xsuaa_std!b...",
|
||||
sapClientSecret: "SAP AI Core client secret",
|
||||
sapTokenUrl: "https://<subdomain>.authentication.sap.hana.ondemand.com",
|
||||
@@ -341,7 +349,10 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
/** Render order for cycling focus with Tab. */
|
||||
const FIELD_ORDER: ProviderConfigFieldKey[] = [
|
||||
"awsRegion",
|
||||
"gcpProjectId",
|
||||
"gcpRegion",
|
||||
"baseUrl",
|
||||
"azureApiVersion",
|
||||
"apiKey",
|
||||
"awsProfile",
|
||||
"sapClientId",
|
||||
@@ -398,11 +409,22 @@ export function ProviderConfigInputContent(
|
||||
config.fields.baseUrl?.defaultValue ??
|
||||
"";
|
||||
}
|
||||
if (config.fields.azureApiVersion) {
|
||||
initial.azureApiVersion =
|
||||
existingSettings?.azure?.apiVersion?.trim() ?? "";
|
||||
}
|
||||
if (config.fields.awsRegion) {
|
||||
const ep = existingSettings?.aws?.profile?.trim() ?? "";
|
||||
initial.awsRegion =
|
||||
existingSettings?.aws?.region?.trim() || getDefaultAwsRegion(ep);
|
||||
}
|
||||
if (config.fields.gcpProjectId)
|
||||
initial.gcpProjectId = existingSettings?.gcp?.projectId?.trim() ?? "";
|
||||
if (config.fields.gcpRegion)
|
||||
initial.gcpRegion =
|
||||
existingSettings?.gcp?.region?.trim() ??
|
||||
config.fields.gcpRegion.defaultValue ??
|
||||
"us-central1";
|
||||
if (config.fields.apiKey)
|
||||
initial.apiKey = existingSettings?.apiKey?.trim() ?? "";
|
||||
if (config.fields.awsProfile)
|
||||
@@ -430,7 +452,9 @@ export function ProviderConfigInputContent(
|
||||
const submit = () => {
|
||||
const apiKey = values.apiKey?.trim();
|
||||
const awsProfile = values.awsProfile?.trim();
|
||||
const hasAzureFields = config.fields.azureApiVersion;
|
||||
const hasAwsFields = config.fields.awsRegion || config.fields.awsProfile;
|
||||
const hasGcpFields = config.fields.gcpProjectId || config.fields.gcpRegion;
|
||||
const hasSapFields =
|
||||
config.fields.sapClientId ||
|
||||
config.fields.sapClientSecret ||
|
||||
@@ -441,6 +465,7 @@ export function ProviderConfigInputContent(
|
||||
providerId,
|
||||
apiKey: config.fields.apiKey ? apiKey : undefined,
|
||||
baseUrl: config.fields.baseUrl ? values.baseUrl?.trim() : undefined,
|
||||
azure: hasAzureFields ? resolveProviderConfigAzure(values) : undefined,
|
||||
aws: hasAwsFields
|
||||
? {
|
||||
region: resolveProviderConfigAwsRegion(values),
|
||||
@@ -448,6 +473,7 @@ export function ProviderConfigInputContent(
|
||||
profile: apiKey ? undefined : awsProfile || undefined,
|
||||
}
|
||||
: undefined,
|
||||
gcp: hasGcpFields ? resolveProviderConfigGcp(values) : undefined,
|
||||
sap: hasSapFields ? resolveProviderConfigSap(values) : undefined,
|
||||
});
|
||||
resolve(true);
|
||||
@@ -671,7 +697,7 @@ export function OAuthLoginContent(
|
||||
if (!isActiveAuthAttempt(attempt)) return;
|
||||
saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
@@ -705,30 +731,24 @@ export function OAuthLoginContent(
|
||||
const manager = new ProviderSettingsManager();
|
||||
const existing = manager.getProviderSettings(providerId);
|
||||
|
||||
loginLocalProvider(
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
existing,
|
||||
(url: string) => {
|
||||
setAuthUrl(url);
|
||||
setStatus("Waiting for authentication in browser...");
|
||||
try {
|
||||
void open(url, { wait: false }).catch(() => {
|
||||
setStatus(
|
||||
"Could not open browser automatically. Open the URL below.",
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
loginLocalProvider(providerId, existing, (url: string) => {
|
||||
setAuthUrl(url);
|
||||
setStatus("Waiting for authentication in browser...");
|
||||
try {
|
||||
void open(url, { wait: false }).catch(() => {
|
||||
setStatus(
|
||||
"Could not open browser automatically. Open the URL below.",
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
});
|
||||
} catch {
|
||||
setStatus("Could not open browser automatically. Open the URL below.");
|
||||
}
|
||||
})
|
||||
.then((credentials) => {
|
||||
if (!isActiveAuthAttempt(attempt)) return;
|
||||
saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
resolveProviderConfigAwsRegion,
|
||||
resolveProviderConfigAzure,
|
||||
resolveProviderConfigGcp,
|
||||
resolveProviderConfigSap,
|
||||
updateProviderConfigValue,
|
||||
} from "./provider-config-values";
|
||||
@@ -66,6 +68,18 @@ describe("provider config values", () => {
|
||||
).toBe("us-west-2");
|
||||
});
|
||||
|
||||
it("resolves Vertex GCP field values into GCP settings", () => {
|
||||
expect(
|
||||
resolveProviderConfigGcp({ gcpRegion: "us-central1" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveProviderConfigGcp({
|
||||
gcpProjectId: " project ",
|
||||
gcpRegion: " europe-west4 ",
|
||||
}),
|
||||
).toEqual({ projectId: "project", region: "europe-west4" });
|
||||
});
|
||||
|
||||
it("resolves SAP AI Core field values into SAP settings", () => {
|
||||
expect(
|
||||
resolveProviderConfigSap({
|
||||
@@ -83,4 +97,24 @@ describe("provider config values", () => {
|
||||
deploymentId: "deployment",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Azure API version into Azure settings", () => {
|
||||
expect(
|
||||
resolveProviderConfigAzure({
|
||||
azureApiVersion: " 2025-01-01-preview ",
|
||||
}),
|
||||
).toEqual({
|
||||
apiVersion: "2025-01-01-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps blank Azure API version so persisted settings can be cleared", () => {
|
||||
expect(
|
||||
resolveProviderConfigAzure({
|
||||
azureApiVersion: " ",
|
||||
}),
|
||||
).toEqual({
|
||||
apiVersion: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ProviderConfigValues = Partial<
|
||||
>;
|
||||
|
||||
const DEFAULT_AWS_REGION = "us-east-1";
|
||||
const DEFAULT_GCP_REGION = "us-central1";
|
||||
|
||||
export function getDefaultAwsRegion(profile?: string): string {
|
||||
return (
|
||||
@@ -20,6 +21,20 @@ export function resolveProviderConfigAwsRegion(
|
||||
return values.awsRegion?.trim() || getDefaultAwsRegion(values.awsProfile);
|
||||
}
|
||||
|
||||
export function resolveProviderConfigGcp(values: ProviderConfigValues):
|
||||
| {
|
||||
projectId?: string;
|
||||
region?: string;
|
||||
}
|
||||
| undefined {
|
||||
const projectId = values.gcpProjectId?.trim() || undefined;
|
||||
if (!projectId) return undefined;
|
||||
return {
|
||||
projectId,
|
||||
region: values.gcpRegion?.trim() || DEFAULT_GCP_REGION,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderConfigSap(values: ProviderConfigValues):
|
||||
| {
|
||||
clientId?: string;
|
||||
@@ -41,6 +56,12 @@ export function resolveProviderConfigSap(values: ProviderConfigValues):
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderConfigAzure(values: ProviderConfigValues): {
|
||||
apiVersion?: string;
|
||||
} {
|
||||
return { apiVersion: values.azureApiVersion?.trim() ?? "" };
|
||||
}
|
||||
|
||||
export function updateProviderConfigValue(
|
||||
previous: ProviderConfigValues,
|
||||
field: ProviderConfigFieldKey,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
type ITelemetryService,
|
||||
isOAuthProvider,
|
||||
loginLocalProvider,
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
@@ -9,16 +10,12 @@ import {
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import open from "open";
|
||||
|
||||
export type OnboardingOAuthProviderId = "cline" | "oca" | "openai-codex";
|
||||
export type OnboardingOAuthProviderId = string;
|
||||
|
||||
export function isOnboardingOAuthProviderId(
|
||||
providerId: string,
|
||||
): providerId is OnboardingOAuthProviderId {
|
||||
return (
|
||||
providerId === "cline" ||
|
||||
providerId === "oca" ||
|
||||
providerId === "openai-codex"
|
||||
);
|
||||
return isOAuthProvider(providerId);
|
||||
}
|
||||
|
||||
export function runOAuthAuthFlow(input: {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getDefaultAwsRegion,
|
||||
type ProviderConfigValues,
|
||||
resolveProviderConfigAwsRegion,
|
||||
resolveProviderConfigAzure,
|
||||
resolveProviderConfigSap,
|
||||
updateProviderConfigValue,
|
||||
} from "../../utils/provider-config-values";
|
||||
@@ -382,6 +383,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
config.fields.baseUrl?.defaultValue ??
|
||||
"";
|
||||
}
|
||||
if (config.fields.azureApiVersion) {
|
||||
initialValues.azureApiVersion =
|
||||
existing?.azure?.apiVersion?.trim() ?? "";
|
||||
}
|
||||
if (config.fields.awsRegion) {
|
||||
const existingProfile = existing?.aws?.profile?.trim() ?? "";
|
||||
initialValues.awsRegion =
|
||||
@@ -444,6 +449,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
// surfaced when the model picker / first turn runs.
|
||||
const apiKey = byoValues.apiKey?.trim();
|
||||
const awsProfile = byoValues.awsProfile?.trim();
|
||||
const hasAzureFields = byoFields.azureApiVersion;
|
||||
const hasAwsFields = byoFields.awsRegion || byoFields.awsProfile;
|
||||
const hasSapFields =
|
||||
byoFields.sapClientId ||
|
||||
@@ -456,6 +462,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
providerId: activeProviderId,
|
||||
apiKey: byoFields.apiKey ? apiKey : undefined,
|
||||
baseUrl: byoFields.baseUrl ? byoValues.baseUrl?.trim() : undefined,
|
||||
azure: hasAzureFields ? resolveProviderConfigAzure(byoValues) : undefined,
|
||||
aws: hasAwsFields
|
||||
? {
|
||||
region: resolveProviderConfigAwsRegion(byoValues),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ProviderConfigFieldKey } from "@cline/core";
|
||||
export const FIELD_ORDER: ProviderConfigFieldKey[] = [
|
||||
"awsRegion",
|
||||
"baseUrl",
|
||||
"azureApiVersion",
|
||||
"apiKey",
|
||||
"awsProfile",
|
||||
"sapClientId",
|
||||
|
||||
@@ -222,6 +222,7 @@ import type {
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
azureApiVersion: "Azure API Version",
|
||||
awsRegion: "AWS Region",
|
||||
awsProfile: "AWS Profile Name",
|
||||
sapClientId: "Client ID",
|
||||
@@ -236,6 +237,7 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
> = {
|
||||
apiKey: "Paste your API key here...",
|
||||
baseUrl: "",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
awsRegion: "us-east-1",
|
||||
awsProfile: "default",
|
||||
sapClientId: "sb-...|xsuaa_std!b...",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockEnsureDetachedHubServer, mockRestartQueuedConnectorsForHub } =
|
||||
vi.hoisted(() => ({
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockRestartQueuedConnectorsForHub: vi.fn(async () => ({
|
||||
restarted: 0,
|
||||
remaining: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
createHubServerUrl: (host: string, port: number, pathname: string) =>
|
||||
`ws://${host}:${port}${pathname}`,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
resolveDefaultHubHost: () => "127.0.0.1",
|
||||
resolveDefaultHubPort: () => 25463,
|
||||
resolveHubEndpointOptions: () => ({
|
||||
host: "127.0.0.1",
|
||||
port: 25463,
|
||||
pathname: "/hub",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/restart", () => ({
|
||||
restartQueuedConnectorsForHub: mockRestartQueuedConnectorsForHub,
|
||||
}));
|
||||
|
||||
import { ensureCliHubServer } from "./hub-runtime";
|
||||
|
||||
describe("ensureCliHubServer", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("drains the connector restart queue after ensuring the hub", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const resolution = await ensureCliHubServer("/workspace");
|
||||
|
||||
expect(resolution.url).toBe("ws://127.0.0.1:25463/hub");
|
||||
expect(mockRestartQueuedConnectorsForHub).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the hub resolution even when draining the queue fails", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRestartQueuedConnectorsForHub.mockRejectedValueOnce(
|
||||
new Error("queue unreadable"),
|
||||
);
|
||||
|
||||
const resolution = await ensureCliHubServer("/workspace");
|
||||
|
||||
expect(resolution.url).toBe("ws://127.0.0.1:25463/hub");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
createHubServerUrl,
|
||||
type DetachedHubResolution,
|
||||
ensureDetachedHubServer,
|
||||
type HubEndpointOverrides,
|
||||
resolveDefaultHubHost,
|
||||
resolveDefaultHubPort,
|
||||
resolveHubEndpointOptions,
|
||||
} from "@cline/core";
|
||||
import { restartQueuedConnectorsForHub } from "../connectors/restart";
|
||||
|
||||
/**
|
||||
* Build a `host:port` rpc address string that respects the current build
|
||||
@@ -15,6 +18,11 @@ export function resolveDefaultCliRpcAddress(): string {
|
||||
return `${resolveDefaultHubHost()}:${resolveDefaultHubPort()}`;
|
||||
}
|
||||
|
||||
export function resolveDefaultCliHubUrl(): string {
|
||||
const endpoint = resolveHubEndpointOptions();
|
||||
return createHubServerUrl(endpoint.host, endpoint.port, endpoint.pathname);
|
||||
}
|
||||
|
||||
export function parseHubEndpointOverride(
|
||||
rawAddress: string | undefined,
|
||||
): HubEndpointOverrides {
|
||||
@@ -43,5 +51,12 @@ export async function ensureCliHubServer(
|
||||
workspaceRoot: string,
|
||||
endpoint: HubEndpointOverrides = {},
|
||||
): Promise<DetachedHubResolution> {
|
||||
return await ensureDetachedHubServer(workspaceRoot, endpoint);
|
||||
const resolution = await ensureDetachedHubServer(workspaceRoot, endpoint);
|
||||
// Connectors queued by hub stop/doctor/update cleanup come back as soon
|
||||
// as any CLI path brings the hub up, not only explicit hub commands.
|
||||
await restartQueuedConnectorsForHub(resolution.url, {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
}).catch(() => undefined);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Llms, type ProviderSettings } from "@cline/core";
|
||||
import { isOAuthProviderId } from "@cline/shared";
|
||||
import {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey as getCorePersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
Llms,
|
||||
type ProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
} from "@cline/core";
|
||||
|
||||
export type OAuthCredentials = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
export type OAuthCredentials = ProviderOAuthCredentials;
|
||||
|
||||
export function normalizeProviderId(providerId: string): string {
|
||||
return Llms.normalizeProviderId(providerId.trim());
|
||||
@@ -22,42 +21,20 @@ export function normalizeAuthProviderId(providerId: string): string {
|
||||
return normalizeProviderId(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-exports `isOAuthProviderId` from `@cline/shared` so the CLI has a
|
||||
* single source of truth for the OAuth provider list. Existing call sites
|
||||
* keep their `isOAuthProvider` import name.
|
||||
*/
|
||||
export const isOAuthProvider = isOAuthProviderId;
|
||||
export { isOAuthProvider };
|
||||
|
||||
export function toProviderApiKey(
|
||||
providerId: string,
|
||||
credentials: Pick<OAuthCredentials, "access">,
|
||||
): string {
|
||||
if (providerId === "cline") {
|
||||
return credentials.access.startsWith("workos:")
|
||||
? credentials.access
|
||||
: `workos:${credentials.access}`;
|
||||
}
|
||||
return credentials.access;
|
||||
return formatProviderOAuthApiKey(providerId, credentials);
|
||||
}
|
||||
|
||||
export function getPersistedProviderApiKey(
|
||||
providerId: string,
|
||||
settings?: ProviderSettings,
|
||||
): string | undefined {
|
||||
const accessToken = settings?.auth?.accessToken?.trim();
|
||||
if (accessToken) {
|
||||
return toProviderApiKey(providerId, { access: accessToken });
|
||||
}
|
||||
const shorthandKey = settings?.apiKey?.trim();
|
||||
if (shorthandKey) {
|
||||
return shorthandKey;
|
||||
}
|
||||
const authKey = settings?.auth?.apiKey?.trim();
|
||||
if (authKey) {
|
||||
return authKey;
|
||||
}
|
||||
return undefined;
|
||||
return getCorePersistedProviderApiKey(providerId, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +53,7 @@ export function isProviderConfigured(
|
||||
settings: ProviderSettings | undefined,
|
||||
): boolean {
|
||||
if (!settings) return false;
|
||||
if (isOAuthProviderId(providerId)) {
|
||||
if (isOAuthProvider(providerId)) {
|
||||
return Boolean(settings.auth?.accessToken?.trim());
|
||||
}
|
||||
if (getPersistedProviderApiKey(providerId, settings)) return true;
|
||||
|
||||
@@ -139,6 +139,12 @@ describe("provider readiness", () => {
|
||||
gcp: { projectId: "test-project" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("vertex", {
|
||||
provider: "vertex",
|
||||
gcp: { projectId: "test-project", region: "us-central1" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("sapaicore", {
|
||||
provider: "sapaicore",
|
||||
|
||||
@@ -33,6 +33,8 @@ function hasAwsRegion(settings: ProviderSettings): boolean {
|
||||
|
||||
function hasGcpCredentials(settings: ProviderSettings): boolean {
|
||||
const gcp = settings.gcp;
|
||||
// Vertex defaults to us-central1 at runtime when no region is stored, so keep
|
||||
// existing project-only configs usable while new CLI saves include a region.
|
||||
return hasText(gcp?.projectId);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,13 @@ import {
|
||||
executeClineAccountAction,
|
||||
getLocalProviderModels,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
@@ -117,17 +116,10 @@ export async function handleDesktopCommand(
|
||||
}
|
||||
if (command === "run_provider_oauth_login") {
|
||||
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
const credentials = await loginLocalProvider(
|
||||
providerId,
|
||||
existing,
|
||||
openExternalUrl,
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
openExternalUrl,
|
||||
);
|
||||
return {
|
||||
provider: providerId,
|
||||
|
||||
@@ -4,9 +4,8 @@ import {
|
||||
getLocalProviderModels,
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import type {
|
||||
@@ -134,17 +133,10 @@ export async function runProviderOAuthLogin(
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
const normalized = normalizeOAuthProvider(providerId);
|
||||
const existing = providerSettingsManager.getProviderSettings(normalized);
|
||||
const credentials = await loginLocalProvider(
|
||||
normalized,
|
||||
existing,
|
||||
openExternalUrl,
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
normalized,
|
||||
existing,
|
||||
credentials,
|
||||
openExternalUrl,
|
||||
);
|
||||
ctx.send(peer, {
|
||||
type: "provider_oauth_login_done",
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
listHookConfigFiles,
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
readGlobalSettings,
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
resolveSessionBackend,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
sendHubCommand,
|
||||
setDisabledPlugin,
|
||||
@@ -1012,10 +1011,9 @@ export async function handleCommand(
|
||||
if (command === "run_provider_oauth_login") {
|
||||
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
|
||||
const manager = new ProviderSettingsManager();
|
||||
const existing = manager.getProviderSettings(providerId);
|
||||
const credentials = await loginLocalProvider(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId,
|
||||
existing,
|
||||
(url) => {
|
||||
const platform = process.platform;
|
||||
const spawned =
|
||||
@@ -1033,12 +1031,6 @@ export async function handleCommand(
|
||||
spawned.unref();
|
||||
},
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
"useEnumInitializers": "off",
|
||||
"useSelfClosingElements": "info",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useNumberNamespace": "info",
|
||||
"noInferrableTypes": "info",
|
||||
"useNumberNamespace": "off",
|
||||
"noInferrableTypes": "off",
|
||||
"useTemplate": "info",
|
||||
"noUselessElse": "info"
|
||||
},
|
||||
|
||||
@@ -81,11 +81,12 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const isDeepSeekReasonerModel = model.id.includes("deepseek-reasoner")
|
||||
const isDeepSeekThinkingModel =
|
||||
model.id.includes("deepseek-reasoner") || model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro"
|
||||
isDeepSeekReasonerModel || model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro"
|
||||
|
||||
const convertedMessages = convertToOpenAiMessages(messages)
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepSeekThinkingModel
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepSeekReasonerModel
|
||||
? [{ role: "system", content: systemPrompt }, ...addReasoningContent(convertedMessages, messages)]
|
||||
: [{ role: "system", content: systemPrompt }, ...convertedMessages]
|
||||
|
||||
@@ -104,6 +105,13 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
}
|
||||
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
@@ -115,13 +123,6 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
}
|
||||
|
||||
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield* this.yieldUsage(model.info, chunk.usage)
|
||||
}
|
||||
|
||||
+105
-1
@@ -15,5 +15,109 @@
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": [
|
||||
"apps/vscode/**"
|
||||
],
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on",
|
||||
"useSortedAttributes": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "info",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noEmptyPattern": "info",
|
||||
"useJsxKeyInIterable": "off",
|
||||
"noInnerDeclarations": "off",
|
||||
"useHookAtTopLevel": "info",
|
||||
"useYield": "info",
|
||||
"noConstructorReturn": "off",
|
||||
"noInvalidPositionAtImportRule": "off",
|
||||
"noSwitchDeclarations": "off",
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"a11y": "info",
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
"useBlockStatements": "off",
|
||||
"useNamingConvention": "off",
|
||||
"useThrowOnlyError": "info",
|
||||
"useConsistentArrayType": "off",
|
||||
"noParameterAssign": "off",
|
||||
"useAsConstAssertion": "off",
|
||||
"useDefaultParameterLast": "off",
|
||||
"noNonNullAssertion": "info",
|
||||
"useEnumInitializers": "off",
|
||||
"useSelfClosingElements": "info",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useNumberNamespace": "off",
|
||||
"noInferrableTypes": "off",
|
||||
"useTemplate": "info",
|
||||
"noUselessElse": "info"
|
||||
},
|
||||
"suspicious": {
|
||||
"noDoubleEquals": "warn",
|
||||
"noImplicitAnyLet": "info",
|
||||
"noThenProperty": "off",
|
||||
"noAsyncPromiseExecutor": "info",
|
||||
"noImportAssign": "off",
|
||||
"noExplicitAny": "info",
|
||||
"noControlCharactersInRegex": "warn",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "info",
|
||||
"noAssignInExpressions": "info",
|
||||
"useIterableCallbackReturn": "info"
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessConstructor": "info",
|
||||
"useOptionalChain": "info",
|
||||
"noBannedTypes": "warn",
|
||||
"useLiteralKeys": "info",
|
||||
"noUselessCatch": "info",
|
||||
"noUselessSwitchCase": "info",
|
||||
"noStaticOnlyClass": "info"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 4,
|
||||
"lineWidth": 130,
|
||||
"lineEnding": "lf",
|
||||
"formatWithErrors": true
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "asNeeded",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": true,
|
||||
"bracketSpacing": true,
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"trailingCommas": "none",
|
||||
"expand": "always"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@cline/cli",
|
||||
"version": "3.0.21",
|
||||
"version": "3.0.22",
|
||||
"bin": {
|
||||
"cline": "src/index.ts",
|
||||
},
|
||||
@@ -371,7 +371,7 @@
|
||||
},
|
||||
"sdk/packages/agents": {
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.45",
|
||||
"version": "0.0.46",
|
||||
"dependencies": {
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
@@ -380,7 +380,7 @@
|
||||
},
|
||||
"sdk/packages/core": {
|
||||
"name": "@cline/core",
|
||||
"version": "0.0.45",
|
||||
"version": "0.0.46",
|
||||
"dependencies": {
|
||||
"@cline/agents": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
@@ -411,7 +411,7 @@
|
||||
},
|
||||
"sdk/packages/llms": {
|
||||
"name": "@cline/llms",
|
||||
"version": "0.0.45",
|
||||
"version": "0.0.46",
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.89",
|
||||
"@ai-sdk/anthropic": "^3.0.68",
|
||||
@@ -445,14 +445,14 @@
|
||||
},
|
||||
"sdk/packages/sdk": {
|
||||
"name": "@cline/sdk",
|
||||
"version": "0.0.45",
|
||||
"version": "0.0.46",
|
||||
"dependencies": {
|
||||
"@cline/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"sdk/packages/shared": {
|
||||
"name": "@cline/shared",
|
||||
"version": "0.0.45",
|
||||
"version": "0.0.46",
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.20",
|
||||
"jsonrepair": "^3.13.2",
|
||||
@@ -468,11 +468,11 @@
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.122", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-U1k2fk7cSH/tS5CZ3ujROiUCOLFwkzb792OqR/Org8Mfm27dKSIdRZG4ZuJUifT8alUWa61IoaRu4foXKlP5TQ=="],
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.123", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rL+3Sp9crOlfE7MwguFPS30qVp6HFcr9na0KYMb4CcQdxAIjBJec3EEdCjc94UyRVZWLmgQ6Yr605FmPlFIi0w=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@3.0.80", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5ORbm/yFUPO0MEvZsxBMN0cdKw2+lwU/wVn5KN3KF8Dmk1LughuDuUohMh/7iU/XFTiyB0OvmTW/tdV/J7O9zg=="],
|
||||
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.140", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/google": "3.0.80", "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Fz3STER9hcrY0uJ6wMg8tSasS5+FbLnBU9N89cw9KBkSUtq+Hefeert7y/UA7RUgPS+d6Z+kzmkqbdAU5dn+oA=="],
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.141", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/google": "3.0.80", "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-UGXQeV+z30Gk1/mhKZoXKbYO7Q0U7pg0Nlz44hb2B1FpYRFBu8+ziaI60BdetncaoxPocdNKhP41AD15IA6nJg=="],
|
||||
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KkdaMjs4C2y+vrZWJE990E3ZxBFiOTHQ94ZlquuIttpphcqJTMxNoIpnKT/4UzMVWXL0BUEE2vs+1UEVXkN8Kg=="],
|
||||
|
||||
@@ -484,7 +484,7 @@
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/react": ["@ai-sdk/react@3.0.196", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.27", "ai": "6.0.194", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-e/XI6e5cY/FYAvd7ThQzA/zadmVLukwUPvLS9+i1ZoSjwcgTB5LyUnvljMY/+8w++aCp0EKEgrJ+jiL81UHpXQ=="],
|
||||
"@ai-sdk/react": ["@ai-sdk/react@3.0.197", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.27", "ai": "6.0.195", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-sx5K0pvIWbgduMYGz++s28ldj+hu+GfDpXw9T2kL4n+bhRQpQvrH+jU0z3OqvjMrhD5oz9cGJ7bv/0JQEKXIbw=="],
|
||||
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
@@ -520,41 +520,41 @@
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1058.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.48", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-5D3cnn3h72xc7oXLfqMIP81Z7H2Y+FXUq+YGSHQRfGTEvbH+m+5IuA1SwmEKIktobXwjpSuQW34eUKSnTgK1ng=="],
|
||||
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1059.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.16", "@aws-sdk/credential-provider-node": "^3.972.49", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-HW3Oq2rL65tbuqkQzoRrjfF3jauRfra056Xv0K2YtBWt+LaeLrr95D8SlRmgGzXZHwy38FO/w0N1EKjsTYmDKw=="],
|
||||
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.974.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.5", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw=="],
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.974.16", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@aws-sdk/xml-builder": "^3.972.27", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-WXPvTfG7J2H4Ae6ewhd0285UC+8+9p/pKoibXXQlbXSqHexFLGM0oXHTwDfQEPmrNnvuWPpVjgoAUfW+cUFbXw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.38", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-OHkK6xOx/IHkSbQdDWxnVCLU+j28EFl8wyWgBILQDFAPY8n240C/O4gjmFx+zFU12lL8njgJQ5GWAIWq88CnSQ=="],
|
||||
"@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.39", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-fp7hiew245BbBiaDGjLDaMXqxbOWnqzuhczWrJq/6/3gDNHtZvkorxHQXYpHYiddetR8sBWe3S49HfZ2BQbYSg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg=="],
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-0+MCOYqeHyADFdeVr5/e1G8JJoWm7/szZAiKssqJS84E9+tO07509YNyQRJ5a7x5wO9YFAV324syrwm6yIcs5w=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA=="],
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-auuhqlnv4PUdfqcdHLKGTdoCceXOuby6WNeCMoxtZmQGWNCibbz95/lSYzNWq9cExN17UlRFqo1nvTcC+zHfEg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA=="],
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/credential-provider-env": "^3.972.42", "@aws-sdk/credential-provider-http": "^3.972.44", "@aws-sdk/credential-provider-login": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.42", "@aws-sdk/credential-provider-sso": "^3.972.46", "@aws-sdk/credential-provider-web-identity": "^3.972.46", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-G+8JuG0CfLcC2IQXFVqMR1+KDF1rksebr+YL6+HHYbNjs/hiwX53ye45sU3pJKljpS2uIXqOrOsicHIv02vWrw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA=="],
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-+H6h2/1Q+iIJ4w+FPCKM//xwy4C9yADknDTR68+K3PbOtB/uLup3zIH8PUKn6QwnttME4VM4ftSTnbvoDf5wXg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.48", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QIbtJP0olSLZ2ImEu636pP+7JJbPfaL3xSJIFXhu472CWuondCc4bGOa8OeyhOFet8z4H1D/ZFKXc39FboWwYA=="],
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.49", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.42", "@aws-sdk/credential-provider-http": "^3.972.44", "@aws-sdk/credential-provider-ini": "^3.972.47", "@aws-sdk/credential-provider-process": "^3.972.42", "@aws-sdk/credential-provider-sso": "^3.972.46", "@aws-sdk/credential-provider-web-identity": "^3.972.46", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-hlyoc+2352BhC+HF91t9avIDJu1+EvQGGltxFB8ADCpAHGYNNLEQAZJIPzw3O/bRiiDSE2B8pdj/fFCXlDTEDg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ=="],
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-r996IYVtQ7rWa5UfSs3fZLT/Dq/SSgH9wv9zahx9lcg6vvaPPQHFpTy45nZajZu/+OUdEQxEjTn9rOMons59mA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/token-providers": "3.1056.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA=="],
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/token-providers": "3.1059.0", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-vd+UqRbiYLvXXH3kTAuTxq+Vdb3rOgg29/FeB35ETsgdJTTB7x3YuqtpRckATsL5bDRXFVQo0uBj0/vxCJ8hHg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ=="],
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xuxBbMorygYsbDV6E/tUGQUgIDfhnzxz8uJYX8rByzehI6gLUu8RPsgOpLmh9YWerqeHZxJbqzgheBSB7tpooQ=="],
|
||||
|
||||
"@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1058.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1058.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-node": "^3.972.48", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/5Yuf7w8GRz2lic2cg0gRvZzJA2OgiX8HYoMNWLbs3b1vJelz222w5VlKsxPP9D376Zv38E+VHJenvwFgKjK+Q=="],
|
||||
"@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1059.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1059.0", "@aws-sdk/core": "^3.974.16", "@aws-sdk/credential-provider-cognito-identity": "^3.972.39", "@aws-sdk/credential-provider-env": "^3.972.42", "@aws-sdk/credential-provider-http": "^3.972.44", "@aws-sdk/credential-provider-ini": "^3.972.47", "@aws-sdk/credential-provider-login": "^3.972.46", "@aws-sdk/credential-provider-node": "^3.972.49", "@aws-sdk/credential-provider-process": "^3.972.42", "@aws-sdk/credential-provider-sso": "^3.972.46", "@aws-sdk/credential-provider-web-identity": "^3.972.46", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-cSdDFb/O3cQHGg78VxPaNruyy9zGxcoeS7DlwYTdwxmYkE0GXmBRoej5N+q+Av9YWBayZ2n3QsSYhtnUXa/GXw=="],
|
||||
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg=="],
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.14", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.16", "@aws-sdk/signature-v4-multi-region": "^3.996.31", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-T5CS1r4P27FjkBYIWwVibWqEuq32BbCga2Z5m5OBSdSdi2wPfW2vl6zLWAB/5MeeyC4s2pY/MY3cj2Gd3rgSkg=="],
|
||||
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw=="],
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.31", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ=="],
|
||||
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA=="],
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1059.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xql+7YBAE7WYb9xJfY0vcAXM8rJXfClmB2wkt+g/EoLUMog0pOb7o741fE96wFqha0uQTEgTo/5lGGguzavxmw=="],
|
||||
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.973.10", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g=="],
|
||||
|
||||
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
|
||||
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="],
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.27", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-hpsCXCOI436kxWpjtRuIHVvuPP81MOw8f18jzfZeg+UOiiOvlqWcmWChzEhJEu16cOC6+ku4ncBN+7rdt+DZ9g=="],
|
||||
|
||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
|
||||
|
||||
@@ -1662,7 +1662,7 @@
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ai": ["ai@6.0.194", "", { "dependencies": { "@ai-sdk/gateway": "3.0.122", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0MkYqrSZZuC1zTECppcaUT0i54aocXpYaUMVue3V8z/weBHCytfO5/CcwZCU80msZpfkbBUKYSSrkZFotEO5wQ=="],
|
||||
"ai": ["ai@6.0.195", "", { "dependencies": { "@ai-sdk/gateway": "3.0.123", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-IYZpuVz0boWbpIQYyinfWFrvQ1N0dG+EVB63it45B2YAU/MxxCnwz4zBswjYnPtHnJBedpMPNrwVbeczbl2GKg=="],
|
||||
|
||||
"ai-sdk-provider-claude-code": ["ai-sdk-provider-claude-code@3.4.4", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.1", "@anthropic-ai/claude-agent-sdk": "^0.2.63" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-iHcup5SHh4Tul1RIi9J+bnpngen8WX66yC3lsz1YlbtwAmRhUEzZUuGKzmFGIN8Pmx9uQrerGfLJdbFxIxKkyw=="],
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Cline SDK Changelog
|
||||
|
||||
## 0.0.46
|
||||
|
||||
- Added support for configured agents as subagent tools
|
||||
- Centralized OAuth management into the SDK
|
||||
- Added Vertex GCP settings configuration
|
||||
- Fixed the Azure Foundry API version for the CLI
|
||||
- Fixed an error caused by disabled reasoning on Fable 5
|
||||
|
||||
## 0.0.45
|
||||
|
||||
- Added support for the Claude Fable 5 model
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.45",
|
||||
"version": "0.0.46",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("AgentRuntime (provider-form config + Agent alias)", () => {
|
||||
apiKey: "test-key",
|
||||
baseUrl: undefined,
|
||||
headers: undefined,
|
||||
options: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -82,6 +83,32 @@ describe("AgentRuntime (provider-form config + Agent alias)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes provider options through to the llms gateway", () => {
|
||||
const model = new ScriptedModel([]);
|
||||
createAgentModel.mockReturnValue(model);
|
||||
|
||||
new Agent({
|
||||
providerId: "openai-compatible",
|
||||
modelId: "gpt-4.1",
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
options: { apiVersion: "2025-01-01-preview" },
|
||||
});
|
||||
|
||||
expect(createGateway).toHaveBeenCalledWith({
|
||||
providerConfigs: [
|
||||
{
|
||||
providerId: "openai-compatible",
|
||||
apiKey: "test-key",
|
||||
baseUrl:
|
||||
"https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
headers: undefined,
|
||||
options: { apiVersion: "2025-01-01-preview" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards abort() to the active AgentRuntime", async () => {
|
||||
let abortReason: unknown;
|
||||
const model = new ScriptedModel([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createGateway } from "@cline/llms";
|
||||
import { createGateway, type GatewayProviderSettings } from "@cline/llms";
|
||||
import type {
|
||||
AgentAfterToolResult,
|
||||
AgentBeforeModelResult,
|
||||
@@ -64,6 +64,8 @@ export interface AgentRuntimeConfigWithProvider
|
||||
baseUrl?: string;
|
||||
/** Additional headers for API requests */
|
||||
headers?: Record<string, string>;
|
||||
/** Provider-specific gateway options */
|
||||
options?: GatewayProviderSettings["options"];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,9 +90,10 @@ function resolveRuntimeConfig(
|
||||
if (hasPrebuiltModel(config)) {
|
||||
return config;
|
||||
}
|
||||
const { providerId, modelId, apiKey, baseUrl, headers, ...rest } = config;
|
||||
const { providerId, modelId, apiKey, baseUrl, headers, options, ...rest } =
|
||||
config;
|
||||
const gateway = createGateway({
|
||||
providerConfigs: [{ providerId, apiKey, baseUrl, headers }],
|
||||
providerConfigs: [{ providerId, apiKey, baseUrl, headers, options }],
|
||||
telemetry: rest.telemetry,
|
||||
});
|
||||
const model = gateway.createAgentModel({ providerId, modelId });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/core",
|
||||
"description": "Cline Core SDK for Node Runtime",
|
||||
"version": "0.0.45",
|
||||
"version": "0.0.46",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -22,7 +22,6 @@ const socketBindingSupported = await (async () => {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const _socketIt = socketBindingSupported ? it : it.skip;
|
||||
|
||||
function createCredentials(
|
||||
overrides: Partial<ClineOAuthCredentials> = {},
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
identifyAccount,
|
||||
} from "../services/telemetry/core-events";
|
||||
import { startLocalOAuthServer } from "./server";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthProviderInterface,
|
||||
} from "./types";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./types";
|
||||
import {
|
||||
isCredentialLikelyExpired,
|
||||
parseAuthorizationInput,
|
||||
@@ -701,22 +697,3 @@ export async function getValidClineCredentials(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createClineOAuthProvider(
|
||||
options: ClineOAuthProviderOptions,
|
||||
): OAuthProviderInterface {
|
||||
return {
|
||||
id: "cline",
|
||||
name: "Cline Account",
|
||||
usesCallbackServer: !(options.useWorkOSDeviceAuth ?? true),
|
||||
async login(callbacks) {
|
||||
return loginClineOAuth({ ...options, callbacks });
|
||||
},
|
||||
async refreshToken(credentials) {
|
||||
return refreshClineToken(credentials as ClineOAuthCredentials, options);
|
||||
},
|
||||
getApiKey(credentials) {
|
||||
return `workos:${credentials.access}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getValidOpenAICodexCredentials,
|
||||
normalizeOpenAICodexCredentials,
|
||||
refreshOpenAICodexToken,
|
||||
} from "./codex";
|
||||
import type { OAuthCredentials } from "./types";
|
||||
@@ -138,19 +137,6 @@ describe("auth/codex token lifecycle", () => {
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("normalizes credentials by deriving accountId from access token", () => {
|
||||
const accessToken = createJwt({
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: "acct-derived" },
|
||||
});
|
||||
const normalized = normalizeOpenAICodexCredentials({
|
||||
access: accessToken,
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
});
|
||||
expect(normalized.accountId).toBe("acct-derived");
|
||||
expect(normalized.metadata).toMatchObject({ provider: "openai-codex" });
|
||||
});
|
||||
|
||||
it("refreshOpenAICodexToken throws when response is structurally invalid", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -15,12 +15,7 @@ import {
|
||||
identifyAccount,
|
||||
} from "../services/telemetry/core-events";
|
||||
import { startLocalOAuthServer } from "./server";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthPrompt,
|
||||
OAuthProviderInterface,
|
||||
} from "./types";
|
||||
import type { OAuthCredentials, OAuthPrompt } from "./types";
|
||||
import {
|
||||
decodeJwtPayload,
|
||||
getProofKey,
|
||||
@@ -442,50 +437,3 @@ export async function getValidOpenAICodexCredentials(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpenAICodexTokenExpired(
|
||||
credentials: OAuthCredentials,
|
||||
refreshBufferMs: number = OPENAI_CODEX_OAUTH_CONFIG.refreshBufferMs,
|
||||
): boolean {
|
||||
return isCredentialLikelyExpired(credentials, refreshBufferMs);
|
||||
}
|
||||
|
||||
export function normalizeOpenAICodexCredentials(
|
||||
credentials: OAuthCredentials,
|
||||
): OAuthCredentials {
|
||||
const accountId = credentials.accountId ?? getAccountId(credentials.access);
|
||||
if (!accountId) {
|
||||
throw new Error("Failed to extract accountId from token");
|
||||
}
|
||||
return {
|
||||
...credentials,
|
||||
accountId,
|
||||
metadata: {
|
||||
...(credentials.metadata ?? {}),
|
||||
provider: "openai-codex",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const openaiCodexOAuthProvider: OAuthProviderInterface = {
|
||||
id: "openai-codex",
|
||||
name: "ChatGPT Plus/Pro (ChatGPT Subscription)",
|
||||
usesCallbackServer: true,
|
||||
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
return loginOpenAICodex({
|
||||
onAuth: callbacks.onAuth,
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
});
|
||||
},
|
||||
|
||||
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
||||
return refreshOpenAICodexToken(credentials.refresh, credentials);
|
||||
},
|
||||
|
||||
getApiKey(credentials: OAuthCredentials): string {
|
||||
return credentials.access;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,8 +12,6 @@ import { startLocalOAuthServer } from "./server";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthProviderInterface,
|
||||
OcaClientMetadata,
|
||||
OcaMode,
|
||||
OcaOAuthConfig,
|
||||
OcaOAuthProviderOptions,
|
||||
@@ -525,64 +523,3 @@ export async function getValidOcaCredentials(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOcaOAuthProvider(
|
||||
options: OcaOAuthProviderOptions = {},
|
||||
): OAuthProviderInterface {
|
||||
return {
|
||||
id: "oca",
|
||||
name: "Oracle Code Assist",
|
||||
usesCallbackServer: true,
|
||||
async login(callbacks) {
|
||||
return loginOcaOAuth({ ...options, callbacks });
|
||||
},
|
||||
async refreshToken(credentials) {
|
||||
return refreshOcaToken(credentials, options);
|
||||
},
|
||||
getApiKey(credentials) {
|
||||
return credentials.access;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateOcaOpcRequestId(
|
||||
taskId: string,
|
||||
token: string,
|
||||
): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const hash8 = async (value: string): Promise<string> => {
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
||||
return Array.from(new Uint8Array(digest).slice(0, 4), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
};
|
||||
|
||||
const [tokenHex, taskHex] = await Promise.all([hash8(token), hash8(taskId)]);
|
||||
const timestampHex = Math.floor(Date.now() / 1000)
|
||||
.toString(16)
|
||||
.padStart(8, "0");
|
||||
const randomPart = new Uint32Array(1);
|
||||
crypto.getRandomValues(randomPart);
|
||||
const randomHex = (randomPart[0] ?? 0).toString(16).padStart(8, "0");
|
||||
return tokenHex + taskHex + timestampHex + randomHex;
|
||||
}
|
||||
|
||||
export async function createOcaRequestHeaders(input: {
|
||||
accessToken: string;
|
||||
taskId: string;
|
||||
metadata?: OcaClientMetadata;
|
||||
}): Promise<Record<string, string>> {
|
||||
const opcRequestId = await generateOcaOpcRequestId(
|
||||
input.taskId,
|
||||
input.accessToken,
|
||||
);
|
||||
return {
|
||||
Authorization: `Bearer ${input.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
client: input.metadata?.client ?? "Cline",
|
||||
"client-version": input.metadata?.clientVersion ?? "unknown",
|
||||
"client-ide": input.metadata?.clientIde ?? "unknown",
|
||||
"client-ide-version": input.metadata?.clientIdeVersion ?? "unknown",
|
||||
[OCI_HEADER_OPC_REQUEST_ID]: opcRequestId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderAuthHandler,
|
||||
getProviderAuthStorageId,
|
||||
isOAuthProvider,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
} from "./provider-auth-registry";
|
||||
|
||||
const { loginClineOAuth } = vi.hoisted(() => ({
|
||||
loginClineOAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./cline", () => ({
|
||||
getValidClineCredentials: vi.fn(),
|
||||
loginClineOAuth,
|
||||
}));
|
||||
|
||||
vi.mock("./oca", () => ({
|
||||
getValidOcaCredentials: vi.fn(),
|
||||
loginOcaOAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./codex", () => ({
|
||||
getValidOpenAICodexCredentials: vi.fn(),
|
||||
loginOpenAICodex: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("provider auth registry", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns handlers for managed OAuth providers only", () => {
|
||||
expect(getProviderAuthHandler("cline")?.providerId).toBe("cline");
|
||||
expect(getProviderAuthHandler("oca")?.providerId).toBe("oca");
|
||||
expect(getProviderAuthHandler("openai-codex")?.providerId).toBe(
|
||||
"openai-codex",
|
||||
);
|
||||
expect(getProviderAuthHandler("openai-codex-cli")).toBeUndefined();
|
||||
expect(isOAuthProvider("openai-codex-cli")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns storage provider IDs from handlers", () => {
|
||||
expect(getProviderAuthStorageId("cline")).toBe("cline");
|
||||
expect(getProviderAuthStorageId("oca")).toBe("oca");
|
||||
expect(getProviderAuthStorageId("openai-codex")).toBe("openai-codex");
|
||||
expect(getProviderAuthStorageId("openai-codex-cli")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("formats Cline WorkOS tokens without double-prefixing", () => {
|
||||
expect(formatProviderOAuthApiKey("cline", { access: "abc" })).toBe(
|
||||
"workos:abc",
|
||||
);
|
||||
expect(formatProviderOAuthApiKey("cline", { access: "workos:abc" })).toBe(
|
||||
"workos:abc",
|
||||
);
|
||||
expect(
|
||||
getPersistedProviderApiKey("cline", {
|
||||
provider: "cline",
|
||||
auth: { accessToken: "abc" },
|
||||
}),
|
||||
).toBe("workos:abc");
|
||||
});
|
||||
|
||||
it("login/save stores credentials under handler storageProviderId", async () => {
|
||||
loginClineOAuth.mockResolvedValueOnce({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: 4_000_000_000_000,
|
||||
accountId: "acct-new",
|
||||
});
|
||||
const getProviderSettings = vi.fn().mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "manual-key",
|
||||
});
|
||||
const saveProviderSettings = vi.fn();
|
||||
const manager = {
|
||||
getProviderSettings,
|
||||
saveProviderSettings,
|
||||
} as never;
|
||||
|
||||
const saved = await loginAndSaveProviderOAuthCredentials(manager, "cline", {
|
||||
callbacks: {
|
||||
onAuth: vi.fn(),
|
||||
onPrompt: vi.fn(async () => ""),
|
||||
},
|
||||
});
|
||||
|
||||
expect(getProviderSettings).toHaveBeenCalledWith("cline");
|
||||
expect(saved).toMatchObject({
|
||||
provider: "cline",
|
||||
apiKey: "manual-key",
|
||||
auth: {
|
||||
accessToken: "workos:new-access",
|
||||
refreshToken: "new-refresh",
|
||||
accountId: "acct-new",
|
||||
expiresAt: 4_000_000_000_000,
|
||||
},
|
||||
});
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: "cline" }),
|
||||
{ tokenSource: "oauth" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
getClineEnvironmentConfig,
|
||||
type ITelemetryService,
|
||||
} from "@cline/shared";
|
||||
import type { ProviderSettingsManager } from "../services/storage/provider-settings-manager";
|
||||
import type { ProviderSettings } from "../types/provider-settings";
|
||||
import {
|
||||
type ClineOAuthCredentials,
|
||||
getValidClineCredentials,
|
||||
loginClineOAuth,
|
||||
} from "./cline";
|
||||
import { getValidOpenAICodexCredentials, loginOpenAICodex } from "./codex";
|
||||
import { getValidOcaCredentials, loginOcaOAuth } from "./oca";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./types";
|
||||
import { decodeJwtPayload } from "./utils";
|
||||
|
||||
const WORKOS_TOKEN_PREFIX = "workos:";
|
||||
|
||||
export type ProviderOAuthCredentials = OAuthCredentials;
|
||||
|
||||
export interface ProviderAuthLoginInput {
|
||||
settings?: ProviderSettings;
|
||||
callbacks: OAuthLoginCallbacks;
|
||||
telemetry?: ITelemetryService;
|
||||
}
|
||||
|
||||
export interface ProviderAuthRefreshInput {
|
||||
settings: ProviderSettings;
|
||||
credentials: ProviderOAuthCredentials;
|
||||
forceRefresh?: boolean;
|
||||
telemetry?: ITelemetryService;
|
||||
}
|
||||
|
||||
export interface ProviderAuthSaveCredentialsInput {
|
||||
manager: ProviderSettingsManager;
|
||||
settings?: ProviderSettings;
|
||||
credentials: ProviderOAuthCredentials;
|
||||
setLastUsed?: boolean;
|
||||
save?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderAuthHandler {
|
||||
providerId: string;
|
||||
storageProviderId: string;
|
||||
getApiKey(settings: ProviderSettings | undefined): string | undefined;
|
||||
login(input: ProviderAuthLoginInput): Promise<ProviderOAuthCredentials>;
|
||||
refresh(
|
||||
input: ProviderAuthRefreshInput,
|
||||
): Promise<ProviderOAuthCredentials | null>;
|
||||
saveCredentials(input: ProviderAuthSaveCredentialsInput): ProviderSettings;
|
||||
isConfigured(settings: ProviderSettings | undefined): boolean;
|
||||
normalizeStoredAccessToken?(accessToken: string): string;
|
||||
}
|
||||
|
||||
function formatClineApiKey(accessToken: string): string {
|
||||
const token = accessToken.trim();
|
||||
return token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
? token
|
||||
: `${WORKOS_TOKEN_PREFIX}${token}`;
|
||||
}
|
||||
|
||||
function stripClineApiKeyPrefix(accessToken: string): string {
|
||||
const token = accessToken.trim();
|
||||
return token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
? token.slice(WORKOS_TOKEN_PREFIX.length)
|
||||
: token;
|
||||
}
|
||||
|
||||
function readExpiryFromToken(accessToken: string): number | null {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const exp = payload?.exp;
|
||||
if (typeof exp === "number" && exp > 0) {
|
||||
return exp * 1000;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveCredentialExpiry(
|
||||
settings: ProviderSettings,
|
||||
normalizedAccessToken: string,
|
||||
): number {
|
||||
const explicitExpiry = settings.auth?.expiresAt;
|
||||
if (
|
||||
typeof explicitExpiry === "number" &&
|
||||
Number.isFinite(explicitExpiry) &&
|
||||
explicitExpiry > 0
|
||||
) {
|
||||
return explicitExpiry;
|
||||
}
|
||||
|
||||
const jwtExpiry = readExpiryFromToken(normalizedAccessToken);
|
||||
if (jwtExpiry) {
|
||||
return jwtExpiry;
|
||||
}
|
||||
|
||||
// Unknown expiry should trigger refresh on next resolution.
|
||||
return Date.now() - 1;
|
||||
}
|
||||
|
||||
function createCredentialsFromSettings(
|
||||
settings: ProviderSettings,
|
||||
options?: { normalizeAccessToken?: (accessToken: string) => string },
|
||||
): ProviderOAuthCredentials | null {
|
||||
const rawAccess = settings.auth?.accessToken?.trim();
|
||||
const refreshToken = settings.auth?.refreshToken?.trim();
|
||||
if (!rawAccess || !refreshToken) {
|
||||
return null;
|
||||
}
|
||||
const access = options?.normalizeAccessToken?.(rawAccess) ?? rawAccess;
|
||||
if (!access) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
access,
|
||||
refresh: refreshToken,
|
||||
expires: deriveCredentialExpiry(settings, access),
|
||||
accountId: settings.auth?.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
function saveOAuthCredentials(input: {
|
||||
manager: ProviderSettingsManager;
|
||||
storageProviderId: string;
|
||||
settings?: ProviderSettings;
|
||||
credentials: ProviderOAuthCredentials;
|
||||
formatAccessToken?: (accessToken: string) => string;
|
||||
setLastUsed?: boolean;
|
||||
save?: boolean;
|
||||
}): ProviderSettings {
|
||||
const accessToken =
|
||||
input.formatAccessToken?.(input.credentials.access) ??
|
||||
input.credentials.access;
|
||||
const auth = {
|
||||
...(input.settings?.auth ?? {}),
|
||||
accessToken,
|
||||
refreshToken: input.credentials.refresh,
|
||||
accountId: input.credentials.accountId,
|
||||
expiresAt: input.credentials.expires,
|
||||
};
|
||||
|
||||
const merged: ProviderSettings = {
|
||||
...(input.settings ?? {
|
||||
provider: input.storageProviderId as ProviderSettings["provider"],
|
||||
}),
|
||||
provider: input.storageProviderId as ProviderSettings["provider"],
|
||||
auth,
|
||||
};
|
||||
if (input.save !== false) {
|
||||
input.manager.saveProviderSettings(merged, {
|
||||
...(input.setLastUsed === undefined
|
||||
? {}
|
||||
: { setLastUsed: input.setLastUsed }),
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function createOAuthHandler(input: {
|
||||
providerId: string;
|
||||
storageProviderId?: string;
|
||||
formatAccessToken?: (accessToken: string) => string;
|
||||
normalizeStoredAccessToken?: (accessToken: string) => string;
|
||||
login: (input: ProviderAuthLoginInput) => Promise<ProviderOAuthCredentials>;
|
||||
refresh: (
|
||||
input: ProviderAuthRefreshInput,
|
||||
) => Promise<ProviderOAuthCredentials | null>;
|
||||
}): ProviderAuthHandler {
|
||||
const storageProviderId = input.storageProviderId ?? input.providerId;
|
||||
return {
|
||||
providerId: input.providerId,
|
||||
storageProviderId,
|
||||
getApiKey(settings) {
|
||||
const accessToken = settings?.auth?.accessToken?.trim();
|
||||
if (accessToken) {
|
||||
return input.formatAccessToken?.(accessToken) ?? accessToken;
|
||||
}
|
||||
|
||||
return (
|
||||
settings?.apiKey?.trim() || settings?.auth?.apiKey?.trim() || undefined
|
||||
);
|
||||
},
|
||||
login: input.login,
|
||||
refresh: input.refresh,
|
||||
saveCredentials(saveInput) {
|
||||
return saveOAuthCredentials({
|
||||
...saveInput,
|
||||
storageProviderId,
|
||||
formatAccessToken: input.formatAccessToken,
|
||||
});
|
||||
},
|
||||
isConfigured(settings) {
|
||||
return !!settings?.auth?.accessToken;
|
||||
},
|
||||
normalizeStoredAccessToken: input.normalizeStoredAccessToken,
|
||||
};
|
||||
}
|
||||
|
||||
const providerAuthHandlers = [
|
||||
createOAuthHandler({
|
||||
providerId: "cline",
|
||||
formatAccessToken: formatClineApiKey,
|
||||
normalizeStoredAccessToken: stripClineApiKeyPrefix,
|
||||
login: ({ settings, callbacks, telemetry }) =>
|
||||
loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
useWorkOSDeviceAuth: true,
|
||||
callbacks,
|
||||
telemetry,
|
||||
}),
|
||||
refresh: ({ settings, credentials, forceRefresh, telemetry }) =>
|
||||
getValidClineCredentials(
|
||||
credentials as ClineOAuthCredentials,
|
||||
{
|
||||
apiBaseUrl:
|
||||
settings.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
telemetry,
|
||||
},
|
||||
{ forceRefresh },
|
||||
),
|
||||
}),
|
||||
createOAuthHandler({
|
||||
providerId: "oca",
|
||||
login: ({ settings, callbacks, telemetry }) =>
|
||||
loginOcaOAuth({ mode: settings?.oca?.mode, callbacks, telemetry }),
|
||||
refresh: ({ settings, credentials, forceRefresh, telemetry }) =>
|
||||
getValidOcaCredentials(
|
||||
credentials,
|
||||
{ forceRefresh, telemetry },
|
||||
{ mode: settings.oca?.mode, telemetry },
|
||||
),
|
||||
}),
|
||||
createOAuthHandler({
|
||||
providerId: "openai-codex",
|
||||
login: ({ callbacks, telemetry }) =>
|
||||
loginOpenAICodex({
|
||||
onAuth: callbacks.onAuth,
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
telemetry,
|
||||
}),
|
||||
refresh: ({ credentials, forceRefresh, telemetry }) =>
|
||||
getValidOpenAICodexCredentials(credentials, { forceRefresh, telemetry }),
|
||||
}),
|
||||
] as const satisfies readonly ProviderAuthHandler[];
|
||||
|
||||
const providerAuthHandlerById = new Map<string, ProviderAuthHandler>(
|
||||
providerAuthHandlers.map((handler) => [handler.providerId, handler]),
|
||||
);
|
||||
|
||||
export function getProviderAuthHandler(
|
||||
providerId: string,
|
||||
): ProviderAuthHandler | undefined {
|
||||
return providerAuthHandlerById.get(providerId.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function isOAuthProvider(providerId: string): boolean {
|
||||
return getProviderAuthHandler(providerId) !== undefined;
|
||||
}
|
||||
|
||||
export function getProviderAuthStorageId(
|
||||
providerId: string,
|
||||
): string | undefined {
|
||||
return getProviderAuthHandler(providerId)?.storageProviderId;
|
||||
}
|
||||
|
||||
export function resolveProviderApiKeyFromSettings(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
): string | undefined {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
const storageProviderId = handler?.storageProviderId ?? providerId;
|
||||
const settings = manager.getProviderSettings(storageProviderId);
|
||||
return (
|
||||
handler?.getApiKey(settings) ??
|
||||
getPersistedProviderApiKey(providerId, settings)
|
||||
);
|
||||
}
|
||||
|
||||
export async function loginAndSaveProviderOAuthCredentials(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
input: {
|
||||
callbacks: OAuthLoginCallbacks;
|
||||
telemetry?: ITelemetryService;
|
||||
},
|
||||
): Promise<ProviderSettings> {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) {
|
||||
throw new Error(`Provider "${providerId}" does not support OAuth login`);
|
||||
}
|
||||
const existing = manager.getProviderSettings(handler.storageProviderId);
|
||||
const credentials = await handler.login({
|
||||
settings: existing,
|
||||
callbacks: input.callbacks,
|
||||
telemetry: input.telemetry,
|
||||
});
|
||||
return handler.saveCredentials({ manager, settings: existing, credentials });
|
||||
}
|
||||
|
||||
export function getProviderOAuthCredentialsFromSettings(
|
||||
providerId: string,
|
||||
settings: ProviderSettings,
|
||||
): ProviderOAuthCredentials | null {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) return null;
|
||||
return createCredentialsFromSettings(settings, {
|
||||
normalizeAccessToken: handler.normalizeStoredAccessToken,
|
||||
});
|
||||
}
|
||||
|
||||
export function saveProviderOAuthCredentials(input: {
|
||||
manager: ProviderSettingsManager;
|
||||
providerId: string;
|
||||
settings?: ProviderSettings;
|
||||
credentials: ProviderOAuthCredentials;
|
||||
setLastUsed?: boolean;
|
||||
save?: boolean;
|
||||
}): ProviderSettings {
|
||||
const handler = getProviderAuthHandler(input.providerId);
|
||||
if (!handler) {
|
||||
throw new Error(
|
||||
`Provider "${input.providerId}" does not support OAuth credentials`,
|
||||
);
|
||||
}
|
||||
return handler.saveCredentials({
|
||||
manager: input.manager,
|
||||
settings: input.settings,
|
||||
credentials: input.credentials,
|
||||
setLastUsed: input.setLastUsed,
|
||||
save: input.save,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPersistedProviderApiKey(
|
||||
providerId: string,
|
||||
settings?: ProviderSettings,
|
||||
): string | undefined {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (handler) {
|
||||
return handler.getApiKey(settings);
|
||||
}
|
||||
|
||||
return (
|
||||
settings?.auth?.accessToken?.trim() ||
|
||||
settings?.apiKey?.trim() ||
|
||||
settings?.auth?.apiKey?.trim() ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function formatProviderOAuthApiKey(
|
||||
providerId: string,
|
||||
credentials: Pick<ProviderOAuthCredentials, "access">,
|
||||
): string {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) return credentials.access;
|
||||
|
||||
return (
|
||||
handler.getApiKey({
|
||||
provider: handler.storageProviderId,
|
||||
auth: { accessToken: credentials.access },
|
||||
}) ?? credentials.access
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentExtension } from "@cline/shared";
|
||||
import type { SkillsExecutorWithMetadata } from "../tools";
|
||||
import {
|
||||
type AvailableRuntimeCommand,
|
||||
listAvailableRuntimeCommandsFromWatcher,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import {
|
||||
type CreateUserInstructionPluginOptions,
|
||||
createUserInstructionPlugin,
|
||||
createUserInstructionSkillsExecutor,
|
||||
getConfiguredSkillsFromWatcher,
|
||||
} from "./user-instruction-plugin";
|
||||
|
||||
@@ -39,6 +41,9 @@ export interface UserInstructionConfigService {
|
||||
listRuntimeCommands(): AvailableRuntimeCommand[];
|
||||
resolveRuntimeSlashCommand(input: string): string;
|
||||
hasConfiguredSkills(allowedSkillNames?: ReadonlyArray<string>): boolean;
|
||||
createSkillsExecutor?(
|
||||
allowedSkillNames?: ReadonlyArray<string>,
|
||||
): SkillsExecutorWithMetadata;
|
||||
createExtension(
|
||||
options: Omit<
|
||||
CreateUserInstructionPluginOptions,
|
||||
@@ -107,6 +112,16 @@ class DefaultUserInstructionConfigService
|
||||
);
|
||||
}
|
||||
|
||||
createSkillsExecutor(
|
||||
allowedSkillNames?: ReadonlyArray<string>,
|
||||
): SkillsExecutorWithMetadata {
|
||||
return createUserInstructionSkillsExecutor(
|
||||
this.watcher,
|
||||
(this.ready ?? Promise.resolve()).catch(() => {}),
|
||||
allowedSkillNames,
|
||||
);
|
||||
}
|
||||
|
||||
createExtension(
|
||||
options: Omit<
|
||||
CreateUserInstructionPluginOptions,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseConfiguredAgentConfig } from "./configured-agent-config";
|
||||
|
||||
describe("configured agent config parser", () => {
|
||||
it("parses YAML frontmatter and system prompt body", () => {
|
||||
const config = parseConfiguredAgentConfig(`---
|
||||
name: code-reviewer
|
||||
description: Reviews code
|
||||
tools: execute_command, read_file
|
||||
skills:
|
||||
- review-pr
|
||||
modelId: anthropic/claude-sonnet-4.6
|
||||
---
|
||||
You are a code reviewer.`);
|
||||
|
||||
expect(config).toMatchObject({
|
||||
name: "code-reviewer",
|
||||
description: "Reviews code",
|
||||
tools: ["execute_command", "read_file"],
|
||||
skills: ["review-pr"],
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
systemPrompt: "You are a code reviewer.",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat delimiter lines in the body as frontmatter delimiters", () => {
|
||||
const config = parseConfiguredAgentConfig(`---
|
||||
name: code-reviewer
|
||||
description: Reviews code
|
||||
tools: read_file
|
||||
---
|
||||
Prompt body
|
||||
---
|
||||
More prompt`);
|
||||
|
||||
expect(config.systemPrompt).toBe("Prompt body\n---\nMore prompt");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty", ""],
|
||||
["comment-only", "# comment"],
|
||||
["scalar", "code-reviewer"],
|
||||
])("rejects %s frontmatter candidates without hanging", (_name, yaml) => {
|
||||
expect(() =>
|
||||
parseConfiguredAgentConfig(`---
|
||||
${yaml}
|
||||
---
|
||||
You are a code reviewer.`),
|
||||
).toThrow("Missing closing YAML frontmatter delimiter");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { type Dirent, existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import { resolveAgentConfigSearchPaths } from "@cline/shared/storage";
|
||||
import YAML from "yaml";
|
||||
import { z } from "zod";
|
||||
|
||||
const ConfiguredAgentFrontmatterSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
description: z.string().trim().min(1),
|
||||
tools: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
skills: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
providerId: z.string().trim().min(1).optional(),
|
||||
modelId: z.string().trim().min(1).optional(),
|
||||
maxIterations: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export interface ConfiguredAgentConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
tools?: string[];
|
||||
skills?: string[];
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
maxIterations?: number;
|
||||
systemPrompt: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface ConfiguredAgentReadError {
|
||||
path: string;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export interface ConfiguredAgentLoadResult {
|
||||
configs: ConfiguredAgentConfig[];
|
||||
errors: ConfiguredAgentReadError[];
|
||||
}
|
||||
|
||||
function splitFrontmatter(content: string): {
|
||||
frontmatter: string;
|
||||
body: string;
|
||||
} {
|
||||
const firstLineMatch = content.match(/^(---)[^\S\r\n]*(?:\r?\n|$)/);
|
||||
if (!firstLineMatch) {
|
||||
throw new Error("Missing YAML frontmatter block in agent config file.");
|
||||
}
|
||||
|
||||
const frontmatterStart = firstLineMatch[0].length;
|
||||
const delimiterPattern = /^---[^\S\r\n]*(?:\r?\n|$)/gm;
|
||||
delimiterPattern.lastIndex = frontmatterStart;
|
||||
let lastValid:
|
||||
| {
|
||||
frontmatter: string;
|
||||
body: string;
|
||||
}
|
||||
| undefined;
|
||||
const candidates = Array.from(content.matchAll(delimiterPattern)).filter(
|
||||
(candidate) => candidate.index >= frontmatterStart,
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
const delimiterStart = candidate.index;
|
||||
const frontmatter = content.slice(frontmatterStart, delimiterStart);
|
||||
try {
|
||||
const parsedYaml = YAML.parse(frontmatter);
|
||||
if (
|
||||
!parsedYaml ||
|
||||
typeof parsedYaml !== "object" ||
|
||||
Array.isArray(parsedYaml)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ConfiguredAgentFrontmatterSchema.parse(parsedYaml);
|
||||
const body = content.slice(delimiterStart + candidate[0].length);
|
||||
lastValid = { frontmatter, body };
|
||||
} catch {
|
||||
// Keep scanning: this delimiter may be literal content inside YAML.
|
||||
}
|
||||
}
|
||||
|
||||
if (lastValid) {
|
||||
return lastValid;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Missing closing YAML frontmatter delimiter in agent config file.",
|
||||
);
|
||||
}
|
||||
|
||||
function parseStringList(
|
||||
value: string | string[] | undefined,
|
||||
): string[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const raw = Array.isArray(value) ? value : value.split(",");
|
||||
return Array.from(
|
||||
new Set(
|
||||
raw.map((entry) => entry.trim()).filter((entry) => entry.length > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAgentName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isYamlFile(fileName: string): boolean {
|
||||
const extension = extname(fileName).toLowerCase();
|
||||
return extension === ".yml" || extension === ".yaml";
|
||||
}
|
||||
|
||||
export function parseConfiguredAgentConfig(
|
||||
content: string,
|
||||
options: { path?: string } = {},
|
||||
): ConfiguredAgentConfig {
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const parsedYaml = YAML.parse(frontmatter);
|
||||
if (
|
||||
!parsedYaml ||
|
||||
typeof parsedYaml !== "object" ||
|
||||
Array.isArray(parsedYaml)
|
||||
) {
|
||||
throw new Error("Agent config frontmatter must be a YAML mapping.");
|
||||
}
|
||||
|
||||
const parsed = ConfiguredAgentFrontmatterSchema.parse(parsedYaml);
|
||||
const systemPrompt = body.trim();
|
||||
if (!systemPrompt) {
|
||||
throw new Error("Missing system prompt body in agent config file.");
|
||||
}
|
||||
|
||||
return {
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
tools: parseStringList(parsed.tools),
|
||||
skills: parseStringList(parsed.skills),
|
||||
providerId: parsed.providerId,
|
||||
modelId: parsed.modelId,
|
||||
maxIterations: parsed.maxIterations,
|
||||
systemPrompt,
|
||||
path: options.path,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfiguredAgentConfigs(input: {
|
||||
workspaceRoot?: string;
|
||||
searchPaths?: string[];
|
||||
}): ConfiguredAgentLoadResult {
|
||||
const searchPaths =
|
||||
input.searchPaths ?? resolveAgentConfigSearchPaths(input.workspaceRoot);
|
||||
const configsByName = new Map<string, ConfiguredAgentConfig>();
|
||||
const errors: ConfiguredAgentReadError[] = [];
|
||||
|
||||
for (const directory of searchPaths.filter(Boolean)) {
|
||||
if (!existsSync(directory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
path: directory,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !isYamlFile(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(directory, entry.name);
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const config = parseConfiguredAgentConfig(raw, { path: filePath });
|
||||
const normalizedName = normalizeAgentName(config.name);
|
||||
if (!configsByName.has(normalizedName)) {
|
||||
configsByName.set(normalizedName, config);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
path: filePath,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const configs = Array.from(configsByName.values()).sort((a, b) =>
|
||||
(a.path ? basename(a.path) : a.name).localeCompare(
|
||||
b.path ? basename(b.path) : b.name,
|
||||
),
|
||||
);
|
||||
return { configs, errors };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildConfiguredAgentToolName,
|
||||
createConfiguredAgentTools,
|
||||
} from "./configured-agent-tool";
|
||||
|
||||
describe("configured agent tools", () => {
|
||||
it("builds stable subagent tool names", () => {
|
||||
expect(buildConfiguredAgentToolName("Code Reviewer")).toBe(
|
||||
"subagent_code_reviewer",
|
||||
);
|
||||
expect(buildConfiguredAgentToolName("___")).toBe("subagent_agent");
|
||||
});
|
||||
|
||||
it("matches spawn_agent timeout and retry policy", () => {
|
||||
const [tool] = createConfiguredAgentTools({
|
||||
configProvider: {
|
||||
getRuntimeConfig: () => ({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: "key",
|
||||
}),
|
||||
getConnectionConfig: () => ({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: "key",
|
||||
}),
|
||||
updateConnectionDefaults: () => {},
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
name: "code-reviewer",
|
||||
description: "Reviews code",
|
||||
systemPrompt: "You are a code reviewer.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(tool?.name).toBe("subagent_code_reviewer");
|
||||
expect(tool?.timeoutMs).toBe(300000);
|
||||
expect(tool?.retryable).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
type AgentEvent,
|
||||
type AgentResult,
|
||||
type AgentTool,
|
||||
type AgentToolContext,
|
||||
createTool,
|
||||
type HookErrorMode,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
type ToolPolicy,
|
||||
zodToJsonSchema,
|
||||
} from "@cline/shared";
|
||||
import { z } from "zod";
|
||||
import type { ConfiguredAgentConfig } from "./configured-agent-config";
|
||||
import {
|
||||
createDelegatedAgent,
|
||||
createDelegatedAgentConfigProvider,
|
||||
type DelegatedAgentConfigProvider,
|
||||
type DelegatedAgentRuntimeConfig,
|
||||
} from "./delegated-agent";
|
||||
import type {
|
||||
SpawnAgentOutput,
|
||||
SubAgentEndContext,
|
||||
SubAgentStartContext,
|
||||
} from "./spawn-agent-tool";
|
||||
|
||||
const CONFIGURED_AGENT_TOOL_NAME_PREFIX = "subagent_";
|
||||
const CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH = 64;
|
||||
|
||||
const ConfiguredAgentInputSchema = z.object({
|
||||
prompt: z.string().trim().min(1).describe("Task for the subagent to perform"),
|
||||
});
|
||||
|
||||
export type ConfiguredAgentInput = z.infer<typeof ConfiguredAgentInputSchema>;
|
||||
|
||||
export interface ConfiguredAgentToolDescriptor {
|
||||
toolName: string;
|
||||
config: ConfiguredAgentConfig;
|
||||
}
|
||||
|
||||
export interface ConfiguredAgentToolConfig {
|
||||
configProvider: DelegatedAgentConfigProvider;
|
||||
agents: ConfiguredAgentConfig[];
|
||||
createSubAgentTools?: (
|
||||
agent: ConfiguredAgentConfig,
|
||||
input: ConfiguredAgentInput,
|
||||
context: AgentToolContext,
|
||||
) => AgentTool[] | Promise<AgentTool[]>;
|
||||
onSubAgentEvent?: (event: AgentEvent) => void;
|
||||
hookErrorMode?: HookErrorMode;
|
||||
toolPolicies?: Record<string, ToolPolicy>;
|
||||
requestToolApproval?: (
|
||||
request: ToolApprovalRequest,
|
||||
) => Promise<ToolApprovalResult> | ToolApprovalResult;
|
||||
onSubAgentStart?: (context: SubAgentStartContext) => void | Promise<void>;
|
||||
onSubAgentEnd?: (context: SubAgentEndContext) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function sanitizeAgentName(name: string): string {
|
||||
let result = "";
|
||||
let lastWasUnderscore = true;
|
||||
|
||||
for (const char of name.trim().toLowerCase()) {
|
||||
const code = char.charCodeAt(0);
|
||||
const isAllowed =
|
||||
(code >= 97 && code <= 122) || (code >= 48 && code <= 57) || char === "_";
|
||||
|
||||
if (!isAllowed || char === "_") {
|
||||
if (!lastWasUnderscore) {
|
||||
result += "_";
|
||||
lastWasUnderscore = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
result += char;
|
||||
lastWasUnderscore = false;
|
||||
}
|
||||
|
||||
return lastWasUnderscore ? result.slice(0, -1) : result;
|
||||
}
|
||||
|
||||
function hashString(value: string): string {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
export function buildConfiguredAgentToolName(agentName: string): string {
|
||||
const sanitized = sanitizeAgentName(agentName) || "agent";
|
||||
const hashSuffix = hashString(agentName).slice(0, 6);
|
||||
const base = `${CONFIGURED_AGENT_TOOL_NAME_PREFIX}${sanitized}`;
|
||||
|
||||
if (base.length <= CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const maxBodyLength =
|
||||
CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH -
|
||||
CONFIGURED_AGENT_TOOL_NAME_PREFIX.length -
|
||||
hashSuffix.length -
|
||||
1;
|
||||
const body = sanitized.slice(0, Math.max(1, maxBodyLength));
|
||||
return `${CONFIGURED_AGENT_TOOL_NAME_PREFIX}${body}_${hashSuffix}`.slice(
|
||||
0,
|
||||
CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildConfiguredAgentToolDescriptors(
|
||||
agents: readonly ConfiguredAgentConfig[],
|
||||
): ConfiguredAgentToolDescriptor[] {
|
||||
const usedToolNames = new Set<string>();
|
||||
const descriptors: ConfiguredAgentToolDescriptor[] = [];
|
||||
|
||||
for (const config of [...agents].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)) {
|
||||
const baseName = buildConfiguredAgentToolName(config.name);
|
||||
let candidate = baseName;
|
||||
let suffix = 2;
|
||||
while (usedToolNames.has(candidate)) {
|
||||
const suffixText = `_${suffix++}`;
|
||||
const maxBaseLength = Math.max(
|
||||
1,
|
||||
CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH - suffixText.length,
|
||||
);
|
||||
candidate = `${baseName.slice(0, maxBaseLength)}${suffixText}`;
|
||||
}
|
||||
usedToolNames.add(candidate);
|
||||
descriptors.push({ toolName: candidate, config });
|
||||
}
|
||||
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
function buildAgentRuntimeConfig(
|
||||
base: DelegatedAgentRuntimeConfig,
|
||||
agent: ConfiguredAgentConfig,
|
||||
): DelegatedAgentRuntimeConfig {
|
||||
return {
|
||||
...base,
|
||||
providerId: agent.providerId ?? base.providerId,
|
||||
modelId: agent.modelId ?? base.modelId,
|
||||
maxIterations: agent.maxIterations ?? base.maxIterations,
|
||||
};
|
||||
}
|
||||
|
||||
export function createConfiguredAgentTools(
|
||||
options: ConfiguredAgentToolConfig,
|
||||
): AgentTool[] {
|
||||
return buildConfiguredAgentToolDescriptors(options.agents).map(
|
||||
({ toolName, config }) => {
|
||||
const tool = createTool<ConfiguredAgentInput, SpawnAgentOutput>({
|
||||
name: toolName,
|
||||
description: `Use the "${config.name}" subagent: ${config.description}`,
|
||||
inputSchema: zodToJsonSchema(ConfiguredAgentInputSchema),
|
||||
execute: async (input, context) => {
|
||||
const baseRuntimeConfig = options.configProvider.getRuntimeConfig();
|
||||
const configProvider = createDelegatedAgentConfigProvider(
|
||||
buildAgentRuntimeConfig(baseRuntimeConfig, config),
|
||||
);
|
||||
const tools = options.createSubAgentTools
|
||||
? await options.createSubAgentTools(config, input, context)
|
||||
: [];
|
||||
const subAgent = createDelegatedAgent({
|
||||
kind: "subagent",
|
||||
prompt: config.systemPrompt,
|
||||
configProvider,
|
||||
tools,
|
||||
maxIterations: config.maxIterations,
|
||||
parentAgentId: context.agentId,
|
||||
abortSignal: context.signal,
|
||||
onEvent: options.onSubAgentEvent,
|
||||
hookErrorMode: options.hookErrorMode,
|
||||
toolPolicies: options.toolPolicies,
|
||||
requestToolApproval: options.requestToolApproval,
|
||||
});
|
||||
const subAgentId = subAgent.getAgentId();
|
||||
const conversationId = subAgent.getConversationId();
|
||||
const parentAgentId = context.agentId;
|
||||
const spawnInput = {
|
||||
systemPrompt: config.systemPrompt,
|
||||
task: input.prompt,
|
||||
};
|
||||
|
||||
if (options.onSubAgentStart) {
|
||||
try {
|
||||
await options.onSubAgentStart({
|
||||
subAgentId,
|
||||
conversationId,
|
||||
parentAgentId,
|
||||
input: spawnInput,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort observer callback.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result: AgentResult = await subAgent.run(input.prompt);
|
||||
const output: SpawnAgentOutput = {
|
||||
text: result.text,
|
||||
iterations: result.iterations,
|
||||
finishReason: result.finishReason,
|
||||
usage: {
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
},
|
||||
};
|
||||
if (options.onSubAgentEnd) {
|
||||
try {
|
||||
await options.onSubAgentEnd({
|
||||
subAgentId,
|
||||
conversationId,
|
||||
parentAgentId,
|
||||
input: spawnInput,
|
||||
result: output,
|
||||
agentResult: result,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort observer callback.
|
||||
}
|
||||
}
|
||||
return output;
|
||||
} catch (error) {
|
||||
if (options.onSubAgentEnd) {
|
||||
try {
|
||||
await options.onSubAgentEnd({
|
||||
subAgentId,
|
||||
conversationId,
|
||||
parentAgentId,
|
||||
input: spawnInput,
|
||||
error:
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
} catch {
|
||||
// Best-effort observer callback.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
timeoutMs: 300000,
|
||||
retryable: false,
|
||||
});
|
||||
return tool as unknown as AgentTool;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
export {
|
||||
type ConfiguredAgentConfig,
|
||||
type ConfiguredAgentLoadResult,
|
||||
type ConfiguredAgentReadError,
|
||||
loadConfiguredAgentConfigs,
|
||||
parseConfiguredAgentConfig,
|
||||
} from "./configured-agent-config";
|
||||
export {
|
||||
buildConfiguredAgentToolDescriptors,
|
||||
buildConfiguredAgentToolName,
|
||||
type ConfiguredAgentInput,
|
||||
type ConfiguredAgentToolConfig,
|
||||
type ConfiguredAgentToolDescriptor,
|
||||
createConfiguredAgentTools,
|
||||
} from "./configured-agent-tool";
|
||||
export {
|
||||
buildTeamProgressSummary,
|
||||
toTeamProgressLifecycleEvent,
|
||||
} from "./projections";
|
||||
export * from "./runtime";
|
||||
export type {
|
||||
SubAgentEndContext,
|
||||
SubAgentStartContext,
|
||||
} from "./spawn-agent-tool";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
@@ -200,48 +258,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;
|
||||
@@ -259,17 +344,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,
|
||||
@@ -284,35 +368,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;
|
||||
@@ -320,8 +441,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) &&
|
||||
@@ -337,7 +461,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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -377,6 +377,8 @@ function createUserInstructionServiceProxy(
|
||||
configuredSkills(snapshot, allowedSkillNames).some(
|
||||
(entry) => !entry.disabled,
|
||||
),
|
||||
createSkillsExecutor: (allowedSkillNames) =>
|
||||
createSnapshotSkillsExecutor(snapshot, allowedSkillNames),
|
||||
createExtension: (options): AgentExtension => ({
|
||||
name: "cline-hub-user-instructions",
|
||||
manifest: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ export type {
|
||||
ListProvidersActionRequest,
|
||||
Message,
|
||||
MessageWithMetadata,
|
||||
OAuthProviderId,
|
||||
ProviderActionRequest,
|
||||
ProviderCatalogResponse,
|
||||
ProviderListItem,
|
||||
@@ -117,7 +116,6 @@ export {
|
||||
} from "./auth/client";
|
||||
export {
|
||||
completeClineDeviceAuth,
|
||||
createClineOAuthProvider,
|
||||
getValidClineCredentials,
|
||||
loginClineOAuth,
|
||||
refreshClineToken,
|
||||
@@ -125,20 +123,30 @@ export {
|
||||
} from "./auth/cline";
|
||||
export {
|
||||
getValidOpenAICodexCredentials,
|
||||
isOpenAICodexTokenExpired,
|
||||
loginOpenAICodex,
|
||||
normalizeOpenAICodexCredentials,
|
||||
openaiCodexOAuthProvider,
|
||||
refreshOpenAICodexToken,
|
||||
} from "./auth/codex";
|
||||
export {
|
||||
createOcaOAuthProvider,
|
||||
createOcaRequestHeaders,
|
||||
generateOcaOpcRequestId,
|
||||
getValidOcaCredentials,
|
||||
loginOcaOAuth,
|
||||
refreshOcaToken,
|
||||
} from "./auth/oca";
|
||||
export {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderAuthHandler,
|
||||
getProviderAuthStorageId,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
isOAuthProvider,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderAuthHandler,
|
||||
type ProviderAuthLoginInput,
|
||||
type ProviderAuthRefreshInput,
|
||||
type ProviderAuthSaveCredentialsInput,
|
||||
type ProviderOAuthCredentials,
|
||||
resolveProviderApiKeyFromSettings,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "./auth/provider-auth-registry";
|
||||
export type {
|
||||
LocalOAuthServer,
|
||||
LocalOAuthServerOptions,
|
||||
@@ -290,10 +298,19 @@ export {
|
||||
type BootstrapAgentTeamsOptions,
|
||||
type BootstrapAgentTeamsResult,
|
||||
bootstrapAgentTeams,
|
||||
buildConfiguredAgentToolDescriptors,
|
||||
buildConfiguredAgentToolName,
|
||||
buildDelegatedAgentConfig,
|
||||
buildTeamProgressSummary,
|
||||
type ConfiguredAgentConfig,
|
||||
type ConfiguredAgentInput,
|
||||
type ConfiguredAgentLoadResult,
|
||||
type ConfiguredAgentReadError,
|
||||
type ConfiguredAgentToolConfig,
|
||||
type ConfiguredAgentToolDescriptor,
|
||||
type CreateAgentTeamsToolsOptions,
|
||||
createAgentTeamsTools,
|
||||
createConfiguredAgentTools,
|
||||
createDelegatedAgent,
|
||||
createDelegatedAgentConfigProvider,
|
||||
createSpawnAgentTool,
|
||||
@@ -301,6 +318,8 @@ export {
|
||||
type DelegatedAgentConnectionConfig,
|
||||
type DelegatedAgentKind,
|
||||
type DelegatedAgentRuntimeConfig,
|
||||
loadConfiguredAgentConfigs,
|
||||
parseConfiguredAgentConfig,
|
||||
reviveTeamStateDates,
|
||||
type SpawnTeammateOptions,
|
||||
type SubAgentEndContext,
|
||||
@@ -455,6 +474,7 @@ export {
|
||||
ensureCustomProvidersLoaded,
|
||||
getLocalProviderModels,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
loginLocalProvider,
|
||||
normalizeOAuthProvider,
|
||||
refreshProviderModelsFromSource,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
normalizeUserInput,
|
||||
} from "@cline/shared";
|
||||
import { setHomeDirIfUnset } from "@cline/shared/storage";
|
||||
import { isOAuthProvider } from "../../auth/provider-auth-registry";
|
||||
import { createContextCompactionPrepareTurn } from "../../extensions/context/compaction";
|
||||
import type { ToolExecutors } from "../../extensions/tools";
|
||||
import { DefaultToolNames } from "../../extensions/tools";
|
||||
@@ -95,6 +96,7 @@ import {
|
||||
} from "./local/session-service-invoker";
|
||||
import {
|
||||
createSessionSpawnTool,
|
||||
createSessionSubAgentLifecycleCallbacks,
|
||||
type SubAgentStartTracker,
|
||||
} from "./local/spawn-tool";
|
||||
import { loadUserFileContent } from "./local/user-files";
|
||||
@@ -358,6 +360,17 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
const pluginEventFallbackAutomation =
|
||||
inputLocalConfig?.extensionContext?.automation;
|
||||
let bootstrap!: Awaited<ReturnType<typeof prepareLocalRuntimeBootstrap>>;
|
||||
const subAgentDeps = {
|
||||
getSession: (sid: string) => this.sessions.get(sid),
|
||||
subAgentStarts: this.subAgentStarts,
|
||||
onAgentEvent: (
|
||||
rootSessionId: string,
|
||||
config: CoreSessionConfig,
|
||||
event: AgentEvent,
|
||||
) => this.eventBridge.dispatchAgentEvent(rootSessionId, config, event),
|
||||
invokeBackendOptional: (method: string, ...args: unknown[]) =>
|
||||
this.invokeOptional(method, ...args),
|
||||
};
|
||||
bootstrap = await prepareLocalRuntimeBootstrap({
|
||||
input: startInput,
|
||||
localRuntime: input.localRuntime,
|
||||
@@ -388,18 +401,17 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
},
|
||||
createSpawnTool: () =>
|
||||
createSessionSpawnTool(
|
||||
{
|
||||
getSession: (sid) => this.sessions.get(sid),
|
||||
subAgentStarts: this.subAgentStarts,
|
||||
onAgentEvent: (rootSessionId, config, event) =>
|
||||
this.eventBridge.dispatchAgentEvent(rootSessionId, config, event),
|
||||
invokeBackendOptional: (method, ...args) =>
|
||||
this.invokeOptional(method, ...args),
|
||||
},
|
||||
subAgentDeps,
|
||||
bootstrap.config,
|
||||
sessionId,
|
||||
sessionToolExecutors,
|
||||
),
|
||||
createSubAgentLifecycleCallbacks: (config) =>
|
||||
createSessionSubAgentLifecycleCallbacks(
|
||||
subAgentDeps,
|
||||
config,
|
||||
sessionId,
|
||||
),
|
||||
readSessionMetadata: async () =>
|
||||
(await this.getSession(sessionId))?.metadata as
|
||||
| Record<string, unknown>
|
||||
@@ -1533,7 +1545,10 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (!isLikelyAuthError(error, session.config.providerId)) {
|
||||
if (
|
||||
!isOAuthProvider(session.config.providerId) ||
|
||||
!isLikelyAuthError(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
await this.syncOAuthCredentials(session, { forceRefresh: true });
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
type ToolExecutors,
|
||||
ToolPresets,
|
||||
} from "../../../extensions/tools";
|
||||
import type {
|
||||
SubAgentEndContext,
|
||||
SubAgentStartContext,
|
||||
} from "../../../extensions/tools/team";
|
||||
import { createSpawnAgentTool } from "../../../extensions/tools/team";
|
||||
import { buildTelemetryAgentIdentity } from "../../../services/agent-events";
|
||||
import { filterDisabledTools } from "../../../services/global-settings";
|
||||
@@ -31,65 +35,18 @@ export interface SpawnToolDeps {
|
||||
invokeBackendOptional(method: string, ...args: unknown[]): Promise<void>;
|
||||
}
|
||||
|
||||
export function createSessionSpawnTool(
|
||||
export interface SessionSubAgentLifecycleCallbacks {
|
||||
onSubAgentEvent: (event: AgentEvent) => void;
|
||||
onSubAgentStart: (context: SubAgentStartContext) => void;
|
||||
onSubAgentEnd: (context: SubAgentEndContext) => void;
|
||||
}
|
||||
|
||||
export function createSessionSubAgentLifecycleCallbacks(
|
||||
deps: SpawnToolDeps,
|
||||
config: CoreSessionConfig,
|
||||
rootSessionId: string,
|
||||
toolExecutors?: Partial<ToolExecutors>,
|
||||
): AgentTool {
|
||||
const createSubAgentTools = () => {
|
||||
const tools: AgentTool[] = config.enableTools
|
||||
? createBuiltinTools({
|
||||
cwd: config.cwd,
|
||||
...ToolPresets[resolveToolPresetName({ mode: config.mode })],
|
||||
executors: toolExecutors,
|
||||
})
|
||||
: [];
|
||||
if (config.enableSpawnAgent) {
|
||||
tools.push(
|
||||
createSessionSpawnTool(deps, config, rootSessionId, toolExecutors),
|
||||
);
|
||||
}
|
||||
return filterDisabledTools(tools);
|
||||
};
|
||||
|
||||
return createSpawnAgentTool({
|
||||
configProvider: {
|
||||
getRuntimeConfig: () =>
|
||||
deps
|
||||
.getSession(rootSessionId)
|
||||
?.runtime.delegatedAgentConfigProvider?.getRuntimeConfig() ?? {
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
cwd: config.cwd,
|
||||
apiKey: config.apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
providerConfig: config.providerConfig,
|
||||
knownModels: config.knownModels,
|
||||
thinking: config.thinking,
|
||||
maxIterations: config.maxIterations,
|
||||
hooks: config.hooks,
|
||||
extensions: config.extensions,
|
||||
logger: config.logger,
|
||||
telemetry: config.telemetry,
|
||||
},
|
||||
getConnectionConfig: () =>
|
||||
deps
|
||||
.getSession(rootSessionId)
|
||||
?.runtime.delegatedAgentConfigProvider?.getConnectionConfig() ?? {
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
apiKey: config.apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
providerConfig: config.providerConfig,
|
||||
knownModels: config.knownModels,
|
||||
thinking: config.thinking,
|
||||
},
|
||||
updateConnectionDefaults: () => {},
|
||||
},
|
||||
createSubAgentTools,
|
||||
): SessionSubAgentLifecycleCallbacks {
|
||||
return {
|
||||
onSubAgentEvent: (event) => deps.onAgentEvent(rootSessionId, config, event),
|
||||
onSubAgentStart: (context) => {
|
||||
const teamRuntime = deps.getSession(rootSessionId)?.runtime.teamRuntime;
|
||||
@@ -158,5 +115,73 @@ export function createSessionSpawnTool(
|
||||
context,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionSpawnTool(
|
||||
deps: SpawnToolDeps,
|
||||
config: CoreSessionConfig,
|
||||
rootSessionId: string,
|
||||
toolExecutors?: Partial<ToolExecutors>,
|
||||
): AgentTool {
|
||||
const lifecycle = createSessionSubAgentLifecycleCallbacks(
|
||||
deps,
|
||||
config,
|
||||
rootSessionId,
|
||||
);
|
||||
const createSubAgentTools = () => {
|
||||
const tools: AgentTool[] = config.enableTools
|
||||
? createBuiltinTools({
|
||||
cwd: config.cwd,
|
||||
...ToolPresets[resolveToolPresetName({ mode: config.mode })],
|
||||
executors: toolExecutors,
|
||||
})
|
||||
: [];
|
||||
if (config.enableSpawnAgent) {
|
||||
tools.push(
|
||||
createSessionSpawnTool(deps, config, rootSessionId, toolExecutors),
|
||||
);
|
||||
}
|
||||
return filterDisabledTools(tools);
|
||||
};
|
||||
|
||||
return createSpawnAgentTool({
|
||||
configProvider: {
|
||||
getRuntimeConfig: () =>
|
||||
deps
|
||||
.getSession(rootSessionId)
|
||||
?.runtime.delegatedAgentConfigProvider?.getRuntimeConfig() ?? {
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
cwd: config.cwd,
|
||||
apiKey: config.apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
providerConfig: config.providerConfig,
|
||||
knownModels: config.knownModels,
|
||||
thinking: config.thinking,
|
||||
maxIterations: config.maxIterations,
|
||||
hooks: config.hooks,
|
||||
extensions: config.extensions,
|
||||
logger: config.logger,
|
||||
telemetry: config.telemetry,
|
||||
},
|
||||
getConnectionConfig: () =>
|
||||
deps
|
||||
.getSession(rootSessionId)
|
||||
?.runtime.delegatedAgentConfigProvider?.getConnectionConfig() ?? {
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
apiKey: config.apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
providerConfig: config.providerConfig,
|
||||
knownModels: config.knownModels,
|
||||
thinking: config.thinking,
|
||||
},
|
||||
updateConnectionDefaults: () => {},
|
||||
},
|
||||
createSubAgentTools,
|
||||
...lifecycle,
|
||||
}) as AgentTool;
|
||||
}
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type AgentConfig,
|
||||
type AgentEvent,
|
||||
type AgentExtension,
|
||||
type AgentTool,
|
||||
createContributionRegistry,
|
||||
type Message,
|
||||
} from "@cline/shared";
|
||||
import { setHomeDir } from "@cline/shared/storage";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { UserInstructionConfigService } from "../../extensions/config";
|
||||
import type { CoreSessionConfig } from "../../types/config";
|
||||
|
||||
const runMock = vi.fn();
|
||||
const agentConstructorSpy = vi.fn();
|
||||
let eventListeners: Array<(event: AgentEvent) => void> = [];
|
||||
|
||||
vi.mock("./session-runtime-orchestrator", () => {
|
||||
return {
|
||||
SessionRuntime: class MockSessionRuntime {
|
||||
constructor(config: unknown) {
|
||||
agentConstructorSpy(config);
|
||||
}
|
||||
|
||||
getAgentId(): string {
|
||||
return "configured-sub-agent";
|
||||
}
|
||||
|
||||
getConversationId(): string {
|
||||
return "configured-sub-conversation";
|
||||
}
|
||||
|
||||
subscribeEvents(listener: (event: AgentEvent) => void): () => void {
|
||||
eventListeners.push(listener);
|
||||
return () => {
|
||||
eventListeners = eventListeners.filter((entry) => entry !== listener);
|
||||
};
|
||||
}
|
||||
|
||||
async run(input: string): Promise<unknown> {
|
||||
for (const listener of eventListeners) {
|
||||
listener({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
message: "configured agent running",
|
||||
});
|
||||
}
|
||||
return runMock(input);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function makeBaseConfig(
|
||||
overrides: Partial<CoreSessionConfig> = {},
|
||||
): CoreSessionConfig {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: "key",
|
||||
systemPrompt: "test",
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectExtensionTools(
|
||||
extensions?: AgentExtension[],
|
||||
): Promise<AgentTool[]> {
|
||||
const registry = createContributionRegistry<
|
||||
AgentExtension,
|
||||
AgentTool,
|
||||
Message[]
|
||||
>({
|
||||
extensions: extensions ?? [],
|
||||
});
|
||||
await registry.initialize();
|
||||
return registry.getRegisteredTools();
|
||||
}
|
||||
|
||||
describe("DefaultRuntimeBuilder configured agent execution", () => {
|
||||
const previousHome = process.env.HOME;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
eventListeners = [];
|
||||
runMock.mockResolvedValue({
|
||||
text: "configured result",
|
||||
iterations: 2,
|
||||
finishReason: "completed",
|
||||
usage: { inputTokens: 13, outputTokens: 8 },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = previousHome;
|
||||
setHomeDir(previousHome ?? "~");
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("invokes configured agents with host callbacks, scoped tools, skills, overrides, and parent context", async () => {
|
||||
const { DefaultRuntimeBuilder } = await import("./runtime-builder");
|
||||
const tempHome = mkdtempSync(join(tmpdir(), "cline-agent-home-"));
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-agent-root-"));
|
||||
const cwd = join(workspaceRoot, "packages", "app");
|
||||
tempDirs.push(tempHome, workspaceRoot);
|
||||
process.env.HOME = tempHome;
|
||||
setHomeDir(tempHome);
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
|
||||
const agentsDir = join(workspaceRoot, ".cline", "agents");
|
||||
const skillDir = join(workspaceRoot, ".cline", "skills", "commit");
|
||||
mkdirSync(agentsDir, { recursive: true });
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(agentsDir, "reviewer.yml"),
|
||||
`---
|
||||
name: reviewer
|
||||
description: Reviews code
|
||||
tools: Execute_Command, Read_File, Use_Skill
|
||||
skills: commit
|
||||
providerId: openai
|
||||
modelId: gpt-4.1
|
||||
maxIterations: 3
|
||||
---
|
||||
You are a reviewer.`,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: commit
|
||||
description: Commit messages
|
||||
---
|
||||
Write a concise commit message.`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const requestToolApproval = vi.fn(async () => ({ approved: true }));
|
||||
const onSubAgentEvent = vi.fn();
|
||||
const onSubAgentStart = vi.fn();
|
||||
const onSubAgentEnd = vi.fn();
|
||||
const effectiveToolPolicies = {
|
||||
"*": { autoApprove: false },
|
||||
read_files: { enabled: false },
|
||||
};
|
||||
const runtime = await new DefaultRuntimeBuilder().build({
|
||||
config: makeBaseConfig({ cwd, workspaceRoot }),
|
||||
configExtensions: [],
|
||||
toolPolicies: effectiveToolPolicies,
|
||||
requestToolApproval,
|
||||
onSubAgentEvent,
|
||||
onSubAgentStart,
|
||||
onSubAgentEnd,
|
||||
});
|
||||
|
||||
expect(
|
||||
(await collectExtensionTools(runtime.extensions)).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).not.toContain("skills");
|
||||
|
||||
const reviewer = runtime.tools.find(
|
||||
(tool) => tool.name === "subagent_reviewer",
|
||||
);
|
||||
expect(reviewer).toBeDefined();
|
||||
if (!reviewer) {
|
||||
throw new Error("Expected configured reviewer tool.");
|
||||
}
|
||||
|
||||
const output = await reviewer.execute(
|
||||
{ prompt: "review this change" },
|
||||
{
|
||||
agentId: "parent-agent",
|
||||
conversationId: "parent-conversation",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
text: "configured result",
|
||||
iterations: 2,
|
||||
finishReason: "completed",
|
||||
usage: { inputTokens: 13, outputTokens: 8 },
|
||||
});
|
||||
expect(runMock).toHaveBeenCalledWith("review this change");
|
||||
expect(onSubAgentStart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subAgentId: "configured-sub-agent",
|
||||
conversationId: "configured-sub-conversation",
|
||||
parentAgentId: "parent-agent",
|
||||
input: {
|
||||
systemPrompt: "You are a reviewer.",
|
||||
task: "review this change",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(onSubAgentEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "notice",
|
||||
message: "configured agent running",
|
||||
}),
|
||||
);
|
||||
expect(onSubAgentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parentAgentId: "parent-agent",
|
||||
result: output,
|
||||
}),
|
||||
);
|
||||
|
||||
const delegatedConfig = agentConstructorSpy.mock.calls.at(-1)?.[0] as
|
||||
| AgentConfig
|
||||
| undefined;
|
||||
expect(delegatedConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
providerId: "openai",
|
||||
modelId: "gpt-4.1",
|
||||
maxIterations: 3,
|
||||
parentAgentId: "parent-agent",
|
||||
requestToolApproval,
|
||||
toolPolicies: effectiveToolPolicies,
|
||||
}),
|
||||
);
|
||||
expect(delegatedConfig?.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"run_commands",
|
||||
"skills",
|
||||
]);
|
||||
|
||||
const skillsTool = delegatedConfig?.tools.find(
|
||||
(tool) => tool.name === "skills",
|
||||
);
|
||||
expect(skillsTool).toBeDefined();
|
||||
if (!skillsTool) {
|
||||
throw new Error("Expected delegated skills tool.");
|
||||
}
|
||||
await expect(
|
||||
skillsTool.execute(
|
||||
{ skill: "commit" },
|
||||
{ agentId: "configured-sub-agent", iteration: 1 },
|
||||
),
|
||||
).resolves.toContain("<command-name>commit</command-name>");
|
||||
await expect(
|
||||
skillsTool.execute(
|
||||
{ skill: "review" },
|
||||
{ agentId: "configured-sub-agent", iteration: 1 },
|
||||
),
|
||||
).resolves.toContain('Skill "review" not found.');
|
||||
|
||||
await runtime.shutdown("test");
|
||||
});
|
||||
|
||||
it("does not require custom user instruction services to implement createSkillsExecutor", async () => {
|
||||
const { DefaultRuntimeBuilder } = await import("./runtime-builder");
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-agent-compat-"));
|
||||
tempDirs.push(workspaceRoot);
|
||||
const agentsDir = join(workspaceRoot, ".cline", "agents");
|
||||
mkdirSync(agentsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(agentsDir, "reviewer.yml"),
|
||||
`---
|
||||
name: reviewer
|
||||
description: Reviews code
|
||||
skills: commit
|
||||
---
|
||||
You are a reviewer.`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const legacyService = {
|
||||
start: vi.fn(async () => {}),
|
||||
stop: vi.fn(),
|
||||
refreshType: vi.fn(async () => {}),
|
||||
listRecords: vi.fn(() => []),
|
||||
listRuntimeCommands: vi.fn(() => []),
|
||||
resolveRuntimeSlashCommand: vi.fn((input: string) => input),
|
||||
hasConfiguredSkills: vi.fn(() => false),
|
||||
createExtension: vi.fn(() => ({
|
||||
name: "legacy-service",
|
||||
manifest: { capabilities: [] },
|
||||
})),
|
||||
} as unknown as UserInstructionConfigService;
|
||||
|
||||
const runtime = await new DefaultRuntimeBuilder().build({
|
||||
config: makeBaseConfig({ cwd: workspaceRoot, workspaceRoot }),
|
||||
configExtensions: [],
|
||||
userInstructionService: legacyService,
|
||||
});
|
||||
const reviewer = runtime.tools.find(
|
||||
(tool) => tool.name === "subagent_reviewer",
|
||||
);
|
||||
expect(reviewer).toBeDefined();
|
||||
if (!reviewer) {
|
||||
throw new Error("Expected configured reviewer tool.");
|
||||
}
|
||||
|
||||
await expect(
|
||||
reviewer.execute(
|
||||
{ prompt: "review this change" },
|
||||
{ agentId: "parent-agent", iteration: 1 },
|
||||
),
|
||||
).resolves.toEqual({
|
||||
text: "configured result",
|
||||
iterations: 2,
|
||||
finishReason: "completed",
|
||||
usage: { inputTokens: 13, outputTokens: 8 },
|
||||
});
|
||||
const delegatedConfig = agentConstructorSpy.mock.calls.at(-1)?.[0] as
|
||||
| AgentConfig
|
||||
| undefined;
|
||||
expect(delegatedConfig?.tools.map((tool) => tool.name)).not.toContain(
|
||||
"skills",
|
||||
);
|
||||
|
||||
await runtime.shutdown("test");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -56,11 +56,15 @@ async function collectExtensionTools(
|
||||
describe("DefaultRuntimeBuilder", () => {
|
||||
const previousHome = process.env.HOME;
|
||||
const previousGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = previousHome;
|
||||
setHomeDir(previousHome ?? "~");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = previousGlobalSettingsPath;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("includes builtin tools when enabled", async () => {
|
||||
@@ -88,6 +92,100 @@ describe("DefaultRuntimeBuilder", () => {
|
||||
expect(runtime.logger).toBe(logger);
|
||||
});
|
||||
|
||||
it("loads configured agent files as named subagent tools", async () => {
|
||||
const tempHome = mkdtempSync(join(tmpdir(), "cline-agent-home-"));
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-agent-workspace-"));
|
||||
tempDirs.push(tempHome, workspaceRoot);
|
||||
setHomeDir(tempHome);
|
||||
|
||||
const globalAgentsDir = join(tempHome, ".cline", "agents");
|
||||
mkdirSync(globalAgentsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(globalAgentsDir, "code-reviewer.yml"),
|
||||
`---
|
||||
name: code-reviewer
|
||||
description: Reviews code for quality and best practices
|
||||
tools: Execute_Command, Read_File
|
||||
modelId: anthropic/claude-sonnet-4.6
|
||||
---
|
||||
You are a code reviewer.`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const runtime = await new DefaultRuntimeBuilder().build({
|
||||
config: makeBaseConfig({
|
||||
cwd: workspaceRoot,
|
||||
workspaceRoot,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
}),
|
||||
createSpawnTool: makeSpawnTool,
|
||||
});
|
||||
|
||||
const configuredAgentTool = runtime.tools.find(
|
||||
(tool) => tool.name === "subagent_code_reviewer",
|
||||
);
|
||||
expect(configuredAgentTool).toBeDefined();
|
||||
expect(configuredAgentTool?.description).toContain(
|
||||
'Use the "code-reviewer" subagent',
|
||||
);
|
||||
expect(runtime.tools.map((tool) => tool.name)).toContain("spawn_agent");
|
||||
});
|
||||
|
||||
it("does not register root skills when only configured agents declare skills", async () => {
|
||||
const tempHome = mkdtempSync(join(tmpdir(), "cline-agent-home-"));
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-agent-workspace-"));
|
||||
const cwd = join(workspaceRoot, "packages", "app");
|
||||
tempDirs.push(tempHome, workspaceRoot);
|
||||
setHomeDir(tempHome);
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
|
||||
const agentsDir = join(workspaceRoot, ".cline", "agents");
|
||||
const skillDir = join(workspaceRoot, ".cline", "skills", "review");
|
||||
mkdirSync(agentsDir, { recursive: true });
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(agentsDir, "code-reviewer.yml"),
|
||||
`---
|
||||
name: code-reviewer
|
||||
description: Reviews code
|
||||
tools: use_skill
|
||||
skills: review
|
||||
---
|
||||
You are a code reviewer.`,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: review
|
||||
---
|
||||
Use the review guidance.`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const runtime = await new DefaultRuntimeBuilder().build({
|
||||
config: makeBaseConfig({
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
enableSpawnAgent: true,
|
||||
}),
|
||||
configExtensions: [],
|
||||
createSpawnTool: makeSpawnTool,
|
||||
});
|
||||
|
||||
expect(runtime.tools.map((tool) => tool.name)).toContain(
|
||||
"subagent_code_reviewer",
|
||||
);
|
||||
expect(runtime.tools.map((tool) => tool.name)).not.toContain("skills");
|
||||
expect(
|
||||
(await collectExtensionTools(runtime.extensions)).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).not.toContain("skills");
|
||||
await runtime.shutdown("test");
|
||||
});
|
||||
|
||||
it("forwards telemetry for downstream runtime consumers", async () => {
|
||||
const telemetry = new TelemetryService();
|
||||
const runtime = await new DefaultRuntimeBuilder().build({
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import {
|
||||
createBuiltinTools,
|
||||
DEFAULT_MODEL_TOOL_ROUTING_RULES,
|
||||
DefaultToolNames,
|
||||
resolveToolPresetName,
|
||||
resolveToolRoutingConfig,
|
||||
type SkillsExecutorWithMetadata,
|
||||
@@ -32,6 +31,9 @@ import {
|
||||
createDelegatedAgentConfigProvider,
|
||||
type TeamEvent,
|
||||
} from "../../extensions/tools/team";
|
||||
import type { ConfiguredAgentConfig } from "../../extensions/tools/team/configured-agent-config";
|
||||
import { loadConfiguredAgentConfigs } from "../../extensions/tools/team/configured-agent-config";
|
||||
import { createConfiguredAgentTools } from "../../extensions/tools/team/configured-agent-tool";
|
||||
import {
|
||||
filterDisabledTools,
|
||||
resolveDisabledToolNames,
|
||||
@@ -81,6 +83,42 @@ function filterAvailableTools(
|
||||
return filterDisabledTools(filterToolsByPolicies(tools, toolPolicies));
|
||||
}
|
||||
|
||||
const CONFIGURED_AGENT_TOOL_NAME_ALIASES: Record<string, string> = {
|
||||
apply_diff: "editor",
|
||||
attempt_completion: "submit_and_exit",
|
||||
bash: "run_commands",
|
||||
execute_command: "run_commands",
|
||||
list_code_definition_names: "search_codebase",
|
||||
list_files: "run_commands",
|
||||
read_file: "read_files",
|
||||
replace_in_file: "editor",
|
||||
search_files: "search_codebase",
|
||||
use_skill: "skills",
|
||||
write_to_file: "editor",
|
||||
};
|
||||
|
||||
function resolveConfiguredAgentToolName(toolName: string): string {
|
||||
const normalized = toolName.trim().toLowerCase();
|
||||
return CONFIGURED_AGENT_TOOL_NAME_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
function filterToolsForConfiguredAgent(
|
||||
tools: AgentTool[],
|
||||
agent: ConfiguredAgentConfig,
|
||||
): AgentTool[] {
|
||||
if (agent.tools === undefined) {
|
||||
return tools;
|
||||
}
|
||||
|
||||
const allowedToolNames = new Set(
|
||||
agent.tools.map(resolveConfiguredAgentToolName),
|
||||
);
|
||||
if (agent.skills !== undefined) {
|
||||
allowedToolNames.add("skills");
|
||||
}
|
||||
return tools.filter((tool) => allowedToolNames.has(tool.name));
|
||||
}
|
||||
|
||||
export function createTeamName(): string {
|
||||
return `team-${nanoid(5)}`;
|
||||
}
|
||||
@@ -310,16 +348,28 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
} = input;
|
||||
const onTeamEvent = input.onTeamEvent ?? (() => {});
|
||||
const normalized = normalizeConfig(config);
|
||||
const workspaceConfigRoot = config.workspaceRoot ?? config.cwd;
|
||||
const effectiveToolPolicies = input.toolPolicies ?? config.toolPolicies;
|
||||
const globallyDisabledToolNames = resolveDisabledToolNames();
|
||||
const tools: AgentTool[] = [];
|
||||
const effectiveTeamName = config.teamName?.trim() || createTeamName();
|
||||
const teamStoreKey = config.sessionId?.trim() || effectiveTeamName;
|
||||
const configuredAgents = normalized.enableSpawnAgent
|
||||
? loadConfiguredAgentConfigs({
|
||||
workspaceRoot: workspaceConfigRoot,
|
||||
})
|
||||
: { configs: [], errors: [] };
|
||||
const configuredAgentsNeedSkills = configuredAgents.configs.some(
|
||||
(agent) => agent.skills !== undefined,
|
||||
);
|
||||
const rulesEnabled = hasConfigExtension(configExtensions, "rules");
|
||||
const skillsEnabled = hasConfigExtension(configExtensions, "skills");
|
||||
const rootSkillsEnabled = hasConfigExtension(configExtensions, "skills");
|
||||
const needsSkillsConfigService =
|
||||
rootSkillsEnabled || configuredAgentsNeedSkills;
|
||||
const workflowsEnabled = hasConfigExtension(configExtensions, "workflows");
|
||||
const pluginsEnabled = hasConfigExtension(configExtensions, "plugins");
|
||||
const userInstructionsEnabled =
|
||||
rulesEnabled || skillsEnabled || workflowsEnabled;
|
||||
rulesEnabled || rootSkillsEnabled || workflowsEnabled;
|
||||
let teamToolsRegistered = false;
|
||||
const userInstructionServiceProvided = Boolean(
|
||||
sharedUserInstructionService,
|
||||
@@ -327,11 +377,20 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
let userInstructionService = sharedUserInstructionService;
|
||||
let mcpShutdown: (() => Promise<void>) | undefined;
|
||||
|
||||
if (!userInstructionService && userInstructionsEnabled) {
|
||||
for (const error of configuredAgents.errors) {
|
||||
(logger ?? config.logger)?.log?.(
|
||||
`[agents] Failed to load agent config at ${error.path}: ${error.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!userInstructionService &&
|
||||
(userInstructionsEnabled || configuredAgentsNeedSkills)
|
||||
) {
|
||||
userInstructionService = createUserInstructionConfigService({
|
||||
skills: skillsEnabled
|
||||
skills: needsSkillsConfigService
|
||||
? {
|
||||
workspacePath: config.cwd,
|
||||
workspacePath: workspaceConfigRoot,
|
||||
includePluginSkills: pluginsEnabled,
|
||||
pluginSkillDirectories: pluginsEnabled
|
||||
? input.pluginSkillDirectories
|
||||
@@ -339,7 +398,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
pluginPaths: config.pluginPaths,
|
||||
cwd: config.cwd,
|
||||
}
|
||||
: { workspacePath: config.cwd },
|
||||
: { workspacePath: workspaceConfigRoot },
|
||||
rules: { workspacePath: config.cwd },
|
||||
workflows: { workspacePath: config.cwd },
|
||||
});
|
||||
@@ -351,7 +410,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
|
||||
const registerSkillsTool =
|
||||
normalized.enableTools &&
|
||||
skillsEnabled &&
|
||||
rootSkillsEnabled &&
|
||||
Boolean(userInstructionService) &&
|
||||
userInstructionService?.hasConfiguredSkills(config.skills) === true &&
|
||||
isSkillsToolEnabledForSession({
|
||||
@@ -360,7 +419,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
mode: normalized.mode,
|
||||
modelId: config.modelId,
|
||||
toolRoutingRules: config.toolRoutingRules,
|
||||
toolPolicies: config.toolPolicies,
|
||||
toolPolicies: effectiveToolPolicies,
|
||||
toolExecutors,
|
||||
});
|
||||
|
||||
@@ -368,7 +427,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
userInstructionService && userInstructionsEnabled
|
||||
? userInstructionService.createExtension({
|
||||
includeRules: rulesEnabled,
|
||||
includeSkills: skillsEnabled,
|
||||
includeSkills: rootSkillsEnabled,
|
||||
includeWorkflows: workflowsEnabled,
|
||||
registerSkillsTool,
|
||||
allowedSkillNames: config.skills,
|
||||
@@ -386,7 +445,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
normalized.mode,
|
||||
config.modelId,
|
||||
config.toolRoutingRules,
|
||||
config.toolPolicies,
|
||||
effectiveToolPolicies,
|
||||
undefined,
|
||||
toolExecutors,
|
||||
),
|
||||
@@ -433,6 +492,46 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
telemetry: input.telemetry ?? config.telemetry,
|
||||
workspaceMetadata: config.workspaceMetadata,
|
||||
});
|
||||
if (normalized.enableSpawnAgent) {
|
||||
if (configuredAgents.configs.length > 0) {
|
||||
tools.push(
|
||||
...filterAvailableTools(
|
||||
createConfiguredAgentTools({
|
||||
configProvider: delegatedAgentConfigProvider,
|
||||
agents: configuredAgents.configs,
|
||||
createSubAgentTools: (agent) =>
|
||||
normalized.enableTools
|
||||
? filterToolsForConfiguredAgent(
|
||||
createBuiltinToolsList(
|
||||
config.cwd,
|
||||
agent.providerId ?? config.providerId,
|
||||
normalized.mode,
|
||||
agent.modelId ?? config.modelId,
|
||||
config.toolRoutingRules,
|
||||
effectiveToolPolicies,
|
||||
agent.skills !== undefined &&
|
||||
userInstructionService?.createSkillsExecutor
|
||||
? userInstructionService.createSkillsExecutor(
|
||||
agent.skills,
|
||||
)
|
||||
: undefined,
|
||||
toolExecutors,
|
||||
),
|
||||
agent,
|
||||
)
|
||||
: [],
|
||||
hookErrorMode: config.hookErrorMode,
|
||||
toolPolicies: effectiveToolPolicies,
|
||||
requestToolApproval: input.requestToolApproval,
|
||||
onSubAgentEvent: input.onSubAgentEvent,
|
||||
onSubAgentStart: input.onSubAgentStart,
|
||||
onSubAgentEnd: input.onSubAgentEnd,
|
||||
}),
|
||||
effectiveToolPolicies,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!this.teamRuntimeEntries.has(registryKey)) {
|
||||
this.teamRuntimeEntries.set(registryKey, {
|
||||
delegatedAgentConfigProvider,
|
||||
@@ -518,7 +617,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
normalized.mode,
|
||||
config.modelId,
|
||||
config.toolRoutingRules,
|
||||
config.toolPolicies,
|
||||
effectiveToolPolicies,
|
||||
undefined,
|
||||
toolExecutors,
|
||||
)
|
||||
@@ -554,10 +653,10 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
ensureTeamRuntime();
|
||||
}
|
||||
|
||||
const finalTools = filterAvailableTools(tools, config.toolPolicies);
|
||||
const finalTools = filterAvailableTools(tools, effectiveToolPolicies);
|
||||
const requiresCompletionTool = finalTools.some(
|
||||
(tool) =>
|
||||
tool.name === DefaultToolNames.SUBMIT_AND_EXIT &&
|
||||
tool.name === "submit_and_exit" &&
|
||||
tool.lifecycle?.completesRun === true,
|
||||
);
|
||||
const teamCompletionGuard = normalized.enableAgentTeams
|
||||
|
||||
@@ -1,102 +1,13 @@
|
||||
import type { ITelemetryService } from "@cline/shared";
|
||||
import {
|
||||
getClineEnvironmentConfig,
|
||||
type ITelemetryService,
|
||||
isOAuthProviderId,
|
||||
type OAuthProviderId,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
type ClineOAuthCredentials,
|
||||
getValidClineCredentials,
|
||||
} from "../../auth/cline";
|
||||
import { getValidOpenAICodexCredentials } from "../../auth/codex";
|
||||
import { getValidOcaCredentials } from "../../auth/oca";
|
||||
import { decodeJwtPayload } from "../../auth/utils";
|
||||
getProviderAuthHandler,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "../../auth/provider-auth-registry";
|
||||
import { ProviderSettingsManager } from "../../services/storage/provider-settings-manager";
|
||||
import type { ProviderSettings } from "../../types/provider-settings";
|
||||
|
||||
const WORKOS_TOKEN_PREFIX = "workos:";
|
||||
|
||||
type ManagedOAuthProviderId = OAuthProviderId;
|
||||
|
||||
function toStoredAccessToken(
|
||||
providerId: ManagedOAuthProviderId,
|
||||
accessToken: string,
|
||||
): string {
|
||||
if (providerId === "cline") {
|
||||
return `${WORKOS_TOKEN_PREFIX}${accessToken}`;
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
function fromStoredAccessToken(
|
||||
providerId: ManagedOAuthProviderId,
|
||||
accessToken: string,
|
||||
): string {
|
||||
if (
|
||||
providerId === "cline" &&
|
||||
accessToken.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
) {
|
||||
return accessToken.slice(WORKOS_TOKEN_PREFIX.length);
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
function readExpiryFromToken(accessToken: string): number | null {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const exp = payload?.exp;
|
||||
if (typeof exp === "number" && exp > 0) {
|
||||
return exp * 1000;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveCredentialExpiry(
|
||||
settings: ProviderSettings,
|
||||
normalizedAccessToken: string,
|
||||
): number {
|
||||
const explicitExpiry = (
|
||||
settings.auth as
|
||||
| (ProviderSettings["auth"] & { expiresAt?: number })
|
||||
| undefined
|
||||
)?.expiresAt;
|
||||
if (
|
||||
typeof explicitExpiry === "number" &&
|
||||
Number.isFinite(explicitExpiry) &&
|
||||
explicitExpiry > 0
|
||||
) {
|
||||
return explicitExpiry;
|
||||
}
|
||||
|
||||
const jwtExpiry = readExpiryFromToken(normalizedAccessToken);
|
||||
if (jwtExpiry) {
|
||||
return jwtExpiry;
|
||||
}
|
||||
|
||||
// Unknown expiry should trigger refresh on next resolution.
|
||||
return Date.now() - 1;
|
||||
}
|
||||
|
||||
function toCredentials(
|
||||
providerId: ManagedOAuthProviderId,
|
||||
settings: ProviderSettings,
|
||||
): ClineOAuthCredentials | null {
|
||||
const rawAccess = settings.auth?.accessToken?.trim();
|
||||
const refreshToken = settings.auth?.refreshToken?.trim();
|
||||
if (!rawAccess || !refreshToken) {
|
||||
return null;
|
||||
}
|
||||
const access = fromStoredAccessToken(providerId, rawAccess);
|
||||
if (!access) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
access,
|
||||
refresh: refreshToken,
|
||||
expires: deriveCredentialExpiry(settings, access),
|
||||
accountId: settings.auth?.accountId,
|
||||
};
|
||||
}
|
||||
type ManagedOAuthProviderId = string;
|
||||
|
||||
function authSettingsEqual(
|
||||
a: ProviderSettings["auth"] | undefined,
|
||||
@@ -156,10 +67,11 @@ export class RuntimeOAuthTokenManager {
|
||||
providerId: string;
|
||||
forceRefresh?: boolean;
|
||||
}): Promise<RuntimeOAuthResolution | null> {
|
||||
if (!isOAuthProviderId(input.providerId)) {
|
||||
const handler = getProviderAuthHandler(input.providerId);
|
||||
if (!handler) {
|
||||
return null;
|
||||
}
|
||||
return this.resolveWithSingleFlight(input.providerId, input.forceRefresh);
|
||||
return this.resolveWithSingleFlight(handler.providerId, input.forceRefresh);
|
||||
}
|
||||
|
||||
private async resolveWithSingleFlight(
|
||||
@@ -185,42 +97,43 @@ export class RuntimeOAuthTokenManager {
|
||||
providerId: ManagedOAuthProviderId,
|
||||
forceRefresh: boolean,
|
||||
): Promise<RuntimeOAuthResolution | null> {
|
||||
const settings =
|
||||
this.providerSettingsManager.getProviderSettings(providerId);
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) {
|
||||
return null;
|
||||
}
|
||||
const settings = this.providerSettingsManager.getProviderSettings(
|
||||
handler.storageProviderId,
|
||||
);
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentCredentials = toCredentials(providerId, settings);
|
||||
const currentCredentials = getProviderOAuthCredentialsFromSettings(
|
||||
providerId,
|
||||
settings,
|
||||
);
|
||||
if (!currentCredentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextCredentials = await this.resolveCredentials(
|
||||
providerId,
|
||||
const nextCredentials = await handler.refresh({
|
||||
settings,
|
||||
currentCredentials,
|
||||
credentials: currentCredentials,
|
||||
forceRefresh,
|
||||
);
|
||||
telemetry: this.telemetry,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new OAuthReauthRequiredError(providerId);
|
||||
}
|
||||
|
||||
const persistedAccessToken = toStoredAccessToken(
|
||||
const nextSettings: ProviderSettings = saveProviderOAuthCredentials({
|
||||
manager: this.providerSettingsManager,
|
||||
providerId,
|
||||
nextCredentials.access,
|
||||
);
|
||||
const nextAuth = {
|
||||
...(settings.auth ?? {}),
|
||||
accessToken: persistedAccessToken,
|
||||
refreshToken: nextCredentials.refresh,
|
||||
accountId: nextCredentials.accountId,
|
||||
} as ProviderSettings["auth"] & { expiresAt?: number };
|
||||
nextAuth.expiresAt = nextCredentials.expires;
|
||||
const nextSettings: ProviderSettings = {
|
||||
...settings,
|
||||
auth: nextAuth,
|
||||
};
|
||||
settings,
|
||||
credentials: nextCredentials,
|
||||
setLastUsed: false,
|
||||
save: false,
|
||||
});
|
||||
const wasRefreshed = !authSettingsEqual(settings.auth, nextSettings.auth);
|
||||
if (wasRefreshed) {
|
||||
this.providerSettingsManager.saveProviderSettings(nextSettings, {
|
||||
@@ -231,39 +144,9 @@ export class RuntimeOAuthTokenManager {
|
||||
|
||||
return {
|
||||
providerId,
|
||||
apiKey: persistedAccessToken,
|
||||
apiKey: handler.getApiKey(nextSettings) ?? nextCredentials.access,
|
||||
accountId: nextCredentials.accountId,
|
||||
refreshed: wasRefreshed,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveCredentials(
|
||||
providerId: ManagedOAuthProviderId,
|
||||
settings: ProviderSettings,
|
||||
currentCredentials: ClineOAuthCredentials,
|
||||
forceRefresh: boolean,
|
||||
): Promise<ClineOAuthCredentials | null> {
|
||||
if (providerId === "cline") {
|
||||
return getValidClineCredentials(
|
||||
currentCredentials,
|
||||
{
|
||||
apiBaseUrl:
|
||||
settings.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
telemetry: this.telemetry,
|
||||
},
|
||||
{ forceRefresh },
|
||||
);
|
||||
}
|
||||
if (providerId === "oca") {
|
||||
return getValidOcaCredentials(
|
||||
currentCredentials,
|
||||
{ forceRefresh, telemetry: this.telemetry },
|
||||
{ mode: settings.oca?.mode, telemetry: this.telemetry },
|
||||
);
|
||||
}
|
||||
return getValidOpenAICodexCredentials(currentCredentials, {
|
||||
forceRefresh,
|
||||
telemetry: this.telemetry,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import type {
|
||||
AgentConfig,
|
||||
AgentEvent,
|
||||
AgentHooks,
|
||||
AgentResult,
|
||||
AgentTool,
|
||||
BasicLogger,
|
||||
ITelemetryService,
|
||||
RuntimeConfigExtensionKind,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/shared";
|
||||
import type { UserInstructionConfigService } from "../../extensions/config";
|
||||
import type { ToolExecutors } from "../../extensions/tools";
|
||||
import type {
|
||||
AgentTeamsRuntime,
|
||||
DelegatedAgentConfigProvider,
|
||||
SubAgentEndContext,
|
||||
SubAgentStartContext,
|
||||
TeamEvent,
|
||||
} from "../../extensions/tools/team";
|
||||
import type { WorkspaceManager } from "../../services/workspace/workspace-manager";
|
||||
@@ -49,15 +54,22 @@ export interface RuntimeBuilderInput {
|
||||
hooks?: AgentHooks;
|
||||
extensions?: AgentConfig["extensions"];
|
||||
onTeamEvent?: (event: TeamEvent) => void;
|
||||
onSubAgentEvent?: (event: AgentEvent) => void;
|
||||
onSubAgentStart?: (context: SubAgentStartContext) => void | Promise<void>;
|
||||
onSubAgentEnd?: (context: SubAgentEndContext) => void | Promise<void>;
|
||||
createSpawnTool?: () => AgentTool;
|
||||
onTeamRestored?: () => void;
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
pluginSkillDirectories?: ReadonlyArray<string>;
|
||||
configExtensions?: RuntimeConfigExtensionKind[];
|
||||
toolExecutors?: Partial<ToolExecutors>;
|
||||
toolPolicies?: CoreSessionConfig["toolPolicies"];
|
||||
workspaceManager?: WorkspaceManager;
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
requestToolApproval?: (
|
||||
request: ToolApprovalRequest,
|
||||
) => Promise<ToolApprovalResult> | ToolApprovalResult;
|
||||
}
|
||||
|
||||
export interface RuntimeBuilder {
|
||||
|
||||
@@ -243,6 +243,75 @@ describe("createAgentModelFromConfig", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards Azure settings as OpenAI-compatible gateway provider options", async () => {
|
||||
const { createAgentModelFromConfig } = await import("./handler-factory");
|
||||
|
||||
createAgentModelFromConfig(
|
||||
{
|
||||
providerId: "openai-compatible",
|
||||
modelId: "gpt-4.1",
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
providerConfig: {
|
||||
providerId: "openai-compatible",
|
||||
modelId: "gpt-4.1",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useIdentity: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(gatewayMock.createGateway).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
providerConfigs: [
|
||||
expect.objectContaining({
|
||||
providerId: "openai-compatible",
|
||||
options: expect.objectContaining({
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useIdentity: false,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not forward Azure settings for non-OpenAI-compatible providers", async () => {
|
||||
const { createAgentModelFromConfig } = await import("./handler-factory");
|
||||
|
||||
createAgentModelFromConfig(
|
||||
{
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-3-5-sonnet",
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
providerConfig: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-3-5-sonnet",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useIdentity: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(gatewayMock.createGateway).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
providerConfigs: [
|
||||
expect.objectContaining({
|
||||
providerId: "anthropic",
|
||||
options: undefined,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a registered handler (adapter) instead of the gateway, building it lazily", async () => {
|
||||
const { createAgentModelFromConfig } = await import("./handler-factory");
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ function compactOptions(
|
||||
return Object.keys(compacted).length > 0 ? compacted : undefined;
|
||||
}
|
||||
|
||||
function usesOpenAICompatibleClient(config: ProviderConfig): boolean {
|
||||
return (
|
||||
config.providerId === "openai-compatible" ||
|
||||
config.clientType === "openai-compatible"
|
||||
);
|
||||
}
|
||||
|
||||
function buildGatewayProviderOptions(
|
||||
config: ProviderConfig,
|
||||
): Record<string, unknown> | undefined {
|
||||
@@ -35,6 +42,13 @@ function buildGatewayProviderOptions(
|
||||
modelCatalog: config.modelCatalog,
|
||||
};
|
||||
|
||||
if (usesOpenAICompatibleClient(config)) {
|
||||
Object.assign(options, {
|
||||
apiVersion: config.azure?.apiVersion,
|
||||
useIdentity: config.azure?.useIdentity,
|
||||
});
|
||||
}
|
||||
|
||||
if (config.providerId === "bedrock") {
|
||||
Object.assign(options, {
|
||||
authentication: config.aws?.authentication,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AgentConfig,
|
||||
AgentEvent,
|
||||
AgentHooks,
|
||||
AgentTool,
|
||||
ExtensionContext,
|
||||
@@ -19,7 +20,11 @@ import type {
|
||||
PluginInitializationFailure,
|
||||
PluginInitializationWarning,
|
||||
} from "../extensions/plugin/plugin-load-report";
|
||||
import type { TeamEvent } from "../extensions/tools/team";
|
||||
import type {
|
||||
SubAgentEndContext,
|
||||
SubAgentStartContext,
|
||||
TeamEvent,
|
||||
} from "../extensions/tools/team";
|
||||
import { createCheckpointHooks } from "../hooks/checkpoint-hooks";
|
||||
import {
|
||||
createHookAuditHooks,
|
||||
@@ -189,6 +194,10 @@ function buildProviderConfig(
|
||||
...(stored?.modelCatalog ?? {}),
|
||||
}
|
||||
: undefined;
|
||||
const sessionProviderConfig =
|
||||
config.providerConfig?.providerId === config.providerId
|
||||
? config.providerConfig
|
||||
: undefined;
|
||||
const settings: ProviderSettings = {
|
||||
...(stored ?? {}),
|
||||
provider: config.providerId,
|
||||
@@ -209,7 +218,10 @@ function buildProviderConfig(
|
||||
reasoning: resolveReasoningSettings(config, stored?.reasoning),
|
||||
modelCatalog,
|
||||
};
|
||||
const providerConfig = toProviderConfig(settings);
|
||||
const providerConfig: ProviderConfig = {
|
||||
...toProviderConfig(settings),
|
||||
...(sessionProviderConfig ?? {}),
|
||||
};
|
||||
if (config.knownModels) {
|
||||
providerConfig.knownModels = config.knownModels;
|
||||
}
|
||||
@@ -241,6 +253,11 @@ export interface PrepareLocalRuntimeBootstrapOptions {
|
||||
defaultFetch?: typeof fetch;
|
||||
onPluginEvent: (event: { name: string; payload?: unknown }) => void;
|
||||
onTeamEvent: (event: TeamEvent) => void;
|
||||
createSubAgentLifecycleCallbacks?: (config: CoreSessionConfig) => {
|
||||
onSubAgentEvent?: (event: AgentEvent) => void;
|
||||
onSubAgentStart?: (context: SubAgentStartContext) => void | Promise<void>;
|
||||
onSubAgentEnd?: (context: SubAgentEndContext) => void | Promise<void>;
|
||||
};
|
||||
createSpawnTool: () => AgentTool;
|
||||
readSessionMetadata: () => Promise<Record<string, unknown> | undefined>;
|
||||
writeSessionMetadata: (
|
||||
@@ -278,6 +295,7 @@ export async function prepareLocalRuntimeBootstrap(
|
||||
defaultFetch,
|
||||
onPluginEvent,
|
||||
onTeamEvent,
|
||||
createSubAgentLifecycleCallbacks,
|
||||
createSpawnTool,
|
||||
localRuntime,
|
||||
readSessionMetadata,
|
||||
@@ -433,6 +451,7 @@ export async function prepareLocalRuntimeBootstrap(
|
||||
);
|
||||
const requestToolApproval = capabilities?.requestToolApproval;
|
||||
const effectiveToolExecutors = capabilities?.toolExecutors;
|
||||
const subAgentLifecycleCallbacks = createSubAgentLifecycleCallbacks?.(config);
|
||||
const workspaceManager = new InMemoryWorkspaceManager({
|
||||
currentWorkspacePath: workspaceInfo.rootPath,
|
||||
workspaces: {
|
||||
@@ -458,13 +477,18 @@ export async function prepareLocalRuntimeBootstrap(
|
||||
onTeamEvent,
|
||||
createSpawnTool,
|
||||
onTeamRestored: onTeamRestored,
|
||||
onSubAgentEvent: subAgentLifecycleCallbacks?.onSubAgentEvent,
|
||||
onSubAgentStart: subAgentLifecycleCallbacks?.onSubAgentStart,
|
||||
onSubAgentEnd: subAgentLifecycleCallbacks?.onSubAgentEnd,
|
||||
userInstructionService: userInstructionService,
|
||||
pluginSkillDirectories,
|
||||
configExtensions: configExtensions,
|
||||
toolExecutors: effectiveToolExecutors,
|
||||
toolPolicies,
|
||||
workspaceManager,
|
||||
logger: config.logger,
|
||||
telemetry: config.telemetry,
|
||||
requestToolApproval,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1248,8 +1248,7 @@ describe("normalizeOAuthProvider", () => {
|
||||
expect(normalizeOAuthProvider("OCA")).toBe("oca");
|
||||
});
|
||||
|
||||
it("normalizes 'codex' and 'openai-codex' to 'openai-codex'", () => {
|
||||
expect(normalizeOAuthProvider("codex")).toBe("openai-codex");
|
||||
it("normalizes 'openai-codex' to 'openai-codex'", () => {
|
||||
expect(normalizeOAuthProvider("openai-codex")).toBe("openai-codex");
|
||||
expect(normalizeOAuthProvider("OPENAI-CODEX")).toBe("openai-codex");
|
||||
});
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import * as LlmsModels from "@cline/llms";
|
||||
import {
|
||||
type AddProviderActionRequest,
|
||||
getClineEnvironmentConfig,
|
||||
type ITelemetryService,
|
||||
type OAuthProviderId,
|
||||
type ProviderCapability,
|
||||
type ProviderConfigField,
|
||||
type ProviderConfigFieldPrimitive,
|
||||
type ProviderListItem,
|
||||
type ProviderModel,
|
||||
type SaveProviderSettingsActionRequest,
|
||||
import type {
|
||||
AddProviderActionRequest,
|
||||
ITelemetryService,
|
||||
ProviderCapability,
|
||||
ProviderConfigField,
|
||||
ProviderConfigFieldPrimitive,
|
||||
ProviderListItem,
|
||||
ProviderModel,
|
||||
SaveProviderSettingsActionRequest,
|
||||
} from "@cline/shared";
|
||||
import { createOAuthClientCallbacks } from "../../auth/client";
|
||||
import { loginClineOAuth } from "../../auth/cline";
|
||||
import { loginOpenAICodex } from "../../auth/codex";
|
||||
import { loginOcaOAuth } from "../../auth/oca";
|
||||
import {
|
||||
getProviderAuthHandler,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderOAuthCredentials,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "../../auth/provider-auth-registry";
|
||||
import { resolveProviderConfig } from "../../services/llms/provider-defaults";
|
||||
import type {
|
||||
ModelInfo,
|
||||
@@ -849,39 +850,23 @@ export async function refreshProviderModelsFromSource(
|
||||
return { providerId: id, refreshed: true, modelsCount: result.modelsCount };
|
||||
}
|
||||
|
||||
export function normalizeOAuthProvider(provider: string): OAuthProviderId {
|
||||
export function normalizeOAuthProvider(provider: string): string {
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
if (normalized === "codex" || normalized === "openai-codex")
|
||||
return "openai-codex";
|
||||
if (normalized === "cline" || normalized === "oca") return normalized;
|
||||
throw new Error(
|
||||
`provider "${provider}" does not support OAuth login (supported: cline, oca, openai-codex)`,
|
||||
);
|
||||
}
|
||||
|
||||
function toProviderApiKey(
|
||||
providerId: OAuthProviderId,
|
||||
credentials: { access: string },
|
||||
): string {
|
||||
if (providerId === "cline") {
|
||||
return credentials.access.startsWith("workos:")
|
||||
? credentials.access
|
||||
: `workos:${credentials.access}`;
|
||||
}
|
||||
return credentials.access;
|
||||
const handler = getProviderAuthHandler(normalized);
|
||||
if (handler) return handler.providerId;
|
||||
throw new Error(`provider "${provider}" does not support OAuth login`);
|
||||
}
|
||||
|
||||
export async function loginLocalProvider(
|
||||
providerId: OAuthProviderId,
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
openUrl: (url: string) => void,
|
||||
telemetry?: ITelemetryService,
|
||||
): Promise<{
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
}> {
|
||||
): Promise<ProviderOAuthCredentials> {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) {
|
||||
throw new Error(`provider "${providerId}" does not support OAuth login`);
|
||||
}
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: async (prompt) => prompt.defaultValue ?? "",
|
||||
openUrl,
|
||||
@@ -889,55 +874,42 @@ export async function loginLocalProvider(
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
},
|
||||
});
|
||||
|
||||
if (providerId === "cline") {
|
||||
return loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
useWorkOSDeviceAuth: true,
|
||||
callbacks,
|
||||
telemetry,
|
||||
});
|
||||
}
|
||||
if (providerId === "oca")
|
||||
return loginOcaOAuth({ mode: existing?.oca?.mode, callbacks, telemetry });
|
||||
return loginOpenAICodex({
|
||||
onAuth: callbacks.onAuth,
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
telemetry,
|
||||
});
|
||||
return handler.login({ settings: existing, callbacks, telemetry });
|
||||
}
|
||||
|
||||
export function saveLocalProviderOAuthCredentials(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: OAuthProviderId,
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
credentials: {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
},
|
||||
credentials: ProviderOAuthCredentials,
|
||||
options?: { setLastUsed?: boolean },
|
||||
): ProviderSettings {
|
||||
const auth = {
|
||||
...(existing?.auth ?? {}),
|
||||
accessToken: toProviderApiKey(providerId, credentials),
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
expiresAt: credentials.expires,
|
||||
} as ProviderSettings["auth"] & { expiresAt?: number };
|
||||
return saveProviderOAuthCredentials({
|
||||
manager,
|
||||
providerId,
|
||||
settings: existing,
|
||||
credentials,
|
||||
setLastUsed: options?.setLastUsed,
|
||||
});
|
||||
}
|
||||
|
||||
const merged: ProviderSettings = {
|
||||
...(existing ?? {
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
}),
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
auth,
|
||||
};
|
||||
manager.saveProviderSettings(merged, { tokenSource: "oauth" });
|
||||
return merged;
|
||||
export async function loginAndSaveLocalProviderOAuthCredentials(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
openUrl: (url: string) => void,
|
||||
telemetry?: ITelemetryService,
|
||||
): Promise<ProviderSettings> {
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: async (prompt) => prompt.defaultValue ?? "",
|
||||
openUrl,
|
||||
onOpenUrlError: ({ error }) => {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
},
|
||||
});
|
||||
return loginAndSaveProviderOAuthCredentials(manager, providerId, {
|
||||
callbacks,
|
||||
telemetry,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveLocalClineAuthToken(
|
||||
|
||||
@@ -84,14 +84,35 @@ describe("getProviderConfigFields", () => {
|
||||
expect(result.fields).toEqual({});
|
||||
});
|
||||
|
||||
it("returns api-key auth with apiKey + baseUrl for OpenAI Compatible", () => {
|
||||
it("returns api-key auth with apiKey, baseUrl, and Azure API version for OpenAI Compatible", () => {
|
||||
const result = getProviderConfigFields("openai-compatible");
|
||||
expect(result.providerId).toBe("openai-compatible");
|
||||
expect(result.authMethod).toBe("api-key");
|
||||
expect(result.description).toMatch(/Azure AI Foundry/i);
|
||||
expect(result.fields.apiKey).toEqual({});
|
||||
expect(result.fields.baseUrl?.defaultValue).toBe(
|
||||
"https://api.openai.com/v1",
|
||||
);
|
||||
expect(result.fields.azureApiVersion).toMatchObject({
|
||||
label: "Azure API Version (optional)",
|
||||
placeholder: "2025-01-01-preview",
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns Vertex GCP config fields with optional API key", () => {
|
||||
const result = getProviderConfigFields("vertex");
|
||||
expect(result.providerId).toBe("vertex");
|
||||
expect(result.authMethod).toBe("api-key");
|
||||
expect(result.description).toMatch(/Application Default Credentials/i);
|
||||
expect(Object.keys(result.fields)).toEqual([
|
||||
"gcpProjectId",
|
||||
"gcpRegion",
|
||||
"apiKey",
|
||||
]);
|
||||
expect(result.fields.gcpProjectId?.label).toBe("Google Cloud Project ID");
|
||||
expect(result.fields.gcpRegion?.defaultValue).toBe("us-central1");
|
||||
expect(result.fields.apiKey?.optional).toBe(true);
|
||||
});
|
||||
|
||||
it("returns api-key auth with awsRegion, apiKey, and awsProfile for bedrock", () => {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import * as LlmsModels from "@cline/llms";
|
||||
import { isOAuthProviderId } from "@cline/shared";
|
||||
import { isOAuthProvider } from "../../auth/provider-auth-registry";
|
||||
|
||||
export type ProviderConfigFieldKey =
|
||||
| "apiKey"
|
||||
| "baseUrl"
|
||||
| "azureApiVersion"
|
||||
| "awsRegion"
|
||||
| "awsProfile"
|
||||
| "gcpProjectId"
|
||||
| "gcpRegion"
|
||||
| "sapClientId"
|
||||
| "sapClientSecret"
|
||||
| "sapTokenUrl"
|
||||
@@ -33,8 +36,11 @@ export interface ProviderConfigFields {
|
||||
const FIELD_KEYS: ProviderConfigFieldKey[] = [
|
||||
"apiKey",
|
||||
"baseUrl",
|
||||
"azureApiVersion",
|
||||
"awsRegion",
|
||||
"awsProfile",
|
||||
"gcpProjectId",
|
||||
"gcpRegion",
|
||||
"sapClientId",
|
||||
"sapClientSecret",
|
||||
"sapTokenUrl",
|
||||
@@ -53,6 +59,39 @@ interface ProviderConfigFieldMetadata {
|
||||
const PROVIDER_CONFIG_FIELD_METADATA: Partial<
|
||||
Record<string, ProviderConfigFieldMetadata>
|
||||
> = {
|
||||
"openai-compatible": {
|
||||
description:
|
||||
"For Azure AI Foundry deployments, use a Base URL ending at /openai/deployments/<deployment> and set the Azure API version.",
|
||||
fields: {
|
||||
azureApiVersion: {
|
||||
label: "Azure API Version (optional)",
|
||||
placeholder: "2025-01-01-preview",
|
||||
note: "Required for Azure AI Foundry deployment URLs.",
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
vertex: {
|
||||
mode: "replace",
|
||||
description:
|
||||
"Vertex AI can use Google Cloud Application Default Credentials with a project/region. An API key is optional for supported Gemini models.",
|
||||
fields: {
|
||||
gcpProjectId: {
|
||||
label: "Google Cloud Project ID",
|
||||
placeholder: "my-gcp-project",
|
||||
},
|
||||
gcpRegion: {
|
||||
label: "Google Cloud Region",
|
||||
placeholder: "us-central1",
|
||||
defaultValue: "us-central1",
|
||||
},
|
||||
apiKey: {
|
||||
label: "API Key (optional)",
|
||||
placeholder: "Leave blank to use Google Cloud credentials",
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
bedrock: {
|
||||
mode: "replace",
|
||||
description:
|
||||
@@ -186,7 +225,7 @@ export function getProviderConfigFields(
|
||||
providerId: string,
|
||||
): ProviderConfigFields {
|
||||
const id = LlmsModels.normalizeProviderId(providerId);
|
||||
if (isOAuthProviderId(id)) {
|
||||
if (isOAuthProvider(id)) {
|
||||
return { providerId: id, authMethod: "oauth", fields: {} };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/llms",
|
||||
"version": "0.0.45",
|
||||
"version": "0.0.46",
|
||||
"description": "Config-driven SDK for selecting, extending, and instantiating LLM providers and models",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -14,7 +14,7 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
version: number;
|
||||
providers: Record<string, Record<string, ModelInfo>>;
|
||||
} = {
|
||||
version: 1781030379130,
|
||||
version: 1781051263433,
|
||||
providers: {
|
||||
aihubmix: {
|
||||
"glm-5v-turbo": {
|
||||
@@ -1720,6 +1720,54 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
},
|
||||
},
|
||||
bedrock: {
|
||||
"eu.anthropic.claude-fable-5": {
|
||||
id: "eu.anthropic.claude-fable-5",
|
||||
name: "Claude Fable 5 (EU)",
|
||||
contextWindow: 1000000,
|
||||
maxInputTokens: 1000000,
|
||||
maxTokens: 128000,
|
||||
capabilities: ["images", "tools", "reasoning", "prompt-cache"],
|
||||
pricing: {
|
||||
input: 11,
|
||||
output: 55,
|
||||
cacheRead: 1.1,
|
||||
cacheWrite: 13.75,
|
||||
},
|
||||
releaseDate: "2026-06-09",
|
||||
family: "claude-fable",
|
||||
},
|
||||
"global.anthropic.claude-fable-5": {
|
||||
id: "global.anthropic.claude-fable-5",
|
||||
name: "Claude Fable 5 (Global)",
|
||||
contextWindow: 1000000,
|
||||
maxInputTokens: 1000000,
|
||||
maxTokens: 128000,
|
||||
capabilities: ["images", "tools", "reasoning", "prompt-cache"],
|
||||
pricing: {
|
||||
input: 10,
|
||||
output: 50,
|
||||
cacheRead: 1,
|
||||
cacheWrite: 12.5,
|
||||
},
|
||||
releaseDate: "2026-06-09",
|
||||
family: "claude-fable",
|
||||
},
|
||||
"us.anthropic.claude-fable-5": {
|
||||
id: "us.anthropic.claude-fable-5",
|
||||
name: "Claude Fable 5 (US)",
|
||||
contextWindow: 1000000,
|
||||
maxInputTokens: 1000000,
|
||||
maxTokens: 128000,
|
||||
capabilities: ["images", "tools", "reasoning", "prompt-cache"],
|
||||
pricing: {
|
||||
input: 10,
|
||||
output: 50,
|
||||
cacheRead: 1,
|
||||
cacheWrite: 12.5,
|
||||
},
|
||||
releaseDate: "2026-06-09",
|
||||
family: "claude-fable",
|
||||
},
|
||||
"anthropic.claude-opus-4-8": {
|
||||
id: "anthropic.claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
@@ -22399,6 +22447,22 @@ export const GENERATED_PROVIDER_MODELS: {
|
||||
},
|
||||
},
|
||||
xiaomi: {
|
||||
"mimo-v2.5-pro-ultraspeed": {
|
||||
id: "mimo-v2.5-pro-ultraspeed",
|
||||
name: "MiMo-V2.5-Pro-UltraSpeed",
|
||||
contextWindow: 1048576,
|
||||
maxInputTokens: 1048576,
|
||||
maxTokens: 131072,
|
||||
capabilities: ["tools", "reasoning", "temperature", "prompt-cache"],
|
||||
pricing: {
|
||||
input: 1.305,
|
||||
output: 2.61,
|
||||
cacheRead: 0.0108,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
releaseDate: "2026-06-08",
|
||||
family: "mimo",
|
||||
},
|
||||
"mimo-v2.5": {
|
||||
id: "mimo-v2.5",
|
||||
name: "MiMo-V2.5",
|
||||
|
||||
@@ -431,6 +431,90 @@ describe("createGatewayApiHandler.createMessage", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds Azure API version to deployment-style OpenAI-compatible requests", async () => {
|
||||
streamTextSpy.mockReturnValue({
|
||||
fullStream: (async function* () {
|
||||
yield { type: "finish", finishReason: "stop" };
|
||||
})(),
|
||||
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }),
|
||||
});
|
||||
const providerFetch = vi.fn(
|
||||
async () => new Response("{}"),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const handler = createGatewayApiHandler({
|
||||
providerId: "openai-compatible",
|
||||
clientType: "openai-compatible",
|
||||
modelId: "gpt-4.1",
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
fetch: providerFetch,
|
||||
azure: { apiVersion: "2025-01-01-preview" },
|
||||
});
|
||||
|
||||
for await (const _chunk of handler.createMessage("", [
|
||||
{ role: "user", content: "Hello" },
|
||||
])) {
|
||||
// Drain the stream so the provider is constructed.
|
||||
}
|
||||
|
||||
const factoryConfig = openaiCompatibleFactorySpy.mock.calls.at(-1)?.[0] as
|
||||
| { fetch?: typeof fetch }
|
||||
| undefined;
|
||||
expect(factoryConfig?.fetch).toEqual(expect.any(Function));
|
||||
|
||||
await factoryConfig?.fetch?.(
|
||||
"https://example.openai.azure.com/openai/deployments/gpt-4.1/chat/completions",
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
expect(providerFetch).toHaveBeenCalledWith(
|
||||
"https://example.openai.azure.com/openai/deployments/gpt-4.1/chat/completions?api-version=2025-01-01-preview",
|
||||
{ method: "POST" },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add Azure API version to OpenAI v1-compatible requests", async () => {
|
||||
streamTextSpy.mockReturnValue({
|
||||
fullStream: (async function* () {
|
||||
yield { type: "finish", finishReason: "stop" };
|
||||
})(),
|
||||
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }),
|
||||
});
|
||||
const providerFetch = vi.fn(
|
||||
async () => new Response("{}"),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const handler = createGatewayApiHandler({
|
||||
providerId: "openai-compatible",
|
||||
clientType: "openai-compatible",
|
||||
modelId: "gpt-4.1",
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
fetch: providerFetch,
|
||||
azure: { apiVersion: "2025-01-01-preview" },
|
||||
});
|
||||
|
||||
for await (const _chunk of handler.createMessage("", [
|
||||
{ role: "user", content: "Hello" },
|
||||
])) {
|
||||
// Drain the stream so the provider is constructed.
|
||||
}
|
||||
|
||||
const factoryConfig = openaiCompatibleFactorySpy.mock.calls.at(-1)?.[0] as
|
||||
| { fetch?: typeof fetch }
|
||||
| undefined;
|
||||
await factoryConfig?.fetch?.(
|
||||
"https://example.openai.azure.com/openai/v1/chat/completions",
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
expect(providerFetch).toHaveBeenCalledWith(
|
||||
"https://example.openai.azure.com/openai/v1/chat/completions",
|
||||
{ method: "POST" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -581,10 +581,16 @@ export function buildGatewayReasoningOptions(
|
||||
explicitBudgetTokens: request.reasoning?.budgetTokens,
|
||||
})
|
||||
: request.reasoning?.budgetTokens;
|
||||
const shouldSendDisabledReasoning =
|
||||
request.reasoning?.enabled === false &&
|
||||
// FIXME: temporary OpenRouter compatibility patch for Claude Fable.
|
||||
// Remove once OpenRouter normalizes disabled reasoning like Vercel does,
|
||||
// or replace with a systematic policy for models that reject it.
|
||||
!request.modelId.toLowerCase().includes("claude-fable");
|
||||
const reasoning: Record<string, unknown> = {
|
||||
...(request.reasoning?.enabled === true
|
||||
? { enabled: true }
|
||||
: request.reasoning?.enabled === false
|
||||
: shouldSendDisabledReasoning
|
||||
? { enabled: false }
|
||||
: request.reasoning?.effort
|
||||
? { enabled: true }
|
||||
|
||||
@@ -1101,6 +1101,20 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () =>
|
||||
context: { family: "kimi-k2.6" },
|
||||
expect: [{ bucket: "cline", has: { reasoning: { enabled: false } } }],
|
||||
},
|
||||
{
|
||||
name: "cline Claude Fable reasoning.enabled=false uses lowest supported reasoning",
|
||||
request: {
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-fable-5",
|
||||
reasoning: { enabled: false },
|
||||
},
|
||||
expect: [
|
||||
{
|
||||
bucket: "cline",
|
||||
has: { reasoning: { max_tokens: 1024 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
// OpenRouter owns the reasoning object regardless of Moonshot family.
|
||||
{
|
||||
name: "openrouter non-K2.6 Moonshot Kimi reasoning.enabled=false -> reasoning.exclude",
|
||||
|
||||
@@ -9,6 +9,70 @@ import { resolveApiKey } from "../http";
|
||||
import { splitToolImagesMiddleware } from "../middleware/split-tool-images";
|
||||
import type { ProviderFactoryResult } from "./types";
|
||||
|
||||
type FetchInput = Parameters<typeof fetch>[0];
|
||||
type FetchWithOptionalPreconnect = typeof fetch & {
|
||||
preconnect?: (...args: unknown[]) => unknown;
|
||||
};
|
||||
|
||||
function readAzureApiVersion(
|
||||
config: GatewayResolvedProviderConfig,
|
||||
): string | undefined {
|
||||
const apiVersion = config.options?.apiVersion;
|
||||
if (typeof apiVersion !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = apiVersion.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function shouldAddAzureApiVersion(url: URL): boolean {
|
||||
return (
|
||||
url.pathname.startsWith("/openai/deployments/") &&
|
||||
!url.searchParams.has("api-version")
|
||||
);
|
||||
}
|
||||
|
||||
function withAzureApiVersion(
|
||||
input: FetchInput,
|
||||
apiVersion: string,
|
||||
): FetchInput {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input instanceof Request ? input.url : input.toString());
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
if (!shouldAddAzureApiVersion(url)) {
|
||||
return input;
|
||||
}
|
||||
url.searchParams.set("api-version", apiVersion);
|
||||
if (input instanceof Request) {
|
||||
return new Request(url.toString(), input);
|
||||
}
|
||||
return (typeof input === "string" ? url.toString() : url) as FetchInput;
|
||||
}
|
||||
|
||||
function createAzureApiVersionFetch(
|
||||
config: GatewayResolvedProviderConfig,
|
||||
): typeof fetch | undefined {
|
||||
const apiVersion = readAzureApiVersion(config);
|
||||
if (!apiVersion) {
|
||||
return config.fetch;
|
||||
}
|
||||
const baseFetch = config.fetch ?? globalThis.fetch;
|
||||
if (!baseFetch) {
|
||||
return config.fetch;
|
||||
}
|
||||
const azureFetch = ((input, init) =>
|
||||
baseFetch(withAzureApiVersion(input, apiVersion), init)) as typeof fetch;
|
||||
const baseFetchWithPreconnect = baseFetch as FetchWithOptionalPreconnect;
|
||||
(azureFetch as FetchWithOptionalPreconnect).preconnect =
|
||||
typeof baseFetchWithPreconnect.preconnect === "function"
|
||||
? baseFetchWithPreconnect.preconnect.bind(baseFetch)
|
||||
: () => undefined;
|
||||
return azureFetch;
|
||||
}
|
||||
|
||||
export async function createOpenAICompatibleProviderModule(
|
||||
config: GatewayResolvedProviderConfig,
|
||||
context: GatewayProviderContext,
|
||||
@@ -18,12 +82,13 @@ export async function createOpenAICompatibleProviderModule(
|
||||
// authoritative error and is surfaced to the user as-is. This keeps
|
||||
// `llms` unopinionated about which providers do or don't need a key.
|
||||
const apiKey = await resolveApiKey(config);
|
||||
const fetch = createAzureApiVersionFetch(config);
|
||||
const provider = createOpenAICompatible({
|
||||
name: context.provider.id,
|
||||
apiKey,
|
||||
...(config.baseUrl ? { baseURL: config.baseUrl } : {}),
|
||||
...(config.headers ? { headers: config.headers } : {}),
|
||||
...(config.fetch ? { fetch: config.fetch } : {}),
|
||||
...(fetch ? { fetch } : {}),
|
||||
includeUsage: true,
|
||||
} as never);
|
||||
return {
|
||||
|
||||
@@ -60,6 +60,30 @@ describe("createVertexProviderModule", () => {
|
||||
expect(createVertexAnthropicMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts nested gcp project and region options", async () => {
|
||||
await createVertexProviderModule(
|
||||
config({
|
||||
options: {
|
||||
gcp: {
|
||||
projectId: "nested-project",
|
||||
region: "europe-west4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
context("gemini-3-flash-preview"),
|
||||
);
|
||||
|
||||
expect(createVertexMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
project: "nested-project",
|
||||
location: "europe-west4",
|
||||
googleAuthOptions: {
|
||||
projectId: "nested-project",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps API-key express mode when Gemini config has no GCP settings", async () => {
|
||||
await createVertexProviderModule(
|
||||
config({
|
||||
|
||||
+36
-12
@@ -8,22 +8,46 @@ import { ensureFetch, resolveApiKey } from "../http";
|
||||
import { isClaudeModelId } from "../model-facts";
|
||||
import type { ProviderFactoryResult } from "./types";
|
||||
|
||||
function readStringOption(
|
||||
options: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = options?.[key];
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readNestedStringOption(
|
||||
options: Record<string, unknown> | undefined,
|
||||
objectKey: string,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const object = options?.[objectKey];
|
||||
if (!object || typeof object !== "object" || Array.isArray(object)) {
|
||||
return undefined;
|
||||
}
|
||||
const value = (object as Record<string, unknown>)[key];
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export async function createVertexProviderModule(
|
||||
config: GatewayResolvedProviderConfig,
|
||||
context: GatewayProviderContext,
|
||||
): Promise<ProviderFactoryResult> {
|
||||
const project = String(
|
||||
config.options?.project ?? config.options?.projectId ?? "",
|
||||
);
|
||||
const location = String(
|
||||
config.options?.location ?? config.options?.region ?? "us-central1",
|
||||
);
|
||||
const googleAuthProjectId =
|
||||
typeof config.options?.project === "string"
|
||||
? config.options.project
|
||||
: typeof config.options?.projectId === "string"
|
||||
? config.options.projectId
|
||||
: undefined;
|
||||
const project =
|
||||
readStringOption(config.options, "project") ??
|
||||
readStringOption(config.options, "projectId") ??
|
||||
readNestedStringOption(config.options, "gcp", "projectId") ??
|
||||
"";
|
||||
const location =
|
||||
readStringOption(config.options, "location") ??
|
||||
readStringOption(config.options, "region") ??
|
||||
readNestedStringOption(config.options, "gcp", "region") ??
|
||||
"us-central1";
|
||||
const googleAuthProjectId = project || undefined;
|
||||
const fetch = ensureFetch(config.fetch);
|
||||
|
||||
if (isClaudeModelId(context.model.id)) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/sdk",
|
||||
"description": "Cline SDK - user-facing alias for @cline/core",
|
||||
"version": "0.0.45",
|
||||
"version": "0.0.46",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user