Compare commits

...
Author SHA1 Message Date
Cline Bot 23c60c95b6 fix(sdk): cache remote config outside workspaces 2026-05-16 03:19:23 +00:00
7 changed files with 196 additions and 4 deletions
+7
View File
@@ -34,6 +34,13 @@ coverage-unit
.worktrees
# Local Cline runtime/config artifacts. The SDK stores remote-config bundle
# caches globally, but keep workspace materializations and legacy cache paths
# out of source control because they can contain enterprise-managed config.
**/.cline/enterprise/
**/.cline/data/
**/.cline/tmp/
## Generated files ##
src/generated/
src/shared/proto/
+3
View File
@@ -7,6 +7,9 @@
.claude/**
.codex/**
CLAUDE.local.md
**/.cline/enterprise/**
**/.cline/data/**
**/.cline/tmp/**
out/
dist-standalone/
node_modules/
+2 -2
View File
@@ -184,8 +184,8 @@ different process.
### Remote-Config Managed Runtime
1. A host or core wrapper fetches a normalized `RemoteConfigBundle`.
2. `@cline/shared/remote-config` caches the bundle when configured.
3. Shared remote-config materializes managed rules/workflows/skills under workspace-local `.cline/<plugin>/`.
2. `@cline/shared/remote-config` caches the bundle under global Cline data (`~/.cline/data/remote-config/...` by default) so sensitive remote-config values such as prompt-upload credentials do not land in the workspace.
3. Shared remote-config materializes only discoverable managed rules/workflows/skills under workspace-local `.cline/<plugin>/`.
4. Shared remote-config derives generic OpenTelemetry config and session blob upload metadata from the bundle.
5. `@cline/core` exposes the app-facing integration wrapper that applies extensions, telemetry, and session metadata to `StartSessionInput`.
6. `@cline/core` consumes the prepared local overrides during local bootstrap.
@@ -41,6 +41,35 @@ export class FileRemoteConfigBundleStore implements RemoteConfigBundleStore {
}
}
export class MigratingRemoteConfigBundleStore implements RemoteConfigBundleStore {
constructor(
private readonly primary: RemoteConfigBundleStore,
private readonly legacy: RemoteConfigBundleStore,
) {}
async read(): Promise<RemoteConfigBundle | undefined> {
const primaryBundle = await this.primary.read();
if (primaryBundle) {
return primaryBundle;
}
const legacyBundle = await this.legacy.read();
if (legacyBundle) {
await this.primary.write(legacyBundle);
await this.legacy.clear().catch(() => undefined);
}
return legacyBundle;
}
async write(bundle: RemoteConfigBundle): Promise<void> {
await this.primary.write(bundle);
await this.legacy.clear().catch(() => undefined);
}
async clear(): Promise<void> {
await Promise.all([this.primary.clear(), this.legacy.clear()]);
}
}
export class FileSystemRemoteConfigManagedArtifactStore
implements RemoteConfigManagedArtifactStore
{
+32 -1
View File
@@ -1,8 +1,39 @@
import { createHash } from "node:crypto";
import path from "node:path";
import { resolveClineDataDir } from "../storage/paths";
import type { RemoteConfigManagedPaths } from "./bundle";
export const DEFAULT_REMOTE_CONFIG_PLUGIN_NAME = "remote-config";
function sanitizeCacheSegment(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]+/g, "-") || "remote-config";
}
export function resolveRemoteConfigBundleCachePath(input: {
workspacePath: string;
pluginName?: string;
}): string {
const pluginName = input.pluginName ?? DEFAULT_REMOTE_CONFIG_PLUGIN_NAME;
const workspaceCacheKey = createHash("sha256")
.update(path.resolve(input.workspacePath))
.digest("hex");
return path.join(
resolveClineDataDir(),
"remote-config",
sanitizeCacheSegment(pluginName),
workspaceCacheKey,
"bundle.json",
);
}
export function resolveLegacyWorkspaceRemoteConfigBundleCachePath(input: {
workspacePath: string;
pluginName?: string;
}): string {
const pluginName = input.pluginName ?? DEFAULT_REMOTE_CONFIG_PLUGIN_NAME;
return path.join(input.workspacePath, ".cline", pluginName, "cache", "bundle.json");
}
export function resolveRemoteConfigPaths(input: {
workspacePath: string;
pluginName?: string;
@@ -14,7 +45,7 @@ export function resolveRemoteConfigPaths(input: {
pluginPath,
workflowsPath: path.join(pluginPath, "workflows"),
skillsPath: path.join(pluginPath, "skills"),
bundleCachePath: path.join(pluginPath, "cache", "bundle.json"),
bundleCachePath: resolveRemoteConfigBundleCachePath(input),
manifestPath: path.join(pluginPath, "managed.json"),
rulesFilePath: path.join(pluginPath, "rules.md"),
};
@@ -7,6 +7,7 @@ import {
prepareRemoteConfigRuntime,
REMOTE_CONFIG_SESSION_BLOB_UPLOAD_METADATA_KEY,
readRemoteConfigSessionBlobUploadMetadata,
resolveRemoteConfigBundleCachePath,
} from "./index";
async function createTempWorkspace(): Promise<string> {
@@ -14,6 +15,117 @@ async function createTempWorkspace(): Promise<string> {
}
describe("remote-config runtime", () => {
it("caches remote-config bundles outside the workspace", async () => {
const workspacePath = await createTempWorkspace();
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "sdk-home-"));
const previousClineDataDir = process.env.CLINE_DATA_DIR;
process.env.CLINE_DATA_DIR = path.join(homeDir, ".cline", "data");
try {
const cachePath = resolveRemoteConfigBundleCachePath({
workspacePath,
pluginName: "enterprise",
});
expect(cachePath).toContain(
path.join(homeDir, ".cline", "data", "remote-config"),
);
expect(cachePath.startsWith(workspacePath)).toBe(false);
const prepared = await prepareRemoteConfigRuntime({
workspacePath,
pluginName: "enterprise",
controlPlane: {
name: "test",
async fetchBundle() {
return {
source: "test",
version: "1",
remoteConfig: {
version: "v1",
enterpriseTelemetry: {
promptUploading: {
enabled: true,
type: "s3_access_keys",
s3AccessSettings: {
bucket: "cline-prompts",
accessKeyId: "key",
secretAccessKey: "secret",
region: "us-west-2",
},
},
},
},
};
},
},
});
expect(prepared.paths.bundleCachePath).toBe(cachePath);
await expect(fs.readFile(cachePath, "utf8")).resolves.toContain(
"secret",
);
await expect(
fs.stat(path.join(workspacePath, ".cline", "enterprise", "cache")),
).rejects.toMatchObject({ code: "ENOENT" });
await expect(
fs.readFile(path.join(workspacePath, ".cline", "enterprise", "managed.json"), "utf8"),
).resolves.toContain("test");
} finally {
if (previousClineDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = previousClineDataDir;
}
}
});
it("migrates legacy workspace remote-config bundle caches", async () => {
const workspacePath = await createTempWorkspace();
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "sdk-home-"));
const previousClineDataDir = process.env.CLINE_DATA_DIR;
process.env.CLINE_DATA_DIR = path.join(homeDir, ".cline", "data");
try {
const legacyCachePath = path.join(
workspacePath,
".cline",
"enterprise",
"cache",
"bundle.json",
);
await fs.mkdir(path.dirname(legacyCachePath), { recursive: true });
await fs.writeFile(
legacyCachePath,
JSON.stringify({
source: "legacy",
version: "1",
remoteConfig: { version: "v1" },
}),
"utf8",
);
const prepared = await prepareRemoteConfigRuntime({
workspacePath,
pluginName: "enterprise",
});
expect(prepared.bundle?.source).toBe("legacy");
await expect(
fs.readFile(prepared.paths.bundleCachePath, "utf8"),
).resolves.toContain("legacy");
await expect(fs.stat(legacyCachePath)).rejects.toMatchObject({
code: "ENOENT",
});
} finally {
if (previousClineDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = previousClineDataDir;
}
}
});
it("materializes remote-config rules and workflows", async () => {
const workspacePath = await createTempWorkspace();
@@ -2,6 +2,7 @@ import type { AgentExtension } from "../agents/types";
import {
FileRemoteConfigBundleStore,
FileSystemRemoteConfigManagedArtifactStore,
MigratingRemoteConfigBundleStore,
} from "./artifact-store";
import type {
PreparedRemoteConfigRuntime,
@@ -16,6 +17,7 @@ import type {
import { FileSystemRemoteConfigPolicyMaterializer } from "./materializer";
import {
getRemoteConfigCommandDirectories,
resolveLegacyWorkspaceRemoteConfigBundleCachePath,
resolveRemoteConfigPaths,
} from "./paths";
import { DefaultRemoteConfigTelemetryAdapter } from "./telemetry";
@@ -137,7 +139,15 @@ export async function prepareRemoteConfigRuntime(
});
const bundleStore =
options.bundleStore ??
new FileRemoteConfigBundleStore(paths.bundleCachePath);
new MigratingRemoteConfigBundleStore(
new FileRemoteConfigBundleStore(paths.bundleCachePath),
new FileRemoteConfigBundleStore(
resolveLegacyWorkspaceRemoteConfigBundleCachePath({
workspacePath: options.workspacePath,
pluginName: options.pluginName,
}),
),
);
const artifactStore: RemoteConfigManagedArtifactStore =
options.artifactStore ?? new FileSystemRemoteConfigManagedArtifactStore();
const materializer: RemoteConfigPolicyMaterializer =