fix(core): load compiled plugin resources directly from bunfs

This commit is contained in:
abeatrix
2026-10-19 14:04:36 -07:00
parent cc56295a19
commit 4554f1194e
30 changed files with 400 additions and 346 deletions
+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
+3 -3
View File
@@ -293,9 +293,9 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
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. Embedded resources
are materialized into build-specific directories under `CLINE_DIR/runtime`
(default: `~/.cline/runtime`).
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
+11 -12
View File
@@ -10,7 +10,7 @@ import {
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { $ } from "bun";
import { buildDashboardRuntimeResources } from "./dashboard-runtime";
import { buildDashboardRuntimeDirectory } from "./dashboard-runtime";
const cliDir = resolve(import.meta.dir, "..");
const rootDir = resolve(cliDir, "../..");
@@ -95,13 +95,8 @@ export interface CompileCliBinaryOptions {
export async function compileCliBinary(
input: CompileCliBinaryOptions,
): Promise<void> {
const pluginResources = JSON.parse(
readFileSync(
join(rootDir, "sdk/packages/core/dist/plugin-runtime-resources.json"),
"utf8",
),
);
const dashboardResources = await buildDashboardRuntimeResources();
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/";
@@ -131,11 +126,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 } : {}),
@@ -145,9 +142,11 @@ export async function compileCliBinary(
external: ["@anthropic-ai/vertex-sdk"],
define: {
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath,
__CLINE_PLUGIN_RUNTIME_RESOURCES__: JSON.stringify(pluginResources),
__CLINE_DASHBOARD_RUNTIME_RESOURCES__:
JSON.stringify(dashboardResources),
// 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(),
+9 -22
View File
@@ -1,33 +1,20 @@
import { readdirSync, readFileSync } from "node:fs";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { type EmbeddedResources, packEmbeddedResources } from "@cline/core";
import { $ } from "bun";
let resources: Promise<EmbeddedResources> | undefined;
let directory: Promise<string> | undefined;
/** Build the dashboard once per platform-build invocation and include every static asset. */
export function buildDashboardRuntimeResources(): Promise<EmbeddedResources> {
resources ??= buildResources();
return resources;
/** Build once per platform-build invocation; the compiler embeds this tree unchanged. */
export function buildDashboardRuntimeDirectory(): Promise<string> {
directory ??= buildDirectory();
return directory;
}
async function buildResources(): Promise<EmbeddedResources> {
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");
const files: Record<string, string> = {};
function collect(relative = "") {
for (const entry of readdirSync(join(directory, relative), {
withFileTypes: true,
})) {
const name = relative + entry.name;
if (entry.isDirectory()) collect(`${name}/`);
else if (entry.isFile())
files[name] = readFileSync(join(directory, name)).toString("base64");
}
}
collect();
if (!files["index.html"])
if (!existsSync(join(directory, "index.html")))
throw new Error("Dashboard build produced no index.html");
return packEmbeddedResources(files);
return directory;
}
+13 -6
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bun
import { isMainThread } from "node:worker_threads";
import { resolvePluginRuntimeDirectory } from "@cline/core";
import { setPluginSandboxLauncher } from "@cline/core";
import {
buildTelemetrySelfcheckReport,
claimHubDaemonProcess,
@@ -33,13 +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-runtime-dir")) {
const directory = resolvePluginRuntimeDirectory();
if (!directory)
throw new Error("This CLI does not ship a complete plugin runtime");
process.stdout.write(`${directory}\n`);
} 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.
+4 -16
View File
@@ -1,19 +1,7 @@
import { join } from "node:path";
import {
type EmbeddedResources,
materializeEmbeddedResources,
} from "@cline/core";
import { resolveClineDir } from "@cline/shared/storage";
declare const __CLINE_DASHBOARD_RUNTIME_RESOURCES__:
| EmbeddedResources
| undefined;
declare const __CLINE_DASHBOARD_RUNTIME_DIR__: string | undefined;
export function resolveEmbeddedDashboard(): string | undefined {
if (typeof __CLINE_DASHBOARD_RUNTIME_RESOURCES__ === "undefined")
return undefined;
return materializeEmbeddedResources(
__CLINE_DASHBOARD_RUNTIME_RESOURCES__,
join(resolveClineDir(), "runtime", "dashboard"),
);
return typeof __CLINE_DASHBOARD_RUNTIME_DIR__ === "undefined"
? undefined
: __CLINE_DASHBOARD_RUNTIME_DIR__;
}
@@ -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}`;
};
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
import {
closeSync,
copyFileSync,
cpSync,
mkdirSync,
mkdtempSync,
openSync,
@@ -71,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;
@@ -345,6 +356,8 @@ test.each([
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.
@@ -385,6 +398,9 @@ test.each([
(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,
execPath: realpathSync(cli),
@@ -396,9 +412,81 @@ test.each([
}
}, 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]) =>
@@ -447,6 +535,9 @@ test("standalone CLI serves the dashboard and its assets without external files
}
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 = [
@@ -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;
@@ -2,7 +2,11 @@ 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 } from "@cline/sdk";
import {
type AgentPlugin,
createTool,
resolvePluginRuntimeDirectory,
} from "@cline/sdk";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
export default {
@@ -15,6 +19,15 @@ export default {
) {
throw new Error("Missing browser SDK 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");
@@ -87,10 +87,10 @@ 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.
Before connecting, the sidecar runs `cline-cli --plugin-runtime-dir` and sets
`CLINE_PLUGIN_RUNTIME_DIR` to the returned Core-owned bootstrap and SDK directory.
The CLI embeds and extracts that complete artifact; Desktop does not ship an
independent bootstrap or SDK copy.
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
@@ -1,9 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
adoptDesktopCliRuntime,
configureDesktopPluginRuntime,
ensureHubWithDesktopCli,
} from "./cli-runtime";
import { adoptDesktopCliRuntime, ensureHubWithDesktopCli } from "./cli-runtime";
describe("ensureHubWithDesktopCli", () => {
it("asks the bundled CLI to start or reuse the Hub and returns only its URL", async () => {
@@ -55,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",
});
@@ -66,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",
});
@@ -74,40 +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();
});
});
describe("configureDesktopPluginRuntime", () => {
it("asks the matching executable for its runtime rather than treating it as a script runner", async () => {
const env = { BUN_BE_BUN: "1" };
const run = vi.fn(async () => "/cache/plugins/build-id\n");
await configureDesktopPluginRuntime("/opt/cline/cline-cli", { env, run });
expect(env).toMatchObject({
CLINE_PLUGIN_RUNTIME_DIR: "/cache/plugins/build-id",
});
expect(run).toHaveBeenCalledWith(
"/opt/cline/cline-cli",
["--plugin-runtime-dir"],
expect.objectContaining({
env: expect.objectContaining({
BUN_BE_BUN: undefined,
CLINE_NO_AUTO_UPDATE: "1",
}),
}),
);
});
it("rejects a CLI that does not report its runtime", async () => {
await expect(
configureDesktopPluginRuntime("/opt/cline/cline-cli", {
env: {},
run: async () => "",
}),
).rejects.toThrow("plugin runtime directory");
expect(setPluginLauncher).not.toHaveBeenCalled();
});
});
@@ -1,8 +1,5 @@
import { execFile } from "node:child_process";
import {
CLINE_PLUGIN_RUNTIME_DIR_ENV,
setHubDaemonLauncher,
} from "@cline/core";
import { setHubDaemonLauncher, setPluginSandboxLauncher } from "@cline/core";
import { withHubLauncherIdentity } from "@cline/shared";
import { DESKTOP_CLIENT_CONTEXT } from "./client-context";
@@ -30,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;
@@ -46,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 };
}
@@ -123,22 +125,3 @@ export async function ensureHubWithDesktopCli(
}
return { url: parsed.url };
}
/** The backend runs as a script, so obtain its matching SDK/bootstrap from the CLI executable. */
export async function configureDesktopPluginRuntime(
cliPath: string,
options: { env?: NodeJS.ProcessEnv; run?: RunCliCommand } = {},
): Promise<void> {
const env = options.env ?? process.env;
const run = options.run ?? runCliCommand;
const directory = (
await run(cliPath, ["--plugin-runtime-dir"], {
cwd: process.cwd(),
env: { ...env, BUN_BE_BUN: undefined, CLINE_NO_AUTO_UPDATE: "1" },
timeoutMs: HUB_ENSURE_TIMEOUT_MS,
})
).trim();
if (!directory)
throw new Error("Cline CLI did not report a plugin runtime directory");
env[CLINE_PLUGIN_RUNTIME_DIR_ENV] = directory;
}
+1 -7
View File
@@ -16,11 +16,7 @@ import {
setClineClientIdentity,
} from "@cline/shared";
import { prewarmWorkspaceMetadata } from "./chat-session";
import {
adoptDesktopCliRuntime,
configureDesktopPluginRuntime,
ensureHubWithDesktopCli,
} from "./cli-runtime";
import { adoptDesktopCliRuntime, ensureHubWithDesktopCli } from "./cli-runtime";
import { DESKTOP_CLIENT_CONTEXT } from "./client-context";
import { configureConnectorCliLaunch } from "./connectors";
import {
@@ -258,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.
if (cliPath) await configureDesktopPluginRuntime(cliPath);
// Before the Hub daemon and agent-spawned processes inherit this env.
ensureLoopbackProxyBypass();
await main(cliPath);
@@ -46,7 +46,7 @@
"icons/app/*.png",
"icons/app/macos/*.png",
"bin/remote-helpers/*",
"bin/desktop-backend/*"
"bin/desktop-backend/**/*"
],
"icon": [
"icons/32x32.png",
+1 -1
View File
@@ -112,7 +112,7 @@ Plugin code never runs on the host's main thread. One other mode is available as
- **`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 embed and materialize this artifact automatically, 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 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.
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.
+12 -13
View File
@@ -783,22 +783,21 @@ 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 archive and materializes it under
`CLINE_DIR/runtime/plugins/<hash>`
when needed. Matching cached bytes are reused and corrupt files are repaired;
builds retain separate directories so running workers survive upgrades. Plugin
SDK imports resolve against this same artifact. VS Code copies the complete
artifact into its extension bundle. Desktop obtains its matching resource
directory from the bundled CLI before starting its existing plugin services.
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 Bun executables can also run subprocess plugins themselves, using
`BUN_BE_BUN=1` when no separate Node or Bun runtime is available. Resource
packaging is independent of execution-mode policy.
The subprocess launch explicitly retains system CA trust when using the compiled
executable as Bun. After startup, the plugin bootstrap clears `BUN_BE_BUN` so
plugin-launched Cline commands run their normal CLI entrypoint.
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.
Sandboxed plugin subprocesses and workers are session-local but lazily recreatable. Core
reclaims a sandbox after 30 minutes without an in-flight RPC call (configurable
+16 -15
View File
@@ -1,13 +1,12 @@
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { isBuiltin } from "node:module";
import { join, resolve } from "node:path";
import { packEmbeddedResources } from "./src/runtime/embedded-resources";
const packagesRoot = resolve(import.meta.dir, "..");
const outdir = resolve(import.meta.dir, "dist/plugin-runtime");
@@ -84,22 +83,24 @@ export async function buildPluginRuntime(): Promise<void> {
}),
);
}
const files: Record<string, string> = {};
function collect(directory: string, prefix = "") {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const name = `${prefix}${entry.name}`;
if (entry.isDirectory()) collect(join(directory, entry.name), `${name}/`);
else if (entry.isFile())
files[name] = readFileSync(join(directory, entry.name)).toString(
"base64",
// 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}`,
);
}
}
}
collect(outdir);
writeFileSync(
resolve(outdir, "../plugin-runtime-resources.json"),
JSON.stringify(packEmbeddedResources(files)),
);
}
if (import.meta.main) await buildPluginRuntime();
@@ -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[] {
@@ -247,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);
}
@@ -0,0 +1,36 @@
import { existsSync } from "node:fs";
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 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("/configured-runtime");
vi.mocked(existsSync).mockReturnValue(false);
expect(resolvePluginRuntimeDirectory).toThrow(
"does not contain a complete plugin runtime",
);
});
});
@@ -1,15 +1,12 @@
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveClineDir } from "@cline/shared/storage";
import {
type EmbeddedResources,
materializeEmbeddedResources,
} from "../../runtime/embedded-resources";
import type { SubprocessSandboxLauncher } from "../../runtime/tools/subprocess-sandbox";
export const CLINE_PLUGIN_RUNTIME_DIR_ENV = "CLINE_PLUGIN_RUNTIME_DIR";
declare const __CLINE_PLUGIN_RUNTIME_RESOURCES__: EmbeddedResources | undefined;
const embeddedDirectories = new Map<string, string>();
declare const __CLINE_PLUGIN_RUNTIME_DIR__: string | undefined;
let searched = false;
let discoveredDirectory: string | undefined;
function isPluginRuntime(directory: string): boolean {
return (
@@ -30,6 +27,10 @@ export function resolvePluginRuntimeDirectory(): string | undefined {
);
return configured;
}
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 [
@@ -37,22 +38,30 @@ export function resolvePluginRuntimeDirectory(): string | undefined {
join(current, "plugin-runtime"),
join(current, "dist", "plugin-runtime"),
]) {
if (isPluginRuntime(candidate)) return candidate;
if (isPluginRuntime(candidate)) {
discoveredDirectory = candidate;
return candidate;
}
}
const parent = dirname(current);
if (parent === current) break;
if (parent === current) return undefined;
current = parent;
}
if (typeof __CLINE_PLUGIN_RUNTIME_RESOURCES__ === "undefined")
return undefined;
const root = join(resolveClineDir(), "runtime", "plugins");
let directory = embeddedDirectories.get(root);
if (!directory) {
directory = materializeEmbeddedResources(
__CLINE_PLUGIN_RUNTIME_RESOURCES__,
root,
);
embeddedDirectories.set(root, directory);
}
return directory;
}
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;
}
@@ -19,7 +19,10 @@ import { SubprocessSandbox } from "../../runtime/tools/subprocess-sandbox";
import { MAX_NODE_TIMER_DELAY_MS } from "../../runtime/tools/subprocess-sandbox-lifecycle";
import { WorkerSandbox } from "../../runtime/tools/worker-sandbox";
import type { PluginLoadDiagnostics } from "./plugin-load-report";
import { resolvePluginRuntimeDirectory } from "./plugin-runtime";
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";
@@ -507,6 +510,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(
@@ -58,6 +58,16 @@ describe("SDK runtime build identity", () => {
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);
+1 -5
View File
@@ -983,6 +983,7 @@ export {
export {
CLINE_PLUGIN_RUNTIME_DIR_ENV,
resolvePluginRuntimeDirectory,
setPluginSandboxLauncher,
} from "./extensions/plugin/plugin-runtime";
export {
ALL_DEFAULT_TOOL_NAMES,
@@ -1040,11 +1041,6 @@ export {
} from "./extensions/tools";
export * from "./remote/remote-environments";
export { ensureLoginShellPath } from "./remote/shell-path";
export {
type EmbeddedResources,
materializeEmbeddedResources,
packEmbeddedResources,
} from "./runtime/embedded-resources";
export { isClineAccountFeatureEnabled } from "./services/feature-flags/cline-account-feature-flags";
export {
applyClineFeaturedModels,
@@ -1,75 +0,0 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, it } from "vitest";
import {
materializeEmbeddedResources,
packEmbeddedResources,
} from "./embedded-resources";
const roots: string[] = [];
function temporaryRoot() {
const root = mkdtempSync(join(tmpdir(), "cline-resources-"));
roots.push(root);
return root;
}
afterEach(() => {
for (const root of roots.splice(0))
rmSync(root, { recursive: true, force: true });
});
it("preserves binary assets and repairs damaged cached files", () => {
const bytes = Buffer.from([0, 255, 128, 10]);
const resources = packEmbeddedResources({
"assets/font.bin": bytes.toString("base64"),
});
const root = temporaryRoot();
const directory = materializeEmbeddedResources(resources, root);
expect(readFileSync(join(directory, "assets/font.bin"))).toEqual(bytes);
writeFileSync(join(directory, "assets/font.bin"), "broken");
expect(materializeEmbeddedResources(resources, root)).toBe(directory);
expect(readFileSync(join(directory, "assets/font.bin"))).toEqual(bytes);
});
it("retains the previous build when a new build is materialized", () => {
const root = temporaryRoot();
const oldDirectory = materializeEmbeddedResources(
packEmbeddedResources({
"index.js": Buffer.from("old").toString("base64"),
}),
root,
);
const newDirectory = materializeEmbeddedResources(
packEmbeddedResources({
"index.js": Buffer.from("new").toString("base64"),
}),
root,
);
expect(oldDirectory).not.toBe(newDirectory);
expect(readFileSync(join(oldDirectory, "index.js"), "utf8")).toBe("old");
});
it("rejects a mismatched archive checksum", () => {
const resources = packEmbeddedResources({ "index.js": "" });
expect(() =>
materializeEmbeddedResources(
{ ...resources, hash: "0".repeat(64) },
temporaryRoot(),
),
).toThrow("checksum");
});
it.each([
"../outside",
"/absolute",
"C:/absolute",
"..\\outside",
"a/../outside",
])("rejects resource path %s", (name) => {
expect(() =>
materializeEmbeddedResources(
packEmbeddedResources({ [name]: "" }),
temporaryRoot(),
),
).toThrow("resource path");
});
@@ -1,75 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join, win32 } from "node:path";
import { gunzipSync, gzipSync } from "node:zlib";
export interface EmbeddedResources {
hash: string;
payload: string;
}
/** Values are base64 so the same archive preserves JavaScript and binary assets. */
export function packEmbeddedResources(
files: Record<string, string>,
): EmbeddedResources {
const json = JSON.stringify(
Object.fromEntries(
Object.entries(files).sort(([a], [b]) => a.localeCompare(b)),
),
);
return {
hash: createHash("sha256").update(json).digest("hex"),
payload: gzipSync(json).toString("base64"),
};
}
/** Restores a build's exact resources, repairing corrupt files without modifying another build. */
export function materializeEmbeddedResources(
resources: EmbeddedResources,
root: string,
): string {
const json = gunzipSync(Buffer.from(resources.payload, "base64")).toString(
"utf8",
);
if (
!/^[a-f0-9]{64}$/.test(resources.hash) ||
createHash("sha256").update(json).digest("hex") !== resources.hash
) {
throw new Error("Invalid embedded resource checksum");
}
const files = JSON.parse(json) as Record<string, unknown>;
const entries = Object.entries(files).map(([name, encoded]) => {
if (
!name ||
name.startsWith("/") ||
win32.isAbsolute(name) ||
name.includes("\\") ||
name.split("/").some((part) => !part || part === ".." || part === ".") ||
typeof encoded !== "string"
) {
throw new Error("Invalid embedded resource path or content");
}
return [name, Buffer.from(encoded, "base64")] as const;
});
const directory = join(root, resources.hash);
for (const [name, content] of entries) {
const path = join(directory, name);
if (existsSync(path) && readFileSync(path).equals(content)) continue;
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const temporary = `${path}.${randomUUID()}.tmp`;
try {
writeFileSync(temporary, content, { mode: 0o600 });
renameSync(temporary, path);
} finally {
rmSync(temporary, { force: true });
}
}
return directory;
}
@@ -95,6 +95,21 @@ describe("SubprocessSandbox runtime resolution", () => {
}
});
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({
@@ -146,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}. */
@@ -155,14 +163,18 @@ 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,
});
// BUN_BE_BUN starts a plain runtime and does not retain compiled execArgv.
// Plain Bun mode does not retain compiled execArgv.
const runtimeArgs = isCompiledBunExecutable(runtimeExecutable)
? ["--use-system-ca", ...args]
: args;
@@ -289,15 +301,16 @@ 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);
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: isCompiledBunExecutable(executable)
? { ...env, BUN_BE_BUN: "1" }
: env,
env,
// Prevent a console window from flashing on Windows.
windowsHide: true,
});