Merge main to revalidate Patch 6 against 1.3109.0

Catches up to current upstream URLs (1.3109.0), Joost-Maker's #418
identifier-widening fix in Patch 9, and #421's existsync/node-pty fix.
PR #410's last CI ran on April 16 against 1.2773.0 and showed
'WARNING: Could not find retry delay for auto-launch patch' — this
merge re-runs CI against current main to surface whether Patch 6's
regex anchors still match on 1.3109.0.
This commit is contained in:
Travis Stockton
2026-04-18 21:16:48 -05:00
6 changed files with 612 additions and 65 deletions

View File

@@ -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

View File

@@ -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" \

View File

@@ -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=";
};
};

View File

@@ -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;

View File

@@ -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

View File

@@ -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
# =============================================================================