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:
Saoud Rizwan
2026-10-20 17:38:24 +00:00
co-authored by Saoud Rizwan
59 changed files with 1306 additions and 378 deletions
+11
View File
@@ -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"
+4
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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`),
+26 -17
View File
@@ -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 });
}
+20
View File
@@ -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;
}
+5 -1
View File
@@ -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[] {
+18 -7
View File
@@ -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]);
});
});
+4 -3
View File
@@ -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
+13
View File
@@ -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.
+7
View File
@@ -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",
+2 -1
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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 {
+7 -5
View File
@@ -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=="],
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+110
View File
@@ -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();
-28
View File
@@ -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);
}
+2 -1
View File
@@ -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"
+11 -2
View File
@@ -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);
+6
View File
@@ -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}`;
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
});
+1
View File
@@ -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",