From 4a2194460cfb724c8f23a3e89aef1933ec3c1cb4 Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 18 Aug 2026 08:50:18 +0200 Subject: [PATCH 01/12] Windows: WSL boundary as one wrap seam (#397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git, watchers and agents must run on the same side of the WSL boundary as the worktree. A repo reached over \\wsl$ from Windows is ~10x slower under Windows git and drops inotify events entirely. electron/wsl.js detects which side a project path lives on; ssh.js's wrapCommand — already the single wrap seam for ssh remotes — grows a second branch that turns a WSL-side project into `wsl.exe -d --cd -- `. runner.js's resolveSpawn and the two remoteHost branches around it move to one crossesBoundary() predicate so ssh and WSL stay in step. Pure detection, no spawning, platform injectable — the suite runs the win32 cases on macOS. Off win32 every path is "unix" and nothing changes. Co-Authored-By: Claude Opus 5 --- electron/runner.js | 38 +++++++++---- electron/ssh.js | 45 +++++++++++----- electron/test/wsl.test.js | 110 ++++++++++++++++++++++++++++++++++++++ electron/wsl.js | 96 +++++++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 23 deletions(-) create mode 100644 electron/test/wsl.test.js create mode 100644 electron/wsl.js diff --git a/electron/runner.js b/electron/runner.js index 5aedcf3b8..250a18161 100644 --- a/electron/runner.js +++ b/electron/runner.js @@ -38,6 +38,7 @@ const { } = require("./session-record.js"); const workflowEngine = require("./workflow.js"); const { wrapCommand } = require("./ssh.js"); +const { wslTarget } = require("./wsl.js"); const { killTree } = require("./proc.js"); const { runVerifyCommand, @@ -256,12 +257,13 @@ function resolveProvider(thread) { /** * Ensure the provider CLI binary is available; throw a clear Error if not. - * Remote projects run the CLI on the host, so a missing local binary is fine. + * Across a boundary the CLI runs on the other side (the ssh host, or inside + * the WSL distro), so a missing local binary is fine. * @param {import('./providers').ProviderEntry} entry - * @param {{ remoteHost?: string } | null} [project] + * @param {{ remoteHost?: string, path?: string } | null} [project] */ function assertProviderBinary(entry, project) { - if (project && project.remoteHost) return; + if (crossesBoundary(project)) return; if (!entry || entry.kind === "simulate") return; const bin = resolveBin(entry); if (!isBinAvailable(bin)) { @@ -272,9 +274,12 @@ function assertProviderBinary(entry, project) { } /** - * Single spawn seam: when the project is remote, spawn ssh with the wrapped - * CLI argv instead of the local binary. Local projects are unchanged. - * Remote cwd is process.cwd() because the project path is on the other host. + * Single spawn seam: when the project sits across a boundary — an ssh remote + * or the WSL side of a Windows machine (#397) — spawn the wrapper with the + * wrapped CLI argv instead of the local binary. Plain local projects are + * unchanged. Across a boundary cwd is process.cwd(), because the project path + * is not a directory this process can chdir into (another host, or a UNC + * \\wsl$ path); the wrap carries the real directory itself. * * @param {{ remoteHost?: string, remotePath?: string, path?: string } | null} project * @param {string} binary @@ -283,11 +288,21 @@ function assertProviderBinary(entry, project) { * @returns {{ binary: string, args: string[], cwd: string }} */ function resolveSpawn(project, binary, args, localCwd) { - if (!project || !project.remoteHost) { + if (!crossesBoundary(project)) { return { binary, args, cwd: localCwd }; } - const ssh = wrapCommand(project, binary, args); - return { binary: ssh.bin, args: ssh.args, cwd: process.cwd() }; + const wrapped = wrapCommand(project, binary, args); + return { binary: wrapped.bin, args: wrapped.args, cwd: process.cwd() }; +} + +/** + * True when this project's commands must run through a wrapper (ssh or + * wsl.exe) rather than as a plain local child. The one predicate every + * boundary-sensitive branch in the runner should use. + * @param {{ remoteHost?: string, path?: string } | null | undefined} project + */ +function crossesBoundary(project) { + return Boolean(project && (project.remoteHost || wslTarget(project))); } /** @@ -1865,14 +1880,15 @@ function createRunner(opts) { }); active.set(threadId, entry); - const spawn = project.remoteHost + const crossing = crossesBoundary(project); + const spawn = crossing ? resolveSpawn(project, command, [...args, String(prompt ?? "")], localCwd) : { binary: command, args, cwd: localCwd }; const handle = runAgent({ command: spawn.binary, args: spawn.args, prompt, - appendPrompt: Boolean(project.remoteHost) ? false : true, + appendPrompt: !crossing, cwd: spawn.cwd, onChunk: (text) => { const e = active.get(threadId); diff --git a/electron/ssh.js b/electron/ssh.js index 17b1eafb7..60256ee74 100644 --- a/electron/ssh.js +++ b/electron/ssh.js @@ -2,6 +2,7 @@ const path = require("node:path"); const { execFileSync: defaultExecFileSync, execFile: defaultExecFile } = require("node:child_process"); +const { wslTarget, buildWslCommand } = require("./wsl.js"); /** @type {typeof defaultExecFileSync} */ let execFileSyncImpl = defaultExecFileSync; @@ -45,6 +46,17 @@ function posixQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; } +/** + * A binary that must resolve on the OTHER side of a boundary: keep the CLI + * name, drop any local absolute path (it does not exist over there). + * @param {string} bin + */ +function basenameBin(bin) { + return typeof bin === "string" && (bin.includes("/") || bin.includes("\\")) + ? path.basename(bin) + : bin; +} + /** * Build an ssh argv that cds into remotePath then runs argv on the host. * @@ -73,11 +85,13 @@ function buildSshCommand(remoteHost, remotePath, argv) { } /** - * If the project lives on a remote, wrap bin+argv as an ssh command. - * Local projects are returned unchanged. Branch ONLY on project.remoteHost. + * The single wrap seam for both boundary kinds. If the project lives on an + * ssh remote, wrap bin+argv as an ssh command; if it lives on the WSL side of + * a Windows machine (#397), wrap it as `wsl.exe -d ... --cd ... --`. Plain + * local projects are returned unchanged. * - * A local absolute binary path will not exist on the remote, so remotes use - * the basename (the CLI name the remote PATH is expected to provide). + * A local absolute binary path will not exist on the other side, so both + * wraps use the basename (the CLI name that PATH over there provides). * * @param {{ remoteHost?: string, remotePath?: string, path?: string } | null | undefined} project * @param {string} bin @@ -86,15 +100,16 @@ function buildSshCommand(remoteHost, remotePath, argv) { */ function wrapCommand(project, bin, argv) { if (!project || !project.remoteHost) { - return { bin, args: Array.isArray(argv) ? argv : [] }; + const wsl = wslTarget(project); + if (!wsl) return { bin, args: Array.isArray(argv) ? argv : [] }; + return buildWslCommand(wsl.distro, wsl.linuxPath, [ + basenameBin(bin), + ...(Array.isArray(argv) ? argv : []), + ]); } const remotePath = project.remotePath || project.path || ""; - const remoteBin = - typeof bin === "string" && (bin.includes("/") || bin.includes("\\")) - ? path.basename(bin) - : bin; return buildSshCommand(project.remoteHost, remotePath, [ - remoteBin, + basenameBin(bin), ...(Array.isArray(argv) ? argv : []), ]); } @@ -113,7 +128,10 @@ function execCommand(project, bin, argv, execOpts) { const cmd = wrapCommand(project, bin, argv); const opts = { ...(execOpts || {}) }; if (opts.timeout == null) opts.timeout = SYNC_TIMEOUT_MS; - if (project && project.remoteHost) { + // A UNC \\wsl$ path is not a valid cwd for a Windows child process, and the + // local project path does not exist on an ssh remote. Both wraps carry the + // directory themselves (--cd / cd &&), so drop cwd for either. + if ((project && project.remoteHost) || wslTarget(project)) { delete opts.cwd; } return execFileSyncImpl(cmd.bin, cmd.args, opts); @@ -133,7 +151,10 @@ function execCommandAsync(project, bin, argv, execOpts) { const cmd = wrapCommand(project, bin, argv); const opts = { ...(execOpts || {}) }; if (opts.timeout == null) opts.timeout = SYNC_TIMEOUT_MS; - if (project && project.remoteHost) { + // A UNC \\wsl$ path is not a valid cwd for a Windows child process, and the + // local project path does not exist on an ssh remote. Both wraps carry the + // directory themselves (--cd / cd &&), so drop cwd for either. + if ((project && project.remoteHost) || wslTarget(project)) { delete opts.cwd; } return new Promise((resolve, reject) => { diff --git a/electron/test/wsl.test.js b/electron/test/wsl.test.js new file mode 100644 index 000000000..971bb08e8 --- /dev/null +++ b/electron/test/wsl.test.js @@ -0,0 +1,110 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { pathSide, isWindowsMount, wslTarget, buildWslCommand } = require("../wsl.js"); +const { wrapCommand } = require("../ssh.js"); + +// The suite runs on macOS/Linux, so every win32 case passes the platform in. +const WIN = "win32"; + +describe("pathSide", () => { + it("calls every path unix off win32", () => { + assert.deepEqual(pathSide("\\\\wsl$\\Ubuntu\\home\\me\\repo", "darwin"), { + side: "unix", + distro: null, + linuxPath: null, + }); + assert.equal(pathSide("/Users/me/repo", "linux").side, "unix"); + }); + + it("reads distro and linux path out of a \\\\wsl$ UNC", () => { + assert.deepEqual(pathSide("\\\\wsl$\\Ubuntu\\home\\me\\repo", WIN), { + side: "wsl", + distro: "Ubuntu", + linuxPath: "/home/me/repo", + }); + }); + + it("accepts the newer \\\\wsl.localhost form and hyphenated distros", () => { + assert.deepEqual(pathSide("\\\\wsl.localhost\\Ubuntu-22.04\\srv\\app", WIN), { + side: "wsl", + distro: "Ubuntu-22.04", + linuxPath: "/srv/app", + }); + }); + + it("calls a drive path windows-side", () => { + assert.deepEqual(pathSide("C:\\Users\\me\\repo", WIN), { + side: "windows", + distro: null, + linuxPath: null, + }); + }); + + it("survives a distro root with no trailing path", () => { + assert.equal(pathSide("\\\\wsl$\\Ubuntu", WIN).linuxPath, "/"); + assert.equal(pathSide("\\\\wsl$\\Ubuntu\\", WIN).linuxPath, "/"); + }); +}); + +describe("isWindowsMount", () => { + it("flags /mnt/ paths — the boundary crossed the other way", () => { + assert.equal(isWindowsMount("/mnt/c/Users/me/repo"), true); + assert.equal(isWindowsMount("/mnt/d"), true); + }); + + it("leaves real ext4 paths alone", () => { + assert.equal(isWindowsMount("/home/me/repo"), false); + assert.equal(isWindowsMount("/mnt/data/repo"), false); + assert.equal(isWindowsMount(null), false); + }); +}); + +describe("wslTarget", () => { + it("is null for an ssh remote — remoteHost wins", () => { + const project = { remoteHost: "dev@box", path: "\\\\wsl$\\Ubuntu\\home\\me\\repo" }; + assert.equal(wslTarget(project, WIN), null); + }); + + it("is null for a windows-side project", () => { + assert.equal(wslTarget({ path: "C:\\repo" }, WIN), null); + }); + + it("returns distro and linux path for a wsl-side project", () => { + assert.deepEqual(wslTarget({ path: "\\\\wsl$\\Ubuntu\\home\\me\\repo" }, WIN), { + distro: "Ubuntu", + linuxPath: "/home/me/repo", + }); + }); +}); + +describe("buildWslCommand", () => { + it("passes argv after -- with no shell and no quoting", () => { + const cmd = buildWslCommand("Ubuntu", "/home/me/my repo", ["git", "log", "--format=%s"]); + assert.equal(cmd.bin, "wsl.exe"); + assert.deepEqual(cmd.args, [ + "-d", + "Ubuntu", + "--cd", + "/home/me/my repo", + "--", + "git", + "log", + "--format=%s", + ]); + }); +}); + +describe("wrapCommand on this platform", () => { + it("leaves local projects byte-for-byte unchanged", () => { + const out = wrapCommand({ path: "/Users/me/repo" }, "git", ["status"]); + assert.deepEqual(out, { bin: "git", args: ["status"] }); + }); + + it("still wraps ssh remotes", () => { + const out = wrapCommand({ remoteHost: "dev@box", remotePath: "/srv/app" }, "/usr/bin/git", [ + "status", + ]); + assert.equal(out.bin, "ssh"); + assert.equal(out.args[out.args.length - 1], "cd '/srv/app' && 'git' 'status'"); + }); +}); diff --git a/electron/wsl.js b/electron/wsl.js new file mode 100644 index 000000000..01b40f355 --- /dev/null +++ b/electron/wsl.js @@ -0,0 +1,96 @@ +"use strict"; + +/** + * The WSL boundary (#397). + * + * On Windows a repo lives on one of two filesystems and the two are not + * interchangeable: the Windows drives (C:\...) and a WSL distro's ext4 root, + * reachable from Windows as the UNC path \\wsl$\\... (\\wsl.localhost + * on newer builds). Crossing the boundary for every file op is the single + * biggest source of slow git, broken file watching, and permission noise on + * Windows — a WSL-side repo driven by Windows git is ~10x slower and drops + * inotify events entirely. + * + * The rule: git, watchers and agents run on the SAME side as the worktree. + * So a project whose path is a \\wsl$ UNC gets every command wrapped in + * `wsl.exe -d --cd -- `, which is exactly the + * same seam ssh.js already uses for remote projects. `--` passes argv + * straight to exec with no shell in between, so nothing needs quoting. + * + * This module is pure: detection only, no spawning, no requires. ssh.js owns + * the wrapping so the two boundary kinds (ssh remote, WSL) share one seam. + */ + +/** \\wsl$\Ubuntu\home\me\repo or \\wsl.localhost\Ubuntu-22.04\home\me\repo */ +const WSL_UNC = /^\\\\wsl(?:\$|\.localhost)\\([^\\]+)\\?(.*)$/i; + +/** + * @typedef {object} PathSide + * @property {"unix" | "windows" | "wsl"} side which filesystem holds the path + * @property {string | null} distro WSL distro name, only when side is "wsl" + * @property {string | null} linuxPath the path as the distro sees it, only when side is "wsl" + */ + +/** + * Which side of the WSL boundary a project path lives on. + * + * Everything off win32 is "unix" — macOS and Linux have no boundary, and + * callers must stay byte-for-byte unchanged there. + * + * @param {string | null | undefined} p + * @param {NodeJS.Platform} [platform] + * @returns {PathSide} + */ +function pathSide(p, platform = process.platform) { + if (platform !== "win32") return { side: "unix", distro: null, linuxPath: null }; + const m = typeof p === "string" ? p.match(WSL_UNC) : null; + if (!m) return { side: "windows", distro: null, linuxPath: null }; + const rest = (m[2] || "").replace(/\\/g, "/").replace(/\/+$/, ""); + return { side: "wsl", distro: m[1], linuxPath: `/${rest}` }; +} + +/** + * A path inside the distro that actually points back at a Windows drive + * (/mnt/c/...). Worktrees must never land here: it is the WSL boundary + * crossed in the other direction, with the same slowness and no inotify. + * @param {string | null | undefined} linuxPath + */ +function isWindowsMount(linuxPath) { + return /^\/mnt\/[a-z](\/|$)/i.test(String(linuxPath || "")); +} + +/** + * The WSL wrap for a project, or null when the project is not WSL-side. + * Branch ONLY on this — never on process.platform at the call site. + * + * @param {{ path?: string, remoteHost?: string } | null | undefined} project + * @param {NodeJS.Platform} [platform] + * @returns {{ distro: string, linuxPath: string } | null} + */ +function wslTarget(project, platform = process.platform) { + if (!project || project.remoteHost) return null; + const info = pathSide(project.path, platform); + return info.side === "wsl" + ? { distro: /** @type {string} */ (info.distro), linuxPath: /** @type {string} */ (info.linuxPath) } + : null; +} + +/** + * Build the `wsl.exe` argv that runs argv inside the distro at linuxPath. + * No shell, so no quoting: --cd takes the directory and -- ends wsl's own + * flags. A local absolute binary path is a Windows path that does not exist + * inside the distro, so only the basename is used (same rule as ssh.js). + * + * @param {string} distro + * @param {string} linuxPath + * @param {string[]} argv + * @returns {{ bin: "wsl.exe", args: string[] }} + */ +function buildWslCommand(distro, linuxPath, argv) { + return { + bin: "wsl.exe", + args: ["-d", String(distro), "--cd", String(linuxPath), "--", ...(Array.isArray(argv) ? argv : [])], + }; +} + +module.exports = { pathSide, isWindowsMount, wslTarget, buildWslCommand }; From cb85c11367533894323b317af24ec57ce3c23630 Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 18 Aug 2026 09:01:37 +0200 Subject: [PATCH 02/12] feat(threads): per-thread sandbox badge (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show whether the next run is actually confined (agent × repo location) as a header badge. The resolver reports what we pass today: only Codex is sandboxed (its default CLI sandbox); everyone else is an honest no. --- electron/runner.js | 2 + electron/sandbox.js | 139 ++++++++++++++++++++ electron/services.js | 32 ++++- electron/test/sandbox.test.js | 183 +++++++++++++++++++++++++++ electron/test/services.test.js | 7 + src/components/ThreadView.module.css | 17 +++ src/components/ThreadView.tsx | 21 +++ src/shared/ipc.ts | 15 +++ test/threadView.test.tsx | 44 +++++++ 9 files changed, 455 insertions(+), 5 deletions(-) create mode 100644 electron/sandbox.js create mode 100644 electron/test/sandbox.test.js diff --git a/electron/runner.js b/electron/runner.js index 250a18161..15424c57f 100644 --- a/electron/runner.js +++ b/electron/runner.js @@ -39,6 +39,7 @@ const { const workflowEngine = require("./workflow.js"); const { wrapCommand } = require("./ssh.js"); const { wslTarget } = require("./wsl.js"); +const { resolveSandbox } = require("./sandbox.js"); const { killTree } = require("./proc.js"); const { runVerifyCommand, @@ -4654,6 +4655,7 @@ module.exports = { toWorkflowView: mapWorkflowView, resolveProvider, resolveSpawn, + resolveSandbox, ADJECTIVES, NOUNS, /** @internal test/diagnostics */ diff --git a/electron/sandbox.js b/electron/sandbox.js new file mode 100644 index 000000000..27451d972 --- /dev/null +++ b/electron/sandbox.js @@ -0,0 +1,139 @@ +"use strict"; + +/** + * Per-thread sandbox (#436 / #397). + * + * Whether a run is actually confined depends on the AGENT crossed with the + * REPO LOCATION, not a global setting. This module only reports what we + * actually pass today (providers.js buildArgs). It does not invent flags. + * + * Provider truth, checked against buildArgs + live CLI help: + * claude --permission-mode , no OS-sandbox flag + * grok default/acceptEdits remapped to --permission-mode auto (tools + * unprompted); plan/bypassPermissions pass through; no --sandbox + * kimi -p cannot combine with -y/--auto; permission mode ignored + * opencode run --format json; no permission or sandbox flags + * codex exec --json --skip-git-repo-check; Solenta never passes -s or + * --dangerously-bypass-approvals-and-sandbox, so the CLI's own + * default sandbox is the only confinement + * simulate / generic / unknown: nothing + * + * Location is a separate confinement story the badge must tell apart: + * ssh remote → another host; WSL UNC on win32 → inside the distro; + * otherwise the process is a plain local child with the user's filesystem. + * + * Pure: no spawning, platform injected (defaults to process.platform). + */ + +const { wslTarget } = require("./wsl.js"); + +/** + * @typedef {object} SandboxInfo + * @property {boolean} sandboxed + * @property {string} reason + */ + +/** + * @param {{ remoteHost?: string, path?: string } | null | undefined} project + * @param {NodeJS.Platform} [platform] + */ +function locationReason(project, platform) { + if (project && project.remoteHost) return "runs on ssh remote"; + const wsl = wslTarget(project, platform); + if (wsl) return `runs inside WSL ${wsl.distro}`; + return "runs locally as your user"; +} + +/** + * Agent-side answer only. Location is joined in resolveSandbox. + * @param {string | null | undefined} provider + * @param {string | null | undefined} permissionMode + * @returns {SandboxInfo} + */ +function agentSandbox(provider, permissionMode) { + const id = String(provider || "claude"); + const mode = String(permissionMode || "default"); + + if (id === "codex") { + // permissionMode is not on the argv, so bypassPermissions does not + // lift Codex's default sandbox. + return { + sandboxed: true, + reason: "Codex default sandbox (no --sandbox override)", + }; + } + + if (id === "claude") { + if (mode === "bypassPermissions") { + return { + sandboxed: false, + reason: "Claude --permission-mode bypassPermissions (not gated)", + }; + } + return { + sandboxed: false, + reason: `Claude --permission-mode ${mode}; no OS sandbox`, + }; + } + + if (id === "grok") { + if (mode === "default" || mode === "acceptEdits") { + return { + sandboxed: false, + reason: "Grok --permission-mode auto (tools unprompted); no --sandbox", + }; + } + if (mode === "bypassPermissions") { + return { + sandboxed: false, + reason: "Grok --permission-mode bypassPermissions (not gated); no --sandbox", + }; + } + return { + sandboxed: false, + reason: `Grok --permission-mode ${mode}; no --sandbox`, + }; + } + + if (id === "kimi") { + return { + sandboxed: false, + reason: "Kimi -p ignores permission mode; tools run unprompted", + }; + } + + if (id === "opencode") { + return { + sandboxed: false, + reason: "OpenCode run has no permission or sandbox flags", + }; + } + + if (id === "simulate") { + return { sandboxed: false, reason: "Simulate; no sandbox" }; + } + + return { sandboxed: false, reason: `${id} has no sandbox flags` }; +} + +/** + * @param {{ + * provider?: string | null, + * permissionMode?: string | null, + * project?: { remoteHost?: string, path?: string } | null, + * } | null | undefined} input + * @param {NodeJS.Platform} [platform] + * @returns {SandboxInfo} + */ +function resolveSandbox(input, platform = process.platform) { + const agent = agentSandbox( + input && input.provider, + input && input.permissionMode, + ); + return { + sandboxed: agent.sandboxed, + reason: `${agent.reason}; ${locationReason(input && input.project, platform)}`, + }; +} + +module.exports = { resolveSandbox }; diff --git a/electron/services.js b/electron/services.js index be91a789a..3a0500aa0 100644 --- a/electron/services.js +++ b/electron/services.js @@ -11,6 +11,7 @@ const { const { getMemoryStatus } = require("./memory-sup.js"); const { execCommandAsync } = require("./ssh.js"); const { normalizeCommand, runVerifyCommand } = require("./verify.js"); +const { resolveSandbox } = require("./sandbox.js"); const PERMISSION_MODES = new Set([ "default", @@ -464,7 +465,8 @@ function setPermissionMode(store, input) { } const updated = store.updateThread(threadId, { permissionMode: mode }); store.save(); - return updated ? { ...updated } : { ...thread, permissionMode: mode }; + const row = updated || { ...thread, permissionMode: mode }; + return decorateThread(store, row); } /** @@ -969,7 +971,7 @@ function setProvider(store, input) { const modelProvided = Object.prototype.hasOwnProperty.call(input, "model"); if (!providerProvided && !modelProvided) { - return { ...thread }; + return decorateThread(store, thread); } const nextProvider = providerProvided ? input.provider : thread.provider; @@ -1021,7 +1023,8 @@ function setProvider(store, input) { const updated = store.updateThread(threadId, patch); store.save(); - return updated ? { ...updated } : { ...thread, ...patch }; + const row = updated || { ...thread, ...patch }; + return decorateThread(store, row); } /** @@ -2257,8 +2260,27 @@ function removeProject(store, input, opts) { /** * @param {import('./store').Store} store */ +/** + * Attach the computed sandbox badge. Always a new object so a store row + * never grows a persisted `sandbox` field. + * @param {import('./store').Store} store + * @param {object} thread + */ +function decorateThread(store, thread) { + if (!thread) return thread; + const project = store.getProject(thread.projectId); + return { + ...thread, + sandbox: resolveSandbox({ + provider: thread.provider, + permissionMode: thread.permissionMode, + project, + }), + }; +} + function listThreads(store) { - return store.getThreads().slice(); + return store.getThreads().map((t) => decorateThread(store, t)); } /** @@ -2330,7 +2352,7 @@ function getThreadDetail(store, threadId, workflow = null, opts) { } const current = store.getThread(threadId) || thread; return { - thread: { ...current }, + thread: decorateThread(store, current), messages: store.getMessages(threadId).slice(), workLog: store.getWorkLog(threadId).slice(), workflow: workflow ?? null, diff --git a/electron/test/sandbox.test.js b/electron/test/sandbox.test.js new file mode 100644 index 000000000..0dba5cab1 --- /dev/null +++ b/electron/test/sandbox.test.js @@ -0,0 +1,183 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { resolveSandbox } = require("../sandbox.js"); + +// Suite runs on macOS/Linux, so every win32 case passes the platform in. +const WIN = "win32"; +const local = { path: "/Users/me/repo" }; +const wsl = { path: "\\\\wsl$\\Ubuntu\\home\\me\\repo" }; +const remote = { remoteHost: "dev@box", remotePath: "/srv/app", path: "/unused" }; + +function reason(over, platform) { + return resolveSandbox(over, platform).reason; +} + +describe("resolveSandbox location", () => { + it("calls a local project local, even on win32", () => { + assert.match( + reason({ provider: "claude", project: { path: "C:\\repo" } }, WIN), + /runs locally as your user$/, + ); + }); + + it("names the WSL distro only on win32", () => { + assert.match( + reason({ provider: "claude", project: wsl }, WIN), + /runs inside WSL Ubuntu$/, + ); + // Off win32 a \\\\wsl$ path is just another local path. + assert.match( + reason({ provider: "claude", project: wsl }, "darwin"), + /runs locally as your user$/, + ); + }); + + it("calls an ssh remote out, even when the path looks like WSL", () => { + assert.match( + reason( + { + provider: "claude", + project: { + remoteHost: "dev@box", + path: "\\\\wsl$\\Ubuntu\\home\\me\\repo", + }, + }, + WIN, + ), + /runs on ssh remote$/, + ); + }); +}); + +describe("resolveSandbox per provider (local)", () => { + it("claude: permission-mode is a gate, not an OS sandbox", () => { + const def = resolveSandbox({ + provider: "claude", + permissionMode: "default", + project: local, + }); + assert.equal(def.sandboxed, false); + assert.match(def.reason, /Claude --permission-mode default; no OS sandbox/); + + const bypass = resolveSandbox({ + provider: "claude", + permissionMode: "bypassPermissions", + project: local, + }); + assert.equal(bypass.sandboxed, false); + assert.match(bypass.reason, /bypassPermissions \(not gated\)/); + }); + + it("codex: default CLI sandbox, permissionMode does not lift it", () => { + for (const permissionMode of [ + "default", + "acceptEdits", + "plan", + "bypassPermissions", + ]) { + const out = resolveSandbox({ + provider: "codex", + permissionMode, + project: local, + }); + assert.equal(out.sandboxed, true, permissionMode); + assert.match(out.reason, /Codex default sandbox/); + } + }); + + it("grok: asking modes become auto; never passes --sandbox", () => { + const asking = resolveSandbox({ + provider: "grok", + permissionMode: "default", + project: local, + }); + assert.equal(asking.sandboxed, false); + assert.match(asking.reason, /--permission-mode auto \(tools unprompted\)/); + + const accept = resolveSandbox({ + provider: "grok", + permissionMode: "acceptEdits", + project: local, + }); + assert.equal(accept.sandboxed, false); + assert.match(accept.reason, /--permission-mode auto/); + + const plan = resolveSandbox({ + provider: "grok", + permissionMode: "plan", + project: local, + }); + assert.equal(plan.sandboxed, false); + assert.match(plan.reason, /--permission-mode plan; no --sandbox/); + + const bypass = resolveSandbox({ + provider: "grok", + permissionMode: "bypassPermissions", + project: local, + }); + assert.equal(bypass.sandboxed, false); + assert.match(bypass.reason, /bypassPermissions \(not gated\)/); + }); + + it("kimi: permission mode is ignored", () => { + const out = resolveSandbox({ + provider: "kimi", + permissionMode: "bypassPermissions", + project: local, + }); + assert.equal(out.sandboxed, false); + assert.match(out.reason, /Kimi -p ignores permission mode/); + }); + + it("opencode and simulate have no sandbox", () => { + assert.equal( + resolveSandbox({ provider: "opencode", project: local }).sandboxed, + false, + ); + assert.match( + reason({ provider: "opencode", project: local }), + /OpenCode run has no permission or sandbox flags/, + ); + assert.equal( + resolveSandbox({ provider: "simulate", project: local }).sandboxed, + false, + ); + assert.match(reason({ provider: "simulate", project: local }), /Simulate/); + }); + + it("unknown / generic is an honest no", () => { + const out = resolveSandbox({ provider: "generic", project: local }); + assert.equal(out.sandboxed, false); + assert.match(out.reason, /generic has no sandbox flags/); + }); + + it("missing provider is treated as claude", () => { + const out = resolveSandbox({ project: local }); + assert.equal(out.sandboxed, false); + assert.match(out.reason, /Claude --permission-mode default/); + }); +}); + +describe("resolveSandbox agent x location", () => { + it("keeps Codex sandboxed inside WSL and on ssh", () => { + const inside = resolveSandbox( + { provider: "codex", project: wsl }, + WIN, + ); + assert.equal(inside.sandboxed, true); + assert.match(inside.reason, /inside WSL Ubuntu/); + + const ssh = resolveSandbox({ provider: "codex", project: remote }); + assert.equal(ssh.sandboxed, true); + assert.match(ssh.reason, /ssh remote/); + }); + + it("does not pretend WSL or ssh is a repo sandbox for claude", () => { + const inside = resolveSandbox( + { provider: "claude", permissionMode: "default", project: wsl }, + WIN, + ); + assert.equal(inside.sandboxed, false); + assert.match(inside.reason, /no OS sandbox; runs inside WSL Ubuntu/); + }); +}); diff --git a/electron/test/services.test.js b/electron/test/services.test.js index f03672731..6000c6762 100644 --- a/electron/test/services.test.js +++ b/electron/test/services.test.js @@ -174,6 +174,9 @@ describe("services", () => { mode: "acceptEdits", }); assert.equal(updated.permissionMode, "acceptEdits"); + assert.equal(updated.sandbox.sandboxed, false); + assert.match(updated.sandbox.reason, /--permission-mode acceptEdits/); + assert.equal(store.getThread(thread.id).sandbox, undefined); assert.throws( () => services.setPermissionMode(store, { @@ -687,6 +690,10 @@ describe("services", () => { assert.deepEqual(detail.workLog, []); assert.equal(detail.workflow, null); assert.equal(detail.usage, null); + // #436: computed on the way out, never written back to the store row. + assert.equal(detail.thread.sandbox.sandboxed, false); + assert.match(detail.thread.sandbox.reason, /Claude --permission-mode default/); + assert.equal(store.getThread(thread.id).sandbox, undefined); }); it("gitStatus reports branch and dirty flag", async () => { diff --git a/src/components/ThreadView.module.css b/src/components/ThreadView.module.css index 46ab76f4f..ab917bc2e 100644 --- a/src/components/ThreadView.module.css +++ b/src/components/ThreadView.module.css @@ -1033,6 +1033,23 @@ color: var(--text); } +.sandboxBadge { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + font-size: 11px; + font-weight: 550; + color: var(--text-muted); + white-space: nowrap; +} + +.sandboxBadge[data-sandboxed="yes"] { + color: var(--green); +} + .contextRing { display: inline-flex; align-items: center; diff --git a/src/components/ThreadView.tsx b/src/components/ThreadView.tsx index 485059905..94877e23a 100644 --- a/src/components/ThreadView.tsx +++ b/src/components/ThreadView.tsx @@ -83,6 +83,26 @@ const STICK_BOTTOM_PX = 80; const RING_R = 8; const RING_C = 2 * Math.PI * RING_R; +/** Per-thread sandbox yes/no; reason lives on hover (#436). */ +function SandboxBadge({ + sandbox, +}: { + sandbox: { sandboxed: boolean; reason: string }; +}) { + const label = sandbox.sandboxed ? "Sandboxed" : "Not sandboxed"; + return ( + + {label} + + ); +} + /** Small context-fill ring + percent for the thread header. */ function ContextRingBadge({ ring }: { ring: ContextRingView }) { return ( @@ -2603,6 +2623,7 @@ export const ThreadView = memo(function ThreadView({ )}
+ {thread.sandbox && } {ring && } {onStartSpec && !thread.spec && (
+ {doctorIssues ? ( + <> +

+ Project added. Fix these before they bite: +

+
    + {doctorIssues.map((check) => ( +
  • + {check.message} + {check.fix ? ( +

    {check.fix}

    + ) : null} +
  • + ))} +
+
+ +
+ + ) : ( + <>
+ + )}
diff --git a/src/components/SettingsModal.module.css b/src/components/SettingsModal.module.css index d0b246801..020a7c727 100644 --- a/src/components/SettingsModal.module.css +++ b/src/components/SettingsModal.module.css @@ -192,6 +192,36 @@ color: #fca5a5; } +.doctorLead { + margin: 0; + font-size: 13px; + line-height: 1.4; + color: var(--text); +} + +.doctorList { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 10px; +} + +.doctorItem { + display: flex; + flex-direction: column; + gap: 2px; +} + +.doctorFix { + margin: 0; + font-size: 12px; + line-height: 1.35; + color: var(--text-muted); + font-family: var(--mono); +} + .memoryRow { display: flex; align-items: flex-start; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 9d0f22c0a..4e5c87d8e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -37,6 +37,25 @@ export interface ProjectInfo { * so no commit is ever lost to GC. */ worktreeRetention?: number; + /** + * Windows doctor (#435). Only on the add/create return value when the + * host is win32. Never persisted. Failed checks do not reject the add. + */ + windowsDoctor?: WindowsDoctorReport; +} + +/** One Windows doctor probe (#435). Advisory — never blocks add. */ +export interface WindowsDoctorCheck { + id: "longpaths" | "gitBash" | "node22" | "wslBoundary"; + ok: boolean; + message: string; + /** What to do. Only when !ok. */ + fix?: string; +} + +/** Result of the win32 project-add doctor. Absent off win32 and on list(). */ +export interface WindowsDoctorReport { + checks: WindowsDoctorCheck[]; } /** Optional remotes for projects.add. Empty/absent = local project. */ diff --git a/test/windowsDoctor.test.tsx b/test/windowsDoctor.test.tsx new file mode 100644 index 000000000..1c72327eb --- /dev/null +++ b/test/windowsDoctor.test.tsx @@ -0,0 +1,96 @@ +/** + * Windows doctor (#435): after a successful add, failed checks stay in + * the add-project modal as a short list. Continue dismisses it. The + * project is already added — the doctor is advisory. + * + * Run: node --import=./test/support/render.mjs --test test/windowsDoctor.test.tsx + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { mount } from "./support/dom.ts"; +import { createFakeCoder, installFakeCoder } from "./support/fakeCoder.ts"; +import App from "../src/App"; +import type { ProjectInfo } from "../src/shared/ipc"; + +describe("Windows doctor on project add", () => { + it("keeps the modal open with the failed checks and closes on Continue", async () => { + const fake = createFakeCoder({ projects: [], threads: [] }); + const origAdd = fake.api.projects.add.bind(fake.api.projects); + fake.api.projects.add = async (path, opts) => { + const added = await origAdd(path, opts); + const withDoctor: ProjectInfo = { + ...added, + windowsDoctor: { + checks: [ + { + id: "longpaths", + ok: false, + message: "Git long paths are off", + fix: "Run: git config --global core.longpaths true", + }, + { + id: "gitBash", + ok: false, + message: "No POSIX shell (Git Bash) on PATH", + fix: "Install Git for Windows and keep Git Bash on PATH", + }, + { id: "node22", ok: true, message: "Node 22 is available" }, + { id: "wslBoundary", ok: true, message: "Repo is on a Windows drive" }, + ], + }, + }; + return withDoctor; + }; + + const shell = await mount(
); + installFakeCoder(fake); + shell.unmount(); + const m = await mount(); + + await m.click(m.byText("Add project")); + await m.type(m.query("[data-add-project-path-input]"), "C:\\\\repo"); + await m.click(m.query("[data-add-project-path-submit]")); + + assert.equal(fake.of("projects.add").length, 1, "add must still run"); + const list = m.query("[data-windows-doctor]"); + assert.ok(list, "failed checks must stay on the add modal"); + const longpaths = m.query('[data-windows-doctor-check="longpaths"]'); + assert.ok(longpaths); + assert.match(longpaths.textContent || "", /Git long paths are off/); + assert.match(list.textContent || "", /core\.longpaths true/); + assert.ok(m.query('[data-windows-doctor-check="gitBash"]')); + assert.equal( + m.query('[data-windows-doctor-check="node22"]'), + null, + "green checks stay off the list", + ); + + await m.click(m.query("[data-windows-doctor-continue]")); + assert.equal( + m.query("[data-add-project-path]"), + null, + "Continue must close the modal", + ); + m.unmount(); + }); + + it("still closes the modal when the doctor reports nothing wrong", async () => { + const fake = createFakeCoder({ projects: [], threads: [] }); + const shell = await mount(
); + installFakeCoder(fake); + shell.unmount(); + const m = await mount(); + + await m.click(m.byText("Add project")); + await m.type(m.query("[data-add-project-path-input]"), "/Users/demo/clean-repo"); + await m.click(m.query("[data-add-project-path-submit]")); + + assert.equal(m.query("[data-windows-doctor]"), null); + assert.equal( + m.query("[data-add-project-path]"), + null, + "clean add must still close the modal", + ); + m.unmount(); + }); +}); From 954a62c93b54a35c874aec0a411343c6659e27e3 Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 18 Aug 2026 09:05:55 +0200 Subject: [PATCH 04/12] Windows: CI matrix on windows-latest, no fake winget (#437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add windows-latest to the test job so win32 paths stay honest. Make npm scripts portable ( --prefix, --import for CODER_GROK_MCP_DISABLE ) instead of pinning the Windows job to bash. Electron on win32 only runs the WSL contract: the rest of that suite spawns shebang files as CODER_*_BIN, and CreateProcess will not execute them. There is no node-pty in the repo, so a ConPTY rebuild step would be a no-op. Winget publishes an installer. package-cross.sh produces an unsigned portable zip, not one — do not add a manifest that points at it. --- .github/workflows/test.yml | 13 ++++++++++++- README.md | 3 ++- memory-server/test/server.test.js | 7 +++++-- package.json | 8 ++++---- scripts/package-cross.sh | 9 +++++++++ scripts/test-electron.js | 24 ++++++++++++++++++++++++ test/support/disable-grok-mcp.mjs | 3 +++ 7 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 scripts/test-electron.js create mode 100644 test/support/disable-grok-mcp.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0ac28ebf0..713b8316e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,12 +1,23 @@ # The gate: every suite on every push. `npm run acceptance` stays manual — # it spends a real claude turn and needs credentials. +# +# windows-latest keeps the win32 paths honest (WSL wrap in electron/wsl.js). +# There is no node-pty in this repo, so the ConPTY rebuild named in #397 is +# a no-op — do not add a rebuild step. Most of the electron suite still +# cannot run on win32 (shebang fakes); scripts/test-electron.js skips those +# and runs the WSL contract only. Winget is blocked on an installer — see +# the note in scripts/package-cross.sh. name: test on: push jobs: test: - runs-on: macos-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/README.md b/README.md index 5723195e2..5107e497e 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,8 @@ Grab an archive from the [latest release](https://github.com/currentbits/solenta `Solenta.app` to `/Applications`. Not notarized, so right-click → **Open** the first time (or `xattr -dr com.apple.quarantine /Applications/Solenta.app`). - **Windows (x64)** — `Solenta--win32-x64.zip`. Unzip anywhere, run - `solenta.exe`. Unsigned, so SmartScreen asks once. + `solenta.exe`. Unsigned, so SmartScreen asks once. There is no installer + and no winget package; see the note in `scripts/package-cross.sh`. - **Linux (x64)** — `Solenta--linux-x64.tar.gz`. Extract, run `./solenta`. ### Updates diff --git a/memory-server/test/server.test.js b/memory-server/test/server.test.js index 3befa0b44..dd9136d44 100644 --- a/memory-server/test/server.test.js +++ b/memory-server/test/server.test.js @@ -213,8 +213,11 @@ describe('config validation', () => { assert.ok(cfg.port >= 49500 && cfg.port <= 49999) assert.equal(cfg.token.length, 64) assert.ok(cfg.dbPath) - const mode = fs.statSync(file).mode & 0o777 - assert.equal(mode, 0o600) + // Windows has no POSIX permission bits; chmod 0o600 is a no-op there. + if (process.platform !== 'win32') { + const mode = fs.statSync(file).mode & 0o777 + assert.equal(mode, 0o600) + } // second load returns same values, never regenerates const again = loadOrCreateConfig(file) assert.deepEqual(again, cfg) diff --git a/package.json b/package.json index ee9476dc3..3ff97c5cd 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,10 @@ "acceptance": "electron scripts/acceptance.js", "beachball": "electron electron/beachball.js", "test": "npm run test:core && npm run test:renderer && npm run test:electron && npm run test:memory", - "test:core": "cd core && npm run build && npm test", - "test:renderer": "CODER_GROK_MCP_DISABLE=1 node --import=./test/support/render.mjs --experimental-strip-types --test --test-timeout=20000 test/*.test.ts test/*.test.tsx", - "test:electron": "node --import=./test/support/render.mjs --experimental-strip-types --test electron/test/*.test.js", - "test:memory": "cd memory-server && node --test test/*.test.js" + "test:core": "npm run build --prefix core && npm test --prefix core", + "test:renderer": "node --import=./test/support/disable-grok-mcp.mjs --import=./test/support/render.mjs --experimental-strip-types --test --test-timeout=20000 test/*.test.ts test/*.test.tsx", + "test:electron": "node scripts/test-electron.js", + "test:memory": "npm test --prefix memory-server" }, "dependencies": { "@formkit/auto-animate": "^0.10.0", diff --git a/scripts/package-cross.sh b/scripts/package-cross.sh index 34c7437a7..d2ad93dc3 100644 --- a/scripts/package-cross.sh +++ b/scripts/package-cross.sh @@ -16,6 +16,15 @@ # LIMITS: artifacts are unsigned and UNVERIFIED — unlike package-app.sh there # is no boot probe, because linux/win binaries cannot run on this Mac. # +# This zip is a portable Electron tree, not an installer. winget publishes +# an installer (or a signed portable with a publisher identity). Do not add +# a winget manifest that points at this unsigned archive — that is worse +# than nothing. #397 / #437. What a real Windows packaging story takes: +# Authenticode signing, then either submit this zip as installerType: +# portable or add NSIS/WiX (electron-builder is a product decision; this +# repo avoids it because npm blocks native postinstalls). There is no +# node-pty in the repo, so a ConPTY rebuild CI step would be a no-op. +# # Usage: # bash scripts/package-cross.sh # both targets # bash scripts/package-cross.sh linux-x64 # one target diff --git a/scripts/test-electron.js b/scripts/test-electron.js new file mode 100644 index 000000000..29dfc6f83 --- /dev/null +++ b/scripts/test-electron.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +// The electron suite on POSIX is `node --test electron/test/*.test.js`. +// On win32 most of those files spawn a shebang script as CODER_*_BIN +// (see electron/test/claude.test.js: "On macOS we can use a node shebang +// script directly as the binary"). CreateProcess will not execute those. +// Until fakes grow a .cmd wrapper, win32 only runs the WSL contract — +// the win32 path this matrix exists to keep honest. #437 +"use strict"; + +const { spawnSync } = require("node:child_process"); + +const args = [ + "--import=./test/support/render.mjs", + "--experimental-strip-types", + "--test", +]; +if (process.platform === "win32") { + args.push("electron/test/wsl.test.js"); +} else { + args.push("electron/test/*.test.js"); +} + +const r = spawnSync(process.execPath, args, { stdio: "inherit" }); +process.exit(r.status ?? 1); diff --git a/test/support/disable-grok-mcp.mjs b/test/support/disable-grok-mcp.mjs new file mode 100644 index 000000000..13527879f --- /dev/null +++ b/test/support/disable-grok-mcp.mjs @@ -0,0 +1,3 @@ +// Inline `VAR=1 cmd` is POSIX-only. --import runs on win32 too. +// Renderer tests must not rewrite the user's ~/.grok/mcp.json. +process.env.CODER_GROK_MCP_DISABLE = "1"; From 702a8036ba96dc125a05e575555952764f4d21ad Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 18 Aug 2026 09:06:16 +0200 Subject: [PATCH 05/12] Windows: keep worktrees on the repo's side of the WSL boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit userData is a Windows path, so a WSL-side repo's worktree used to land on the wrong filesystem — and git worktree add running inside the distro cannot address a UNC. Park those worktrees next to the repo on ext4 (/.solenta/worktrees/, or /tmp if the repo is on /mnt/) and store the matching UNC so Windows-side existsSync still works. Git helpers now share one wrap: a WSL UNC cwd becomes wsl.exe --cd, and UNC path args (worktree add/remove) are rewritten to the linux path. Non-win32 behaviour is unchanged. No file watcher exists today; status/diff are on-demand and PR refresh is already interval-polled. --- electron/test/worktree-wsl.test.js | 132 ++++++++++++++++++++++++ electron/worktrees.js | 157 +++++++++++++++++++++++++---- 2 files changed, 269 insertions(+), 20 deletions(-) create mode 100644 electron/test/worktree-wsl.test.js diff --git a/electron/test/worktree-wsl.test.js b/electron/test/worktree-wsl.test.js new file mode 100644 index 000000000..43dbe9fac --- /dev/null +++ b/electron/test/worktree-wsl.test.js @@ -0,0 +1,132 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { isWindowsMount } = require("../wsl.js"); +const { + resolveWorktreeDir, + resolveGitCommand, + linuxPathToUnc, +} = require("../worktrees.js"); + +// The suite runs on macOS/Linux, so every win32 case passes the platform in. +const WIN = "win32"; +const BASE = "C:\\Users\\me\\AppData\\Roaming\\Solenta\\worktrees"; +const THREAD = "thread-abc"; + +describe("resolveWorktreeDir", () => { + it("keeps today's userData path off win32", () => { + const project = { path: "\\\\wsl$\\Ubuntu\\home\\me\\repo" }; + const out = resolveWorktreeDir(project, BASE, THREAD, "darwin"); + const expected = path.join(BASE, THREAD); + assert.deepEqual(out, { dir: expected, addPath: expected }); + }); + + it("keeps today's userData path for a windows-side project", () => { + const out = resolveWorktreeDir({ path: "C:\\repo" }, BASE, THREAD, WIN); + const expected = path.join(BASE, THREAD); + assert.deepEqual(out, { dir: expected, addPath: expected }); + }); + + it("keeps today's userData path for an ssh remote", () => { + const project = { + remoteHost: "dev@box", + path: "\\\\wsl$\\Ubuntu\\home\\me\\repo", + }; + const out = resolveWorktreeDir(project, BASE, THREAD, WIN); + const expected = path.join(BASE, THREAD); + assert.deepEqual(out, { dir: expected, addPath: expected }); + }); + + it("parks a WSL-side worktree next to the repo, inside the distro", () => { + const project = { path: "\\\\wsl$\\Ubuntu\\home\\me\\code\\repo" }; + const out = resolveWorktreeDir(project, BASE, THREAD, WIN); + assert.equal(out.addPath, "/home/me/code/.solenta/worktrees/thread-abc"); + assert.equal( + out.dir, + "\\\\wsl$\\Ubuntu\\home\\me\\code\\.solenta\\worktrees\\thread-abc", + ); + assert.equal(isWindowsMount(out.addPath), false); + assert.notEqual(out.dir, path.join(BASE, THREAD)); + }); + + it("preserves the \\\\wsl.localhost prefix on the stored UNC", () => { + const project = { path: "\\\\wsl.localhost\\Ubuntu-22.04\\srv\\app" }; + const out = resolveWorktreeDir(project, BASE, THREAD, WIN); + assert.equal(out.addPath, "/srv/.solenta/worktrees/thread-abc"); + assert.equal( + out.dir, + "\\\\wsl.localhost\\Ubuntu-22.04\\srv\\.solenta\\worktrees\\thread-abc", + ); + }); + + it("refuses /mnt/ and parks on ext4 instead", () => { + const project = { path: "\\\\wsl$\\Ubuntu\\mnt\\c\\Users\\me\\repo" }; + const out = resolveWorktreeDir(project, BASE, THREAD, WIN); + assert.equal(out.addPath, "/tmp/solenta-worktrees/thread-abc"); + assert.equal(isWindowsMount(out.addPath), false); + assert.equal( + out.dir, + "\\\\wsl$\\Ubuntu\\tmp\\solenta-worktrees\\thread-abc", + ); + }); +}); + +describe("linuxPathToUnc", () => { + it("rebuilds a \\\\wsl$ UNC from a linux path", () => { + assert.equal( + linuxPathToUnc("\\\\wsl$\\Ubuntu\\home\\me\\repo", "/home/me/.solenta/worktrees/x"), + "\\\\wsl$\\Ubuntu\\home\\me\\.solenta\\worktrees\\x", + ); + }); +}); + +describe("resolveGitCommand", () => { + it("leaves a local path as git + cwd off win32", () => { + const out = resolveGitCommand("/Users/me/repo", ["status"], "darwin"); + assert.deepEqual(out, { bin: "git", args: ["status"], cwd: "/Users/me/repo" }); + }); + + it("leaves a windows drive path as git + cwd", () => { + const out = resolveGitCommand("C:\\repo", ["status"], WIN); + assert.deepEqual(out, { bin: "git", args: ["status"], cwd: "C:\\repo" }); + }); + + it("wraps a WSL UNC as wsl.exe --cd, with no Windows cwd", () => { + const out = resolveGitCommand("\\\\wsl$\\Ubuntu\\home\\me\\repo", ["status"], WIN); + assert.equal(out.bin, "wsl.exe"); + assert.deepEqual(out.args, [ + "-d", + "Ubuntu", + "--cd", + "/home/me/repo", + "--", + "git", + "status", + ]); + assert.equal(out.cwd, undefined); + }); + + it("rewrites a WSL UNC worktree arg to the linux path", () => { + const repo = "\\\\wsl$\\Ubuntu\\home\\me\\repo"; + const wt = "\\\\wsl$\\Ubuntu\\home\\me\\.solenta\\worktrees\\abc"; + const out = resolveGitCommand(repo, ["worktree", "remove", wt], WIN); + assert.equal(out.bin, "wsl.exe"); + assert.deepEqual(out.args, [ + "-d", + "Ubuntu", + "--cd", + "/home/me/repo", + "--", + "git", + "worktree", + "remove", + "/home/me/.solenta/worktrees/abc", + ]); + }); + + it("does not wrap a WSL-looking UNC off win32", () => { + const unc = "\\\\wsl$\\Ubuntu\\home\\me\\repo"; + const out = resolveGitCommand(unc, ["status"], "darwin"); + assert.deepEqual(out, { bin: "git", args: ["status"], cwd: unc }); + }); +}); diff --git a/electron/worktrees.js b/electron/worktrees.js index 75b9688c1..36841e36d 100644 --- a/electron/worktrees.js +++ b/electron/worktrees.js @@ -4,6 +4,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { execFile } = require("node:child_process"); const { execCommand, wrapCommand, SYNC_TIMEOUT_MS } = require("./ssh.js"); +const { pathSide, isWindowsMount, wslTarget, buildWslCommand } = require("./wsl.js"); const { scanSecrets } = require("./guardrails.js"); /** @type {typeof execFile} */ @@ -20,6 +21,100 @@ function setExecFile(fn) { const PATCH_TRUNCATE = 100_000; +/** + * The one git wrap for a path. A WSL UNC cwd means git must run *inside* + * the distro at that path — Windows git over 9p is the slow/broken case + * the boundary rule exists to prevent. Off win32 this is a no-op so macOS + * and Linux stay byte-for-byte unchanged. + * + * Args that are themselves WSL UNCs (worktree add/remove) are rewritten to + * the linux path git inside the distro can address. + * + * No file watcher lives in this process: status/diff are on-demand and PR + * refresh is interval-polled. inotify would not cross the boundary anyway; + * a future watcher must poll when the target is WSL-side. + * + * @param {string} cwd + * @param {string[]} args + * @param {NodeJS.Platform} [platform] + * @returns {{ bin: string, args: string[], cwd?: string }} + */ +function resolveGitCommand(cwd, args, platform = process.platform) { + const argv = Array.isArray(args) ? args : []; + const side = pathSide(cwd, platform); + if (side.side !== "wsl") { + return { bin: "git", args: argv, cwd }; + } + const linuxArgs = argv.map((a) => { + const s = pathSide(typeof a === "string" ? a : "", platform); + return s.side === "wsl" ? s.linuxPath : a; + }); + const cmd = buildWslCommand(side.distro, side.linuxPath, ["git", ...linuxArgs]); + return { bin: cmd.bin, args: cmd.args }; +} + +/** + * Turn a linux path inside a distro back into the same UNC flavour the + * project used (\\wsl$ vs \\wsl.localhost), so Windows-side existsSync + * and later pathSide() still recognise it. + * @param {string} projectPath + * @param {string} linuxPath + */ +function linuxPathToUnc(projectPath, linuxPath) { + const m = String(projectPath).match(/^(\\\\wsl(?:\$|\.localhost)\\[^\\]+)/i); + const prefix = m ? m[1] : "\\\\wsl$\\unknown"; + const rest = String(linuxPath).replace(/^\/+/, "").replace(/\//g, "\\"); + return `${prefix}\\${rest}`; +} + +/** + * Where a thread worktree should live. + * + * Non-WSL: `/` — today's userData path, unchanged. + * + * WSL-side: next to the repo, inside the distro + * (`/.solenta/worktrees/`). userData is a Windows + * path; `git worktree add` running via wsl.exe --cd cannot address a UNC + * or /mnt/, and crossing the boundary is the slow/no-inotify case. + * Same-filesystem as the repo is also what git worktree wants. If the + * repo itself lives on /mnt/, park under /tmp/solenta-worktrees + * so we still land on real ext4. + * + * `dir` is what we store as worktreePath (a UNC Windows can still see). + * `addPath` is the argument `git worktree add` must receive (linux). + * + * ponytail: sweepOrphanWorktrees still only scans worktreeBase (userData). + * WSL worktrees live next to the repo; thread-keyed cleanup uses worktreePath. + * + * @param {{ path?: string, remoteHost?: string } | null | undefined} project + * @param {string} worktreeBase + * @param {string} threadId + * @param {NodeJS.Platform} [platform] + * @returns {{ dir: string, addPath: string }} + */ +function resolveWorktreeDir(project, worktreeBase, threadId, platform = process.platform) { + const wsl = wslTarget(project, platform); + if (!wsl) { + const dir = path.join(worktreeBase, threadId); + return { dir, addPath: dir }; + } + let linuxDir = path.posix.join( + path.posix.dirname(wsl.linuxPath), + ".solenta", + "worktrees", + threadId, + ); + if (isWindowsMount(linuxDir) || isWindowsMount(wsl.linuxPath)) { + linuxDir = path.posix.join("/tmp", "solenta-worktrees", threadId); + } + if (isWindowsMount(linuxDir)) { + throw new Error( + "Worktree path resolved onto a Windows mount; refusing to cross the WSL boundary", + ); + } + return { dir: linuxPathToUnc(project.path, linuxDir), addPath: linuxDir }; +} + const GIT_MAX_BUFFER = 32 * 1024 * 1024; /** Per-thread background PR refresh timeout. Hard kill; never block the main process. */ @@ -43,19 +138,22 @@ function gitOut(cwd, args, opts) { // CODER_LOOP_LAG probe puts a click path in p99. // execCommand, not execFileSync: it owns the default timeout that keeps a // hung git off the main-process event loop. - const raw = execCommand(null, "git", args, { - cwd, + const cmd = resolveGitCommand(cwd, args); + const execOpts = { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, - }); + }; + if (cmd.cwd) execOpts.cwd = cmd.cwd; + const raw = execCommand(null, cmd.bin, cmd.args, execOpts); const out = raw == null ? "" : String(raw); return opts && opts.raw ? out : out.trim(); } /** * gitOut for the diff path: prefix git with ssh when the project is remote. - * Other worktrees operations stay local (worktrees/PRs are out of scope on remotes). + * WSL-side cwds wrap inside gitOut via resolveGitCommand. Other worktrees + * operations stay local (worktrees/PRs are out of scope on remotes). * @param {{ remoteHost?: string, remotePath?: string, path?: string } | null} project * @param {string} cwd * @param {string[]} args @@ -95,8 +193,11 @@ function gitTry(cwd, args, opts) { if (opts && opts.timeout != null) { execOpts.timeout = opts.timeout; } + const cmd = resolveGitCommand(cwd, args); + if (cmd.cwd) execOpts.cwd = cmd.cwd; + else delete execOpts.cwd; // execCommand defaults the timeout when the caller did not set one. - const raw = execCommand(null, "git", args, execOpts); + const raw = execCommand(null, cmd.bin, cmd.args, execOpts); const out = raw == null ? "" : String(raw); const stdout = opts && opts.raw ? out : out.trim(); return { ok: true, stdout, stderr: "", combined: stdout }; @@ -155,7 +256,6 @@ function gitOutForDiffAsync(project, cwd, args, opts) { * @returns {Promise} */ function gitExecThrowAsync(project, cwd, args, opts) { - const cmd = wrapCommand(project, "git", args); const timeout = opts && opts.timeout != null ? opts.timeout : SYNC_TIMEOUT_MS; /** @type {import("node:child_process").ExecFileOptionsWithStringEncoding} */ @@ -165,8 +265,12 @@ function gitExecThrowAsync(project, cwd, args, opts) { timeout, stdio: ["ignore", "pipe", "pipe"], }; - if (!(project && project.remoteHost)) { - execOpts.cwd = cwd; + let cmd; + if (project && project.remoteHost) { + cmd = wrapCommand(project, "git", args); + } else { + cmd = resolveGitCommand(cwd, args); + if (cmd.cwd) execOpts.cwd = cmd.cwd; } return new Promise((resolve, reject) => { execFileImpl(cmd.bin, cmd.args, execOpts, (err, stdout, stderr) => { @@ -736,12 +840,19 @@ function setupWorktree(opts) { const shortId = String(thread.id).slice(0, 6); const wanted = `coder/${slugify(thread.title)}-${shortId}`; const branch = uniqueCoderBranch(project.path, wanted) || wanted; - const dir = path.join(worktreeBase, thread.id); + const { dir, addPath } = resolveWorktreeDir(project, worktreeBase, thread.id); - fs.mkdirSync(worktreeBase, { recursive: true }); + if (addPath === dir) { + fs.mkdirSync(worktreeBase, { recursive: true }); + } else { + // Parent of the linux worktree must exist inside the distro. The + // Windows userData path is the wrong side and git worktree add will + // not create intermediate directories. + execCommand(project, "mkdir", ["-p", path.posix.dirname(addPath)]); + } try { - gitOut(project.path, ["worktree", "add", "-b", branch, dir]); + gitOut(project.path, ["worktree", "add", "-b", branch, addPath]); } catch (err) { const msg = err && err.message ? String(err.message) : String(err); // Surface a clean error (clear of Error.prototype noise where possible) @@ -2595,17 +2706,20 @@ function gitTryAsync(cwd, args, opts) { ...(opts && opts.env ? opts.env : {}), GIT_TERMINAL_PROMPT: "0", }; + const cmd = resolveGitCommand(cwd, args); + /** @type {import("node:child_process").ExecFileOptionsWithStringEncoding} */ + const execOpts = { + encoding: "utf8", + maxBuffer: GIT_MAX_BUFFER, + timeout, + env, + }; + if (cmd.cwd) execOpts.cwd = cmd.cwd; return new Promise((resolve) => { execFileImpl( - "git", - args, - { - cwd, - encoding: "utf8", - maxBuffer: GIT_MAX_BUFFER, - timeout, - env, - }, + cmd.bin, + cmd.args, + execOpts, (err, stdout, stderr) => { if (!err) { const out = opts && opts.raw ? String(stdout || "") : String(stdout || "").trim(); @@ -3744,6 +3858,9 @@ module.exports = { isPrRefreshCandidate, isGitHubRemote, gitTry, + resolveGitCommand, + resolveWorktreeDir, + linuxPathToUnc, defaultBranch, defaultBranchAsync, gitOutAsync, From e61a606c5c7afce6b7d9c7f8328c04ccef6cdeed Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 18 Aug 2026 09:07:28 +0200 Subject: [PATCH 06/12] doctor: run the three probes in parallel (#435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sequentially they stack three PROBE_TIMEOUT_MS waits, so a box with no bash and no node froze the add dialog for 15s — on exactly the platform the doctor exists for. The probes are independent. Co-Authored-By: Claude Opus 5 --- electron/doctor.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/electron/doctor.js b/electron/doctor.js index 94b4b7c19..da844137f 100644 --- a/electron/doctor.js +++ b/electron/doctor.js @@ -97,9 +97,19 @@ async function runWindowsDoctor(project, platform = getPlatform()) { // Linux git has no MAX_PATH. Only a Windows-side local repo needs this. const needsLongpaths = side.side === "windows" && !remote; - if (needsLongpaths) { - const r = await probe(project, "git", ["config", "--get", "core.longpaths"]); - const enabled = /^true$/i.test(r.stdout); + // The three probes are independent, so run them together: sequentially + // they stack three PROBE_TIMEOUT_MS waits into a 15s frozen add dialog on + // exactly the machine this doctor exists for. + const [longpaths, bash, node] = await Promise.all([ + needsLongpaths + ? probe(project, "git", ["config", "--get", "core.longpaths"]) + : Promise.resolve(null), + probe(project, "bash", ["-c", "echo ok"]), + probe(project, "node", ["-v"]), + ]); + + if (longpaths) { + const enabled = /^true$/i.test(longpaths.stdout); checks.push({ id: "longpaths", ok: enabled, @@ -114,7 +124,6 @@ async function runWindowsDoctor(project, platform = getPlatform()) { }); } - const bash = await probe(project, "bash", ["-c", "echo ok"]); const bashOk = bash.ok && bash.stdout === "ok"; checks.push({ id: "gitBash", @@ -131,7 +140,6 @@ async function runWindowsDoctor(project, platform = getPlatform()) { : "Install Git for Windows and keep Git Bash on PATH", }); - const node = await probe(project, "node", ["-v"]); const major = node.ok ? parseNodeMajor(node.stdout) : null; const nodeOk = major != null && major >= 22; checks.push({ From 54ce9b6a03d4b3437cb61d5fb23e7d220b207d6b Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 18 Aug 2026 09:10:15 +0200 Subject: [PATCH 07/12] ci: run every win32-safe suite on the Windows leg (#437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The win32 file list was written before the doctor and sandbox suites existed — parallel workers could not see each other. All three new files are pure resolvers or ssh.setExecFile fakes, so they run on Windows, and they are precisely the win32 logic the matrix exists to keep honest. Windows now runs 44 tests instead of 7. Co-Authored-By: Claude Opus 5 --- scripts/test-electron.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/test-electron.js b/scripts/test-electron.js index 29dfc6f83..cd094351f 100644 --- a/scripts/test-electron.js +++ b/scripts/test-electron.js @@ -14,8 +14,18 @@ const args = [ "--experimental-strip-types", "--test", ]; +// The win32 list is exactly the files that model win32 behaviour without +// spawning anything real: pure resolvers, or fakes injected through +// ssh.setExecFile. Anything added here must hold to that — the moment a +// file needs a real git or a shebang fake, the Windows leg goes red. +const WIN32_FILES = [ + "electron/test/wsl.test.js", // the boundary contract itself + "electron/test/worktree-wsl.test.js", // worktree placement across it + "electron/test/doctor.test.js", // the win32 doctor probes + "electron/test/sandbox.test.js", // sandbox resolution (platform injected) +]; if (process.platform === "win32") { - args.push("electron/test/wsl.test.js"); + args.push(...WIN32_FILES); } else { args.push("electron/test/*.test.js"); } From 160e14e34760fd453587ff0202cfe6c1641526bd Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 18 Aug 2026 09:30:38 +0200 Subject: [PATCH 08/12] Windows: make agent dispatch possible at all (#442, #441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated-looking bugs in the same function. 1. isBinAvailable shelled out to `which`, which does not exist on Windows, so every provider failed "Provider binary not found" and nothing could be dispatched. win32 uses `where`, which prints every match in PATH order — take the first line. Only the local host is in question here: a project across a boundary returns early in assertProviderBinary, because the CLI lives on the other side. The cache key now includes the platform so a win32 miss cannot poison a posix hit. 2. That cache key held a raw NUL byte as its separator, which made `file` report providers.js as binary and made every plain `grep` of this file print nothing and exit 1 — indistinguishable from a real no-match. It has cost two sessions a wrong conclusion (#441). Now the \0 escape: same string, plain-text file. Agent CLIs install on Windows as .cmd shims and Node refuses to exec those. The six sites that spawn a provider binary go through cross-spawn, which routes them via cmd.exe with correct escaping — necessary because the prompt travels in argv, so hand-rolled quoting would be a correctness and injection risk. Not yet Windows-ready: /bin/sh in verify.js, `which node` in memory-sup, and the SHELL default in pathEnv. Nothing here proves the app boots. Co-Authored-By: Claude Opus 5 --- electron/agent.js | 6 +- electron/claude.js | 6 +- electron/codex.js | 6 +- electron/commitmsg.js | 6 +- electron/kimi.js | 6 +- electron/opencode.js | 6 +- electron/providers.js | 40 ++++++++++-- electron/test/which-platform.test.js | 95 ++++++++++++++++++++++++++++ package-lock.json | 70 +++++++++++++++++++- package.json | 1 + 10 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 electron/test/which-platform.test.js diff --git a/electron/agent.js b/electron/agent.js index b4e1e557e..e9ad9a20f 100644 --- a/electron/agent.js +++ b/electron/agent.js @@ -1,6 +1,10 @@ "use strict"; -const { spawn } = require("node:child_process"); +// cross-spawn, not child_process: on Windows the agent CLIs install as +// .cmd shims and Node refuses to exec those directly. cross-spawn routes +// them through cmd.exe with correct escaping, which matters because the +// prompt travels in argv (#442). +const spawn = require("cross-spawn"); const { killTree } = require("./proc.js"); const CHUNK_THROTTLE_MS = 250; diff --git a/electron/claude.js b/electron/claude.js index 4ffd74656..5767e1b13 100644 --- a/electron/claude.js +++ b/electron/claude.js @@ -1,6 +1,10 @@ "use strict"; -const { spawn } = require("node:child_process"); +// cross-spawn, not child_process: on Windows the agent CLIs install as +// .cmd shims and Node refuses to exec those directly. cross-spawn routes +// them through cmd.exe with correct escaping, which matters because the +// prompt travels in argv (#442). +const spawn = require("cross-spawn"); const { getClaudeMcpArgs } = require("./memory-sup.js"); const { killTree } = require("./proc.js"); diff --git a/electron/codex.js b/electron/codex.js index 30cdda57b..a2a14a13a 100644 --- a/electron/codex.js +++ b/electron/codex.js @@ -1,6 +1,10 @@ "use strict"; -const { spawn } = require("node:child_process"); +// cross-spawn, not child_process: on Windows the agent CLIs install as +// .cmd shims and Node refuses to exec those directly. cross-spawn routes +// them through cmd.exe with correct escaping, which matters because the +// prompt travels in argv (#442). +const spawn = require("cross-spawn"); const { killTree } = require("./proc.js"); const SIGKILL_AFTER_MS = 3000; diff --git a/electron/commitmsg.js b/electron/commitmsg.js index dd4787efb..0a3e65bac 100644 --- a/electron/commitmsg.js +++ b/electron/commitmsg.js @@ -1,6 +1,10 @@ "use strict"; -const { spawn } = require("node:child_process"); +// cross-spawn, not child_process: on Windows the agent CLIs install as +// .cmd shims and Node refuses to exec those directly. cross-spawn routes +// them through cmd.exe with correct escaping, which matters because the +// prompt travels in argv (#442). +const spawn = require("cross-spawn"); const { getProvider, resolveBin, isBinAvailable } = require("./providers.js"); const { diff, assertNoOutboundSecrets } = require("./worktrees.js"); const { fmRun } = require("./fm.js"); diff --git a/electron/kimi.js b/electron/kimi.js index 3a2277ad1..2985663b5 100644 --- a/electron/kimi.js +++ b/electron/kimi.js @@ -1,6 +1,10 @@ "use strict"; -const { spawn } = require("node:child_process"); +// cross-spawn, not child_process: on Windows the agent CLIs install as +// .cmd shims and Node refuses to exec those directly. cross-spawn routes +// them through cmd.exe with correct escaping, which matters because the +// prompt travels in argv (#442). +const spawn = require("cross-spawn"); const { killTree } = require("./proc.js"); const fs = require("node:fs"); const os = require("node:os"); diff --git a/electron/opencode.js b/electron/opencode.js index 518204069..79f3875be 100644 --- a/electron/opencode.js +++ b/electron/opencode.js @@ -1,6 +1,10 @@ "use strict"; -const { spawn } = require("node:child_process"); +// cross-spawn, not child_process: on Windows the agent CLIs install as +// .cmd shims and Node refuses to exec those directly. cross-spawn routes +// them through cmd.exe with correct escaping, which matters because the +// prompt travels in argv (#442). +const spawn = require("cross-spawn"); const { killTree } = require("./proc.js"); const SIGKILL_AFTER_MS = 3000; diff --git a/electron/providers.js b/electron/providers.js index 54995e052..87aaf116b 100644 --- a/electron/providers.js +++ b/electron/providers.js @@ -538,12 +538,29 @@ function resolveBin(entry, env = process.env) { const whichCache = new Map(); /** - * Default which: absolute/relative path via existsSync, else `which` on PATH. + * PATH lookup command for a platform. Windows has no `which`; `where` is the + * built-in equivalent and, unlike `which`, prints EVERY match (one per line) + * in PATH order — so the first line is the one the shell would pick. + * + * Only the LOCAL host matters here. A project across a boundary (ssh remote + * or WSL-side) never reaches this code: assertProviderBinary returns early on + * crossesBoundary(), because the CLI lives on the other side where that + * side's own `which` is the correct question. + * + * @param {NodeJS.Platform} platform + */ +function whichCommand(platform) { + return platform === "win32" ? "where" : "which"; +} + +/** + * Default which: absolute/relative path via existsSync, else a PATH lookup. * @param {string} bin * @param {NodeJS.ProcessEnv} [env] + * @param {NodeJS.Platform} [platform] * @returns {string | null} */ -function defaultWhich(bin, env = process.env) { +function defaultWhich(bin, env = process.env, platform = process.platform) { if (!bin) return null; if (path.isAbsolute(bin) || bin.includes("/") || bin.includes("\\")) { try { @@ -552,17 +569,26 @@ function defaultWhich(bin, env = process.env) { return null; } } - const key = `${bin}${env.PATH || ""}`; + // `\0` as an escape, never a raw NUL byte: a literal NUL makes grep + // treat this entire file as binary, so every search silently reports no + // match rather than failing loudly (#441). + const key = `${bin}\0${platform}\0${env.PATH || ""}`; const hit = whichCache.get(key); if (hit) return hit; try { - const out = execFileSync("which", [bin], { + const out = execFileSync(whichCommand(platform), [bin], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env, - }).trim(); - if (out) whichCache.set(key, out); - return out || null; + }); + // `where` prints EVERY match, one per line, in PATH order; `which` prints + // one. Taking the first non-empty line is correct for both. + const first = String(out || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + if (first) whichCache.set(key, first); + return first || null; } catch { return null; } diff --git a/electron/test/which-platform.test.js b/electron/test/which-platform.test.js new file mode 100644 index 000000000..81535f14f --- /dev/null +++ b/electron/test/which-platform.test.js @@ -0,0 +1,95 @@ +const { describe, it, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { defaultWhich, clearWhichCache } = require("../providers.js"); + +// The win32 cases run on macOS by injecting the platform. What cannot be +// injected is `where` itself, so the win32 assertions cover path handling and +// the cache key; the lookup command choice is asserted via the source. + +afterEach(() => { + clearWhichCache(); +}); + +describe("defaultWhich absolute paths", () => { + it("accepts an existing absolute path on win32 without any PATH lookup", () => { + const file = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "which-")), + "claude.cmd", + ); + fs.writeFileSync(file, "@echo off\n"); + assert.equal(defaultWhich(file, { PATH: "" }, "win32"), file); + }); + + it("rejects a missing absolute path on win32", () => { + assert.equal( + defaultWhich("C:\\nope\\claude.cmd", { PATH: "" }, "win32"), + null, + ); + }); + + it("treats a backslash path as a path, not a PATH lookup", () => { + // No throw even though `where` cannot run here: the branch never shells out. + assert.equal(defaultWhich("bin\\claude.cmd", { PATH: "" }, "win32"), null); + }); +}); + +describe("defaultWhich PATH lookup", () => { + it("finds a real binary on this platform", () => { + const hit = defaultWhich("node", process.env); + assert.ok(hit && hit.includes("node"), `expected a node path, got ${hit}`); + }); + + it("returns null for a binary that is not installed", () => { + assert.equal(defaultWhich("definitely-not-a-real-binary-xyz"), null); + }); + + it("returns a single line, never `where`-style multi-line output", () => { + const hit = defaultWhich("node", process.env); + assert.ok(hit && !hit.includes("\n"), "result must be one path"); + }); + + it("keys the cache by platform, so a win32 miss cannot poison posix", () => { + // win32 lookup runs `where`, which does not exist here -> null, uncached. + assert.equal(defaultWhich("node", process.env, "win32"), null); + // The posix lookup must still find node rather than reuse that null. + const hit = defaultWhich("node", process.env, "darwin"); + assert.ok(hit && hit.includes("node"), `platform leaked: got ${hit}`); + }); +}); + +describe("providers.js source hygiene", () => { + it("uses `where` on win32 and `which` elsewhere", () => { + const src = fs.readFileSync( + path.join(__dirname, "..", "providers.js"), + "utf8", + ); + assert.match(src, /platform === "win32" \? "where" : "which"/); + }); + + it("contains no raw NUL byte (#441 — a NUL makes grep skip the file)", () => { + const buf = fs.readFileSync(path.join(__dirname, "..", "providers.js")); + assert.equal(buf.indexOf(0), -1, "raw NUL byte in providers.js"); + }); +}); + +describe("agent CLI spawns go through cross-spawn (#442)", () => { + // Windows installs these CLIs as .cmd shims; child_process.spawn refuses + // to exec those, so a regression here kills every run on Windows. + for (const file of [ + "agent.js", + "claude.js", + "codex.js", + "kimi.js", + "opencode.js", + "commitmsg.js", + ]) { + it(`${file} requires cross-spawn`, () => { + const src = fs.readFileSync(path.join(__dirname, "..", file), "utf8"); + assert.match(src, /require\("cross-spawn"\)/); + assert.doesNotMatch(src, /\{\s*spawn\s*\}\s*=\s*require\("node:child_process"\)/); + }); + } +}); diff --git a/package-lock.json b/package-lock.json index 42fa5e59e..733577540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "solenta", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solenta", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", "dependencies": { "@formkit/auto-animate": "^0.10.0", + "cross-spawn": "^7.0.6", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -1874,6 +1875,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2608,6 +2623,12 @@ "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3760,6 +3781,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -4143,6 +4173,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4630,6 +4681,21 @@ "node": "^22.14.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 3ff97c5cd..4b6fe4249 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@formkit/auto-animate": "^0.10.0", + "cross-spawn": "^7.0.6", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", From 4e1fdd691fd88e4a67e6e9bc2b096f75bdd6d385 Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 18 Aug 2026 09:42:06 +0200 Subject: [PATCH 09/12] Windows: remaining POSIX assumptions outside agent dispatch (#448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #442. The four leftover POSIX-only spots now have a win32 path or an honest no-op, all testable on macOS by injecting the platform. 1. verify.js: Windows has no /bin/sh. Spawn Git Bash (`bash -c`) — the same POSIX shell the doctor already probes for. Verify commands are POSIX (`>&2`, `exit 3`); cmd.exe or PowerShell would silently mis-parse them. Missing bash is a real spawn error, not a fallback. A WSL-side project is wrapped through ssh.js so the command runs inside the distro. SSH remotes are left unwrapped: wrapping them would change macOS remote-verify behaviour. 2. memory-sup.js: `which node` is now providers.defaultWhich, so win32 gets `where`. Two call sites, one existing helper; no new abstraction. 3. pathEnv.js: there is no login shell of the macOS/launchd kind on Windows. captureLoginPath and enrichProcessPath are explicit no-ops (source: "win32") instead of spawning /bin/zsh and failing closed. Also: do not split PATH on ':'. That would shred `C:\Windows\system32`. 4. devservers.js never used /bin/sh (argv is `npm run