diff --git a/README.md b/README.md index 5f912c9..efbbf1a 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ Special thanks to: - **[gianluca-peri](https://github.com/gianluca-peri)** - Reporting the GNOME quit accessibility issue - Confirming tray behavior with AppIndicator +- **[martin152](https://github.com/martin152)** for detailed diagnosis and a complete patch for three launcher cleanup bugs: `cleanup_orphaned_cowork_daemon` self-match, `cleanup_stale_cowork_socket` socat dependency no-op, and the same self-match in `--doctor` ## Sponsorship diff --git a/build.sh b/build.sh index ffd3a21..09da09b 100755 --- a/build.sh +++ b/build.sh @@ -103,15 +103,15 @@ detect_architecture() { case "$raw_arch" in x86_64) - claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.2773.0/Claude-884b3735b1ce5042a0c286824c6f9bd2d341f7c8.exe' - claude_exe_sha256='a8a8d2c0afbb980bb8627083127a9ae040074f6cd17dd440938659fd49d7d895' + claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.3109.0/Claude-35cbf6530e05912137624cde0f075dc7f121fa60.exe' + claude_exe_sha256='616a7a1c6235709650b0dabe3a06d32f9ade08340891713bd647dff47065f230' architecture='amd64' claude_exe_filename='Claude-Setup-x64.exe' echo 'Configured for amd64 (x86_64) build.' ;; aarch64) - claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.2773.0/Claude-884b3735b1ce5042a0c286824c6f9bd2d341f7c8.exe' - claude_exe_sha256='3459bdba3d8d540269b68a31dddb9f10d2714f1fd63bcc8cccaa5d14e359d7b4' + claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.3109.0/Claude-35cbf6530e05912137624cde0f075dc7f121fa60.exe' + claude_exe_sha256='43fc00b2b94ebf412cae20f15db9fa780a0a3a14e90c60c4549b58f748c3d08d' architecture='arm64' claude_exe_filename='Claude-Setup-arm64.exe' echo 'Configured for arm64 (aarch64) build.' @@ -1497,25 +1497,29 @@ if (serviceErrorIdx !== -1) { const regionStart = Math.max(0, anchorIdx - 1000); const region = code.substring(regionStart, anchorIdx); + // JS identifier may start with $, _, or letter; \w doesn't + // match $ so use [$\w]+ to capture vars like `$e` (Claude + // >= 1.3109.0 uses $e for the fs module to avoid collision + // with the parameter `e`). See issue #418. // path var: VAR.join(process.resourcesPath, const pathMatch = region.match( - /(\w+)\.join\(\s*process\.resourcesPath\s*,/ + /([$\w]+)\.join\(\s*process\.resourcesPath\s*,/ ); // fs var: VAR.existsSync( - const fsMatch = region.match(/(\w+)\.existsSync\(/); + const fsMatch = region.match(/([$\w]+)\.existsSync\(/); // logger var: VAR.info("[VM:start] const logMatch = region.match( - /(\w+)\.info\(\s*[`"]\[VM:start\]/ + /([$\w]+)\.info\(\s*[`"]\[VM:start\]/ ); // stream/pipeline var: VAR.pipeline( - const streamMatch = region.match(/(\w+)\.pipeline\(/); + const streamMatch = region.match(/([$\w]+)\.pipeline\(/); // arch function: const VAR=FUNC(), used in smol-bin const archMatch = region.match( - /const\s+(\w+)\s*=\s*(\w+)\(\)\s*,\s*\w+\s*=\s*\w+\.join/ + /const\s+([$\w]+)\s*=\s*([$\w]+)\(\)\s*,\s*[$\w]+\s*=\s*[$\w]+\.join/ ); // bundlePath var: PATH.join(VAR,"smol-bin.vhdx") const bundleMatch = region.match( - /\.join\(\s*(\w+)\s*,\s*"smol-bin\.vhdx"\s*\)/ + /\.join\(\s*([$\w]+)\s*,\s*"smol-bin\.vhdx"\s*\)/ ); if (pathMatch && fsMatch && logMatch && @@ -1548,9 +1552,34 @@ if (serviceErrorIdx !== -1) { '`[VM:start] smol-bin.${_la}' + '.vhdx not found at ${_ls}`)' + '}'; - code = code.substring(0, closingBrace + 1) + + // Defensive: if a future upstream emits its own + // if(process.platform==="linux"){...} block right + // after the win32 close brace, strip it before + // injecting our correctly-wired linuxBlock so we + // don't end up with two competing blocks. + const insertPos = closingBrace + 1; + let stripUntil = insertPos; + const afterWin32 = code.substring(insertPos); + const upstreamRe = /^\s*if\s*\(\s*process\.platform\s*===\s*"linux"\s*\)\s*\{/; + const upstreamMatch = afterWin32.match(upstreamRe); + if (upstreamMatch) { + const matchEnd = insertPos + upstreamMatch[0].length; + let depth = 1, pos = matchEnd; + while (depth > 0 && pos < code.length) { + if (code[pos] === '{') depth++; + else if (code[pos] === '}') depth--; + pos++; + } + if (depth === 0) { + stripUntil = pos; + console.log(' Stripped pre-existing upstream Linux block'); + } else { + console.log(' WARNING: Upstream Linux block found but braces unbalanced; not stripping'); + } + } + code = code.substring(0, insertPos) + linuxBlock + - code.substring(closingBrace + 1); + code.substring(stripUntil); console.log(' Injected Linux smol-bin copy block (skips _.configure)'); console.log(` vars: path=${pathVar} fs=${fsVar} log=${logVar} stream=${streamVar} arch=${archFunc} bundle=${bundleVar}`); patchCount++; @@ -1670,6 +1699,18 @@ install_node_pty() { "$app_staging_dir/app.asar.contents/node_modules/node-pty/" || exit 1 cp "$pty_src_dir/package.json" \ "$app_staging_dir/app.asar.contents/node_modules/node-pty/" || exit 1 + # Also stage build/ so `asar pack --unpack '**/*.node'` can + # create a properly-tracked .unpacked entry. Without this, + # the asar manifest has no node-pty/build/ entry and + # Electron's asar->.unpacked redirect never fires, so + # require('../build/Release/pty.node') from inside the asar + # fails with MODULE_NOT_FOUND even when the binary exists + # in app.asar.unpacked/. + if [[ -d $pty_src_dir/build ]]; then + cp -r "$pty_src_dir/build" \ + "$app_staging_dir/app.asar.contents/node_modules/node-pty/" || exit 1 + echo 'node-pty build/ staged (will be unpacked during asar pack)' + fi echo 'node-pty JavaScript files copied' elif [[ -z $pty_src_dir ]]; then echo 'node-pty source directory not set' @@ -1682,7 +1723,13 @@ install_node_pty() { } finalize_app_asar() { - "$asar_exec" pack app.asar.contents app.asar || exit 1 + # Pack with --unpack so native modules (.node) are extracted + # into app.asar.unpacked/ AND tracked in the asar manifest as + # unpacked. Electron's asar->.unpacked redirect requires the + # manifest entry to exist; otherwise loaders that require() + # files from inside the asar get MODULE_NOT_FOUND. + "$asar_exec" pack app.asar.contents app.asar \ + --unpack '**/*.node' || exit 1 mkdir -p "$app_staging_dir/app.asar.unpacked/node_modules/@ant/claude-native" || exit 1 cp "$source_dir/scripts/claude-native-stub.js" \ diff --git a/nix/claude-desktop.nix b/nix/claude-desktop.nix index ec5f0b6..8cc8aaf 100644 --- a/nix/claude-desktop.nix +++ b/nix/claude-desktop.nix @@ -16,16 +16,16 @@ }: let pname = "claude-desktop"; - version = "1.2773.0"; + version = "1.3109.0"; srcs = { x86_64-linux = fetchurl { - url = "https://downloads.claude.ai/releases/win32/x64/1.2773.0/Claude-884b3735b1ce5042a0c286824c6f9bd2d341f7c8.exe"; - hash = "sha256-qKjSwK+7mAu4YnCDEnqa4EAHT2zRfdRAk4ZZ/UnX2JU="; + url = "https://downloads.claude.ai/releases/win32/x64/1.3109.0/Claude-35cbf6530e05912137624cde0f075dc7f121fa60.exe"; + hash = "sha256-YWp6HGI1cJZQsNq+OgbTL5reCDQIkXE71kff9HBl8jA="; }; aarch64-linux = fetchurl { - url = "https://downloads.claude.ai/releases/win32/arm64/1.2773.0/Claude-884b3735b1ce5042a0c286824c6f9bd2d341f7c8.exe"; - hash = "sha256-NFm9uj2NVAJptoox3dufENJxTx/WO8yMzKpdFONZ17Q="; + url = "https://downloads.claude.ai/releases/win32/arm64/1.3109.0/Claude-35cbf6530e05912137624cde0f075dc7f121fa60.exe"; + hash = "sha256-Q/wAsrlOv0EsriDxXbn6eAoKOhTpDGDEVJtY90jD0I0="; }; }; diff --git a/scripts/cowork-vm-service.js b/scripts/cowork-vm-service.js index 9913462..5b36ee3 100644 --- a/scripts/cowork-vm-service.js +++ b/scripts/cowork-vm-service.js @@ -244,6 +244,23 @@ function buildMountMap(additionalMounts, mountBinds) { return map; } +/** + * Find the primary user mount name in mountMap — the first key that + * is not a dotfile mount (e.g. .claude, .auto-memory) and not the + * uploads mount. Used by both resolveWorkDir (HostBackend) and + * BwrapBackend.spawn to derive a sensible cwd from the user-selected + * project folder when the Electron app sends a session-root path with + * no /mnt/{name} component to translate. + * + * Returns the mount name (string) or null if no user mount exists. + */ +function findPrimaryMount(mountMap) { + if (!mountMap) return null; + return Object.keys(mountMap).find( + n => !n.startsWith('.') && n !== 'uploads', + ) || null; +} + /** * Build a merged environment for a spawned process. Combines filtered * daemon env with app-provided env, and translates guest paths in @@ -325,21 +342,91 @@ function buildSpawnEnv(appEnv, mountMap) { return mergedEnv; } +/** + * Split a CSV --allowedTools / --disallowedTools value into entries + * while respecting parentheses. Tool patterns may legitimately contain + * commas inside parens (e.g. "Bash(npm test, npm build)"), so a naive + * split on "," would corrupt them. Returns an array of entries with no + * trimming applied. + */ +function splitToolList(csv) { + const result = []; + if (!csv) return result; + let depth = 0; + let start = 0; + for (let i = 0; i < csv.length; i++) { + const ch = csv[i]; + if (ch === '(') depth++; + else if (ch === ')') depth = Math.max(0, depth - 1); + else if (ch === ',' && depth === 0) { + result.push(csv.slice(start, i)); + start = i + 1; + } + } + result.push(csv.slice(start)); + return result; +} + +/** + * Translate VM guest paths embedded inside a CSV tool-permission + * string (e.g. --allowedTools value). Each entry is either "Tool" + * (passed through) or "Tool(pattern)" (pattern is translated if it + * looks like a /sessions/ guest path). Entries whose guest path can't + * be mapped to a host path are dropped — a permission rule that + * can never match is worse than absent. + * + * Defensively normalizes leading double slashes (the Electron app + * emits "//sessions/..." due to an upstream path.join('/', ...) on an + * already-absolute path). + */ +function translateEmbeddedGuestPaths(csv, mountMap) { + if (!csv) return csv; + const out = []; + for (const entry of splitToolList(csv)) { + const m = entry.match(/^(\w+)\(([^)]+)\)$/); + if (!m) { + out.push(entry); + continue; + } + const tool = m[1]; + const normalized = m[2].replace(/^\/+/, '/'); + if (!normalized.startsWith('/sessions/')) { + out.push(entry); + continue; + } + const translated = translateGuestPath(normalized, mountMap || {}); + if (!translated) { + log(`translateEmbeddedGuestPaths: dropping "${entry}" (no host mapping)`); + continue; + } + log(`translateEmbeddedGuestPaths: ${entry} -> ${tool}(${translated})`); + out.push(`${tool}(${translated})`); + } + return out.join(','); +} + /** * Translate args that reference VM guest paths (/sessions/...) to host - * paths using mountMap. If translation fails, the flag pair is removed. + * paths using mountMap. Two flag styles are handled: + * - Single-path flags (--add-dir, --plugin-dir): the value is one + * guest path. Translation failure drops the whole flag pair. + * - Tool-list flags (--allowedTools, --disallowedTools): the value + * is a CSV of "Tool" or "Tool(pattern)" entries. Each entry is + * translated independently; entries that fail are dropped from + * the CSV but the flag itself is retained. */ function cleanSpawnArgs(rawArgs, mountMap) { const cleanArgs = []; const guestPathFlags = new Set(['--add-dir', '--plugin-dir']); + const toolListFlags = new Set(['--allowedTools', '--disallowedTools']); for (let i = 0; i < rawArgs.length; i++) { - if (guestPathFlags.has(rawArgs[i]) && + const flag = rawArgs[i]; + const value = rawArgs[i + 1]; + + if (guestPathFlags.has(flag) && i + 1 < rawArgs.length && - rawArgs[i + 1].startsWith('/sessions/')) { - const flag = rawArgs[i]; - let hostPath = translateGuestPath( - rawArgs[i + 1], mountMap - ); + value.startsWith('/sessions/')) { + let hostPath = translateGuestPath(value, mountMap); if (hostPath) { // --plugin-dir needs the plugin root, not a skills/ // subdirectory — walk up to find it. @@ -348,15 +435,25 @@ function cleanSpawnArgs(rawArgs, mountMap) { hostPath, os.homedir() ); } - log(`cleanSpawnArgs: translated ${flag} ${rawArgs[i + 1]} -> ${hostPath}`); + log(`cleanSpawnArgs: translated ${flag} ${value} -> ${hostPath}`); cleanArgs.push(flag, hostPath); } else { - log(`cleanSpawnArgs: removing ${flag} ${rawArgs[i + 1]} (no host mapping)`); + log(`cleanSpawnArgs: removing ${flag} ${value} (no host mapping)`); } i++; continue; } - cleanArgs.push(rawArgs[i]); + + if (toolListFlags.has(flag) && i + 1 < rawArgs.length) { + cleanArgs.push( + flag, + translateEmbeddedGuestPaths(value, mountMap), + ); + i++; + continue; + } + + cleanArgs.push(flag); } return cleanArgs; } @@ -410,8 +507,18 @@ function resolveWorkDir(cwd, sharedCwdPath, mountMap) { log(`resolveWorkDir: translated "${cwd}" -> "${translated}"`); workDir = translated; } else { - log(`resolveWorkDir: cwd is VM guest path "${cwd}", using home dir`); - workDir = os.homedir(); + // Session-root path (e.g. /sessions/bold-sharp-clarke) has no + // /mnt/ component, so translateGuestPath can't resolve it. + // Derive cwd from the primary user mount, mirroring what + // BwrapBackend does at spawn time. + const primaryMount = findPrimaryMount(mountMap); + if (primaryMount && mountMap[primaryMount]) { + log(`resolveWorkDir: session root "${cwd}", using primary mount "${primaryMount}" -> "${mountMap[primaryMount]}"`); + workDir = mountMap[primaryMount]; + } else { + log(`resolveWorkDir: cwd is VM guest path "${cwd}", no primary mount found, using home dir`); + workDir = os.homedir(); + } } } @@ -1163,9 +1270,7 @@ class BwrapBackend extends LocalBackend { ); // Use the primary user mount as cwd (first non-dotfile, non-uploads mount) - const primaryMount = Object.keys(mountMap).find( - n => !n.startsWith('.') && n !== 'uploads', - ); + const primaryMount = findPrimaryMount(mountMap); const guestWorkDir = primaryMount ? `${sessionMnt}/${primaryMount}` : sessionMnt; diff --git a/scripts/launcher-common.sh b/scripts/launcher-common.sh index ab877e4..f4b7268 100755 --- a/scripts/launcher-common.sh +++ b/scripts/launcher-common.sh @@ -94,8 +94,10 @@ build_electron_args() { # Kill orphaned cowork-vm-service daemon processes. # After a crash or unclean shutdown the cowork daemon may outlive the # main Electron UI process. The orphaned daemon holds LevelDB locks -# in ~/.config/Claude/Local Storage/ which cause new launches to -# detect a "main instance" and silently quit. +# in ~/.config/Claude/Local Storage/ AND keeps the Unix socket at +# $XDG_RUNTIME_DIR/cowork-vm-service.sock bound, which causes a new +# launch to either silently quit (LevelDB) or connect to the stale +# daemon (socket) and hang with a blank window. # Must run BEFORE cleanup_stale_lock / cleanup_stale_cowork_socket # so that stale files left behind by the daemon can be cleaned up. cleanup_orphaned_cowork_daemon() { @@ -103,23 +105,58 @@ cleanup_orphaned_cowork_daemon() { cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \ || return 0 - # Check if a Claude Desktop UI process is also running. - # Any claude-desktop electron process that is NOT the cowork - # daemon indicates the app is alive and the daemon is expected. - local pid cmdline - for pid in $(pgrep -f 'claude-desktop' 2>/dev/null); do + # Check if a live Claude Desktop UI process is also running. + # + # We can NOT use `pgrep -f 'claude-desktop'` on its own for this: + # it matches the launcher's own bash process (this script's + # cmdline contains "/usr/bin/claude-desktop"), any stale launcher + # bash left stopped/zombie after a previous crash, and the cowork + # daemon itself. Counting any of those as "the UI is alive" + # causes a false negative and the orphan survives. + # + # The reliable definition of "UI is alive" is: an Electron main + # process whose cmdline references app.asar and is NOT a Chromium + # helper (--type=...) and NOT the cowork daemon, and is actually + # runnable (not stopped/zombie). + local pid cmdline state + for pid in $(pgrep -f 'app\.asar' 2>/dev/null); do + # Skip our own launcher bash and its parent. + [[ $pid == "$$" || $pid == "$PPID" ]] && continue cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null) \ || continue + # Skip the cowork daemon (matches app.asar.unpacked path). [[ $cmdline == *cowork-vm-service* ]] && continue - # Found a non-daemon claude-desktop process — not orphaned + # Skip Chromium helpers: zygote, renderer, gpu, utility, etc. + [[ $cmdline == *--type=* ]] && continue + # Skip stopped (T/t) and zombie (Z) processes — not a live UI. + state=$(awk '/^State:/ {print $2; exit}' \ + "/proc/$pid/status" 2>/dev/null) || continue + [[ $state == T || $state == t || $state == Z ]] && continue + # Found a genuine live Electron UI — daemon is expected return 0 done - # No UI process found — daemon is orphaned, terminate it + # No UI process found — daemon is orphaned, terminate it. + # Escalate to SIGKILL if a daemon is stuck and does not exit + # after SIGTERM within ~2s, so cleanup_stale_cowork_socket + # (which runs next) reliably sees no daemon. for pid in $cowork_pids; do kill "$pid" 2>/dev/null || true done - log_message "Killed orphaned cowork-vm-service daemon (PIDs: $cowork_pids)" + local _wait=0 + while ((_wait < 20)); do + pgrep -f 'cowork-vm-service\.js' &>/dev/null || break + sleep 0.1 + ((_wait++)) + done + if pgrep -f 'cowork-vm-service\.js' &>/dev/null; then + for pid in $cowork_pids; do + kill -KILL "$pid" 2>/dev/null || true + done + log_message "Killed orphaned cowork-vm-service daemon (SIGKILL, PIDs: $cowork_pids)" + else + log_message "Killed orphaned cowork-vm-service daemon (PIDs: $cowork_pids)" + fi } # Clean up stale SingletonLock if the owning process is no longer running. @@ -155,26 +192,31 @@ cleanup_stale_lock() { # $XDG_RUNTIME_DIR/cowork-vm-service.sock. After a crash or unclean # shutdown, the socket file persists but nothing is listening, causing # ECONNREFUSED instead of ENOENT when the app tries to connect. +# +# NOTE: this function MUST run after cleanup_orphaned_cowork_daemon, +# which is responsible for killing any orphaned daemon. Given that +# ordering, the presence of a live daemon proves the socket is in +# use; the absence of a daemon proves the socket is stale. +# We use that invariant directly instead of depending on socat (not +# shipped by default on Debian/Ubuntu) or an age heuristic (the old +# 24h fallback effectively disabled the cleanup for any recent +# crash). cleanup_stale_cowork_socket() { local sock="${XDG_RUNTIME_DIR:-/tmp}/cowork-vm-service.sock" [[ -S $sock ]] || return 0 - if command -v socat &>/dev/null; then - # Try connecting — if refused, the socket is stale - if socat -u OPEN:/dev/null UNIX-CONNECT:"$sock" 2>/dev/null; then - return 0 - fi - else - # No socat: fall back to age-based check (>24h = stale) - if [[ -z $(find "$sock" -mmin +1440 2>/dev/null) ]]; then - return 0 - fi - log_message "No socat available; removing old socket (>24h)" + # If a cowork daemon is alive, it owns this socket; leave it. + # cleanup_orphaned_cowork_daemon has already run and removed any + # orphan (with SIGKILL escalation), so anything still alive here + # is a non-orphaned, live daemon. + if pgrep -f 'cowork-vm-service\.js' &>/dev/null; then + return 0 fi + # No daemon — the socket file is left over from a crash. rm -f "$sock" - log_message "Removed stale cowork-vm-service socket" + log_message "Removed stale cowork-vm-service socket (no daemon running)" } # Set common environment variables @@ -746,15 +788,29 @@ print(len(servers)) _doctor_check_bwrap_mounts # -- Orphaned cowork daemon -- + # Uses the same live-UI detection as cleanup_orphaned_cowork_daemon + # above: a live UI is an Electron main process on app.asar that is + # not a Chromium helper (--type=...), not the cowork daemon itself, + # and not stopped/zombie. Counting any `claude-desktop`-matching + # process (as the old check did) would include the launcher's own + # bash and stuck launcher bashes from previous crashes, producing + # false negatives where a real orphan is misreported as "parent + # alive". local _cowork_pids _cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \ || true if [[ -n $_cowork_pids ]]; then - local _daemon_orphaned=true _pid _cmdline - for _pid in $(pgrep -f 'claude-desktop' 2>/dev/null); do + local _daemon_orphaned=true _pid _cmdline _state + for _pid in $(pgrep -f 'app\.asar' 2>/dev/null); do + [[ $_pid == "$$" || $_pid == "$PPID" ]] && continue _cmdline=$(tr '\0' ' ' \ < "/proc/$_pid/cmdline" 2>/dev/null) || continue [[ $_cmdline == *cowork-vm-service* ]] && continue + [[ $_cmdline == *--type=* ]] && continue + _state=$(awk '/^State:/ {print $2; exit}' \ + "/proc/$_pid/status" 2>/dev/null) || continue + [[ $_state == T || $_state == t || $_state == Z ]] \ + && continue _daemon_orphaned=false break done diff --git a/tests/cowork-path-translation.bats b/tests/cowork-path-translation.bats index 5144458..11d1ec5 100644 --- a/tests/cowork-path-translation.bats +++ b/tests/cowork-path-translation.bats @@ -96,17 +96,58 @@ function resolvePluginRoot(pluginPath, mountBase) { return pluginPath; } +function splitToolList(csv) { + const result = []; + if (!csv) return result; + let depth = 0; + let start = 0; + for (let i = 0; i < csv.length; i++) { + const ch = csv[i]; + if (ch === "(") depth++; + else if (ch === ")") depth = Math.max(0, depth - 1); + else if (ch === "," && depth === 0) { + result.push(csv.slice(start, i)); + start = i + 1; + } + } + result.push(csv.slice(start)); + return result; +} + +function translateEmbeddedGuestPaths(csv, mountMap) { + if (!csv) return csv; + const out = []; + for (const entry of splitToolList(csv)) { + const m = entry.match(/^(\w+)\(([^)]+)\)$/); + if (!m) { + out.push(entry); + continue; + } + const tool = m[1]; + const normalized = m[2].replace(/^\/+/, "/"); + if (!normalized.startsWith("/sessions/")) { + out.push(entry); + continue; + } + const translated = translateGuestPath(normalized, mountMap || {}); + if (!translated) continue; + out.push(`${tool}(${translated})`); + } + return out.join(","); +} + function cleanSpawnArgs(rawArgs, mountMap) { const cleanArgs = []; const guestPathFlags = new Set(["--add-dir", "--plugin-dir"]); + const toolListFlags = new Set(["--allowedTools", "--disallowedTools"]); for (let i = 0; i < rawArgs.length; i++) { - if (guestPathFlags.has(rawArgs[i]) && + const flag = rawArgs[i]; + const value = rawArgs[i + 1]; + + if (guestPathFlags.has(flag) && i + 1 < rawArgs.length && - rawArgs[i + 1].startsWith("/sessions/")) { - const flag = rawArgs[i]; - let hostPath = translateGuestPath( - rawArgs[i + 1], mountMap || {} - ); + value.startsWith("/sessions/")) { + let hostPath = translateGuestPath(value, mountMap || {}); if (hostPath) { if (flag === "--plugin-dir") { hostPath = resolvePluginRoot( @@ -120,11 +161,28 @@ function cleanSpawnArgs(rawArgs, mountMap) { i++; continue; } - cleanArgs.push(rawArgs[i]); + + if (toolListFlags.has(flag) && i + 1 < rawArgs.length) { + cleanArgs.push( + flag, + translateEmbeddedGuestPaths(value, mountMap), + ); + i++; + continue; + } + + cleanArgs.push(flag); } return cleanArgs; } +function findPrimaryMount(mountMap) { + if (!mountMap) return null; + return Object.keys(mountMap).find( + n => !n.startsWith(".") && n !== "uploads", + ) || null; +} + function resolveWorkDir(cwd, sharedCwdPath, mountMap) { let workDir = cwd || os.homedir(); if (sharedCwdPath) { @@ -134,7 +192,12 @@ function resolveWorkDir(cwd, sharedCwdPath, mountMap) { if (translated) { workDir = translated; } else { - workDir = os.homedir(); + const primaryMount = findPrimaryMount(mountMap); + if (primaryMount && mountMap[primaryMount]) { + workDir = mountMap[primaryMount]; + } else { + workDir = os.homedir(); + } } } if (!fs.existsSync(workDir)) { @@ -540,6 +603,177 @@ assertDeepEqual(result, [[ "$status" -eq 0 ]] } +@test "cleanSpawnArgs: translates --allowedTools embedded guest paths" { + run node -e "${NODE_PREAMBLE} +const result = cleanSpawnArgs( + [ + '--allowedTools', + 'Read,Edit,Edit(//sessions/abc/mnt/.auto-memory/**),Write(//sessions/abc/mnt/.auto-memory/**)' + ], + {'.auto-memory': '/host/memory'} +); +assertDeepEqual(result, + [ + '--allowedTools', + 'Read,Edit,Edit(/host/memory/**),Write(/host/memory/**)' + ], + '--allowedTools translated, plain entries preserved'); +" + [[ "$status" -eq 0 ]] +} + +@test "cleanSpawnArgs: translates --disallowedTools embedded guest paths" { + run node -e "${NODE_PREAMBLE} +const result = cleanSpawnArgs( + [ + '--disallowedTools', + 'Bash(rm),Edit(//sessions/abc/mnt/data/secret/**)' + ], + {'data': '/host/data'} +); +assertDeepEqual(result, + [ + '--disallowedTools', + 'Bash(rm),Edit(/host/data/secret/**)' + ], + '--disallowedTools translated'); +" + [[ "$status" -eq 0 ]] +} + +# ============================================================================= +# splitToolList +# ============================================================================= + +@test "splitToolList: empty / null input" { + run node -e "${NODE_PREAMBLE} +assertDeepEqual(splitToolList(''), [], 'empty string -> []'); +assertDeepEqual(splitToolList(null), [], 'null -> []'); +assertDeepEqual(splitToolList(undefined), [], 'undefined -> []'); +" + [[ "$status" -eq 0 ]] +} + +@test "splitToolList: simple CSV with no parens" { + run node -e "${NODE_PREAMBLE} +assertDeepEqual( + splitToolList('Read,Edit,Write'), + ['Read', 'Edit', 'Write'], + 'plain CSV'); +" + [[ "$status" -eq 0 ]] +} + +@test "splitToolList: respects parentheses around commas" { + run node -e "${NODE_PREAMBLE} +assertDeepEqual( + splitToolList('Bash(npm test, npm build),Edit,Read'), + ['Bash(npm test, npm build)', 'Edit', 'Read'], + 'commas inside parens are preserved'); +" + [[ "$status" -eq 0 ]] +} + +@test "splitToolList: handles trailing entry without comma" { + run node -e "${NODE_PREAMBLE} +assertDeepEqual( + splitToolList('A,B(c,d)'), + ['A', 'B(c,d)'], + 'final entry includes nested commas'); +" + [[ "$status" -eq 0 ]] +} + +# ============================================================================= +# translateEmbeddedGuestPaths +# ============================================================================= + +@test "translateEmbeddedGuestPaths: passes through entries without parens" { + run node -e "${NODE_PREAMBLE} +assertEqual( + translateEmbeddedGuestPaths( + 'Read,Edit,Write', + {'.auto-memory': '/host/memory'} + ), + 'Read,Edit,Write', + 'plain tool names unchanged'); +" + [[ "$status" -eq 0 ]] +} + +@test "translateEmbeddedGuestPaths: translates Edit() with double-slash guest path" { + run node -e "${NODE_PREAMBLE} +assertEqual( + translateEmbeddedGuestPaths( + 'Edit(//sessions/abc/mnt/.auto-memory/**)', + {'.auto-memory': '/host/memory'} + ), + 'Edit(/host/memory/**)', + 'leading // normalized and translated'); +" + [[ "$status" -eq 0 ]] +} + +@test "translateEmbeddedGuestPaths: translates entry with single-slash guest path" { + run node -e "${NODE_PREAMBLE} +assertEqual( + translateEmbeddedGuestPaths( + 'Write(/sessions/abc/mnt/.auto-memory/**)', + {'.auto-memory': '/host/memory'} + ), + 'Write(/host/memory/**)', + 'single-slash variant also translated'); +" + [[ "$status" -eq 0 ]] +} + +@test "translateEmbeddedGuestPaths: drops entries whose mount is unknown" { + run node -e "${NODE_PREAMBLE} +assertEqual( + translateEmbeddedGuestPaths( + 'Read,Edit(//sessions/abc/mnt/unknown/**),Write', + {'.auto-memory': '/host/memory'} + ), + 'Read,Write', + 'unresolvable entry is dropped, others retained'); +" + [[ "$status" -eq 0 ]] +} + +@test "translateEmbeddedGuestPaths: leaves non-/sessions paths alone" { + run node -e "${NODE_PREAMBLE} +assertEqual( + translateEmbeddedGuestPaths( + 'Bash(rm),Edit(/home/user/explicit/file)', + {'.auto-memory': '/host/memory'} + ), + 'Bash(rm),Edit(/home/user/explicit/file)', + 'host paths and non-paths unchanged'); +" + [[ "$status" -eq 0 ]] +} + +@test "translateEmbeddedGuestPaths: handles MCP-style tool names with underscores" { + run node -e "${NODE_PREAMBLE} +assertEqual( + translateEmbeddedGuestPaths( + 'mcp__server__tool(//sessions/abc/mnt/data/**)', + {'data': '/host/data'} + ), + 'mcp__server__tool(/host/data/**)', + 'mcp-style tool name preserved'); +" + [[ "$status" -eq 0 ]] +} + +@test "translateEmbeddedGuestPaths: empty / null input" { + run node -e "${NODE_PREAMBLE} +assertEqual(translateEmbeddedGuestPaths('', {}), '', 'empty -> empty'); +assertEqual(translateEmbeddedGuestPaths(null, {}), null, 'null -> null'); +" + [[ "$status" -eq 0 ]] +} + # ============================================================================= # resolvePluginRoot # ============================================================================= @@ -711,6 +945,110 @@ assertEqual( [[ "$status" -eq 0 ]] } +@test "resolveWorkDir: session-root cwd uses primary user mount" { + mkdir -p "${TEST_TMP}/project" + + run node -e "${NODE_PREAMBLE} +assertEqual( + resolveWorkDir( + '/sessions/bold-sharp-clarke', + null, + {'project': '${TEST_TMP}/project'} + ), + '${TEST_TMP}/project', + 'session-root falls through to primary mount'); +" + [[ "$status" -eq 0 ]] +} + +@test "resolveWorkDir: session-root cwd skips dotfile and uploads mounts" { + mkdir -p "${TEST_TMP}/project" + + run node -e "${NODE_PREAMBLE} +assertEqual( + resolveWorkDir( + '/sessions/abc', + null, + { + '.claude': '${TEST_TMP}/dotclaude', + '.auto-memory': '${TEST_TMP}/automem', + 'uploads': '${TEST_TMP}/uploads', + 'project': '${TEST_TMP}/project' + } + ), + '${TEST_TMP}/project', + 'dotfile and uploads mounts are skipped'); +" + [[ "$status" -eq 0 ]] +} + +@test "resolveWorkDir: session-root cwd with no user mount falls back to home" { + run node -e "${NODE_PREAMBLE} +assertEqual( + resolveWorkDir( + '/sessions/abc', + null, + { + '.claude': '/host/dotclaude', + 'uploads': '/host/uploads' + } + ), + os.homedir(), + 'no user mount -> homedir fallback'); +" + [[ "$status" -eq 0 ]] +} + +# ============================================================================= +# findPrimaryMount +# ============================================================================= + +@test "findPrimaryMount: returns null for null mountMap" { + run node -e "${NODE_PREAMBLE} +assert(findPrimaryMount(null) === null, 'null mountMap'); +assert(findPrimaryMount(undefined) === null, 'undefined mountMap'); +assert(findPrimaryMount({}) === null, 'empty mountMap'); +" + [[ "$status" -eq 0 ]] +} + +@test "findPrimaryMount: returns first non-dotfile non-uploads key" { + run node -e "${NODE_PREAMBLE} +assertEqual( + findPrimaryMount({'project': '/h/p'}), + 'project', + 'single user mount'); +assertEqual( + findPrimaryMount({ + '.claude': '/h/c', + 'uploads': '/h/u', + 'project': '/h/p' + }), + 'project', + 'skips dotfiles and uploads'); +" + [[ "$status" -eq 0 ]] +} + +@test "findPrimaryMount: returns null when all mounts are dotfiles or uploads" { + run node -e "${NODE_PREAMBLE} +assert( + findPrimaryMount({'.claude': '/h/c', 'uploads': '/h/u'}) === null, + 'no user mount -> null'); +" + [[ "$status" -eq 0 ]] +} + +@test "findPrimaryMount: insertion order determines primary when multiple exist" { + run node -e "${NODE_PREAMBLE} +assertEqual( + findPrimaryMount({'first': '/h/1', 'second': '/h/2'}), + 'first', + 'first inserted user mount wins'); +" + [[ "$status" -eq 0 ]] +} + # ============================================================================= # buildSpawnEnv # =============================================================================