mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
Merge remote-tracking branch 'origin/main' into bee/shared-plugin-commands
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
@@ -34,3 +34,14 @@ updates:
|
||||
- "version-update:semver-major"
|
||||
- "version-update:semver-minor"
|
||||
- "version-update:semver-patch"
|
||||
|
||||
# Pinned skills CLI release (devDependency of @cline/core). `cline skill`
|
||||
# and the marketplace skill fallback run this exact version through npx,
|
||||
# so version updates are wanted here, unlike the entries above: each bump
|
||||
# goes through CI and review before it reaches users.
|
||||
- package-ecosystem: "bun"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
allow:
|
||||
- dependency-name: "skills"
|
||||
|
||||
@@ -104,6 +104,10 @@ jobs:
|
||||
bun -F @cline/code test:sidecar
|
||||
bun -F @cline/code test:sidecar-startup
|
||||
|
||||
- name: Run Windows compiled plugin resource tests
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os == 'windows-latest' }}
|
||||
run: bun -F @cline/code test:sidecar-startup --test-name-pattern "standalone CLI"
|
||||
|
||||
- name: Run Desktop Settings UI Tests
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' }}
|
||||
run: bun -F @cline/code test:settings-ui
|
||||
|
||||
@@ -291,6 +291,12 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
|
||||
- `cline hub` - Manage the local hub daemon
|
||||
- `cline kanban` - Run the external `kanban` app, installing it first when needed
|
||||
|
||||
Compiled CLI binaries embed the plugin bootstrap, SDK modules, and dashboard
|
||||
assets. They can start the Hub, load SDK plugins, and serve the dashboard without
|
||||
a separate Node/Bun installation or adjacent runtime files. Resources are read directly from the binary’s read-only Bun filesystem; no
|
||||
runtime cache is extracted. `--plugin-sandbox` is an internal subprocess dispatch
|
||||
flag, not a user command.
|
||||
|
||||
## Hub dashboard
|
||||
|
||||
Run `cline hub` to list hub subcommands. To start the browser dashboard:
|
||||
|
||||
+6
-9
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -136,16 +136,13 @@ if (result.logs.length > 0) {
|
||||
}
|
||||
}
|
||||
|
||||
const coreBootstrapPath = join(
|
||||
const corePluginRuntimePath = join(
|
||||
rootDir,
|
||||
"../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
|
||||
"../../sdk/packages/core/dist/plugin-runtime",
|
||||
);
|
||||
const cliBootstrapPath = join(
|
||||
rootDir,
|
||||
"./dist/extensions/plugin-sandbox-bootstrap.js",
|
||||
);
|
||||
mkdirSync(dirname(cliBootstrapPath), { recursive: true });
|
||||
copyFileSync(coreBootstrapPath, cliBootstrapPath);
|
||||
const cliPluginRuntimePath = join(rootDir, "dist/plugin-runtime");
|
||||
rmSync(cliPluginRuntimePath, { recursive: true, force: true });
|
||||
cpSync(corePluginRuntimePath, cliPluginRuntimePath, { recursive: true });
|
||||
|
||||
if (existsSync(hubWebviewDistPath)) {
|
||||
mkdirSync(dirname(cliHubWebviewDistPath), { recursive: true });
|
||||
|
||||
+15
-74
@@ -1,15 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { type AddressInfo, createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
@@ -65,7 +56,7 @@ if (optionsError) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await $`rm -rf dist`;
|
||||
rmSync(join(cliDir, "dist"), { recursive: true, force: true });
|
||||
|
||||
// Pre-install all platform variants of native packages so cross-compilation
|
||||
// can resolve them. Without this, Bun only has the host platform's native
|
||||
@@ -83,48 +74,6 @@ if (!buildOptions.skipSdkBuild) {
|
||||
await $`bun -F @cline/cli build`.cwd(rootDir);
|
||||
}
|
||||
|
||||
const hubWebviewSource = join(cliDir, "../cline-hub/src/webview");
|
||||
const hubWebviewDist = join(cliDir, "../cline-hub/dist/webview");
|
||||
const hubWebviewIndex = join(hubWebviewDist, "index.html");
|
||||
|
||||
function newestFileMtimeMs(dir: string): number {
|
||||
let newest = 0;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.name === "node_modules" ||
|
||||
entry.name === "dist" ||
|
||||
entry.name === ".turbo"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
newest = Math.max(newest, newestFileMtimeMs(path));
|
||||
} else if (entry.isFile()) {
|
||||
newest = Math.max(newest, statSync(path).mtimeMs);
|
||||
}
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
function shouldBuildHubWebview(): boolean {
|
||||
if (!existsSync(hubWebviewIndex)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return (
|
||||
newestFileMtimeMs(hubWebviewSource) > statSync(hubWebviewIndex).mtimeMs
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldBuildHubWebview()) {
|
||||
console.log("Building Cline Hub webview...");
|
||||
await $`bun -F @cline/cline-hub build:webview`.cwd(rootDir);
|
||||
}
|
||||
|
||||
const binaries: Record<string, string> = {};
|
||||
|
||||
function findFreePort(): Promise<number> {
|
||||
@@ -211,7 +160,19 @@ for (const item of targets) {
|
||||
console.log(` Passed: hub core ${status.coreVersion}`);
|
||||
} finally {
|
||||
await $`${outfile} hub stop`.env(smokeEnv).quiet().nothrow();
|
||||
rmSync(smokeHome, { recursive: true, force: true });
|
||||
try {
|
||||
rmSync(smokeHome, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 100,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
` Warning: failed to remove smoke test dir ${smokeHome}:`,
|
||||
cleanupError,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(` Smoke test FAILED for ${name}:`, e);
|
||||
@@ -219,26 +180,6 @@ for (const item of targets) {
|
||||
}
|
||||
}
|
||||
|
||||
// Copy plugin sandbox bootstrap if it exists
|
||||
const bootstrapSrc = join(
|
||||
rootDir,
|
||||
"sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
|
||||
);
|
||||
if (existsSync(bootstrapSrc)) {
|
||||
const bootstrapDir = join(cliDir, `dist/${dirName}/extensions`);
|
||||
mkdirSync(bootstrapDir, { recursive: true });
|
||||
const content = readFileSync(bootstrapSrc);
|
||||
await Bun.write(join(bootstrapDir, "plugin-sandbox-bootstrap.js"), content);
|
||||
}
|
||||
|
||||
if (existsSync(hubWebviewDist)) {
|
||||
const hubWebviewDest = join(cliDir, `dist/${dirName}/cline-hub/webview`);
|
||||
mkdirSync(join(cliDir, `dist/${dirName}/cline-hub`), {
|
||||
recursive: true,
|
||||
});
|
||||
cpSync(hubWebviewDist, hubWebviewDest, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate platform package.json
|
||||
await Bun.write(
|
||||
join(cliDir, `dist/${dirName}/package.json`),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { $ } from "bun";
|
||||
import { buildDashboardRuntimeDirectory } from "./dashboard-runtime";
|
||||
|
||||
const cliDir = resolve(import.meta.dir, "..");
|
||||
const rootDir = resolve(cliDir, "../..");
|
||||
@@ -94,6 +96,8 @@ export interface CompileCliBinaryOptions {
|
||||
export async function compileCliBinary(
|
||||
input: CompileCliBinaryOptions,
|
||||
): Promise<void> {
|
||||
const pluginRuntime = join(rootDir, "sdk/packages/core/dist/plugin-runtime");
|
||||
const dashboardRuntime = await buildDashboardRuntimeDirectory();
|
||||
const parserWorker = findOpenTuiParserWorker();
|
||||
const targetOs = input.bunTarget.includes("windows") ? "windows" : "posix";
|
||||
const bunfsRoot = targetOs === "windows" ? "B:/~BUN/root/" : "/$bunfs/root/";
|
||||
@@ -106,15 +110,13 @@ export async function compileCliBinary(
|
||||
// layer in containerized environments (virtiofs, overlayfs).
|
||||
const entrypoint = join(cliDir, "src/index.ts");
|
||||
const scratchRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
||||
const tmpDir = join(
|
||||
scratchRoot,
|
||||
`cline-build-${input.bunTarget}-${process.pid}`,
|
||||
const tmpDir = mkdtempSync(
|
||||
join(scratchRoot, `cline-build-${input.bunTarget}-`),
|
||||
);
|
||||
const tmpOutfile = join(
|
||||
tmpDir,
|
||||
input.outfile.endsWith(".exe") ? "cline.exe" : "cline",
|
||||
);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(scratchRoot);
|
||||
@@ -123,11 +125,13 @@ export async function compileCliBinary(
|
||||
const autoload = input.autoloadLaunchDirectoryConfig !== false;
|
||||
result = await Bun.build({
|
||||
entrypoints: [entrypoint, parserWorker],
|
||||
root: rootDir,
|
||||
splitting: true,
|
||||
compile: {
|
||||
target: input.bunTarget,
|
||||
outfile: tmpOutfile,
|
||||
// Plugins import SDK packages from disk at runtime; let Bun resolve
|
||||
assets: [pluginRuntime, dashboardRuntime],
|
||||
// Plugins import SDK packages from the runtime tree; let Bun resolve
|
||||
// their package exports without falling back to transpiling the SDK.
|
||||
autoloadPackageJson: true,
|
||||
...(input.execArgv?.length ? { execArgv: input.execArgv } : {}),
|
||||
@@ -137,26 +141,31 @@ export async function compileCliBinary(
|
||||
external: ["@anthropic-ai/vertex-sdk"],
|
||||
define: {
|
||||
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath,
|
||||
// compile.assets keeps each directory tree under its basename.
|
||||
__CLINE_PLUGIN_RUNTIME_DIR__: JSON.stringify(
|
||||
`${bunfsRoot}plugin-runtime`,
|
||||
),
|
||||
__CLINE_DASHBOARD_RUNTIME_DIR__: JSON.stringify(`${bunfsRoot}webview`),
|
||||
// Inline telemetry/OTEL env vars at build time so the compiled
|
||||
// binary ships with production telemetry configuration baked in.
|
||||
...buildInlinedEnvDefines(),
|
||||
},
|
||||
throw: false,
|
||||
});
|
||||
if (!result.success) {
|
||||
for (const log of result.logs) {
|
||||
console.error(log);
|
||||
}
|
||||
throw new Error(`CLI build failed for ${input.bunTarget}`);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(input.outfile), { recursive: true });
|
||||
copyFileSync(tmpOutfile, input.outfile);
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(input.outfile, 0o755);
|
||||
}
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
for (const log of result.logs) {
|
||||
console.error(log);
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
throw new Error(`CLI build failed for ${input.bunTarget}`);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(input.outfile), { recursive: true });
|
||||
copyFileSync(tmpOutfile, input.outfile);
|
||||
chmodSync(input.outfile, 0o755);
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { $ } from "bun";
|
||||
|
||||
let directory: Promise<string> | undefined;
|
||||
|
||||
/** Build once per platform-build invocation; the compiler embeds this tree unchanged. */
|
||||
export function buildDashboardRuntimeDirectory(): Promise<string> {
|
||||
directory ??= buildDirectory();
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function buildDirectory(): Promise<string> {
|
||||
const root = resolve(import.meta.dir, "../../..");
|
||||
await $`bun -F @cline/cline-hub build:webview`.cwd(root).quiet();
|
||||
const directory = join(root, "apps/cline-hub/dist/webview");
|
||||
if (!existsSync(join(directory, "index.html")))
|
||||
throw new Error("Dashboard build produced no index.html");
|
||||
return directory;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
||||
import { arch, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveEmbeddedDashboard } from "../utils/embedded-dashboard";
|
||||
import { configureSandboxEnvironment } from "../utils/helpers";
|
||||
import open from "../utils/open";
|
||||
import { c } from "../utils/output";
|
||||
@@ -109,7 +110,10 @@ function resolveDefaultWebviewDistDir(): string | undefined {
|
||||
join(dirname(process.execPath), "../cline-hub/webview"),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate));
|
||||
return (
|
||||
candidates.find((candidate) => existsSync(candidate)) ??
|
||||
resolveEmbeddedDashboard()
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInstalledPlatformPackageWebviewCandidates(): string[] {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { SKILLS_CLI_PACKAGE } from "@cline/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSkillsArgs } from "./skill";
|
||||
|
||||
describe("buildSkillsArgs", () => {
|
||||
it("runs the skills package through npx with -y", () => {
|
||||
expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]);
|
||||
expect(buildSkillsArgs(["list"])).toEqual([
|
||||
"-y",
|
||||
SKILLS_CLI_PACKAGE,
|
||||
"list",
|
||||
]);
|
||||
});
|
||||
|
||||
it("injects --agent cline for install-style subcommands", () => {
|
||||
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
SKILLS_CLI_PACKAGE,
|
||||
"add",
|
||||
"owner/repo",
|
||||
"--agent",
|
||||
@@ -23,7 +28,7 @@ describe("buildSkillsArgs", () => {
|
||||
it("aliases uninstall to the skills remove subcommand", () => {
|
||||
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
SKILLS_CLI_PACKAGE,
|
||||
"remove",
|
||||
"my-skill",
|
||||
"--agent",
|
||||
@@ -48,7 +53,7 @@ describe("buildSkillsArgs", () => {
|
||||
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
SKILLS_CLI_PACKAGE,
|
||||
"--agent",
|
||||
"cursor",
|
||||
"add",
|
||||
@@ -56,7 +61,13 @@ describe("buildSkillsArgs", () => {
|
||||
]);
|
||||
expect(
|
||||
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
|
||||
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
|
||||
).toEqual([
|
||||
"-y",
|
||||
SKILLS_CLI_PACKAGE,
|
||||
"--agent=cursor",
|
||||
"remove",
|
||||
"my-skill",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not scope non-install subcommands to cline", () => {
|
||||
@@ -67,7 +78,7 @@ describe("buildSkillsArgs", () => {
|
||||
it("scopes remove-style subcommands to cline", () => {
|
||||
expect(buildSkillsArgs(["remove"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
SKILLS_CLI_PACKAGE,
|
||||
"remove",
|
||||
"--agent",
|
||||
"cline",
|
||||
@@ -83,6 +94,6 @@ describe("buildSkillsArgs", () => {
|
||||
});
|
||||
|
||||
it("forwards an empty arg list unchanged", () => {
|
||||
expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]);
|
||||
expect(buildSkillsArgs([])).toEqual(["-y", SKILLS_CLI_PACKAGE]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type SpawnOptions, spawn } from "node:child_process";
|
||||
import { SKILLS_CLI_PACKAGE } from "@cline/core";
|
||||
|
||||
export interface SkillCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
@@ -7,9 +8,9 @@ export interface SkillCommandIo {
|
||||
|
||||
// `cline skill` is a thin wrapper around the open skills CLI
|
||||
// (https://www.npmjs.com/package/skills). We run it through `npx` so users
|
||||
// don't need a separate global install. Pin the version here if we ever need to
|
||||
// lock behavior to a known-good release.
|
||||
const SKILLS_PACKAGE = "skills@latest";
|
||||
// don't need a separate global install. The release is pinned in @cline/core
|
||||
// (sdk/packages/core/package.json) and bumped by Dependabot.
|
||||
const SKILLS_PACKAGE = SKILLS_CLI_PACKAGE;
|
||||
|
||||
// Subcommands that write skill files into an agent's skills directory. For a
|
||||
// `cline skill` command we default these to Cline unless the user picked their
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { isMainThread } from "node:worker_threads";
|
||||
import { setPluginSandboxLauncher } from "@cline/core";
|
||||
import {
|
||||
buildTelemetrySelfcheckReport,
|
||||
claimHubDaemonProcess,
|
||||
@@ -32,8 +33,20 @@ disableCurrentDirectoryExecutableSearch();
|
||||
// Before any personality probes the local hub over 127.0.0.1.
|
||||
ensureLoopbackProxyBypass();
|
||||
|
||||
if (Bun.isStandaloneExecutable) {
|
||||
setPluginSandboxLauncher({
|
||||
command: process.execPath,
|
||||
argsPrefix: ["--plugin-sandbox"],
|
||||
});
|
||||
}
|
||||
|
||||
if (!isMainThread) {
|
||||
// Worker imports of the bundled CLI entrypoint should not start the CLI.
|
||||
} else if (process.argv.includes("--plugin-sandbox")) {
|
||||
// Boot as the compiled host so the child mounts the same read-only resources.
|
||||
const bootstrap = process.argv[process.argv.indexOf("--plugin-sandbox") + 1];
|
||||
if (!bootstrap) throw new Error("Missing plugin sandbox bootstrap path");
|
||||
await import(bootstrap);
|
||||
} else if (process.argv.includes("--telemetry-selfcheck")) {
|
||||
// Inspect the config embedded in this executable without starting a hub,
|
||||
// loading user settings, or initializing telemetry exporters.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
declare const __CLINE_DASHBOARD_RUNTIME_DIR__: string | undefined;
|
||||
|
||||
export function resolveEmbeddedDashboard(): string | undefined {
|
||||
return typeof __CLINE_DASHBOARD_RUNTIME_DIR__ === "undefined"
|
||||
? undefined
|
||||
: __CLINE_DASHBOARD_RUNTIME_DIR__;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { SKILLS_CLI_PACKAGE } from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMarketplaceMcpInput,
|
||||
@@ -171,7 +172,7 @@ describe("marketplace installer", () => {
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
SKILLS_CLI_PACKAGE,
|
||||
"add",
|
||||
"vercel-labs/agent-skills",
|
||||
"--skill",
|
||||
@@ -314,7 +315,7 @@ describe("marketplace installer", () => {
|
||||
});
|
||||
expect(spawnCommand).toHaveBeenCalledWith("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
SKILLS_CLI_PACKAGE,
|
||||
"remove",
|
||||
"cline-sdk",
|
||||
"-g",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type MarketplaceEntryInput,
|
||||
resolveSkillsConfigSearchPaths,
|
||||
resolveWorkflowsConfigSearchPaths,
|
||||
SKILLS_CLI_PACKAGE,
|
||||
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
|
||||
uninstallPlugin as uninstallLocalPlugin,
|
||||
} from "@cline/core";
|
||||
@@ -763,7 +764,7 @@ async function installSkill(
|
||||
ensureGlobalSkillsDirWritable();
|
||||
const result = await spawnCommand("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
SKILLS_CLI_PACKAGE,
|
||||
"add",
|
||||
...(entry.install.args ?? []),
|
||||
"-g",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cline/sdk": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/bun": "^1.4.2",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { cpSync, existsSync, rmSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { $ } from "bun";
|
||||
import {
|
||||
@@ -95,6 +95,13 @@ const bundleDesktopBackend = async (): Promise<void> => {
|
||||
// from Finder/the Dock has no OTEL_* env at runtime, so without this the
|
||||
// backend silently ships with telemetry disabled. Verify with
|
||||
// `BUN_BE_BUN=1 cline-cli run <bundle> --telemetry-selfcheck`.
|
||||
// The script runner does not mount the CLI's bunfs. Ship the same runtime
|
||||
// artifact beside the backend, just as npm and VS Code hosts do.
|
||||
const runtime = `${APP_ROOT}/${BIN_DIR}/desktop-backend/plugin-runtime`;
|
||||
rmSync(runtime, { recursive: true, force: true });
|
||||
cpSync(`${REPO_ROOT}/sdk/packages/core/dist/plugin-runtime`, runtime, {
|
||||
recursive: true,
|
||||
});
|
||||
const defines = telemetryDefineArgs();
|
||||
await $`bun build ./sidecar/index.ts --target bun --minify ${defines} --outfile ${DESKTOP_BACKEND_BUNDLE}`;
|
||||
};
|
||||
@@ -141,53 +148,6 @@ const buildUniversalMacCli = async (): Promise<void> => {
|
||||
await $`lipo -info ${outfile}`;
|
||||
};
|
||||
|
||||
// Plugin installs drop their @cline/* dependencies because the host provides
|
||||
// them, and the plugin worker resolves those from node_modules beside its
|
||||
// bootstrap. Nothing like that exists next to the compiled CLI, so ship
|
||||
// self-contained builds there. Splitting keeps one shared copy of the SDK,
|
||||
// placed in core so every chunk sits inside a `"type": "module"` package.
|
||||
const bundlePluginSdkPackages = async (): Promise<void> => {
|
||||
const outdir = `${BIN_DIR}/extensions/node_modules/@cline`;
|
||||
const packagesRoot = "../../../sdk/packages";
|
||||
const packages = await Promise.all(
|
||||
["sdk", "core", "agents", "llms", "shared"].map(async (name) => {
|
||||
const root = `${packagesRoot}/${name}`;
|
||||
const manifest = (await Bun.file(`${root}/package.json`).json()) as {
|
||||
exports: Record<string, { import: string }>;
|
||||
};
|
||||
// Type-only exports (e.g. @cline/shared/types) have no JS to ship.
|
||||
const exports = Object.fromEntries(
|
||||
Object.entries(manifest.exports)
|
||||
.map(([path, entry]) => [path, entry.import] as const)
|
||||
.filter(([, file]) => existsSync(`${root}/${file}`)),
|
||||
);
|
||||
return { root, name, exports };
|
||||
}),
|
||||
);
|
||||
await $`rm -rf ${outdir}`;
|
||||
const result = await Bun.build({
|
||||
entrypoints: packages.flatMap(({ root, exports }) =>
|
||||
Object.values(exports).map((file) => `${root}/${file}`),
|
||||
),
|
||||
root: packagesRoot,
|
||||
outdir,
|
||||
naming: { chunk: "core/dist/[name]-[hash].[ext]" },
|
||||
target: "node",
|
||||
format: "esm",
|
||||
splitting: true,
|
||||
minify: true,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new AggregateError(result.logs, "plugin SDK bundle failed");
|
||||
}
|
||||
for (const { name, exports } of packages) {
|
||||
await Bun.write(
|
||||
`${outdir}/${name}/package.json`,
|
||||
JSON.stringify({ name: `@cline/${name}`, type: "module", exports }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasOpenTuiNativeVariant = (os: string, arch: string): boolean =>
|
||||
existsSync(`${REPO_ROOT}/node_modules/.bun`) &&
|
||||
[
|
||||
@@ -203,10 +163,7 @@ const main = async () => {
|
||||
await $`bun run build:sdk`.cwd(REPO_ROOT);
|
||||
process.chdir(APP_ROOT);
|
||||
const targetTriple = await resolveTargetTriple();
|
||||
await $`mkdir -p ${BIN_DIR}/desktop-backend ${REMOTE_CLI_DIR} ${BIN_DIR}/extensions`;
|
||||
// Plugin workers load this bootstrap from disk in the backend and Hub.
|
||||
await $`cp ../../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js ${BIN_DIR}/extensions/plugin-sandbox-bootstrap.js`;
|
||||
await bundlePluginSdkPackages();
|
||||
await $`mkdir -p ${BIN_DIR}/desktop-backend ${REMOTE_CLI_DIR}`;
|
||||
// Every build cross-compiles the Linux SSH copies (and macOS builds both
|
||||
// architectures), which needs the target platforms' OpenTUI native
|
||||
// packages in addition to the host's.
|
||||
|
||||
@@ -2,9 +2,13 @@ import { afterAll, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
closeSync,
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
@@ -68,6 +72,16 @@ function resolveBackendBundle(): string {
|
||||
{ encoding: "utf8", timeout: 120_000 },
|
||||
);
|
||||
expect(build.status, build.stderr || String(build.error)).toBe(0);
|
||||
cpSync(
|
||||
fileURLToPath(
|
||||
new URL(
|
||||
"../../../../sdk/packages/core/dist/plugin-runtime",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
join(outdir, "plugin-runtime"),
|
||||
{ recursive: true },
|
||||
);
|
||||
bundledBackend = join(outdir, "index.js");
|
||||
}
|
||||
return bundledBackend;
|
||||
@@ -332,3 +346,233 @@ for (const discoveryContents of ["{ invalid json", "{}"]) {
|
||||
);
|
||||
}, 120_000);
|
||||
}
|
||||
|
||||
test.each([
|
||||
"worker",
|
||||
"sandbox",
|
||||
])("standalone CLI loads an SDK plugin without Node (%s)", async (mode) => {
|
||||
const cli = await resolveCliBinary();
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-plugin-installed-"));
|
||||
try {
|
||||
const directory = join(root, ".cline", "plugins");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
// Occupy the old cache path: startup must succeed without extraction.
|
||||
writeFileSync(join(root, ".cline", "runtime"), "no extraction");
|
||||
const marker = join(root, "plugin-result.json");
|
||||
// Copy the raw TypeScript fixture so SDK imports must resolve from the
|
||||
// packaged runtime, outside the monorepo and without prebundling.
|
||||
copyFileSync(
|
||||
fileURLToPath(
|
||||
new URL("./fixtures/embedded-plugin/smoke.ts", import.meta.url),
|
||||
),
|
||||
join(directory, "smoke.ts"),
|
||||
);
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([key]) =>
|
||||
!/^(CLINE_|BUN_BE_BUN|NODE_PATH|npm_node_execpath|NODE$|BUN_EXEC_PATH)/.test(
|
||||
key,
|
||||
),
|
||||
),
|
||||
);
|
||||
const result = spawnSync(cli, ["config", "tools", "--json"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
timeout: 30000,
|
||||
env: {
|
||||
...env,
|
||||
HOME: root,
|
||||
USERPROFILE: root,
|
||||
CLINE_DIR: join(root, ".cline"),
|
||||
PATH: root,
|
||||
JITI_FS_CACHE: "false",
|
||||
CLINE_PLUGIN_MODE: mode,
|
||||
CLINE_TEST_PLUGIN_RESULT_PATH: marker,
|
||||
TMPDIR: root,
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
},
|
||||
});
|
||||
expect(result.status, result.stderr || String(result.error)).toBe(0);
|
||||
expect(
|
||||
JSON.parse(result.stdout).some(
|
||||
(tool: { name: string }) => tool.name === "embedded_ping",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(readFileSync(join(root, ".cline", "runtime"), "utf8")).toBe(
|
||||
"no extraction",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(marker, "utf8"))).toEqual({
|
||||
pong: true,
|
||||
// The native resolver expands Windows 8.3 short names (RUNNER~1), as
|
||||
// the child's process.execPath does.
|
||||
execPath: realpathSync.native(cli),
|
||||
isMainThread: mode === "sandbox",
|
||||
bunRuntimeFlag: null,
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
test.each([
|
||||
"worker",
|
||||
"sandbox",
|
||||
])("packaged desktop backend loads its on-disk SDK runtime (%s)", async (mode) => {
|
||||
const cli = await resolveCliBinary();
|
||||
const outdir = dirname(resolveBackendBundle());
|
||||
const probe = join(outdir, "plugin-runtime-probe.js");
|
||||
const built = await Bun.build({
|
||||
entrypoints: [
|
||||
fileURLToPath(
|
||||
new URL("./fixtures/desktop-plugin-runtime.ts", import.meta.url),
|
||||
),
|
||||
],
|
||||
target: "bun",
|
||||
outdir,
|
||||
naming: "plugin-runtime-probe.js",
|
||||
});
|
||||
expect(built.success, built.logs.map(String).join("\n")).toBe(true);
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-desktop-plugin-"));
|
||||
try {
|
||||
const plugins = join(root, ".cline", "plugins");
|
||||
mkdirSync(plugins, { recursive: true });
|
||||
copyFileSync(
|
||||
fileURLToPath(
|
||||
new URL("./fixtures/embedded-plugin/desktop.ts", import.meta.url),
|
||||
),
|
||||
join(plugins, "desktop.ts"),
|
||||
);
|
||||
writeFileSync(join(root, ".cline", "runtime"), "no extraction");
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([key]) =>
|
||||
!/^(CLINE_|BUN_BE_BUN|NODE_PATH|npm_node_execpath|NODE$|BUN_EXEC_PATH)/.test(
|
||||
key,
|
||||
),
|
||||
),
|
||||
);
|
||||
const result = spawnSync(
|
||||
cli,
|
||||
["run", "--use-system-ca", "--no-env-file", probe],
|
||||
{
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
timeout: 30000,
|
||||
env: {
|
||||
...env,
|
||||
PATH: root,
|
||||
HOME: root,
|
||||
USERPROFILE: root,
|
||||
CLINE_DIR: join(root, ".cline"),
|
||||
BUN_BE_BUN: "1",
|
||||
CLINE_DESKTOP_CLI_PATH: cli,
|
||||
CLINE_PLUGIN_MODE: mode,
|
||||
JITI_FS_CACHE: "false",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.status, result.stderr || String(result.error)).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
commands: [{ name: "packaged-ping" }],
|
||||
result: { reply: "pong" },
|
||||
});
|
||||
expect(readFileSync(join(root, ".cline", "runtime"), "utf8")).toBe(
|
||||
"no extraction",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
test("standalone CLI serves the dashboard and its assets without external files or Node", async () => {
|
||||
const cli = await resolveCliBinary();
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-standalone-dashboard-"));
|
||||
mkdirSync(join(root, ".cline"));
|
||||
writeFileSync(join(root, ".cline", "runtime"), "no extraction");
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([key]) =>
|
||||
!/^(CLINE_|BUN_BE_BUN|NODE_PATH|HOST$|PORT$|ROOM_SECRET$|PUBLIC_URL$)/.test(
|
||||
key,
|
||||
),
|
||||
),
|
||||
);
|
||||
const port = await reserveLoopbackPort();
|
||||
const isolatedEnv = {
|
||||
...env,
|
||||
HOME: root,
|
||||
USERPROFILE: root,
|
||||
CLINE_DIR: join(root, ".cline"),
|
||||
CLINE_HUB_DISCOVERY_PATH: join(root, "hub.json"),
|
||||
CLINE_HUB_PORT: String(await reserveLoopbackPort()),
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
PATH: root,
|
||||
};
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
cli,
|
||||
"dashboard",
|
||||
"--no-open",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(port),
|
||||
"--cwd",
|
||||
root,
|
||||
],
|
||||
{ cwd: root, env: isolatedEnv, stdout: "ignore", stderr: "pipe" },
|
||||
);
|
||||
try {
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
let response: Response | undefined;
|
||||
const deadline = Date.now() + 20000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
response = await fetch(url);
|
||||
if (response.status === 200) break;
|
||||
} catch {}
|
||||
if (child.exitCode !== null)
|
||||
throw new Error(await new Response(child.stderr).text());
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
expect(response?.status).toBe(200);
|
||||
if (!response) throw new Error("Dashboard did not start");
|
||||
expect(readFileSync(join(root, ".cline", "runtime"), "utf8")).toBe(
|
||||
"no extraction",
|
||||
);
|
||||
const html = await response.text();
|
||||
expect(html).toContain("<html");
|
||||
const assets = [
|
||||
...html.matchAll(/(?:src|href)="([^"]+\.(?:js|css))"/g),
|
||||
].map((match) => match[1]);
|
||||
expect(assets.length).toBeGreaterThan(0);
|
||||
for (const asset of assets) {
|
||||
if (!asset) throw new Error("Missing dashboard asset URL");
|
||||
const resource = await fetch(new URL(asset, url));
|
||||
expect(resource.status).toBe(200);
|
||||
expect(resource.headers.get("content-type")).toMatch(
|
||||
asset.endsWith(".css") ? /text\/css/ : /(?:javascript|ecmascript)/,
|
||||
);
|
||||
expect((await resource.arrayBuffer()).byteLength).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
await child.exited;
|
||||
spawnSync(cli, ["hub", "stop"], {
|
||||
cwd: root,
|
||||
env: isolatedEnv,
|
||||
timeout: 10000,
|
||||
});
|
||||
// The stopped Hub can hold its cwd briefly on Windows (EBUSY).
|
||||
try {
|
||||
rmSync(root, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 20,
|
||||
retryDelay: 100,
|
||||
});
|
||||
} catch {
|
||||
/* The runner discards its temp directory anyway. */
|
||||
}
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
createPluginCommandService,
|
||||
resolvePluginRuntimeDirectory,
|
||||
} from "@cline/core";
|
||||
import { adoptDesktopCliRuntime } from "../../sidecar/cli-runtime";
|
||||
|
||||
async function main() {
|
||||
adoptDesktopCliRuntime();
|
||||
const runtime = resolvePluginRuntimeDirectory();
|
||||
if (!runtime?.includes("desktop-backend"))
|
||||
throw new Error(`Expected the packaged backend runtime, got ${runtime}`);
|
||||
const service = createPluginCommandService({ cwd: process.cwd() });
|
||||
try {
|
||||
const commands = await service.listCommands();
|
||||
const result = await service.run("packaged-ping", "");
|
||||
process.stdout.write(JSON.stringify({ commands, result }));
|
||||
} finally {
|
||||
await service.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AgentPlugin } from "@cline/sdk";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
|
||||
|
||||
export default {
|
||||
name: "packaged-backend-smoke",
|
||||
manifest: { capabilities: ["commands"] },
|
||||
setup(api) {
|
||||
if (typeof CLINE_DEFAULT_MODEL_ID !== "string")
|
||||
throw new Error("Missing SDK browser exports");
|
||||
api.registerCommand({ name: "packaged-ping", handler: () => "pong" });
|
||||
},
|
||||
} satisfies AgentPlugin;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { isMainThread } from "node:worker_threads";
|
||||
import { getAllProviders } from "@cline/llms/browser";
|
||||
import {
|
||||
type AgentPlugin,
|
||||
createTool,
|
||||
resolvePluginRuntimeDirectory,
|
||||
} from "@cline/sdk";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
|
||||
import { AUTH_ERROR_PATTERNS, isLikelyAuthError } from "@cline/shared/types";
|
||||
|
||||
export default {
|
||||
name: "embedded-smoke",
|
||||
manifest: { capabilities: ["tools"] },
|
||||
async setup(api) {
|
||||
if (
|
||||
typeof CLINE_DEFAULT_MODEL_ID !== "string" ||
|
||||
typeof getAllProviders !== "function"
|
||||
) {
|
||||
throw new Error("Missing browser SDK exports");
|
||||
}
|
||||
if (
|
||||
!AUTH_ERROR_PATTERNS.includes("unauthorized") ||
|
||||
!isLikelyAuthError(new Error("401 Unauthorized"))
|
||||
) {
|
||||
throw new Error("Missing shared/types runtime exports");
|
||||
}
|
||||
if (!process.execArgv.includes("--use-system-ca")) {
|
||||
throw new Error("Plugin runtime lost compiled system CA trust");
|
||||
}
|
||||
const runtime = resolvePluginRuntimeDirectory();
|
||||
const bunfsRoot =
|
||||
process.platform === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/";
|
||||
if (!runtime?.replaceAll("\\", "/").startsWith(bunfsRoot)) {
|
||||
throw new Error(`Expected the embedded runtime, got ${runtime}`);
|
||||
}
|
||||
const marker = process.env.CLINE_TEST_PLUGIN_RESULT_PATH;
|
||||
if (!marker) {
|
||||
throw new Error("Missing CLINE_TEST_PLUGIN_RESULT_PATH");
|
||||
}
|
||||
const nested = spawnSync(process.execPath, ["--telemetry-selfcheck"], {
|
||||
env: process.env,
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
});
|
||||
if (nested.status !== 0) throw new Error(nested.stderr);
|
||||
JSON.parse(nested.stdout);
|
||||
const tool = createTool({
|
||||
name: "embedded_ping",
|
||||
description: "Packaged SDK tool",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
execute: async () => ({
|
||||
pong: true,
|
||||
execPath: process.execPath,
|
||||
isMainThread,
|
||||
bunRuntimeFlag: process.env.BUN_BE_BUN ?? null,
|
||||
}),
|
||||
});
|
||||
writeFileSync(
|
||||
marker,
|
||||
JSON.stringify(
|
||||
await tool.execute({}, { agentId: "embedded-smoke", iteration: 0 }),
|
||||
),
|
||||
);
|
||||
api.registerTool(tool);
|
||||
},
|
||||
} satisfies AgentPlugin;
|
||||
@@ -87,6 +87,11 @@ Hub daemon is that same CLI binary. Any Hub the backend itself has to start
|
||||
later goes through the same binary (`setHubDaemonLauncher`). Startup discovery
|
||||
and locking ensure concurrent clients converge on one Hub.
|
||||
|
||||
The backend ships Core’s complete `plugin-runtime` artifact beside its script.
|
||||
The standalone CLI embeds the same artifact in its read-only Bun filesystem.
|
||||
The script runner does not mount that filesystem, so Desktop uses its packaged
|
||||
copy without extracting resources at startup.
|
||||
|
||||
Every create, restart, fork, and restore also attaches the serializable Desktop
|
||||
`ExtensionContext.client` and current `ExtensionContext.user`. Core forwards
|
||||
that context across the Hub transport and scopes the daemon-owned telemetry
|
||||
|
||||
@@ -51,8 +51,11 @@ describe("adoptDesktopCliRuntime", () => {
|
||||
};
|
||||
const chdir = vi.fn();
|
||||
const setLauncher = vi.fn();
|
||||
const setPluginLauncher = vi.fn();
|
||||
|
||||
expect(adoptDesktopCliRuntime({ env, chdir, setLauncher })).toEqual({
|
||||
expect(
|
||||
adoptDesktopCliRuntime({ env, chdir, setLauncher, setPluginLauncher }),
|
||||
).toEqual({
|
||||
cliPath: "/Applications/Cline.app/Contents/MacOS/cline-cli",
|
||||
});
|
||||
|
||||
@@ -62,6 +65,10 @@ describe("adoptDesktopCliRuntime", () => {
|
||||
"/Applications/Cline.app/Contents/MacOS/cline-cli",
|
||||
);
|
||||
expect(chdir).toHaveBeenCalledWith("/Users/dev/project");
|
||||
expect(setPluginLauncher).toHaveBeenCalledWith({
|
||||
command: env.CLINE_DESKTOP_CLI_PATH,
|
||||
argsPrefix: ["--plugin-sandbox"],
|
||||
});
|
||||
expect(setLauncher).toHaveBeenCalledWith({
|
||||
command: "/Applications/Cline.app/Contents/MacOS/cline-cli",
|
||||
});
|
||||
@@ -70,11 +77,20 @@ describe("adoptDesktopCliRuntime", () => {
|
||||
it("keeps the default Hub launcher when no bundled CLI is provided", () => {
|
||||
const chdir = vi.fn();
|
||||
const setLauncher = vi.fn();
|
||||
const setPluginLauncher = vi.fn();
|
||||
|
||||
expect(adoptDesktopCliRuntime({ env: {}, chdir, setLauncher })).toEqual({
|
||||
expect(
|
||||
adoptDesktopCliRuntime({
|
||||
env: {},
|
||||
chdir,
|
||||
setLauncher,
|
||||
setPluginLauncher,
|
||||
}),
|
||||
).toEqual({
|
||||
cliPath: undefined,
|
||||
});
|
||||
expect(chdir).not.toHaveBeenCalled();
|
||||
expect(setLauncher).not.toHaveBeenCalled();
|
||||
expect(setPluginLauncher).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { setHubDaemonLauncher } from "@cline/core";
|
||||
import { setHubDaemonLauncher, setPluginSandboxLauncher } from "@cline/core";
|
||||
import { withHubLauncherIdentity } from "@cline/shared";
|
||||
import { DESKTOP_CLIENT_CONTEXT } from "./client-context";
|
||||
|
||||
@@ -27,6 +27,7 @@ export function adoptDesktopCliRuntime(
|
||||
env?: NodeJS.ProcessEnv;
|
||||
chdir?: (directory: string) => void;
|
||||
setLauncher?: typeof setHubDaemonLauncher;
|
||||
setPluginLauncher?: typeof setPluginSandboxLauncher;
|
||||
} = {},
|
||||
): { cliPath?: string } {
|
||||
const env = options.env ?? process.env;
|
||||
@@ -43,6 +44,10 @@ export function adoptDesktopCliRuntime(
|
||||
const cliPath = resolveDesktopCliPath(env);
|
||||
if (cliPath) {
|
||||
setLauncher({ command: cliPath });
|
||||
(options.setPluginLauncher ?? setPluginSandboxLauncher)({
|
||||
command: cliPath,
|
||||
argsPrefix: ["--plugin-sandbox"],
|
||||
});
|
||||
}
|
||||
return { cliPath };
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
initializeSessionManager,
|
||||
} from "./context";
|
||||
import { createDesktopObservability } from "./observability";
|
||||
import { configurePluginBootstrap } from "./plugin-bootstrap";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { startServer } from "./server";
|
||||
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
|
||||
@@ -255,8 +254,6 @@ async function runEntrypoint(): Promise<void> {
|
||||
setClineClientIdentity(DESKTOP_CLIENT_CONTEXT);
|
||||
|
||||
disableCurrentDirectoryExecutableSearch();
|
||||
// Before the bundled CLI starts the Hub daemon and loads any plugin.
|
||||
configurePluginBootstrap();
|
||||
// Before the Hub daemon and agent-spawned processes inherit this env.
|
||||
ensureLoopbackProxyBypass();
|
||||
await main(cliPath);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { installPlugin } from "@cline/core";
|
||||
import { installPlugin, SKILLS_CLI_PACKAGE } from "@cline/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getOfficialPluginInstallPath,
|
||||
@@ -229,7 +229,7 @@ describe("skill install failures", () => {
|
||||
);
|
||||
expect(spawnCommand).toHaveBeenCalledWith("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
SKILLS_CLI_PACKAGE,
|
||||
"add",
|
||||
"https://github.com/cline/skills.git",
|
||||
"-g",
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
parseMcpInstallArgs,
|
||||
resolveSkillsConfigSearchPaths,
|
||||
resolveWorkflowsConfigSearchPaths,
|
||||
SKILLS_CLI_PACKAGE,
|
||||
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
|
||||
uninstallPlugin as uninstallLocalPlugin,
|
||||
} from "@cline/core";
|
||||
@@ -781,7 +782,7 @@ async function installSkill(
|
||||
ensureGlobalSkillsDirWritable();
|
||||
const result = await spawnCommand("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
SKILLS_CLI_PACKAGE,
|
||||
"add",
|
||||
...(entry.install.args ?? []),
|
||||
"-g",
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { CLINE_PLUGIN_BOOTSTRAP_PATH_ENV } from "@cline/core";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
configurePluginBootstrap,
|
||||
resolveDesktopPluginBootstrap,
|
||||
} from "./plugin-bootstrap";
|
||||
|
||||
function withLayout(
|
||||
resourceDirectory: (root: string) => string,
|
||||
execPath: (root: string) => string,
|
||||
check: (bootstrap: string, execPath: string) => void,
|
||||
): void {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-plugin-bootstrap-"));
|
||||
try {
|
||||
const directory = join(resourceDirectory(root), "bin", "extensions");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const bootstrap = join(directory, "plugin-sandbox-bootstrap.js");
|
||||
writeFileSync(bootstrap, "// bootstrap");
|
||||
check(bootstrap, execPath(root));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it("finds the bootstrap in the macOS app bundle resources", () => {
|
||||
withLayout(
|
||||
(root) => join(root, "Cline.app", "Contents", "Resources"),
|
||||
(root) => join(root, "Cline.app", "Contents", "MacOS", "cline-cli"),
|
||||
(bootstrap, execPath) => {
|
||||
expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe(
|
||||
bootstrap,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("finds the bootstrap in the Linux and Windows resource layouts", () => {
|
||||
withLayout(
|
||||
(root) => join(root, "usr", "lib", "Cline"),
|
||||
(root) => join(root, "usr", "bin", "cline-cli"),
|
||||
(bootstrap, execPath) => {
|
||||
expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe(
|
||||
bootstrap,
|
||||
);
|
||||
},
|
||||
);
|
||||
withLayout(
|
||||
(root) => root,
|
||||
(root) => join(root, "cline-cli.exe"),
|
||||
(bootstrap, execPath) => {
|
||||
expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe(
|
||||
bootstrap,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicitly configured bootstrap path", () => {
|
||||
const env = { [CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]: "/custom/bootstrap.js" };
|
||||
configurePluginBootstrap(env);
|
||||
expect(env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]).toBe("/custom/bootstrap.js");
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { CLINE_PLUGIN_BOOTSTRAP_PATH_ENV } from "@cline/core";
|
||||
import { linuxResourceCandidates } from "./remote-helper";
|
||||
|
||||
const BUNDLED_BOOTSTRAP = join("bin", "extensions", "plugin-sandbox-bootstrap.js");
|
||||
|
||||
/**
|
||||
* Finds the plugin worker bootstrap the app bundle ships as a Tauri resource.
|
||||
* The backend and CLI-managed Hub need an on-disk bootstrap to run plugins
|
||||
* in workers; without it they would fall back to loading plugins in-process.
|
||||
*/
|
||||
export function resolveDesktopPluginBootstrap(
|
||||
options: { execPath?: string; cwd?: string } = {},
|
||||
): string | undefined {
|
||||
const executableDirectory = dirname(options.execPath ?? process.execPath);
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
return [
|
||||
join(executableDirectory, BUNDLED_BOOTSTRAP),
|
||||
join(executableDirectory, "..", "Resources", BUNDLED_BOOTSTRAP),
|
||||
...linuxResourceCandidates(executableDirectory, BUNDLED_BOOTSTRAP),
|
||||
join(cwd, "src-tauri", BUNDLED_BOOTSTRAP),
|
||||
].find(existsSync);
|
||||
}
|
||||
|
||||
/** Exposes the bundled bootstrap to core and to the Hub daemon this spawns. */
|
||||
export function configurePluginBootstrap(env = process.env): void {
|
||||
if (env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]) return;
|
||||
const bootstrap = resolveDesktopPluginBootstrap();
|
||||
if (bootstrap) env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV] = bootstrap;
|
||||
}
|
||||
@@ -46,8 +46,7 @@
|
||||
"icons/app/*.png",
|
||||
"icons/app/macos/*.png",
|
||||
"bin/remote-helpers/*",
|
||||
"bin/desktop-backend/*",
|
||||
"bin/extensions/**/*"
|
||||
"bin/desktop-backend/**/*"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
|
||||
+9
-14
@@ -191,25 +191,20 @@ const e2eBuildConfig = {
|
||||
plugins: [aliasResolverPlugin, esbuildProblemMatcherPlugin],
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins run in worker threads that load this self-contained bootstrap from
|
||||
* disk, next to the bundle (core looks for it beside dist/extension.js).
|
||||
* Shipped as .mjs because this package is not `"type": "module"`. Without it,
|
||||
* core falls back to loading plugins inside the extension host.
|
||||
*/
|
||||
function copyPluginBootstrap() {
|
||||
const source = path.resolve(__dirname, "../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js")
|
||||
if (!fs.existsSync(source)) {
|
||||
console.warn(`[esbuild] ${source} not found; plugins will load in-process`)
|
||||
return
|
||||
/** Copy core's complete plugin runtime; its own package.json provides ESM scope. */
|
||||
function copyPluginRuntime() {
|
||||
const source = path.resolve(__dirname, "../../sdk/packages/core/dist/plugin-runtime")
|
||||
if (!fs.existsSync(path.join(source, "plugin-sandbox-bootstrap.js"))) {
|
||||
throw new Error(`[esbuild] ${source} not found; run bun run build:sdk first`)
|
||||
}
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
fs.copyFileSync(source, path.join(destDir, "plugin-sandbox-bootstrap.mjs"))
|
||||
const destination = path.join(destDir, "plugin-runtime")
|
||||
fs.rmSync(destination, { recursive: true, force: true })
|
||||
fs.cpSync(source, destination, { recursive: true })
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = standalone ? standaloneConfig : e2eBuild ? e2eBuildConfig : extensionConfig
|
||||
if (!e2eBuild) copyPluginBootstrap()
|
||||
if (!e2eBuild) copyPluginRuntime()
|
||||
const extensionCtx = await esbuild.context(config)
|
||||
if (watch) {
|
||||
await extensionCtx.watch()
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
parseMcpInstallArgs,
|
||||
readGlobalSettings,
|
||||
resolvePluginConfigSearchPaths,
|
||||
SKILLS_CLI_PACKAGE,
|
||||
setDisabledPlugin,
|
||||
syncPluginMcpServersToSettings,
|
||||
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
|
||||
@@ -366,7 +367,7 @@ async function installPluginMarketplaceEntry(entry: MarketplaceEntry, args: stri
|
||||
|
||||
async function installSkillMarketplaceEntry(entry: MarketplaceEntry, args: string[]): Promise<MarketplaceInstallResult> {
|
||||
const command = "npx"
|
||||
const commandArgs = ["-y", "skills@latest", "add", ...args, "-g", "-a", "cline", "-y"]
|
||||
const commandArgs = ["-y", SKILLS_CLI_PACKAGE, "add", ...args, "-g", "-a", "cline", "-y"]
|
||||
const displayCommand = formatCommand(command, commandArgs)
|
||||
let result: SpawnResult
|
||||
try {
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
"zod": "^3.24.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cline/sdk": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/bun": "^1.4.2",
|
||||
@@ -687,6 +688,7 @@
|
||||
"@types/proxy-from-env": "^1.0.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"posthog-node": "^5.8.0",
|
||||
"skills": "1.7.2",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"posthog-node": "^5.8.0",
|
||||
@@ -712,7 +714,7 @@
|
||||
"@ai-sdk/provider-utils": "^5.0.50",
|
||||
"@aws-sdk/credential-providers": "^3.922.0",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@jerome-benoit/sap-ai-provider": "4.8.0",
|
||||
"@jerome-benoit/sap-ai-provider": "5.0.3",
|
||||
"@langfuse/core": "5.10.1",
|
||||
"@langfuse/otel": "5.10.1",
|
||||
"@langfuse/tracing": "5.10.1",
|
||||
@@ -1621,7 +1623,7 @@
|
||||
|
||||
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="],
|
||||
|
||||
"@jerome-benoit/sap-ai-provider": ["@jerome-benoit/sap-ai-provider@4.8.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.13", "@ai-sdk/provider-utils": "^4.0.35", "@sap-ai-sdk/foundation-models": "^2.12.0", "@sap-ai-sdk/orchestration": "^2.12.0", "zod": "^4.4.3" }, "peerDependencies": { "ai": "^5.0.0 || ^6.0.0" } }, "sha512-rJvAW9eDFj+IFTJQ+UK0CrPyq78BG+PF6AgmHE5C0bHAalJewT119rryHqttITVpCNuX49k2ajBSax8r0+SXcg=="],
|
||||
"@jerome-benoit/sap-ai-provider": ["@jerome-benoit/sap-ai-provider@5.0.3", "", { "dependencies": { "@ai-sdk/provider": "^4.0.14", "@ai-sdk/provider-utils": "^5.0.40", "@sap-ai-sdk/ai-api": "^2.15.0", "@sap-ai-sdk/core": "^2.15.0", "@sap-ai-sdk/foundation-models": "^2.15.0", "@sap-ai-sdk/orchestration": "^2.15.0", "@sap-cloud-sdk/connectivity": "^4.9.1", "@sap-cloud-sdk/util": "^4.9.1", "zod": "^4.6.5" }, "peerDependencies": { "ai": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-rE1z1wLgVNhcBMPjnz+2Q9D6rFDN01ZvIYSsU0kvkbYHVVFtyOjEVcd+j0wyKyL9Ync3ZGQW9zBIWj2e1MoX0g=="],
|
||||
|
||||
"@jest/diff-sequences": ["@jest/diff-sequences@30.4.0", "", {}, "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g=="],
|
||||
|
||||
@@ -4705,6 +4707,8 @@
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"skills": ["skills@1.7.2", "", { "dependencies": { "tar": "^7.5.20", "yaml": "^2.8.3" }, "bin": { "skills": "bin/cli.mjs", "add-skill": "bin/cli.mjs" } }, "sha512-j+GzyF/oNahsjjjup6y3lJpFkC1lQjIkf4kAMXICA1hboBqi0fAGXAGoE4x9FRZiP1br/YE9+L3/Tqq6PIAe0A=="],
|
||||
|
||||
"slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
|
||||
@@ -5257,9 +5261,7 @@
|
||||
|
||||
"@heroui/use-is-mobile/@react-aria/ssr": ["@react-aria/ssr@3.9.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ=="],
|
||||
|
||||
"@jerome-benoit/sap-ai-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||
|
||||
"@jerome-benoit/sap-ai-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-l3LqNFx3hgIqaCNHKbZgWPhKMw8xxOjsNhElzeawWe1Gsjozu2EA4NJUMoKi7sUsNQjn1cw0DPs/zR519oHRXQ=="],
|
||||
"@jerome-benoit/sap-ai-provider/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
||||
|
||||
"@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
||||
|
||||
|
||||
@@ -110,9 +110,10 @@ Values passed to and returned from a plugin cross a thread boundary, so they mus
|
||||
|
||||
Plugin code never runs on the host's main thread. One other mode is available as an opt-in through `CLINE_PLUGIN_MODE` (or `mode` on `resolveAndLoadAgentPlugins`):
|
||||
|
||||
- **`sandbox`**: the previous per-session subprocess sandbox. It needs `node` or `bun` on `PATH` when the host isn't `node` or `bun` itself, and does not apply the per-session plugin policy or report plugin status.
|
||||
- **`sandbox`**: the previous per-session subprocess sandbox. A compiled Cline CLI uses its embedded Bun runtime; other hosts need `node` or `bun` on `PATH` when the host isn't `node` or `bun` itself, and this mode does not apply the per-session plugin policy or report plugin status.
|
||||
|
||||
Both modes need Core's complete plugin runtime. An SDK host that bundles `@cline/core` should ship `dist/plugin-runtime` beside its bundle, or point `CLINE_PLUGIN_RUNTIME_DIR` at that directory. It includes the bootstrap and SDK package exports used by plugins. Compiled CLI binaries read this artifact directly from their embedded read-only filesystem, without installing SDK packages into each plugin directory. Hosts without a bootstrap fail closed with `PluginBootstrapMissingError`; plugin code never falls back to the host's main thread.
|
||||
|
||||
Both modes need the worker bootstrap. The CLI, the hub, the desktop app, and the VS Code extension ship it; an SDK host that bundles `@cline/core` can copy `dist/extensions/plugin-sandbox-bootstrap.js` next to its bundle, or point `CLINE_PLUGIN_BOOTSTRAP_PATH` at it. A host that ships none (the SSH remote, which receives only the Cline CLI binary, does not yet) cannot load any plugin; each one is reported `failed` with a message naming that variable.
|
||||
|
||||
Plugins are bound to a session when it starts. Adding, editing, or reloading a plugin affects only sessions started afterwards; running sessions keep the plugins and code they started with. Every new session imports the plugin fresh, so any change is picked up; `cline hub plugins --reload <plugin>` re-checks it and clears a failed status.
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"build": "bun run clean && bun install && bun run build:sdk && bun -F @cline/cli build",
|
||||
"build:sdk": "bun --production -F './sdk/packages/*' build",
|
||||
"build:sdk": "bun --production -F './sdk/packages/*' build && bun sdk/packages/core/build-plugin-runtime.mts",
|
||||
"build:apps": "bun -F './apps/**' --production build",
|
||||
"build:models": "bun -F @cline/llms generate:models && bun format --write",
|
||||
"dev": "bun --conditions=development run build:sdk && bun run cli && bun run cli hub stop",
|
||||
|
||||
+23
-14
@@ -763,10 +763,10 @@ Because no plugin code runs on the daemon's thread, its `uncaughtException` /
|
||||
#### Worker and subprocess transports
|
||||
|
||||
Both transports share one RPC protocol and one bootstrap
|
||||
(`plugin-sandbox-bootstrap.ts`), bundled self-contained into
|
||||
`dist/extensions/plugin-sandbox-bootstrap.js` (the core build fails if it
|
||||
imports anything but Node builtins, because hosts ship it without
|
||||
`node_modules`):
|
||||
(`plugin-sandbox-bootstrap.ts`). After SDK compilation, Core builds
|
||||
`dist/plugin-runtime/`, containing the self-contained bootstrap and the complete
|
||||
SDK import graph under `node_modules/@cline`. Package exports come from the SDK
|
||||
manifests, including browser subpaths. Hosts ship this directory as one artifact:
|
||||
|
||||
- `WorkerSandbox` (worker mode): structured clone over the worker's message
|
||||
port. A worker must not `chdir` (Bun would change the host's cwd), so the
|
||||
@@ -779,16 +779,25 @@ imports anything but Node builtins, because hosts ship it without
|
||||
- `SubprocessSandbox` (sandbox mode): JSON IPC to a `node`/`bun` child with
|
||||
stdout ignored.
|
||||
|
||||
Hosts find the bootstrap beside their bundle (`plugin-sandbox-bootstrap.js`,
|
||||
or `.mjs` for the CommonJS VS Code bundle), beside the CLI binary
|
||||
(`extensions/`), or through `CLINE_PLUGIN_BOOTSTRAP_PATH` (the desktop
|
||||
sidecar sets it to the copy shipped as a Tauri resource). A host that ships
|
||||
none cannot run plugins at all: every load fails with
|
||||
`PluginBootstrapMissingError`, which names the env var, rather than
|
||||
importing plugin code into the host. The SSH remote host is in that state
|
||||
today: since #14792 it is the bundled Cline CLI uploaded as a single file
|
||||
with no bootstrap beside it, so plugins fail on remotes until the remote
|
||||
runtime carries one.
|
||||
Core resolves the complete artifact through `CLINE_PLUGIN_RUNTIME_DIR`, a
|
||||
package-local `plugin-runtime` directory, or embedded resources. Source hosts
|
||||
prefer their sibling TypeScript bootstrap over packaged runtimes so development
|
||||
and tests execute the current bootstrap and module importer without rebuilding.
|
||||
The CLI compiler embeds the runtime tree and dashboard assets in Bun's read-only
|
||||
filesystem. Worker and subprocess transports read the same embedded bootstrap
|
||||
and SDK exports without extracting files or creating a runtime cache.
|
||||
VS Code and npm CLI copy the complete artifact into their bundles. Desktop
|
||||
ships it beside its external backend script, whose plain Bun host does not
|
||||
mount the CLI's filesystem.
|
||||
A host without a bootstrap fails closed with
|
||||
`PluginBootstrapMissingError`; plugin code never runs on the host main thread.
|
||||
|
||||
Compiled hosts register their subprocess entrypoint with `setPluginSandboxLauncher`.
|
||||
The CLI and Desktop’s bundled CLI use the internal `--plugin-sandbox` flag,
|
||||
which lazily imports the embedded bootstrap with bunfs mounted and compiled
|
||||
runtime flags (including system CA trust) intact. Generic script sandboxes
|
||||
still use plain Bun mode when reusing a compiled runtime.
|
||||
|
||||
|
||||
Command ownership is independent of how the host packages or locates the plugin
|
||||
bootstrap and SDK resources. Resource loading supplies the existing plugin
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { isBuiltin } from "node:module";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const packagesRoot = resolve(import.meta.dir, "..");
|
||||
const outdir = resolve(import.meta.dir, "dist/plugin-runtime");
|
||||
|
||||
/** Run after SDK packages build, so the runtime uses the same compiled package exports. */
|
||||
export async function buildPluginRuntime(): Promise<void> {
|
||||
const packages = ["sdk", "core", "agents", "llms", "shared"].map((name) => {
|
||||
const root = join(packagesRoot, name);
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(root, "package.json"), "utf8"),
|
||||
) as {
|
||||
name: string;
|
||||
version: string;
|
||||
exports: Record<string, { import?: string } | string>;
|
||||
};
|
||||
const exports = Object.fromEntries(
|
||||
Object.entries(manifest.exports).flatMap(([subpath, entry]) => {
|
||||
const target = typeof entry === "string" ? entry : entry.import;
|
||||
// Declaration-only exports need no runtime file. Declared JavaScript
|
||||
// exports must exist; silently omitting one ships an incomplete SDK.
|
||||
if (!target || /\.d\.[cm]?ts$/.test(target)) return [];
|
||||
if (!existsSync(join(root, target)))
|
||||
throw new Error(
|
||||
`Missing plugin runtime export ${manifest.name}${subpath === "." ? "" : subpath.slice(1)}: ${target}. Build the SDK before packaging.`,
|
||||
);
|
||||
return [[subpath, target]];
|
||||
}),
|
||||
);
|
||||
return { root, manifest, exports };
|
||||
});
|
||||
rmSync(outdir, { recursive: true, force: true });
|
||||
const results = await Promise.all([
|
||||
Bun.build({
|
||||
entrypoints: packages.flatMap(({ root, exports }) =>
|
||||
Object.values(exports).map((file) => join(root, file as string)),
|
||||
),
|
||||
root: packagesRoot,
|
||||
outdir: join(outdir, "node_modules/@cline"),
|
||||
naming: { chunk: "core/dist/[name]-[hash].[ext]" },
|
||||
target: "node",
|
||||
format: "esm",
|
||||
splitting: true,
|
||||
minify: true,
|
||||
}),
|
||||
Bun.build({
|
||||
entrypoints: [
|
||||
resolve(
|
||||
import.meta.dir,
|
||||
"./src/extensions/plugin/plugin-sandbox-bootstrap.ts",
|
||||
),
|
||||
],
|
||||
outdir,
|
||||
target: "node",
|
||||
format: "esm",
|
||||
minify: true,
|
||||
}),
|
||||
]);
|
||||
for (const result of results)
|
||||
if (!result.success)
|
||||
throw new AggregateError(result.logs, "Plugin runtime build failed");
|
||||
mkdirSync(outdir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(outdir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@cline/plugin-runtime",
|
||||
private: true,
|
||||
type: "module",
|
||||
}),
|
||||
);
|
||||
for (const { manifest, exports } of packages) {
|
||||
writeFileSync(
|
||||
join(outdir, "node_modules", manifest.name, "package.json"),
|
||||
JSON.stringify({
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
type: "module",
|
||||
exports,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Any unresolved bare import would require node_modules beside a lone binary.
|
||||
// Validate every emitted module, including the SDK's split chunks.
|
||||
const scanner = new Bun.Transpiler({ loader: "js" });
|
||||
for (const file of new Bun.Glob("**/*.js").scanSync({ cwd: outdir })) {
|
||||
for (const { path } of scanner.scanImports(
|
||||
readFileSync(join(outdir, file), "utf8"),
|
||||
)) {
|
||||
if (
|
||||
!isBuiltin(path) &&
|
||||
!path.startsWith("./") &&
|
||||
!path.startsWith("../")
|
||||
) {
|
||||
throw new Error(
|
||||
`Plugin runtime is not self-contained: ${file} imports ${path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await buildPluginRuntime();
|
||||
@@ -1,5 +1,4 @@
|
||||
/// <reference types="@types/bun" />
|
||||
import { builtinModules } from "node:module";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
@@ -85,17 +84,6 @@ const builds: Parameters<typeof Bun.build>[0][] = [
|
||||
outdir: "./dist/services/feature-flags",
|
||||
...buildConfig,
|
||||
},
|
||||
// The plugin sandbox bootstrap runs in an isolated child process
|
||||
// (SubprocessSandbox) or worker thread (WorkerSandbox) and is emitted as a
|
||||
// separate entrypoint. It bundles every package it uses: the CLI ships it
|
||||
// beside the compiled binary with no node_modules to resolve from, so it
|
||||
// may import only Node builtins.
|
||||
{
|
||||
entrypoints: ["./src/extensions/plugin/plugin-sandbox-bootstrap.ts"],
|
||||
outdir: "./dist/extensions",
|
||||
...buildConfig,
|
||||
external: [],
|
||||
},
|
||||
];
|
||||
|
||||
for (const config of builds) {
|
||||
@@ -112,19 +100,3 @@ for (const config of builds) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fail the build if the bootstrap still imports a package: it would load in
|
||||
// dev and tests, then fail in a published CLI install.
|
||||
const bootstrapPath = "./dist/extensions/plugin-sandbox-bootstrap.js";
|
||||
const bootstrapPackages = new Bun.Transpiler({ loader: "js" })
|
||||
.scanImports(await Bun.file(bootstrapPath).text())
|
||||
.map((entry) => entry.path)
|
||||
.filter(
|
||||
(path) => !path.startsWith("node:") && !builtinModules.includes(path),
|
||||
);
|
||||
if (bootstrapPackages.length > 0) {
|
||||
console.error(
|
||||
`${bootstrapPath} must be self-contained but imports: ${[...new Set(bootstrapPackages)].join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,8 @@
|
||||
"devDependencies": {
|
||||
"@types/proxy-from-env": "^1.0.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"posthog-node": "^5.8.0"
|
||||
"posthog-node": "^5.8.0",
|
||||
"skills": "1.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
@@ -2,7 +2,13 @@ import { createHash } from "node:crypto";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { basename, join, relative } from "node:path";
|
||||
|
||||
const RUNTIME_PACKAGE_NAMES = ["shared", "llms", "agents", "core"] as const;
|
||||
const RUNTIME_PACKAGE_NAMES = [
|
||||
"shared",
|
||||
"llms",
|
||||
"agents",
|
||||
"core",
|
||||
"sdk",
|
||||
] as const;
|
||||
const TEST_DIRECTORY_NAMES = new Set(["__tests__", "fixtures", "tests"]);
|
||||
|
||||
function collectRuntimeSourceFiles(directory: string): string[] {
|
||||
@@ -34,7 +40,10 @@ function collectRuntimeSourceFiles(directory: string): string[] {
|
||||
*/
|
||||
export function resolveSdkRuntimeBuildId(repoRoot: string): string {
|
||||
const packagesRoot = join(repoRoot, "sdk", "packages");
|
||||
const inputs = [join(repoRoot, "bun.lock")];
|
||||
const inputs = [
|
||||
join(repoRoot, "bun.lock"),
|
||||
join(packagesRoot, "core", "build-plugin-runtime.mts"),
|
||||
];
|
||||
|
||||
for (const packageName of RUNTIME_PACKAGE_NAMES) {
|
||||
const packageRoot = join(packagesRoot, packageName);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { builtinModules, createRequire } from "node:module";
|
||||
import { dirname, extname, isAbsolute, resolve } from "node:path";
|
||||
import { dirname, extname, isAbsolute, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { PLUGIN_FILE_EXTENSIONS } from "@cline/shared";
|
||||
|
||||
import { resolvePluginRuntimeDirectory } from "./plugin-runtime";
|
||||
|
||||
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const HOST_REQUIRE = createRequire(import.meta.url);
|
||||
// `plugin-module-import.ts` lives at `packages/core/src/extensions/plugin`, so
|
||||
@@ -245,7 +247,9 @@ function resolveFromHostRuntime(specifier: string): string | null {
|
||||
try {
|
||||
return HOST_REQUIRE.resolve(specifier);
|
||||
} catch {
|
||||
// Continue with package export resolution for ESM-only package exports.
|
||||
// Resolve ESM-only exports and embedded bunfs packages through their
|
||||
// manifests. Bun 1.4.2 cannot resolve bare packages inside bunfs with
|
||||
// createRequire.resolve, even though native imports of absolute aliases work.
|
||||
}
|
||||
return resolveHostPackageExport(specifier);
|
||||
}
|
||||
@@ -297,6 +301,8 @@ function resolveHostPackageExport(specifier: string): string | null {
|
||||
|
||||
function getHostPackageSearchRoots(): string[] {
|
||||
const roots = [MODULE_DIR];
|
||||
const runtimeDirectory = resolvePluginRuntimeDirectory();
|
||||
if (runtimeDirectory) roots.unshift(runtimeDirectory);
|
||||
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (wrapperPath) {
|
||||
roots.push(dirname(dirname(wrapperPath)));
|
||||
@@ -725,6 +731,14 @@ export async function importPluginModule(
|
||||
if (!createJiti) {
|
||||
throw new Error("Unable to load jiti");
|
||||
}
|
||||
const runtimeDirectory = resolvePluginRuntimeDirectory();
|
||||
const nativeSdkModules = Object.entries(sortedAliases)
|
||||
.filter(([specifier, target]) => {
|
||||
if (!runtimeDirectory || !isClineSdkSpecifier(specifier)) return false;
|
||||
const path = relative(runtimeDirectory, target);
|
||||
return !path.startsWith("..") && !isAbsolute(path);
|
||||
})
|
||||
.map(([specifier]) => getPackageName(specifier));
|
||||
const transformModules = Object.entries(sortedAliases)
|
||||
.filter(([, target]) => shouldTransformAliasTarget(target))
|
||||
.map(([specifier]) => specifier);
|
||||
@@ -754,7 +768,7 @@ export async function importPluginModule(
|
||||
requireCache: options.freshModule ? false : options.useCache,
|
||||
esmResolve: true,
|
||||
interopDefault: false,
|
||||
nativeModules: [...BUILTIN_MODULES],
|
||||
nativeModules: [...BUILTIN_MODULES, ...nativeSdkModules],
|
||||
transformModules,
|
||||
// On Bun (the packaged binary), tryNative defaults to true, which makes
|
||||
// jiti hand the plugin path straight to Bun's `import()`. Bun then owns
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.mocked(existsSync).mockReset().mockReturnValue(false);
|
||||
vi.stubEnv("CLINE_PLUGIN_RUNTIME_DIR", "");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("plugin runtime discovery", () => {
|
||||
it("caches the package walk, including an absent runtime", async () => {
|
||||
const { resolvePluginRuntimeDirectory } = await import("./plugin-runtime");
|
||||
expect(resolvePluginRuntimeDirectory()).toBeUndefined();
|
||||
const probes = vi.mocked(existsSync).mock.calls.length;
|
||||
expect(probes).toBeGreaterThan(0);
|
||||
expect(resolvePluginRuntimeDirectory()).toBeUndefined();
|
||||
expect(existsSync).toHaveBeenCalledTimes(probes);
|
||||
});
|
||||
|
||||
it("validates and resolves explicit configuration even after a cached discovery miss", async () => {
|
||||
const { resolvePluginRuntimeDirectory } = await import("./plugin-runtime");
|
||||
resolvePluginRuntimeDirectory();
|
||||
vi.stubEnv("CLINE_PLUGIN_RUNTIME_DIR", "./configured-runtime");
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
expect(resolvePluginRuntimeDirectory()).toBe(
|
||||
resolve("./configured-runtime"),
|
||||
);
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
expect(resolvePluginRuntimeDirectory).toThrow(
|
||||
"does not contain a complete plugin runtime",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { SubprocessSandboxLauncher } from "../../runtime/tools/subprocess-sandbox";
|
||||
|
||||
export const CLINE_PLUGIN_RUNTIME_DIR_ENV = "CLINE_PLUGIN_RUNTIME_DIR";
|
||||
declare const __CLINE_PLUGIN_RUNTIME_DIR__: string | undefined;
|
||||
let searched = false;
|
||||
let discoveredDirectory: string | undefined;
|
||||
|
||||
function isPluginRuntime(directory: string): boolean {
|
||||
return (
|
||||
existsSync(join(directory, "plugin-sandbox-bootstrap.js")) &&
|
||||
existsSync(
|
||||
join(directory, "node_modules", "@cline", "core", "package.json"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Core owns the bootstrap and SDK as one artifact for every host and transport. */
|
||||
export function resolvePluginRuntimeDirectory(): string | undefined {
|
||||
const configured = process.env[CLINE_PLUGIN_RUNTIME_DIR_ENV]?.trim();
|
||||
if (configured) {
|
||||
const resolved = resolve(configured);
|
||||
if (!isPluginRuntime(resolved))
|
||||
throw new Error(
|
||||
`${CLINE_PLUGIN_RUNTIME_DIR_ENV} does not contain a complete plugin runtime: ${configured}`,
|
||||
);
|
||||
return resolved;
|
||||
}
|
||||
if (typeof __CLINE_PLUGIN_RUNTIME_DIR__ !== "undefined")
|
||||
return __CLINE_PLUGIN_RUNTIME_DIR__;
|
||||
if (searched) return discoveredDirectory;
|
||||
searched = true;
|
||||
let current = dirname(fileURLToPath(import.meta.url));
|
||||
for (;;) {
|
||||
for (const candidate of [
|
||||
current,
|
||||
join(current, "plugin-runtime"),
|
||||
join(current, "dist", "plugin-runtime"),
|
||||
]) {
|
||||
if (isPluginRuntime(candidate)) {
|
||||
discoveredDirectory = candidate;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return undefined;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
let sandboxLauncher: SubprocessSandboxLauncher | undefined;
|
||||
|
||||
/** Let a compiled host launch plugin children with its embedded filesystem mounted. */
|
||||
export function setPluginSandboxLauncher(
|
||||
launcher: SubprocessSandboxLauncher | undefined,
|
||||
): void {
|
||||
sandboxLauncher = launcher
|
||||
? { command: launcher.command, argsPrefix: [...launcher.argsPrefix] }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function getPluginSandboxLauncher():
|
||||
| SubprocessSandboxLauncher
|
||||
| undefined {
|
||||
return sandboxLauncher;
|
||||
}
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
type PluginTargeting,
|
||||
} from "./plugin-targeting";
|
||||
|
||||
// The executable has already selected its runtime mode. Do not make commands
|
||||
// launched by plugins inherit Bun-only mode instead of their CLI entrypoint.
|
||||
if (isMainThread) delete process.env.BUN_BE_BUN;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types (intentionally minimal – mirrors only what the RPC protocol needs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
@@ -289,7 +289,13 @@ describe.each([
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const sdkDepDir = join(dir, "node_modules", "@cline", "shared");
|
||||
const sdkDepDir = join(
|
||||
dir,
|
||||
"sdk-plugin",
|
||||
"node_modules",
|
||||
"@cline",
|
||||
"shared",
|
||||
);
|
||||
await mkdir(sdkDepDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(sdkDepDir, "package.json"),
|
||||
@@ -306,7 +312,7 @@ describe.each([
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(dir, "plugin-sdk.ts"),
|
||||
join(dir, "sdk-plugin", "plugin-sdk.ts"),
|
||||
[
|
||||
"import { sdkMarker } from '@cline/shared';",
|
||||
"export default {",
|
||||
@@ -397,7 +403,7 @@ describe.each([
|
||||
join(dir, "plugin-rules.mjs"),
|
||||
join(dir, "plugin-ts.ts"),
|
||||
join(dir, "plugin-dep.ts"),
|
||||
join(dir, "plugin-sdk.ts"),
|
||||
join(dir, "sdk-plugin", "plugin-sdk.ts"),
|
||||
join(dir, "plugin-host-dep.ts"),
|
||||
join(dir, "plugin-create-tool.ts"),
|
||||
],
|
||||
@@ -791,8 +797,9 @@ describe.each([
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores the npm wrapper platform package bootstrap when running from source", async () => {
|
||||
it("ignores packaged runtime and npm wrapper bootstraps when running from source", async () => {
|
||||
const previousWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const previousRuntimeDirectory = process.env.CLINE_PLUGIN_RUNTIME_DIR;
|
||||
const wrapperRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-plugin-sandbox-wrapper-"),
|
||||
);
|
||||
@@ -855,10 +862,33 @@ describe.each([
|
||||
);
|
||||
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
// A complete packaged runtime must not bypass the source bootstrap.
|
||||
const runtimeDirectory = join(wrapperRoot, "plugin-runtime");
|
||||
const corePackageDirectory = join(
|
||||
runtimeDirectory,
|
||||
"node_modules",
|
||||
"@cline",
|
||||
"core",
|
||||
);
|
||||
await mkdir(corePackageDirectory, { recursive: true });
|
||||
await writeFile(
|
||||
join(corePackageDirectory, "package.json"),
|
||||
JSON.stringify({ name: "@cline/core" }),
|
||||
);
|
||||
await writeFile(
|
||||
join(runtimeDirectory, "package.json"),
|
||||
JSON.stringify({ type: "module" }),
|
||||
);
|
||||
await copyFile(
|
||||
bootstrapPath,
|
||||
join(runtimeDirectory, "plugin-sandbox-bootstrap.js"),
|
||||
);
|
||||
process.env.CLINE_PLUGIN_RUNTIME_DIR = runtimeDirectory;
|
||||
vi.resetModules();
|
||||
const { loadSandboxedPlugins: loadSandboxedPluginsFromWrapper } =
|
||||
await import("./plugin-sandbox");
|
||||
const sandboxed = await loadSandboxedPluginsFromWrapper({
|
||||
transport,
|
||||
pluginPaths: [join(wrapperRoot, "unused-plugin.mjs")],
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
@@ -881,6 +911,11 @@ describe.each([
|
||||
await sandboxed.shutdown();
|
||||
}
|
||||
} finally {
|
||||
if (previousRuntimeDirectory === undefined) {
|
||||
delete process.env.CLINE_PLUGIN_RUNTIME_DIR;
|
||||
} else {
|
||||
process.env.CLINE_PLUGIN_RUNTIME_DIR = previousRuntimeDirectory;
|
||||
}
|
||||
if (previousWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
@@ -1276,3 +1311,32 @@ describe("plugin-sandbox (worker only)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the subprocess Bun mode before importing plugin code", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "plugin-runtime-env-"));
|
||||
const original = process.env.BUN_BE_BUN;
|
||||
let sandbox: Awaited<ReturnType<typeof loadSandboxedPlugins>> | undefined;
|
||||
try {
|
||||
const plugin = join(directory, "environment.js");
|
||||
await writeFile(
|
||||
plugin,
|
||||
`
|
||||
if (process.env.BUN_BE_BUN) throw new Error('Bun runtime mode leaked into plugin code');
|
||||
export default {name:'environment',manifest:{capabilities:['commands']},setup(api){api.registerCommand({name:'env',handler:()=> 'ready'})}};
|
||||
`,
|
||||
);
|
||||
process.env.BUN_BE_BUN = "1";
|
||||
sandbox = await loadSandboxedPlugins({
|
||||
pluginPaths: [plugin],
|
||||
transport: "subprocess",
|
||||
});
|
||||
expect(sandbox.failures).toEqual([]);
|
||||
expect(sandbox.extensions).toHaveLength(1);
|
||||
expect(process.env.BUN_BE_BUN).toBe("1");
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.BUN_BE_BUN;
|
||||
else process.env.BUN_BE_BUN = original;
|
||||
await sandbox?.shutdown();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,9 +20,14 @@ import { MAX_NODE_TIMER_DELAY_MS } from "../../runtime/tools/subprocess-sandbox-
|
||||
import { WorkerSandbox } from "../../runtime/tools/worker-sandbox";
|
||||
import { markPluginCommand } from "./plugin-command-origin";
|
||||
import type { PluginLoadDiagnostics } from "./plugin-load-report";
|
||||
import {
|
||||
getPluginSandboxLauncher,
|
||||
resolvePluginRuntimeDirectory,
|
||||
} from "./plugin-runtime";
|
||||
import type { PluginTargeting } from "./plugin-targeting";
|
||||
|
||||
export const CLINE_PLUGIN_IDLE_TIMEOUT_MS_ENV = "CLINE_PLUGIN_IDLE_TIMEOUT_MS";
|
||||
|
||||
/**
|
||||
* Absolute path to a compiled `plugin-sandbox-bootstrap.js`, for hosts that
|
||||
* ship it somewhere this module cannot find on its own (an app bundle's
|
||||
@@ -294,6 +299,14 @@ function resolveBootstrap():
|
||||
if (configured && existsSync(configured)) {
|
||||
return { file: configured };
|
||||
}
|
||||
const sourceBootstrapPath = join(dir, "plugin-sandbox-bootstrap.ts");
|
||||
// Source hosts must execute the current bootstrap and module importer,
|
||||
// even when an earlier SDK build left a packaged runtime on disk.
|
||||
const runtimeDirectory = existsSync(sourceBootstrapPath)
|
||||
? undefined
|
||||
: resolvePluginRuntimeDirectory();
|
||||
if (runtimeDirectory)
|
||||
return { file: join(runtimeDirectory, "plugin-sandbox-bootstrap.js") };
|
||||
const selected = selectBootstrapCandidate({
|
||||
siblingCandidates: [
|
||||
join(dir, "plugin-sandbox-bootstrap.js"),
|
||||
@@ -303,7 +316,7 @@ function resolveBootstrap():
|
||||
join(dir, "extensions", "plugin-sandbox-bootstrap.js"),
|
||||
join(dir, "agents", "plugin-sandbox-bootstrap.js"),
|
||||
],
|
||||
sourceBootstrapPath: join(dir, "plugin-sandbox-bootstrap.ts"),
|
||||
sourceBootstrapPath,
|
||||
installedCandidates: [
|
||||
resolveBootstrapFromWrapper(),
|
||||
resolveBootstrapFromExecutable(),
|
||||
@@ -498,6 +511,10 @@ export async function loadSandboxedPlugins(
|
||||
})
|
||||
: new SubprocessSandbox({
|
||||
name: "plugin-sandbox",
|
||||
launcher:
|
||||
"bootstrapFile" in bootstrap
|
||||
? getPluginSandboxLauncher()
|
||||
: undefined,
|
||||
...bootstrap,
|
||||
idleTimeoutMs,
|
||||
onEvent,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("SDK runtime build identity", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-runtime-build-id-"));
|
||||
tempDirs.push(root);
|
||||
writeFileSync(join(root, "bun.lock"), "lockfile-v1\n");
|
||||
for (const packageName of ["shared", "llms", "agents", "core"]) {
|
||||
for (const packageName of ["shared", "llms", "agents", "core", "sdk"]) {
|
||||
const packageRoot = join(root, "sdk", "packages", packageName);
|
||||
mkdirSync(join(packageRoot, "src"), { recursive: true });
|
||||
writeFileSync(
|
||||
@@ -29,6 +29,10 @@ describe("SDK runtime build identity", () => {
|
||||
`export const packageName = ${JSON.stringify(packageName)};\n`,
|
||||
);
|
||||
}
|
||||
writeFileSync(
|
||||
join(root, "sdk", "packages", "core", "build-plugin-runtime.mts"),
|
||||
"// package runtime\n",
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -44,6 +48,26 @@ describe("SDK runtime build identity", () => {
|
||||
expect(resolveSdkRuntimeBuildId(root)).not.toBe(first);
|
||||
});
|
||||
|
||||
it("changes with plugin runtime packaging", () => {
|
||||
const root = createRuntimeFixture();
|
||||
const first = resolveSdkRuntimeBuildId(root);
|
||||
writeFileSync(
|
||||
join(root, "sdk", "packages", "core", "build-plugin-runtime.mts"),
|
||||
"// changed packaging\n",
|
||||
);
|
||||
expect(resolveSdkRuntimeBuildId(root)).not.toBe(first);
|
||||
});
|
||||
|
||||
it("changes when the shipped SDK facade changes", () => {
|
||||
const root = createRuntimeFixture();
|
||||
const first = resolveSdkRuntimeBuildId(root);
|
||||
writeFileSync(
|
||||
join(root, "sdk", "packages", "sdk", "src", "index.ts"),
|
||||
"export const changed = true;\n",
|
||||
);
|
||||
expect(resolveSdkRuntimeBuildId(root)).not.toBe(first);
|
||||
});
|
||||
|
||||
it("ignores test-only source changes", () => {
|
||||
const root = createRuntimeFixture();
|
||||
const first = resolveSdkRuntimeBuildId(root);
|
||||
|
||||
@@ -982,6 +982,11 @@ export {
|
||||
createCompactionStateAwarePrepareTurn,
|
||||
createContextCompactionPrepareTurn,
|
||||
} from "./extensions/context/compaction";
|
||||
export {
|
||||
CLINE_PLUGIN_RUNTIME_DIR_ENV,
|
||||
resolvePluginRuntimeDirectory,
|
||||
setPluginSandboxLauncher,
|
||||
} from "./extensions/plugin/plugin-runtime";
|
||||
export {
|
||||
ALL_DEFAULT_TOOL_NAMES,
|
||||
type ApplyPatchExecutor,
|
||||
@@ -1124,6 +1129,7 @@ export type {
|
||||
RegisterModelInput,
|
||||
RegisterProviderInput,
|
||||
} from "./services/llms/runtime-types";
|
||||
export { SKILLS_CLI_PACKAGE, SKILLS_CLI_VERSION } from "./services/skills-cli";
|
||||
export {
|
||||
TelemetryService,
|
||||
type TelemetryServiceOptions,
|
||||
|
||||
@@ -51,6 +51,65 @@ describe("SubprocessSandbox runtime resolution", () => {
|
||||
).toBe("node");
|
||||
});
|
||||
|
||||
it("uses the current compiled Bun executable without relying on PATH", () => {
|
||||
const path = "/opt/cline/cline";
|
||||
expect(
|
||||
resolveSubprocessRuntimeExecutable({
|
||||
execPath: path,
|
||||
env: {},
|
||||
host: { isBunRuntime: true, selfExecPath: path },
|
||||
}),
|
||||
).toBe(path);
|
||||
expect(
|
||||
resolveSubprocessRuntimeExecutable({
|
||||
execPath: path,
|
||||
env: {},
|
||||
host: { isBunRuntime: false, selfExecPath: path },
|
||||
}),
|
||||
).toBe("node");
|
||||
expect(
|
||||
resolveSubprocessRuntimeExecutable({
|
||||
execPath: "/other/cline",
|
||||
env: {},
|
||||
host: { isBunRuntime: true, selfExecPath: path },
|
||||
}),
|
||||
).toBe("node");
|
||||
});
|
||||
|
||||
it("retains system CA trust when a compiled executable becomes the helper runtime", () => {
|
||||
const executable = "/opt/cline/cline";
|
||||
const originalExecPath = process.execPath;
|
||||
vi.stubGlobal("Bun", {});
|
||||
try {
|
||||
process.execPath = executable;
|
||||
expect(
|
||||
buildSubprocessSandboxCommand(["bootstrap.js"], {
|
||||
execPath: executable,
|
||||
env: {},
|
||||
execArgv: [],
|
||||
}),
|
||||
).toEqual([executable, "--use-system-ca", "bootstrap.js"]);
|
||||
} finally {
|
||||
process.execPath = originalExecPath;
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the host launcher without switching the executable to plain Bun", () => {
|
||||
expect(
|
||||
buildSubprocessSandboxCommand(["/embedded/plugin-sandbox-bootstrap.js"], {
|
||||
launcher: {
|
||||
command: "/opt/cline/cline",
|
||||
argsPrefix: ["--plugin-sandbox"],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
"/opt/cline/cline",
|
||||
"--plugin-sandbox",
|
||||
"/embedded/plugin-sandbox-bootstrap.js",
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows an explicit helper runtime override", () => {
|
||||
expect(
|
||||
resolveSubprocessRuntimeExecutable({
|
||||
@@ -102,6 +161,23 @@ describe("SubprocessSandbox runtime resolution", () => {
|
||||
});
|
||||
|
||||
describe("SubprocessSandbox call", () => {
|
||||
it("clears plain Bun mode for host-dispatched children without mutating the parent environment", async () => {
|
||||
vi.stubEnv("CLINE_BUILD_ENV", "production");
|
||||
vi.stubEnv("BUN_BE_BUN", "parent-value");
|
||||
const sandbox = new SubprocessSandbox({
|
||||
launcher: { command: process.execPath, argsPrefix: [] },
|
||||
bootstrapScript:
|
||||
"process.on('message', m => process.send({type:'response', id:m.id, ok:true, result:process.env.BUN_BE_BUN ?? null}));",
|
||||
});
|
||||
try {
|
||||
await expect(sandbox.call("environment", {})).resolves.toBeNull();
|
||||
expect(process.env.BUN_BE_BUN).toBe("parent-value");
|
||||
} finally {
|
||||
await sandbox.shutdown();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-serializable payloads without arming the timeout shutdown", async () => {
|
||||
const sandbox = new SubprocessSandbox({
|
||||
name: "sandbox-serialization-test",
|
||||
|
||||
@@ -30,7 +30,15 @@ interface SandboxEventMessage {
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export interface SubprocessSandboxLauncher {
|
||||
command: string;
|
||||
/** Host-specific arguments placed before the bootstrap file path. */
|
||||
argsPrefix: string[];
|
||||
}
|
||||
|
||||
export interface SubprocessSandboxOptions {
|
||||
/** A compiled host can dispatch a bootstrap with its embedded filesystem mounted. */
|
||||
launcher?: SubprocessSandboxLauncher;
|
||||
/** Inline script to execute via `node -e`. Mutually exclusive with {@link bootstrapFile}. */
|
||||
bootstrapScript?: string;
|
||||
/** Path to a JavaScript file to execute via `node <file>`. Mutually exclusive with {@link bootstrapScript}. */
|
||||
@@ -90,11 +98,34 @@ function isRuntimeExecutable(value: string | undefined): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `execPath` is this process's own `bun build --compile` executable.
|
||||
* Such a binary can still act as the plain `bun` runtime for a child process
|
||||
* when spawned with `BUN_BE_BUN=1`, so packaged hosts (the desktop sidecar, a
|
||||
* compiled CLI) need neither node nor bun installed on the user's machine.
|
||||
*/
|
||||
export function isCompiledBunExecutable(
|
||||
execPath: string | undefined,
|
||||
host: { isBunRuntime?: boolean; selfExecPath?: string } = {},
|
||||
): boolean {
|
||||
const isBunRuntime =
|
||||
host.isBunRuntime ??
|
||||
typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
|
||||
const selfExecPath = host.selfExecPath ?? process.execPath;
|
||||
return (
|
||||
isBunRuntime &&
|
||||
!!execPath &&
|
||||
execPath === selfExecPath &&
|
||||
!isRuntimeExecutable(execPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSubprocessRuntimeExecutable(
|
||||
options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
execPath?: string;
|
||||
runtimeExecutable?: string;
|
||||
host?: { isBunRuntime?: boolean; selfExecPath?: string };
|
||||
} = {},
|
||||
): string {
|
||||
const env = options.env ?? process.env;
|
||||
@@ -120,6 +151,7 @@ export function resolveSubprocessRuntimeExecutable(
|
||||
}
|
||||
}
|
||||
|
||||
if (isCompiledBunExecutable(execPath, options.host)) return execPath;
|
||||
return "node";
|
||||
}
|
||||
|
||||
@@ -131,14 +163,22 @@ export function buildSubprocessSandboxCommand(
|
||||
name?: string;
|
||||
execPath?: string;
|
||||
runtimeExecutable?: string;
|
||||
launcher?: SubprocessSandboxLauncher;
|
||||
} = {},
|
||||
): string[] {
|
||||
if (options.launcher) {
|
||||
return [options.launcher.command, ...options.launcher.argsPrefix, ...args];
|
||||
}
|
||||
const runtimeExecutable = resolveSubprocessRuntimeExecutable({
|
||||
env: options.env,
|
||||
execPath: options.execPath,
|
||||
runtimeExecutable: options.runtimeExecutable,
|
||||
});
|
||||
return augmentNodeCommandForDebug([runtimeExecutable, ...args], {
|
||||
// Plain Bun mode does not retain compiled execArgv.
|
||||
const runtimeArgs = isCompiledBunExecutable(runtimeExecutable)
|
||||
? ["--use-system-ca", ...args]
|
||||
: args;
|
||||
return augmentNodeCommandForDebug([runtimeExecutable, ...runtimeArgs], {
|
||||
env: options.env,
|
||||
execArgv: options.execArgv,
|
||||
debugRole: options.name === "plugin-sandbox" ? "plugin-sandbox" : "sandbox",
|
||||
@@ -261,17 +301,19 @@ export class SubprocessSandbox {
|
||||
const command = buildSubprocessSandboxCommand(args, {
|
||||
name: this.options.name,
|
||||
runtimeExecutable: this.options.runtimeExecutable,
|
||||
launcher: this.options.launcher,
|
||||
});
|
||||
const executable =
|
||||
command[0] ?? resolveSubprocessRuntimeExecutable(this.options);
|
||||
const env = { ...withResolvedClineBuildEnv(process.env) };
|
||||
if (this.options.launcher) delete env.BUN_BE_BUN;
|
||||
else if (isCompiledBunExecutable(executable)) env.BUN_BE_BUN = "1";
|
||||
const child = spawn(executable, command.slice(1), {
|
||||
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
||||
env,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
const child = spawn(
|
||||
command[0] ?? resolveSubprocessRuntimeExecutable(this.options),
|
||||
command.slice(1),
|
||||
{
|
||||
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
||||
env: withResolvedClineBuildEnv(process.env),
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
this.process = child;
|
||||
let stderrBuffer = "";
|
||||
const appendStderr = (chunk: string) => {
|
||||
|
||||
@@ -606,9 +606,12 @@ describe("createAgentModelFromConfig", () => {
|
||||
const provider = await createSapAiCoreProviderModule(
|
||||
gatewayConfig?.providerConfigs[0] as never,
|
||||
);
|
||||
const model = provider.operations.language(
|
||||
"anthropic--claude-4.6-sonnet",
|
||||
) as {
|
||||
// The /v4 entrypoint wraps the V3 model that holds the resolved SAP config.
|
||||
const model = (
|
||||
provider.operations.language("anthropic--claude-4.6-sonnet") as {
|
||||
internalModel?: unknown;
|
||||
}
|
||||
).internalModel as {
|
||||
config?: {
|
||||
destination?: Record<string, unknown>;
|
||||
deploymentConfig?: Record<string, unknown>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
||||
import { setClineDir, setHomeDir } from "@cline/shared/storage";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { uninstallMarketplaceEntry } from "./marketplace";
|
||||
import { SKILLS_CLI_PACKAGE } from "./skills-cli";
|
||||
|
||||
describe("marketplace service", () => {
|
||||
let root = "";
|
||||
@@ -114,7 +115,7 @@ describe("marketplace service", () => {
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: "npx",
|
||||
args: ["-y", "skills@latest", "remove", "review-team", "-g", "-y"],
|
||||
args: ["-y", SKILLS_CLI_PACKAGE, "remove", "review-team", "-g", "-y"],
|
||||
},
|
||||
]);
|
||||
expect(existsSync(skillDir)).toBe(false);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resolveClineDir, resolveMcpSettingsPath } from "@cline/shared/storage";
|
||||
import { updateMcpSettingsFileSync } from "../extensions/mcp";
|
||||
import { parseMcpInstallArgs } from "./mcp-install";
|
||||
import { uninstallPlugin } from "./plugin-uninstall";
|
||||
import { SKILLS_CLI_PACKAGE } from "./skills-cli";
|
||||
|
||||
export type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
|
||||
|
||||
@@ -300,7 +301,7 @@ export async function uninstallMarketplaceSkill(
|
||||
const command = "npx";
|
||||
const commandArgs = [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
SKILLS_CLI_PACKAGE,
|
||||
"remove",
|
||||
installedName,
|
||||
"-g",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SKILLS_CLI_PACKAGE, SKILLS_CLI_VERSION } from "./skills-cli";
|
||||
|
||||
describe("skills CLI pin", () => {
|
||||
// The call-site tests compare against the constant itself, so this is the
|
||||
// one place that checks what the constant promises: the exact release that
|
||||
// package.json declares, not a tag or a range.
|
||||
it("is the exact release declared in core's package.json", () => {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(
|
||||
fileURLToPath(new URL("../../package.json", import.meta.url)),
|
||||
"utf8",
|
||||
),
|
||||
) as { devDependencies?: Record<string, string> };
|
||||
const declared = packageJson.devDependencies?.skills;
|
||||
|
||||
expect(declared).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
expect(SKILLS_CLI_VERSION).toBe(declared);
|
||||
expect(SKILLS_CLI_PACKAGE).toBe(`skills@${declared}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { version as skillsCliVersion } from "skills/package.json";
|
||||
|
||||
// The `skills` CLI release that `cline skill` and the marketplace skill
|
||||
// fallback run through npx. Read from the devDependency so the pin lives in
|
||||
// package.json, where Dependabot bumps it, and each bump goes through CI and
|
||||
// review instead of landing on users' machines the moment Vercel publishes.
|
||||
export const SKILLS_CLI_VERSION: string = skillsCliVersion;
|
||||
export const SKILLS_CLI_PACKAGE = `skills@${skillsCliVersion}`;
|
||||
@@ -63,7 +63,7 @@
|
||||
"@ai-sdk/provider-utils": "^5.0.50",
|
||||
"@aws-sdk/credential-providers": "^3.922.0",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@jerome-benoit/sap-ai-provider": "4.8.0",
|
||||
"@jerome-benoit/sap-ai-provider": "5.0.3",
|
||||
"@langfuse/core": "5.10.1",
|
||||
"@langfuse/otel": "5.10.1",
|
||||
"@langfuse/tracing": "5.10.1",
|
||||
|
||||
+13
-8
@@ -11,6 +11,11 @@ function deferred() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
// The /v4 entrypoint wraps the V3 model that holds the resolved SAP config.
|
||||
function sapInternalModel(model: unknown): unknown {
|
||||
return (model as { internalModel?: unknown }).internalModel;
|
||||
}
|
||||
|
||||
describe("createSapAiCoreProviderModule", () => {
|
||||
afterEach(() => {
|
||||
if (originalServiceKey === undefined) {
|
||||
@@ -34,8 +39,8 @@ describe("createSapAiCoreProviderModule", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const model = provider.operations.language(
|
||||
"anthropic--claude-4.6-sonnet",
|
||||
const model = sapInternalModel(
|
||||
provider.operations.language("anthropic--claude-4.6-sonnet"),
|
||||
) as {
|
||||
config?: {
|
||||
destination?: Record<string, unknown>;
|
||||
@@ -177,8 +182,8 @@ describe("createSapAiCoreProviderModule", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const model = provider.operations.language(
|
||||
"anthropic--claude-4.6-sonnet",
|
||||
const model = sapInternalModel(
|
||||
provider.operations.language("anthropic--claude-4.6-sonnet"),
|
||||
) as {
|
||||
config?: {
|
||||
deploymentConfig?: Record<string, unknown>;
|
||||
@@ -204,8 +209,8 @@ describe("createSapAiCoreProviderModule", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const model = provider.operations.language(
|
||||
"anthropic--claude-4.6-sonnet",
|
||||
const model = sapInternalModel(
|
||||
provider.operations.language("anthropic--claude-4.6-sonnet"),
|
||||
) as {
|
||||
config?: {
|
||||
requestConfig?: {
|
||||
@@ -245,8 +250,8 @@ describe("createSapAiCoreProviderModule", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const model = provider.operations.language(
|
||||
"anthropic--claude-4.6-sonnet",
|
||||
const model = sapInternalModel(
|
||||
provider.operations.language("anthropic--claude-4.6-sonnet"),
|
||||
) as {
|
||||
config?: {
|
||||
requestConfig?: { fetch?: unknown };
|
||||
|
||||
+2
-2
@@ -30,7 +30,7 @@ const sapImportScope = new AsyncLocalStorage<Set<ProcessListener>>();
|
||||
// The specifier is a literal so bundlers still include the package (a
|
||||
// computed one left the VSIX loading it from node_modules at runtime).
|
||||
async function importSapAiProvider(): Promise<
|
||||
typeof import("@jerome-benoit/sap-ai-provider")
|
||||
typeof import("@jerome-benoit/sap-ai-provider/v4")
|
||||
> {
|
||||
const emitter = process as unknown as EventEmitter;
|
||||
const owned = new Set<ProcessListener>();
|
||||
@@ -43,7 +43,7 @@ async function importSapAiProvider(): Promise<
|
||||
try {
|
||||
return await sapImportScope.run(
|
||||
owned,
|
||||
() => import("@jerome-benoit/sap-ai-provider"),
|
||||
() => import("@jerome-benoit/sap-ai-provider/v4"),
|
||||
);
|
||||
} finally {
|
||||
emitter.removeListener("newListener", record);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Wire-contract tests for SAP AI Core, exercised through the real
|
||||
// `@jerome-benoit/sap-ai-provider` and SAP AI SDK clients against a local
|
||||
// HTTP server that serves both the OAuth token and the inference endpoints.
|
||||
// Each case streams one turn through the gateway and asserts on the inference
|
||||
// request body. Together they pin:
|
||||
//
|
||||
// 1. the output limit is sent as `max_completion_tokens`, never the
|
||||
// deprecated `max_tokens`, which OpenAI reasoning models reject and
|
||||
// newer SAP AI Core landscapes reject for every model family;
|
||||
// 2. no `reasoning_effort` is sent, whatever reasoning setting the request
|
||||
// carries. SAP AI Core requests have never carried one, so reasoning
|
||||
// settings saved meanwhile would otherwise start reaching models that
|
||||
// reject the parameter; sending it needs its own change.
|
||||
import { createServer, type IncomingMessage, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { GatewayStreamRequest } from "@cline/shared";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createGateway } from "../gateway";
|
||||
|
||||
const DEPLOYMENT_ID = "d0123456789abcde";
|
||||
|
||||
interface RecordedRequest {
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
const inferenceRequests: RecordedRequest[] = [];
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
req.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
req.on("end", () => resolve(data));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function base64Url(value: object): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
}
|
||||
|
||||
// The SAP SDK reads the token's expiry from its JWT payload.
|
||||
const ACCESS_TOKEN = [
|
||||
base64Url({ alg: "none", typ: "JWT" }),
|
||||
base64Url({ exp: Math.floor(Date.now() / 1000) + 3600, zid: "test-zone" }),
|
||||
"",
|
||||
].join(".");
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
const path = req.url ?? "";
|
||||
if (path.startsWith("/oauth/token")) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: ACCESS_TOKEN,
|
||||
token_type: "bearer",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
inferenceRequests.push({ path, body: raw ? JSON.parse(raw) : {} });
|
||||
res.writeHead(400, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { message: "stub" } }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
/**
|
||||
* Stream one turn and return the inference request that reached the server.
|
||||
* The server answers 400: only the request is under test.
|
||||
*/
|
||||
async function wireRequest(input: {
|
||||
modelId: string;
|
||||
useOrchestrationMode: boolean;
|
||||
reasoning?: GatewayStreamRequest["reasoning"];
|
||||
}): Promise<RecordedRequest> {
|
||||
const before = inferenceRequests.length;
|
||||
const gateway = createGateway({
|
||||
providerConfigs: [
|
||||
{
|
||||
providerId: "sapaicore",
|
||||
baseUrl,
|
||||
options: {
|
||||
clientId: "sap-client",
|
||||
clientSecret: "sap-secret",
|
||||
tokenUrl: baseUrl,
|
||||
deploymentId: DEPLOYMENT_ID,
|
||||
useOrchestrationMode: input.useOrchestrationMode,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
for await (const _event of await gateway.stream({
|
||||
providerId: "sapaicore",
|
||||
modelId: input.modelId,
|
||||
reasoning: input.reasoning,
|
||||
maxTokens: 16,
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
createdAt: 0,
|
||||
},
|
||||
],
|
||||
})) {
|
||||
// drain
|
||||
}
|
||||
const request = inferenceRequests[before];
|
||||
if (!request) {
|
||||
throw new Error("no inference request reached the server");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Collect every key in a nested request body, for shape-agnostic checks. */
|
||||
function keysOf(value: unknown, keys = new Map<string, unknown>()) {
|
||||
if (value && typeof value === "object") {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
keys.set(key, child);
|
||||
keysOf(child, keys);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const MODELS = ["gpt-5.4", "gpt-4o", "anthropic--claude-4.5-sonnet"];
|
||||
const REASONING: Array<GatewayStreamRequest["reasoning"]> = [
|
||||
undefined,
|
||||
{ enabled: false },
|
||||
{ enabled: true, effort: "high" },
|
||||
];
|
||||
|
||||
describe.each([
|
||||
{ api: "foundation models", useOrchestrationMode: false },
|
||||
{ api: "orchestration", useOrchestrationMode: true },
|
||||
])("SAP AI Core $api wire contract", ({ useOrchestrationMode }) => {
|
||||
it.each(
|
||||
MODELS.flatMap((modelId) =>
|
||||
REASONING.map((reasoning) => ({ modelId, reasoning })),
|
||||
),
|
||||
)("sends max_completion_tokens and no reasoning_effort for $modelId with reasoning $reasoning", async ({
|
||||
modelId,
|
||||
reasoning,
|
||||
}) => {
|
||||
const request = await wireRequest({
|
||||
modelId,
|
||||
useOrchestrationMode,
|
||||
reasoning,
|
||||
});
|
||||
const keys = keysOf(request.body);
|
||||
|
||||
expect(request.path).toContain(
|
||||
`/v2/inference/deployments/${DEPLOYMENT_ID}`,
|
||||
);
|
||||
expect(keys.get("max_completion_tokens")).toBe(16);
|
||||
expect(keys.has("max_tokens")).toBe(false);
|
||||
expect(keys.has("reasoning_effort")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ const runBuild = async (
|
||||
await runBuild("node", {
|
||||
entrypoints: [
|
||||
"./src/index.ts",
|
||||
"./src/types/index.ts",
|
||||
"./src/automation/index.ts",
|
||||
"./src/db/index.ts",
|
||||
"./src/node.ts",
|
||||
|
||||
Reference in New Issue
Block a user