fix(pi): add snap node compatibility diagnostics and documentation
- Added detailed error logging to detect snap node compatibility issues - When Pi process exits with code 1, log helpful diagnostic info including node path - Add hint to check for snap node and reinstall via apt/nvm - Create SNAP_NODE_COMPATIBILITY.md with full troubleshooting guide - Document root cause: snap node has file descriptor incompatibility with Bun.spawn stdin pipes - Provide clear installation instructions for NodeSource and nvm alternatives
This commit is contained in:
@@ -91,7 +91,10 @@ export async function sendAndAwait(params: SendAndAwaitParams): Promise<SendAndA
|
||||
const lockPromise = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
sessionLocks.set(sessionId, existing.then(() => lockPromise));
|
||||
sessionLocks.set(
|
||||
sessionId,
|
||||
existing.then(() => lockPromise),
|
||||
);
|
||||
|
||||
await existing;
|
||||
|
||||
@@ -291,23 +294,18 @@ async function doSend(sessionId: string, params: SendAndAwaitParams): Promise<Se
|
||||
(async () => {
|
||||
try {
|
||||
if (!session.piProcess) {
|
||||
let spawnOptions: { sessionFile?: string } | undefined;
|
||||
let spawnOptions: { sessionFile?: string; username?: string } | undefined;
|
||||
if (session.messages.length > 0) {
|
||||
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
|
||||
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
|
||||
if (hostPath) {
|
||||
// Remap host path to container path
|
||||
const containerHome = `/home/${username}`;
|
||||
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
|
||||
const relativePart = hostPath.slice(sessionsPrefix.length);
|
||||
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
|
||||
spawnOptions = { sessionFile: hostPath, username };
|
||||
}
|
||||
}
|
||||
if (!spawnOptions) spawnOptions = { username };
|
||||
|
||||
const dispatcher = createDispatcher(sessionId);
|
||||
const sandbox = { userId, username, email, homeDir };
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model!, userId, email, dispatcher, sandbox, spawnOptions);
|
||||
session.sandboxed = true;
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model!, userId, email, dispatcher, spawnOptions);
|
||||
|
||||
const proc = session.piProcess;
|
||||
proc.exited.then(() => {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { existsSync, symlinkSync, rmdirSync } from 'node:fs';
|
||||
import { ensureDockerContainer } from '@@/api/terminal/websocket';
|
||||
import { getHomeDir, DATA_PATH, getNativeToolsDir, getGlobalToolsDir, getUserToolsDir, getNativeSkillsDir, getGlobalSkillsDir, getUserSkillsDir } from '@@/data-path';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import {
|
||||
getHomeDir,
|
||||
DATA_PATH,
|
||||
getNativeToolsDir,
|
||||
getGlobalToolsDir,
|
||||
getUserToolsDir,
|
||||
getNativeSkillsDir,
|
||||
getGlobalSkillsDir,
|
||||
getUserSkillsDir,
|
||||
toShellUsername,
|
||||
} from '@@/data-path';
|
||||
import { readToolDirs, parseFrontmatter as parseToolFrontmatter } from '@@/api/tools/tools';
|
||||
import { readSkillDirs, parseFrontmatter as parseSkillFrontmatter } from '@@/api/skills/skills';
|
||||
import { buildHostToolEnv } from '@@/api/pi/pi-bridge';
|
||||
@@ -11,6 +19,12 @@ import type { MessageCost, PiEvent } from '@@/api/pi/types';
|
||||
|
||||
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
// Resolve absolute path to claude binary so sudo -u can find it regardless of target user's PATH
|
||||
const CLAUDE_BIN = (() => {
|
||||
const result = Bun.spawnSync({ cmd: ['which', 'claude'], stdout: 'pipe', stderr: 'ignore' });
|
||||
return result.stdout.toString().trim() || 'claude';
|
||||
})();
|
||||
|
||||
type ClaudeCodeParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
@@ -85,9 +99,16 @@ async function buildToolsSystemPrompt(email: string): Promise<string | null> {
|
||||
Array.from(mergedTools.entries()).map(async ([dirName, filePath]) => {
|
||||
const raw = await Bun.file(filePath).text();
|
||||
const { frontmatter, body } = parseToolFrontmatter(raw);
|
||||
const toolDir = dirname(filePath);
|
||||
const toolDir = join(filePath, '..');
|
||||
const hasImpl = await Bun.file(`${toolDir}/index.ts`).exists();
|
||||
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, body, toolDir, hasImpl };
|
||||
return {
|
||||
dirName,
|
||||
name: frontmatter.name || dirName,
|
||||
description: frontmatter.description,
|
||||
body,
|
||||
toolDir,
|
||||
hasImpl,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const runnerPath = `${getNativeToolsDir()}/run.ts`;
|
||||
@@ -125,45 +146,59 @@ async function buildToolsSystemPrompt(email: string): Promise<string | null> {
|
||||
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
|
||||
const { userId, email, username, prompt, sessionKey } = params;
|
||||
const homeDir = getHomeDir(email);
|
||||
const shellUsername = toShellUsername(username, email);
|
||||
const toolEnv = await buildHostToolEnv(userId, email);
|
||||
|
||||
const container = await ensureDockerContainer(email, userId, homeDir, username);
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const containerId = container.dockerId;
|
||||
const containerHome = `/home/${username}`;
|
||||
|
||||
const args = [
|
||||
dockerPath, 'exec', '-i',
|
||||
'-u', username,
|
||||
'-w', containerHome,
|
||||
'-e', `HOME=${containerHome}`,
|
||||
containerId,
|
||||
'claude', '-p', prompt,
|
||||
'--dangerously-skip-permissions',
|
||||
'--output-format', 'json',
|
||||
];
|
||||
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
|
||||
|
||||
const existingSession = claudeCodeSessions.get(sessionKey);
|
||||
if (existingSession) {
|
||||
args.push('--resume', existingSession);
|
||||
claudeArgs.push('--resume', existingSession);
|
||||
}
|
||||
|
||||
logger.info('Claude Code exec', { sessionKey, containerId, resume: existingSession ?? null });
|
||||
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
||||
|
||||
const proc = Bun.spawn(args, {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
// For service user, keep real HOME so Claude Code finds its credentials
|
||||
const env: Record<string, string> = {
|
||||
...toolEnv,
|
||||
...(isServiceUser ? { HOME: process.env.HOME ?? '' } : {}),
|
||||
PATH: process.env.PATH ?? '',
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
|
||||
logger.info('Claude Code exec', {
|
||||
sessionKey,
|
||||
username: shellUsername,
|
||||
isServiceUser,
|
||||
resume: existingSession ?? null,
|
||||
});
|
||||
|
||||
const proc = isServiceUser
|
||||
? Bun.spawn(claudeArgs, {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, ...env },
|
||||
})
|
||||
: Bun.spawn(
|
||||
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs],
|
||||
{
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}, SEND_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
clearTimeout(timeout);
|
||||
@@ -233,7 +268,6 @@ type ClaudeCodeStreamingParams = {
|
||||
prompt: string;
|
||||
sessionKey: string;
|
||||
cwd?: string;
|
||||
sandboxed?: boolean;
|
||||
onEvent: (event: PiEvent) => void;
|
||||
};
|
||||
|
||||
@@ -242,12 +276,19 @@ type ClaudeCodeStreamingHandle = {
|
||||
};
|
||||
|
||||
export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise<ClaudeCodeStreamingHandle> {
|
||||
const { userId, email, username, prompt, sessionKey, cwd, sandboxed = false, onEvent } = params;
|
||||
const { userId, email, username, prompt, sessionKey, cwd, onEvent } = params;
|
||||
|
||||
const shellUsername = toShellUsername(username, email);
|
||||
const homeDir = getHomeDir(email);
|
||||
const workDir = cwd ?? homeDir;
|
||||
|
||||
const claudeArgs = [
|
||||
'claude', '-p', prompt,
|
||||
CLAUDE_BIN,
|
||||
'-p',
|
||||
prompt,
|
||||
'--dangerously-skip-permissions',
|
||||
'--output-format', 'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--include-partial-messages',
|
||||
];
|
||||
@@ -263,69 +304,51 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams)
|
||||
claudeArgs.push('--append-system-prompt', systemPrompt);
|
||||
}
|
||||
|
||||
let proc: ReturnType<typeof Bun.spawn>;
|
||||
const toolEnv = await buildHostToolEnv(userId, email);
|
||||
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
||||
|
||||
if (sandboxed) {
|
||||
// Container execution via docker exec
|
||||
const homeDir = getHomeDir(email);
|
||||
const container = await ensureDockerContainer(email, userId, homeDir, username);
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const containerId = container.dockerId;
|
||||
const containerHome = `/home/${username}`;
|
||||
const workDir = cwd ?? containerHome;
|
||||
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
||||
|
||||
const args = [
|
||||
dockerPath, 'exec',
|
||||
'-u', username,
|
||||
'-w', workDir,
|
||||
'-e', `HOME=${containerHome}`,
|
||||
containerId,
|
||||
...claudeArgs,
|
||||
];
|
||||
// For service user, keep real HOME so Claude Code finds its credentials
|
||||
const env: Record<string, string> = {
|
||||
...toolEnv,
|
||||
...(isServiceUser ? { HOME: cleanEnv.HOME ?? '' } : {}),
|
||||
PATH: cleanEnv.PATH ?? '',
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
|
||||
logger.info('Claude Code streaming exec (container)', { sessionKey, containerId, resume: existingSession ?? null });
|
||||
logger.info('Claude Code streaming exec', {
|
||||
sessionKey,
|
||||
username: shellUsername,
|
||||
isServiceUser,
|
||||
cwd: workDir,
|
||||
resume: existingSession ?? null,
|
||||
});
|
||||
|
||||
proc = Bun.spawn(args, {
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
} else {
|
||||
// Host execution — run claude directly
|
||||
const claudePath = Bun.which('claude') ?? 'claude';
|
||||
claudeArgs[0] = claudePath;
|
||||
const workDir = cwd ?? homedir();
|
||||
|
||||
logger.info('Claude Code streaming exec (host)', { sessionKey, cwd: workDir, resume: existingSession ?? null });
|
||||
|
||||
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
||||
const toolEnv = await buildHostToolEnv(userId, email);
|
||||
|
||||
// Ensure Claude Code can find ~/.claude credentials in the user's data home.
|
||||
// Symlink the host's .claude config into the data home if not already there.
|
||||
const dataHome = toolEnv.HOME!;
|
||||
const hostClaudeConfig = join(homedir(), '.claude');
|
||||
const targetClaudeConfig = join(dataHome, '.claude');
|
||||
const targetCredentials = join(targetClaudeConfig, '.credentials.json');
|
||||
if (!existsSync(targetCredentials) && existsSync(hostClaudeConfig)) {
|
||||
try {
|
||||
// Remove empty placeholder dir if it exists, then symlink
|
||||
if (existsSync(targetClaudeConfig)) rmdirSync(targetClaudeConfig);
|
||||
symlinkSync(hostClaudeConfig, targetClaudeConfig);
|
||||
} catch { /* race, permission, or non-empty dir */ }
|
||||
}
|
||||
|
||||
proc = Bun.spawn(claudeArgs, {
|
||||
cwd: workDir,
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...cleanEnv, ...toolEnv },
|
||||
});
|
||||
}
|
||||
const proc = isServiceUser
|
||||
? Bun.spawn(claudeArgs, {
|
||||
cwd: workDir,
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...cleanEnv, ...env },
|
||||
})
|
||||
: Bun.spawn(
|
||||
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs],
|
||||
{
|
||||
cwd: workDir,
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
onEvent({ type: 'error', message: 'Claude Code timed out after 5 minutes' });
|
||||
}, SEND_TIMEOUT_MS);
|
||||
|
||||
@@ -493,12 +516,19 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams)
|
||||
if (!gotResult) {
|
||||
const exitCode = await proc.exited;
|
||||
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text();
|
||||
logger.info('Claude Code exited without result event', { sessionKey, exitCode, stderr: stderr.trim().slice(0, 500) });
|
||||
logger.info('Claude Code exited without result event', {
|
||||
sessionKey,
|
||||
exitCode,
|
||||
stderr: stderr.trim().slice(0, 500),
|
||||
});
|
||||
if (textBuffer) {
|
||||
onEvent({ type: 'text', text: textBuffer });
|
||||
}
|
||||
if (exitCode !== 0) {
|
||||
onEvent({ type: 'error', message: `Claude Code exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}` });
|
||||
onEvent({
|
||||
type: 'error',
|
||||
message: `Claude Code exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}`,
|
||||
});
|
||||
} else {
|
||||
onEvent({ type: 'result', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user