Files
cline/apps/cli/src/utils/enterprise.ts
T
BeeandSaoud Rizwan 3e06abc366 feat(desktop): support chat without workspaces (#12412)
* feat(core): support pathless sessions with temporary workspaces

* fix(desktop): mark editor icons as decorative

* fix(core): omit absent auth request IDs

* test(sdk): restore request_id auth telemetry param in core-events test

The branch's drive-by request_id -> requestId rename was dropped while
resolving the merge conflict with #12444 (which added requestIdDetails on
main), so the public captureAuthLoggedOut/captureAuthRefreshSoftFailure
API keeps its original parameter name.

* refactor(sdk): root pathless session workspaces under the cline data dir

Move the workspace created for pathless session starts from
<os.tmpdir()>/cline/sessions/<id>-temp/project to
<cline-data-dir>/workspaces/<id>/project (default
~/.cline/data/workspaces/<id>/project), per PR review:

- OS tmp reapers (macOS ~3-day purge, systemd-tmpfiles, reboot cleanup)
  silently delete user work created in 'New Project' sessions
- /tmp is a shared namespace on Linux: the first user to create /tmp/cline
  owns it (EACCES for everyone else), and guessable session IDs let a local
  attacker pre-create the workspace directory
- under the data dir the workspace shares the session store's lifecycle and
  the existing CLINE_DATA_DIR / CLINE_DIR overrides for tests and sandboxes

isTemporaryWorkspacePath now matches the .cline/data/workspaces/<id>/project
segment shape, and the -temp suffix is gone since the id-scoped directory no
longer needs to mark itself as reapable.

* feat(sdk): open pathless sessions in one shared chat workspace

Instead of minting a workspace directory per session
(<data>/workspaces/<session-id>/project), all sessions started without a
cwd/workspaceRoot now share <cline-data-dir>/workspaces/chat (default
~/.cline/data/workspaces/chat). Starting a pathless session seeds the
directory with an AGENTS.md rules file (only when missing, so users can
edit it) that tells the agent to treat the session as a chat: don't create
or edit files unprompted, ask where a project should live when the user
wants one built, and default to a new named folder inside the chat
directory that later sessions can reference.

This avoids unbounded per-session directory sprawl, gives chat sessions a
stable home the user can revisit, and groups them naturally in the desktop
sidebar. The desktop app now labels the shared workspace "Chat" (menu
action "Just chat") instead of "New Project", and isChatWorkspacePath
matches only the chat directory itself, so project folders created inside
it behave as regular workspaces.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 18:09:03 -07:00

187 lines
5.2 KiB
TypeScript

