diff --git a/apps/cli/src/runtime/prompt.test.ts b/apps/cli/src/runtime/prompt.test.ts index 9cd3c19f69..ccbfdb61d9 100644 --- a/apps/cli/src/runtime/prompt.test.ts +++ b/apps/cli/src/runtime/prompt.test.ts @@ -1,8 +1,17 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { buildUserInputMessage } from "./prompt"; +import { basename, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildUserInputMessage, resolveSystemPrompt } from "./prompt"; + +const workspaceDirectories: string[] = []; + +afterEach(() => { + for (const directory of workspaceDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); describe("buildUserInputMessage", () => { it("extracts image mentions into userImages", async () => { @@ -43,3 +52,39 @@ describe("buildUserInputMessage", () => { expect(result.userFiles).toEqual([filePath]); }); }); + +describe("resolveSystemPrompt workspace metadata", () => { + it("includes git remotes and the latest commit for Cline requests", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-")); + workspaceDirectories.push(cwd); + execFileSync("git", ["init"], { cwd }); + execFileSync("git", ["config", "user.email", "test@cline.bot"], { cwd }); + execFileSync("git", ["config", "user.name", "Cline Test"], { cwd }); + writeFileSync(join(cwd, "README.md"), "test\n"); + execFileSync("git", ["add", "README.md"], { cwd }); + execFileSync("git", ["commit", "-m", "initial"], { cwd }); + execFileSync("git", ["remote", "add", "origin", "https://example.com/cline/repo.git"], { cwd }); + const commit = execFileSync("git", ["rev-parse", "HEAD"], { + cwd, + encoding: "utf8", + }).trim(); + + const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" }); + + expect(prompt).toContain("origin: https://example.com/cline/repo.git"); + expect(prompt).toContain(commit); + }); + + it("includes parseable metadata outside a project", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-")); + workspaceDirectories.push(cwd); + + const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" }); + + expect(prompt).toContain("# Workspace Configuration"); + expect(prompt).toContain(JSON.stringify(cwd)); + expect(prompt).toContain(`"hint": "${basename(cwd)}"`); + expect(prompt).not.toContain("associatedRemoteUrls"); + expect(prompt).not.toContain("latestGitCommitHash"); + }); +}); diff --git a/apps/vscode/src/sdk/cline-session-factory.test.ts b/apps/vscode/src/sdk/cline-session-factory.test.ts index 51bf943afe..586e8078b0 100644 --- a/apps/vscode/src/sdk/cline-session-factory.test.ts +++ b/apps/vscode/src/sdk/cline-session-factory.test.ts @@ -355,6 +355,8 @@ describe("buildSessionConfig", () => { expect(config.providerId).toBe("cline") expect(config.apiKey).toBe("workos:test-access-token") + expect(config.systemPrompt).toContain("# Workspace Configuration") + expect(config.systemPrompt).toContain(JSON.stringify("/tmp/workspace")) }) it("resolves ClinePass from the shared Cline OAuth credentials", async () => { diff --git a/apps/vscode/src/sdk/cline-session-factory.ts b/apps/vscode/src/sdk/cline-session-factory.ts index c4cb053fcd..4ea7deeb98 100644 --- a/apps/vscode/src/sdk/cline-session-factory.ts +++ b/apps/vscode/src/sdk/cline-session-factory.ts @@ -9,6 +9,7 @@ // The factory does NOT handle UI concerns — that's the SdkController's job. import { + buildWorkspaceMetadata, type ClineCoreStartInput, type CoreSessionConfig, getProviderAuthHandler, @@ -25,7 +26,7 @@ import { MODEL_COLLECTIONS_BY_PROVIDER_ID, OLLAMA_DEFAULT_CONTEXT_WINDOW, } from "@cline/llms" -import { buildClineSystemPrompt } from "@cline/shared" +import { buildClineSystemPrompt, isClineProvider } from "@cline/shared" import type { ApiConfiguration } from "@shared/api" import { ClineClient } from "@shared/cline" import type { HistoryItem } from "@shared/HistoryItem" @@ -901,10 +902,17 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise { + it("redacts URL credentials while preserving SCP-style SSH remotes", () => { + const metadata = JSON.parse( + processWorkspaceInfo({ + rootPath: "/workspace/project", + associatedRemoteUrls: [ + "origin: https://user:token@github.com/cline/cline.git", + "backup: ssh://git:secret@example.com/cline/cline.git", + "mirror: git@github.com:cline/cline.git", + ], + }), + ); + + expect( + metadata.workspaces["/workspace/project"].associatedRemoteUrls, + ).toEqual([ + "origin: https://github.com/cline/cline.git", + "backup: ssh://example.com/cline/cline.git", + "mirror: git@github.com:cline/cline.git", + ]); + }); +}); + describe("buildClineSystemPrompt mode instructions", () => { it("explains the user_input mode attribute in act mode", () => { const prompt = buildClineSystemPrompt({ ...BASE_OPTIONS, mode: "act" }); @@ -78,6 +102,27 @@ describe("buildClineSystemPrompt mode instructions", () => { expect(rulesIndex).toBeLessThan(prompt.indexOf(MODE_TAG_INSTRUCTIONS)); }); + it("includes rich workspace metadata for the Cline backend parser", () => { + const metadata = JSON.stringify({ + workspaces: { + "/workspace/project": { + hint: "project", + associatedRemoteUrls: [ + "origin: https://github.com/cline/cline.git", + ], + latestGitCommitHash: "abc123", + }, + }, + }); + const prompt = buildClineSystemPrompt({ + ...BASE_OPTIONS, + providerId: "cline", + metadata, + }); + + expect(prompt).toContain(`# Workspace Configuration\n${metadata}`); + }); + it("respects an explicit override prompt without injecting mode sections", () => { const prompt = buildClineSystemPrompt({ ...BASE_OPTIONS, diff --git a/sdk/packages/shared/src/prompt/cline.ts b/sdk/packages/shared/src/prompt/cline.ts index e634c0ce1e..f8069d791b 100644 --- a/sdk/packages/shared/src/prompt/cline.ts +++ b/sdk/packages/shared/src/prompt/cline.ts @@ -58,13 +58,39 @@ export const PLAN_MODE_INSTRUCTIONS_MANUAL_SWITCH = `${PLAN_MODE_INSTRUCTIONS_BA Once you have presented your plan, end your turn and wait for the user's response. You do NOT have the ability to switch to act mode yourself -- the user must do it manually with the Plan/Act toggle once they are satisfied with the plan. If the task requires tools that are only available in act mode, ask the user to "toggle to Act mode" (use those words).`; +function redactRemoteUrlCredentials(remote: string): string { + const schemeEnd = remote.indexOf("://"); + if (schemeEnd < 1) return remote; + + const authorityStart = schemeEnd + 3; + let authorityEnd = authorityStart; + while (authorityEnd < remote.length) { + const char = remote[authorityEnd]; + if ( + char === "/" || + char === "?" || + char === "#" || + char.charCodeAt(0) <= 32 + ) { + break; + } + authorityEnd++; + } + + const userInfoEnd = remote.lastIndexOf("@", authorityEnd - 1); + if (userInfoEnd < authorityStart) return remote; + return remote.slice(0, authorityStart) + remote.slice(userInfoEnd + 1); +} + export function processWorkspaceInfo(info: WorkspaceInfo): string { return JSON.stringify( { workspaces: { [info.rootPath]: { hint: info.hint, - associatedRemoteUrls: info.associatedRemoteUrls, + associatedRemoteUrls: info.associatedRemoteUrls?.map( + redactRemoteUrlCredentials, + ), latestGitCommitHash: info.latestGitCommitHash, latestGitBranchName: info.latestGitBranchName, },