mirror of
https://github.com/cline/cline.git
synced 2026-09-17 17:45:33 +08:00
fix: stop Windows resolving bare program names through the workspace cwd (#14171)
* feat(shared): add disableCurrentDirectoryExecutableSearch for Windows
libuv resolves a bare program name on Windows by searching the child's
working directory before PATH, gated on the spawning process having
NoDefaultCurrentDirectoryInExePath defined. Cline spawns rg, git and
powershell with the user's workspace as cwd, so a repo shipping an rg.exe
would get it executed at index time. Expose a one-call helper that sets
Microsoft's documented opt-out, plus a Windows-only test that plants a
zero-byte cmd.exe and asserts the real one still runs.
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
* fix: stop Windows resolving bare program names through the workspace cwd
Call disableCurrentDirectoryExecutableSearch() at startup in every
process that hosts Cline core: the CLI (and the hub daemon it boots), the
desktop sidecar, the VS Code extension host, and the JetBrains cline-core
process. One environment variable covers every spawn site (file indexer,
search, simple-git, shell executor, hooks, MCP, taskkill) and is inherited
by children, which cmd.exe, libuv and Bun 1.4+ honor as well.
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
* fix: hand user-facing children the inherited NoDefaultCurrentDirectoryInExePath state
libuv reads the opt-out from the spawning process, so Cline's own protection
does not depend on children carrying it. But cmd.exe, Go, Bun 1.4 and
libuv-based children honor it as well, so letting them inherit Cline's
setting would silently change how a user's own bare program names resolve
(npm scripts running a cwd-local .bat through cmd.exe, MCP servers named
relative to cwd, hooks that spawn helpers).
disableCurrentDirectoryExecutableSearch() now latches the value the process
inherited, and withInheritedExecutableSearch() restores that state on the
child env at the spawn sites that run user-authored programs: the shell
executor, MCP stdio servers, hook subprocesses, the plugin subprocess
sandbox, and the VS Code host's HookProcess.
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
* Revert "fix: hand user-facing children the inherited NoDefaultCurrentDirectoryInExePath state"
This reverts commit b643517257.
---------
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
co-authored by
Saoud Rizwan
parent
3871851962
commit
173837314b
@@ -8,6 +8,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- On Windows, opening a repository that contains a file named `rg.exe`, `git.exe`, or `powershell.exe` no longer runs that file in place of the real program. Bare program names were resolved through the workspace directory before PATH, so a planted executable ran with your privileges as soon as the workspace was indexed. Cline now sets Windows' `NoDefaultCurrentDirectoryInExePath` opt-out at startup, in both the VS Code extension and the JetBrains core. Processes Cline launches inherit it, so inside a Command Prompt shell a program in the current directory now needs `.\` as it already does in PowerShell.
|
||||
- A model turn that fails mid-stream with a transient provider error is now retried up to three times with backoff instead of ending the task. A single rate-limit response forwarded by a gateway previously surfaced as a failed task. A turn that has already streamed output is never retried, so nothing is duplicated.
|
||||
- Terminal commands that succeed without printing anything (`git add -A` on a clean tree, for example) are now reported as empty output. They were treated as a shell-integration failure, which fed the model a snapshot of unrelated terminal scrollback prefixed with a warning that the output could not be captured, so silent commands intermittently looked like failures.
|
||||
- Checkpoints no longer re-hash every untracked file on each message. In workspaces holding large untracked directories this delayed every message by seconds to minutes; a persistent per-task index now lets git skip files it has already seen.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isMainThread } from "node:worker_threads";
|
||||
import {
|
||||
claimHubDaemonProcess,
|
||||
claimSupervisedConnectorProcess,
|
||||
disableCurrentDirectoryExecutableSearch,
|
||||
disposeAll,
|
||||
initVcr,
|
||||
setConnectorCliLaunchSpec,
|
||||
@@ -21,6 +22,9 @@ import { writeErr } from "./utils/output";
|
||||
// Set CLINE_VCR=record|playback and CLINE_VCR_CASSETTE=<path> to enable.
|
||||
initVcr(process.env.CLINE_VCR);
|
||||
|
||||
// Before any personality below can spawn a child with the workspace as cwd.
|
||||
disableCurrentDirectoryExecutableSearch();
|
||||
|
||||
if (!isMainThread) {
|
||||
// Worker imports of the bundled CLI entrypoint should not start the CLI.
|
||||
} else if (claimHubDaemonProcess()) {
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
setModelToolEnabledGlobally,
|
||||
watchManagedHubBuildMismatch,
|
||||
} from "@cline/core";
|
||||
import { captureSdkError, claimHubDaemonProcess } from "@cline/shared";
|
||||
import {
|
||||
captureSdkError,
|
||||
claimHubDaemonProcess,
|
||||
disableCurrentDirectoryExecutableSearch,
|
||||
} from "@cline/shared";
|
||||
import { prewarmWorkspaceMetadata } from "./chat-session";
|
||||
import { configureConnectorCliLaunch } from "./connectors";
|
||||
import {
|
||||
@@ -232,6 +236,7 @@ async function runEntrypoint(): Promise<void> {
|
||||
runTelemetrySelfcheck();
|
||||
return;
|
||||
}
|
||||
disableCurrentDirectoryExecutableSearch();
|
||||
// Claim rather than read: consuming the sentinel keeps daemon-hosted sessions
|
||||
// from handing it to every process they spawn.
|
||||
if (claimHubDaemonProcess()) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Import the module and reference it with the alias vscode in your code below
|
||||
|
||||
import assert from "node:assert"
|
||||
import { disableCurrentDirectoryExecutableSearch } from "@cline/shared"
|
||||
import * as vscode from "vscode"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { sendAccountButtonClickedEvent } from "./core/controller/ui/subscribeToAccountButtonClicked"
|
||||
@@ -66,6 +67,9 @@ export async function reportRolloutActivation(input: RolloutBundleActivation): P
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
const activationStartTime = performance.now()
|
||||
|
||||
// Before anything spawns with the workspace as cwd (rg, git, hooks, MCP).
|
||||
disableCurrentDirectoryExecutableSearch()
|
||||
|
||||
// 1. Set up HostProvider for VSCode
|
||||
// IMPORTANT: This must be done before any service can be registered
|
||||
setupHostProvider(context)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// I/O, so KEEP IT FIRST: do not add an import above this line that performs network
|
||||
// work at module-eval time, or proxy support silently breaks on JetBrains/CLI.
|
||||
import "@/shared/net"
|
||||
import { disableCurrentDirectoryExecutableSearch } from "@cline/shared"
|
||||
import { ExternalCommentReviewController } from "@hosts/external/ExternalCommentReviewController"
|
||||
import { ExternalEditPreview } from "@hosts/external/ExternalEditPreview"
|
||||
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
|
||||
@@ -30,6 +31,9 @@ let globalCoreConnection: CoreConnection | undefined
|
||||
let shutdownPromise: Promise<void> | undefined
|
||||
|
||||
async function main() {
|
||||
// Before anything spawns with the workspace as cwd (rg, git, hooks, MCP).
|
||||
disableCurrentDirectoryExecutableSearch()
|
||||
|
||||
// Capture the per-spawn secret and scrub it from the environment before
|
||||
// initialization can launch provider or MCP child processes, and before the
|
||||
// environment is logged below. Descendants must never inherit the credential;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## 0.0.83
|
||||
|
||||
- On Windows, a program planted in the workspace can no longer run in place of the real one. libuv resolves a bare program name (`rg`, `git`, `powershell`, anything the model names) by searching the child's working directory before PATH, and Cline spawns those with the user's repo as cwd, so opening a repo containing an `rg.exe` executed it during file indexing, before any approval. New `disableCurrentDirectoryExecutableSearch()` in `@cline/shared` sets Windows' documented `NoDefaultCurrentDirectoryInExePath` opt-out, which libuv, Bun 1.4+, cmd.exe and the C runtime all honor, so one call at process startup covers every spawn and every child. Embedders hosting `@cline/core` in their own Windows process should call it before anything spawns; the Cline CLI, desktop sidecar, VS Code extension and JetBrains core do
|
||||
- Hub-managed Agent Plugins. Packages under `~/.agents/plugins/*` on the hub host are discovered and validated from their root `plugin.json`; valid skills under `skills/` are exposed through the skills tool as `plugin-name:skill-name`, and stdio, Streamable HTTP, and legacy SSE servers from `mcp.json` are started without touching `cline_mcp_settings.json`. Workspace `.agents/plugins` directories are deliberately not scanned, so opening a repo cannot implicitly start repo-controlled MCP servers; extra roots require an explicit `agentPluginPaths`. Enablement lives in hub settings keyed by manifest name and publishes `settings.changed`, so clients no longer need their own loader or enablement store. Two bugs fixed along the way: `settings.toggle({type: "skills"})` wrote a `disabled` key into a plugin skill's SKILL.md frontmatter, which the strict Agent Skills parser then rejected so the skill silently vanished until hand-edited; and `InMemoryMcpManager.dispose()` aborted on the first `disconnect()` rejection, leaking every remaining server's process
|
||||
- A model turn that dies mid-stream with a transient provider error is now retried up to 3 times with exponential backoff instead of failing the whole run — a single forwarded 429 previously aborted the run outright. Retryability is read from the AI SDK's typed signals, and a turn is never retried once it has streamed any text, reasoning, media, or tool call, so nothing is duplicated. Model calls also now allow 5 SDK-level retries for request-start 429/5xx/network failures, up from 2
|
||||
- Streaming is no longer throttled by hook forwarding. The hub proxied every runtime event to client-contributed `onEvent` hooks as a capability round trip carrying the full session snapshot, with the agent loop awaiting it — so each streamed token cost a few hundred KB of serialization, a persisted row, and a blocking IPC hop. Per-chunk text, reasoning, and tool-update deltas are no longer forwarded to remote `onEvent` hooks; every other event still reaches hooks unchanged. The hub event log also moved to `synchronous = NORMAL`, dropping one fsync per appended delta
|
||||
|
||||
@@ -515,6 +515,10 @@ export {
|
||||
setConnectorCliLaunchSpec,
|
||||
setStartingConnectorInstance,
|
||||
} from "./runtime/hub-daemon-env";
|
||||
export {
|
||||
disableCurrentDirectoryExecutableSearch,
|
||||
NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV,
|
||||
} from "./runtime/windows-exe-path";
|
||||
export type {
|
||||
CaptureAgentUnexpectedReasoningTokensInput,
|
||||
CaptureSdkErrorInput,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
disableCurrentDirectoryExecutableSearch,
|
||||
NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV,
|
||||
} from "./windows-exe-path";
|
||||
|
||||
describe("disableCurrentDirectoryExecutableSearch", () => {
|
||||
it("defines the Windows opt-out variable", () => {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
disableCurrentDirectoryExecutableSearch({ env, platform: "win32" });
|
||||
expect(env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV]).toBe("1");
|
||||
});
|
||||
|
||||
it("leaves other platforms alone", () => {
|
||||
for (const platform of ["darwin", "linux"] as const) {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
disableCurrentDirectoryExecutableSearch({ env, platform });
|
||||
expect(env).toEqual({});
|
||||
}
|
||||
});
|
||||
|
||||
// Runs a bare `cmd` with a zero-byte cmd.exe planted in the working
|
||||
// directory. libuv picks the planted file first unless the opt-out is set,
|
||||
// and the spawn then fails on the invalid executable.
|
||||
it.runIf(process.platform === "win32")(
|
||||
"keeps a cmd.exe planted in the working directory from shadowing the real one",
|
||||
async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "cline-planted-exe-"));
|
||||
const previous =
|
||||
process.env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV];
|
||||
try {
|
||||
await writeFile(join(cwd, "cmd.exe"), "");
|
||||
delete process.env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV];
|
||||
expect(await runsRealCmd(cwd)).toBe(false);
|
||||
|
||||
disableCurrentDirectoryExecutableSearch();
|
||||
expect(await runsRealCmd(cwd)).toBe(true);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV];
|
||||
} else {
|
||||
process.env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV] = previous;
|
||||
}
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function runsRealCmd(cwd: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn("cmd", ["/d", "/c", "echo ok"], {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.on("error", () => resolve(false));
|
||||
child.on("close", (code) => resolve(code === 0 && stdout.trim() === "ok"));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Windows' documented opt-out from resolving bare program names through the
|
||||
* current directory. Its presence is what matters, not its value.
|
||||
*/
|
||||
export const NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV =
|
||||
"NoDefaultCurrentDirectoryInExePath";
|
||||
|
||||
/**
|
||||
* Stop Windows from resolving bare program names through the child's working
|
||||
* directory.
|
||||
*
|
||||
* When `child_process.spawn("rg", ...)` runs on Windows, libuv resolves the
|
||||
* bare name by looking in the child's cwd before walking PATH (it gates that
|
||||
* step on `NeedCurrentDirectoryForExePathW`, which reads this variable from
|
||||
* the spawning process). Cline spawns `rg`, `git`, `powershell` and
|
||||
* model-named programs with the user's workspace as cwd, so a repo that ships
|
||||
* an `rg.exe` would get it executed, with the user's privileges, the moment
|
||||
* the workspace opened. Bun's spawn (1.4+) honors the same variable, as do
|
||||
* cmd.exe and the C runtime, so children inherit the protection too.
|
||||
*
|
||||
* Call once at process startup, from the main thread (a worker thread's
|
||||
* `process.env` is a copy that native code never sees), before anything can
|
||||
* spawn. No-op off Windows.
|
||||
*/
|
||||
export function disableCurrentDirectoryExecutableSearch(
|
||||
options: {
|
||||
env?: Record<string, string | undefined>;
|
||||
platform?: NodeJS.Platform;
|
||||
} = {},
|
||||
): void {
|
||||
const { env = process.env, platform = process.platform } = options;
|
||||
if (platform !== "win32") return;
|
||||
env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV] = "1";
|
||||
}
|
||||
Reference in New Issue
Block a user