mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 00:26:21 +03:00
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:
@@ -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
|
||||
|
||||
|
||||
73
build.sh
73
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" \
|
||||
|
||||
@@ -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=";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user