import {
buildRemoteConfigSessionBlobUploadMetadata,
ClineAccountService,
type ClineCoreStartInput,
createRemoteConfigSessionMessagesArtifactUploader,
ProviderSettingsManager,
prepareRemoteConfigCoreIntegration,
REMOTE_CONFIG_SESSION_BLOB_UPLOAD_METADATA_KEY,
readRemoteConfigSessionBlobUploadMetadata,
registerRemoteConfigSessionBlobUpload,
resolveLocalClineAuthToken,
type SessionMessagesArtifactUploader,
} from "@cline/core";
import {
getClineEnvironmentConfig,
type RemoteConfigBundle,
RemoteConfigSchema,
} from "@cline/shared";
import { getCliTelemetryService } from "./telemetry";
const initializedRemoteConfigKeys = new Set<string>();
let cliRemoteConfigBundlePromise:
| Promise<RemoteConfigBundle | undefined>
| undefined;
async function loadCliRemoteConfigBundle(): Promise<
RemoteConfigBundle | undefined
> {
cliRemoteConfigBundlePromise ??= loadCliRemoteConfigBundleUncached().finally(
() => {
cliRemoteConfigBundlePromise = undefined;
},
);
return await cliRemoteConfigBundlePromise;
}
async function loadCliRemoteConfigBundleUncached(): Promise<
RemoteConfigBundle | undefined
> {
const manager = new ProviderSettingsManager();
const settings = manager.getProviderSettings("cline");
const authToken = resolveLocalClineAuthToken(settings)?.trim();
if (!authToken) {
return undefined;
}
const service = new ClineAccountService({
apiBaseUrl:
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => authToken,
});
const response = await service.fetchRemoteConfig().catch(() => null);
if (!response?.enabled || !response.value?.trim()) {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(response.value);
} catch {
return undefined;
}
const remoteConfigResult = RemoteConfigSchema.safeParse(parsed);
if (!remoteConfigResult.success) {
return undefined;
}
return {
source: "cline-account",
version: response.organizationId?.trim() || "remote-config",
remoteConfig: remoteConfigResult.data,
};
}
export function createCliMessagesArtifactUploader() {
const uploader = createRemoteConfigSessionMessagesArtifactUploader();
const telemetry = getCliTelemetryService();
return {
async uploadMessagesFile(input) {
const metadata = readRemoteConfigSessionBlobUploadMetadata(input.row);
const startedAt = Date.now();
try {
await uploader.uploadMessagesFile(input);
if (!metadata) {
return;
}
telemetry?.capture?.({
event: "enterprise.prompt_upload_succeeded",
properties: {
sessionId: input.sessionId,
adapterType: metadata.storage.adapterType,
bucket: metadata.storage.bucket,
keyPrefix: metadata.keyPrefix,
bytes: input.contents.length,
durationMs: Date.now() - startedAt,
},
});
} catch (error) {
telemetry?.capture?.({
event: "enterprise.prompt_upload_failed",
properties: {
sessionId: input.sessionId,
adapterType: metadata?.storage.adapterType,
bucket: metadata?.storage.bucket,
keyPrefix: metadata?.keyPrefix,
bytes: input.contents.length,
durationMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
},
});
throw error;
}
},
} satisfies SessionMessagesArtifactUploader;
}
function captureRemoteConfigInitialized(bundle: RemoteConfigBundle): void {
const telemetry = getCliTelemetryService();
const key = `${bundle.source}:${bundle.version}`;
if (initializedRemoteConfigKeys.has(key)) {
return;
}
initializedRemoteConfigKeys.add(key);
const promptUploading =
bundle.remoteConfig?.enterpriseTelemetry?.promptUploading;
telemetry?.capture?.({
event: "enterprise.remote_config_initialized",
properties: {
source: bundle.source,
version: bundle.version,
hasPromptUploading: Boolean(promptUploading),
promptUploadingType: promptUploading?.type,
promptUploadingEnabled: promptUploading?.enabled !== false,
hasGlobalRules: (bundle.remoteConfig?.globalRules?.length ?? 0) > 0,
hasGlobalWorkflows:
(bundle.remoteConfig?.globalWorkflows?.length ?? 0) > 0,
},
});
}
export async function prepareCliEnterpriseIntegration(
input: ClineCoreStartInput,
) {
const workspacePath =
input.config.workspaceRoot?.trim() || input.config.cwd?.trim();
if (!workspacePath) {
return undefined;
}
const bundle = await loadCliRemoteConfigBundle();
if (!bundle) {
return undefined;
}
captureRemoteConfigInitialized(bundle);
return prepareRemoteConfigCoreIntegration({
workspacePath,
pluginName: "enterprise",
controlPlane: {
name: "cline-account",
async fetchBundle() {
return bundle;
},
},
requireBundle: false,
});
}
export async function resolveCliSessionMetadata(
sessionId?: string,
): Promise<Record<string, unknown> | undefined> {
const bundle = await loadCliRemoteConfigBundle();
if (bundle) {
captureRemoteConfigInitialized(bundle);
}
if (sessionId) {
registerRemoteConfigSessionBlobUpload(sessionId, bundle?.remoteConfig);
}
const blobUploadMetadata = buildRemoteConfigSessionBlobUploadMetadata(
bundle?.remoteConfig,
);
if (!blobUploadMetadata) {
return undefined;
}
return {
[REMOTE_CONFIG_SESSION_BLOB_UPLOAD_METADATA_KEY]: blobUploadMetadata,
};
}