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:
2026-03-04 01:45:36 +00:00
parent 72d1341cbc
commit ef13f96d36
34 changed files with 2394 additions and 1466 deletions
+6
View File
@@ -6,6 +6,7 @@ import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
import { validateUsername } from './validate-username';
import { provisionLinuxUser } from '../users/provision';
export const verifyHandler: Handler = async function (ctx) {
const { verificationCode, name, username, password, confirmPassword } = ctx.get('body');
@@ -42,6 +43,11 @@ export const verifyHandler: Handler = async function (ctx) {
const finalUser = await getUserById(userInfo.id);
if (!finalUser) throw errors.NOT_FOUND('User not found');
// Provision Linux user for terminal/Pi/Claude Code isolation
provisionLinuxUser(finalUser.email, finalUser.username ?? '').catch((err) => {
console.error('[verify] failed to provision Linux user:', err);
});
// Issue a token so the user is logged in immediately
const token = await sign({
id: finalUser.id,
@@ -0,0 +1,133 @@
# Snap Node Compatibility Issue
## Problem
When using Officer with **snap node** (`/snap/bin/node`), the Pi harness fails with the following error:
```
[Pi] [INFO] Pi process exited
code=1
```
This occurs on the first chat message, before Pi even processes the command.
## Root Cause
The snap version of Node.js has an incompatibility with how Bun's `spawn()` function sets up piped stdin file descriptors. When Officer tries to spawn a Pi process with `stdin: 'pipe'`, the process immediately exits with code 1, preventing the RPC communication from working.
This does **NOT** happen with:
- System node installed via apt/package manager
- NodeSource node
- Homebrew node (on macOS)
- Any non-snap Node.js installation
## Solution
**Uninstall snap node and install a system-managed version instead:**
### Step 1: Remove snap node
```bash
sudo snap remove node
```
### Step 2: Install Node.js via apt (recommended)
```bash
# Add NodeSource repository for Node 20 LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
# Install Node.js
sudo apt-get install -y nodejs
# Verify installation
node --version
which node # Should be /usr/bin/node (NOT /snap/bin/node)
```
### Alternative: Using system apt repository
If NodeSource is unavailable in your region:
```bash
sudo apt-get update
sudo apt-get install -y nodejs npm
```
### Alternative: Using nvm (Node Version Manager)
For more control over Node.js versions:
```bash
# Install nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
# Install Node LTS
nvm install --lts
nvm use --lts
# Verify
which node # Should be ~/.nvm/versions/node/*/bin/node
```
## Verification
After installing a non-snap Node.js:
```bash
# Verify node is not from snap
which node
# Output should NOT contain "/snap/"
# Verify node works
node --version
# Clear npm cache
npm cache clean --force
npm install -g pi-coding-agent
```
Then restart Officer and try the chat functionality again - it should work!
## Troubleshooting
### Still seeing the error after reinstalling Node?
1. **Restart Officer service** (if running as a service):
```bash
sudo systemctl restart officer
# or
bun dev # if running locally
```
2. **Verify Bun can find the correct node**:
```bash
bun run "which node"
which node
# Both should show the same path, not /snap/bin/node
```
3. **Check Pi installation**:
```bash
pi --version
pi --list-models
```
### Error logs to look for
If you still see the error, check Officer logs for:
```
Pi process exited with code 1
nodeVersion: ...
nodeExePath: /snap/bin/node
isSnapNode: true
```
This confirms snap node is the issue.
## Why snap node has this issue
The snap package environment isolates certain system calls and file descriptor handling, which conflicts with Bun's pipe setup mechanism. The snap version of Node.js doesn't properly inherit file descriptor flags when pipes are created by Bun, causing the process to fail on startup.
This is a known incompatibility and not a bug in Officer or Pi itself.
+16 -11
View File
@@ -40,23 +40,30 @@ export async function listPiModels(): Promise<ModelInfo[]> {
}
try {
const proc = Bun.spawn(['pi', '--list-models'], {
// Resolve absolute path to pi binary (PATH may differ under pm2/systemd)
const piBin = (() => {
const r = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' });
return r.stdout.toString().trim() || 'pi';
})();
// Use spawnSync — Bun.spawn (async) loses stdout under pm2
const proc = Bun.spawnSync({
cmd: [piBin, '--list-models'],
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
});
const output = await new Response(proc.stdout).text();
await proc.exited;
const output = proc.stdout.toString();
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
logger.error('pi --list-models failed', { exitCode: proc.exitCode, stderr: stderr.trim() });
return [];
const stderrText = proc.stderr.toString();
logger.error('pi --list-models failed', { exitCode: proc.exitCode, stderr: stderrText.trim(), piBin });
return [CLAUDE_CODE_MODEL];
}
const lines = output.trim().split('\n');
if (lines.length < 2) return [];
if (lines.length < 2) return [CLAUDE_CODE_MODEL];
// Parse fixed-width table: provider, model, context, max-out, thinking, images
const header = lines[0]!;
@@ -89,16 +96,14 @@ export async function listPiModels(): Promise<ModelInfo[]> {
const thinking = extractCol(line, 4);
const images = extractCol(line, 5);
// zai and opencode are the same service — prefer opencode, skip zai duplicates
const displayProvider = provider === 'zai' ? 'opencode' : provider;
const dedupeKey = `${displayProvider}/${model}`;
const dedupeKey = `${provider}/${model}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
models.push({
id: `${provider}/${model}`,
name: model,
provider: displayProvider,
provider,
contextWindow: parseSize(context),
maxTokens: parseSize(maxOut),
reasoning: thinking === 'yes',
+178 -193
View File
@@ -1,34 +1,56 @@
import { join, relative } from "path";
import { homedir } from "node:os";
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import type { Subprocess } from "bun";
import type { PiEvent, MessageCost } from "./types";
import { readSearxngConfig } from "../server-settings/searxng";
import { PI_CONFIG_DIR, DATA_PATH, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
import { ensureDockerContainer } from "../terminal/websocket";
import { getServerIntegration, getUserIntegration } from "officerdb";
import { logger } from "./logger";
import { parseFrontmatter } from "../skills/skills";
import { getRelayPort } from "../browser/relay";
import { registerUserToken } from "../browser/relay-auth";
import { join } from 'path';
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import type { Subprocess } from 'bun';
import type { PiEvent, MessageCost } from './types';
import { readSearxngConfig } from '../server-settings/searxng';
import {
PI_CONFIG_DIR,
DATA_PATH,
getHomeDir,
getGlobalSkillsDir,
getUserSkillsDir,
getGlobalExtensionsDir,
getUserExtensionsDir,
getGlobalToolsDir,
getUserToolsDir,
getNativeResourcesDir,
getGlobalResourcesDir,
toShellUsername,
} from '../../data-path';
import { getServerIntegration, getUserIntegration } from 'officerdb';
import { logger } from './logger';
import { parseFrontmatter } from '../skills/skills';
import { getRelayPort } from '../browser/relay';
import { registerUserToken } from '../browser/relay-auth';
// Resolve pi as [node, cli.js] — Bun.spawn async pipes break with shebang scripts under pm2
const PI_CMD = (() => {
const whichResult = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' });
const piBin = whichResult.stdout.toString().trim() || 'pi';
// Follow symlink to get the actual .js file, then invoke via node directly
const readlinkResult = Bun.spawnSync({ cmd: ['readlink', '-f', piBin], stdout: 'pipe', stderr: 'ignore' });
const realPath = readlinkResult.stdout.toString().trim();
const nodeResult = Bun.spawnSync({ cmd: ['which', 'node'], stdout: 'pipe', stderr: 'ignore' });
const nodeBin = nodeResult.stdout.toString().trim() || 'node';
if (realPath && realPath.endsWith('.js')) {
return [nodeBin, realPath];
}
// Fallback: use pi binary directly (works for non-pm2 environments)
return [piBin];
})();
export type PiEventHandler = (event: PiEvent) => void;
type PathOverrides = { global: string; user: string };
function collectSkillFlags(email: string, containerPaths?: PathOverrides): string[] {
function collectSkillFlags(email: string): string[] {
const flags: string[] = [];
const pairs: Array<[hostDir: string, outputDir: string]> = [
[getGlobalSkillsDir(), containerPaths?.global ?? getGlobalSkillsDir()],
[getUserSkillsDir(email), containerPaths?.user ?? getUserSkillsDir(email)],
];
const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)];
for (const [hostDir, outputDir] of pairs) {
if (!existsSync(hostDir)) continue;
for (const entry of readdirSync(hostDir, { withFileTypes: true })) {
for (const dir of dirs) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(hostDir, entry.name, 'SKILL.md'))) {
flags.push('--skill', `${outputDir}/${entry.name}`);
if (existsSync(join(dir, entry.name, 'SKILL.md'))) {
flags.push('--skill', `${dir}/${entry.name}`);
}
}
}
@@ -36,19 +58,16 @@ function collectSkillFlags(email: string, containerPaths?: PathOverrides): strin
return flags;
}
function collectExtensionFlags(email: string, containerPaths?: PathOverrides): string[] {
function collectExtensionFlags(email: string): string[] {
const flags: string[] = [];
const pairs: Array<[hostDir: string, outputDir: string]> = [
[getGlobalExtensionsDir(), containerPaths?.global ?? getGlobalExtensionsDir()],
[getUserExtensionsDir(email), containerPaths?.user ?? getUserExtensionsDir(email)],
];
const dirs = [getGlobalExtensionsDir(), getUserExtensionsDir(email)];
for (const [hostDir, outputDir] of pairs) {
if (!existsSync(hostDir)) continue;
for (const entry of readdirSync(hostDir, { withFileTypes: true })) {
for (const dir of dirs) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(hostDir, entry.name, 'index.ts'))) {
flags.push('--extension', `${outputDir}/${entry.name}/index.ts`);
if (existsSync(join(dir, entry.name, 'index.ts'))) {
flags.push('--extension', `${dir}/${entry.name}/index.ts`);
}
}
}
@@ -78,14 +97,22 @@ export function generateResourceSkill(outputDir: string): string | null {
for (const [name, baseDir] of resourceDirs) {
const resourceMd = join(baseDir, name, 'RESOURCE.md');
let mdContent = '';
try { mdContent = readFileSync(resourceMd, 'utf-8'); } catch { continue; }
try {
mdContent = readFileSync(resourceMd, 'utf-8');
} catch {
continue;
}
const { frontmatter } = parseFrontmatter(mdContent);
// Merge native + global config
let nativeConfig: Record<string, string> = {};
let globalConfig: Record<string, string> = {};
try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {}
try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {}
try {
nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8'));
} catch {}
try {
globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8'));
} catch {}
const config: Record<string, string> = {};
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
@@ -94,13 +121,15 @@ export function generateResourceSkill(outputDir: string): string | null {
const hasValues = Object.values(config).some((v) => v !== '');
const configLines = Object.entries(config)
.filter(([, v]) => v)
.map(([k, v]) => /key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`);
.map(([k, v]) => (/key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`));
sections.push([
`### ${frontmatter.name || name}`,
hasValues ? 'Status: **configured**' : 'Status: not configured',
...configLines,
].join('\n'));
sections.push(
[
`### ${frontmatter.name || name}`,
hasValues ? 'Status: **configured**' : 'Status: not configured',
...configLines,
].join('\n'),
);
}
const skillContent = [
@@ -145,8 +174,12 @@ function buildResourcesEnv(): string {
for (const [name] of resourceDirs) {
let nativeConfig: Record<string, string> = {};
let globalConfig: Record<string, string> = {};
try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {}
try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {}
try {
nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8'));
} catch {}
try {
globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8'));
} catch {}
const config: Record<string, string> = {};
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
@@ -161,7 +194,6 @@ function buildResourcesEnv(): string {
return JSON.stringify(result);
}
async function getApifyToken(): Promise<string> {
try {
const integration = await getServerIntegration('apify');
@@ -195,22 +227,16 @@ async function resolveApiKeyForModel(model: string): Promise<string | null> {
try {
const authFile = Bun.file(join(PI_CONFIG_DIR, 'auth.json'));
if (!(await authFile.exists())) return null;
const auth = await authFile.json() as Record<string, { key?: string }>;
const auth = (await authFile.json()) as Record<string, { key?: string }>;
return auth[provider]?.key?.trim() || null;
} catch {
return null;
}
}
type SandboxOptions = {
userId: number;
username: string;
email: string;
homeDir: string;
};
type SpawnPiOptions = {
sessionFile?: string;
username?: string;
};
export async function spawnPi(
@@ -219,132 +245,86 @@ export async function spawnPi(
userId: number,
email: string,
onEvent: PiEventHandler,
sandbox?: SandboxOptions,
options?: SpawnPiOptions,
): Promise<Subprocess> {
let proc: Subprocess;
const searxng = await readSearxngConfig();
const skillFlags = collectSkillFlags(email);
const extensionFlags = collectExtensionFlags(email);
if (sandbox) {
const container = await ensureDockerContainer(sandbox.email, sandbox.userId, sandbox.homeDir, sandbox.username);
const searxng = await readSearxngConfig();
const dockerPath = Bun.which('docker') ?? 'docker';
const containerId = container.dockerId;
const containerHome = `/home/${sandbox.username}`;
const resourceSkillDir = generateResourceSkill(DATA_PATH);
const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : [];
// Collect skill/extension flags using container-side paths
const skillFlags = collectSkillFlags(sandbox.email, {
global: '/officer/skills',
user: '/officer/user/skills',
});
const extensionFlags = collectExtensionFlags(sandbox.email, {
global: '/officer/extensions',
user: '/officer/user/extensions',
});
const piArgs = [
...PI_CMD,
'--mode',
'rpc',
'--no-skills',
'--no-prompt-templates',
'--no-themes',
...skillFlags,
...extensionFlags,
...resourceSkillFlags,
];
if (model) piArgs.push('--model', model);
if (options?.sessionFile) piArgs.push('--session', options.sessionFile);
// Generate resource context skill (host-side, mounted into container)
const resourceSkillHost = generateResourceSkill(DATA_PATH);
const resourceSkillFlags = resourceSkillHost ? ['--skill', '/officer/generated/available-resources'] : [];
const apiKey = await resolveApiKeyForModel(model);
if (apiKey) piArgs.push('--api-key', apiKey);
const piArgs = [
'pi', '--mode', 'rpc',
'--no-skills', '--no-prompt-templates', '--no-themes',
...skillFlags,
...extensionFlags,
...resourceSkillFlags,
];
if (model) piArgs.push('--model', model);
if (options?.sessionFile) piArgs.push('--session', options.sessionFile);
// Pass API key for the model's provider so the container doesn't need auth.json
const apiKey = await resolveApiKeyForModel(model);
if (apiKey) piArgs.push('--api-key', apiKey);
const resourcesEnv = buildResourcesEnv();
const browserRelayEnv = await getBrowserRelayEnv(sandbox.userId);
const apifyToken = await getApifyToken();
const envFlags = [
'-e', `HOME=${containerHome}`,
'-e', `OFFICER_USER_HOME=${containerHome}`,
'-e', `OFFICER_USER_ROOT=/officer/user`,
'-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`,
'-e', `PI_SEARXNG_URL=${searxng.url}`,
'-e', `OFFICER_RESOURCES=${resourcesEnv}`,
'-e', `OFFICER_EMAIL_DB=/officer/data/emails.db`,
...(apifyToken ? ['-e', `OFFICER_APIFY_TOKEN=${apifyToken}`] : []),
...Object.entries(browserRelayEnv).flatMap(([k, v]) => ['-e', `${k}=${v}`]),
];
const rel = relative(sandbox.homeDir, cwd);
const workdir = rel && !rel.startsWith('..') ? join(containerHome, rel) : containerHome;
proc = Bun.spawn([
dockerPath, 'exec', '-i',
'-u', `${sandbox.username}`,
'-w', workdir,
...envFlags,
containerId,
...piArgs,
], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
logger.info('Spawned Pi in container', {
containerId,
model,
skills: skillFlags.filter((f) => f !== '--skill').length,
extensions: extensionFlags.filter((f) => f !== '--extension').length,
});
} else {
const searxng = await readSearxngConfig();
const skillFlags = collectSkillFlags(email);
const extensionFlags = collectExtensionFlags(email);
// Generate resource context skill
const resourceSkillDir = generateResourceSkill(DATA_PATH);
const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : [];
const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, ...resourceSkillFlags];
if (model) args.push('--model', model);
if (options?.sessionFile) args.push('--session', options.sessionFile);
if (!existsSync(cwd)) {
mkdirSync(cwd, { recursive: true });
}
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
const browserRelayEnv = await getBrowserRelayEnv(userId);
const apifyTokenLocal = await getApifyToken();
proc = Bun.spawn(args, {
cwd,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
HOME: getHomeDir(email),
OFFICER_USER_HOME: getHomeDir(email),
OFFICER_USER_ROOT: join(DATA_PATH, email),
PI_CODING_AGENT_DIR: PI_CONFIG_DIR,
PI_TOOLS_DIRS: toolsDirs,
PI_SEARXNG_URL: searxng.url,
OFFICER_RESOURCES: buildResourcesEnv(),
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
...(apifyTokenLocal ? { OFFICER_APIFY_TOKEN: apifyTokenLocal } : {}),
...browserRelayEnv,
},
});
logger.info('Spawned Pi locally', {
model,
skills: skillFlags.filter((f) => f !== '--skill').length,
extensions: extensionFlags.filter((f) => f !== '--extension').length,
});
if (!existsSync(cwd)) {
mkdirSync(cwd, { recursive: true });
}
const homeDir = getHomeDir(email);
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
const browserRelayEnv = await getBrowserRelayEnv(userId);
const apifyToken = await getApifyToken();
const shellUsername = options?.username ?? toShellUsername('', email);
const env: Record<string, string> = {
HOME: homeDir,
OFFICER_USER_HOME: homeDir,
OFFICER_USER_ROOT: join(DATA_PATH, email),
PI_CODING_AGENT_DIR: PI_CONFIG_DIR,
PI_TOOLS_DIRS: toolsDirs,
PI_SEARXNG_URL: searxng.url,
OFFICER_RESOURCES: buildResourcesEnv(),
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
TERM: 'xterm-256color',
PATH: process.env.PATH ?? '',
...(apifyToken ? { OFFICER_APIFY_TOKEN: apifyToken } : {}),
...browserRelayEnv,
};
const isServiceUser = shellUsername === (process.env.USER ?? '');
// For service user, keep real HOME so Pi finds its config
if (isServiceUser) {
env.HOME = process.env.HOME ?? '';
}
const proc = isServiceUser
? Bun.spawn(piArgs, {
cwd,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, ...env },
})
: Bun.spawn(['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...piArgs], {
cwd,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
logger.info('Spawned Pi as user', {
username: shellUsername,
model,
skills: skillFlags.filter((f) => f !== '--skill').length,
extensions: extensionFlags.filter((f) => f !== '--extension').length,
});
// Read stdout JSON event stream (runs in background)
const stdout = proc.stdout as ReadableStream<Uint8Array>;
const reader = stdout.getReader();
@@ -360,7 +340,7 @@ export async function spawnPi(
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
try {
@@ -388,12 +368,14 @@ export async function spawnPi(
const stderr = proc.stderr as ReadableStream<Uint8Array>;
const stderrReader = stderr.getReader();
const stderrDecoder = new TextDecoder();
let stderrOutput = '';
(async () => {
try {
while (true) {
const { done, value } = await stderrReader.read();
if (done) break;
const text = stderrDecoder.decode(value, { stream: true });
stderrOutput += text;
if (text.trim()) logger.info('Pi stderr', { text: text.trim() });
}
} catch {
@@ -403,7 +385,19 @@ export async function spawnPi(
// Handle process exit
proc.exited.then((code) => {
logger.info('Pi process exited', { code });
if (code === 1) {
// Exit code 1 often indicates a startup issue, possibly snap node + piped stdin incompatibility
logger.error('Pi process exited with code 1', {
nodeVersion: process.version,
nodeExePath: process.execPath,
isSnapNode: process.execPath?.includes('/snap/'),
hint: 'If node is from snap (/snap/bin/node), uninstall snap node and install via apt instead',
});
} else if (code !== 0) {
logger.error('Pi process exited with error code', { code });
} else {
logger.info('Pi process exited normally');
}
});
return proc;
@@ -462,7 +456,11 @@ function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: strin
if (typeof result === 'object' && result !== null) {
resultObj = result as Record<string, unknown>;
} else if (typeof result === 'string') {
try { resultObj = JSON.parse(result); } catch { /* not JSON */ }
try {
resultObj = JSON.parse(result);
} catch {
/* not JSON */
}
}
const isError = (event.isError as boolean) ?? (resultObj?.isError as boolean) ?? false;
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
@@ -513,21 +511,14 @@ function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>): vo
}
}
export function setThinkingLevel(
process: Subprocess,
level: string,
): void {
export function setThinkingLevel(process: Subprocess, level: string): void {
writeRpcCommand(process, {
type: 'set_thinking_level',
level,
});
}
export function sendPrompt(
process: Subprocess,
prompt: string,
requestId: string
): void {
export function sendPrompt(process: Subprocess, prompt: string, requestId: string): void {
writeRpcCommand(process, {
type: 'prompt',
id: requestId,
@@ -535,20 +526,14 @@ export function sendPrompt(
});
}
export function abort(
process: Subprocess,
requestId: string
): void {
export function abort(process: Subprocess, requestId: string): void {
writeRpcCommand(process, {
type: 'abort',
id: requestId,
});
}
export function cancelExtensionUi(
process: Subprocess,
id: unknown
): void {
export function cancelExtensionUi(process: Subprocess, id: unknown): void {
writeRpcCommand(process, {
type: 'extension_ui_response',
id,
+29 -6
View File
@@ -30,6 +30,7 @@ piRestRouter.get('/pi/models', async (ctx: Context) => {
providerNames[`officer-local-${lp.id}`] = lp.name;
}
logger.info('Models endpoint', { count: models.length, providers: [...new Set(models.map((m) => m.provider))] });
return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' });
} catch (err) {
logger.error('Failed to list models', { error: String(err) });
@@ -51,7 +52,9 @@ piRestRouter.post('/pi/sessions', async (ctx: Context) => {
const body = await ctx.req.json().catch(() => ({}));
const userHome = getHomeDir(user.email);
const filterCwd = body.cwd ? resolveBaseCwd(user.email, body.cwdRoot, body.cwd) : null;
const contextFilter = body.context ? { context: body.context as string, contextId: body.contextId as string | undefined } : undefined;
const contextFilter = body.context
? { context: body.context as string, contextId: body.contextId as string | undefined }
: undefined;
try {
let sessions = await storage.listUserSessions(userHome, contextFilter);
@@ -119,6 +122,14 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
}
});
/**
* PUT /api/pi/sessions/:sessionId/messages
* Client-side message save — no-op, sessions are persisted server-side via WebSocket events
*/
piRestRouter.put('/pi/sessions/:sessionId/messages', async (ctx: Context) => {
return ctx.json({ success: true });
});
/**
* PATCH /api/pi/sessions/:sessionId
* Update session metadata (e.g., rename)
@@ -162,9 +173,14 @@ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
}
}
const updatedMeta = await storage.updateSessionMeta(userHome, sessionId, {
title: body.title,
}, groupSlug);
const updatedMeta = await storage.updateSessionMeta(
userHome,
sessionId,
{
title: body.title,
},
groupSlug,
);
return ctx.json({
success: true,
@@ -246,7 +262,9 @@ piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
}
const body = await ctx.req.json().catch(() => ({}));
const contextFilter = body.context ? { context: body.context as string, contextId: body.contextId as string | undefined } : undefined;
const contextFilter = body.context
? { context: body.context as string, contextId: body.contextId as string | undefined }
: undefined;
const userHome = getHomeDir(user.email);
try {
@@ -260,7 +278,12 @@ piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
// Skip sessions that fail to delete
}
}
logger.info('Bulk deleted sessions', { email: user.email, deleted, total: sessions.length, context: contextFilter?.context });
logger.info('Bulk deleted sessions', {
email: user.email,
deleted,
total: sessions.length,
context: contextFilter?.context,
});
return ctx.json({ success: true, deleted });
} catch (err) {
logger.error('Failed to bulk delete sessions', { email: user.email, error: String(err) });
+93 -61
View File
@@ -30,6 +30,7 @@ type WSData = {
email: string;
username: string;
role: string;
sandboxed: boolean;
provider: string;
};
@@ -70,11 +71,11 @@ export async function open(ws: ServerWebSocket<WSData>): Promise<void> {
export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void {
const data = typeof raw === 'string' ? raw : raw.toString();
(async () => {
try {
const clientMsg = JSON.parse(data) as ClientMessage;
if (clientMsg.type === 'chat') {
await handleChat(ws, clientMsg);
} else if (clientMsg.type === 'resume') {
@@ -91,7 +92,7 @@ export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void
export function close(ws: ServerWebSocket<WSData>): void {
// logger.info('WebSocket connection closed', { email: ws.data.email });
const sessionId = wsToSessionMap.get(ws);
if (sessionId) {
sessionManager.detachWs(sessionId);
@@ -118,7 +119,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
const text = event.text || session.streamBuffer;
if (text) {
sendToClient(ws, { type: 'assistant:text', text });
const assistantMsg: Message = {
id: randomUUID(),
timestamp: Date.now(),
@@ -137,7 +138,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
// Flush any pending streaming text first
if (session.streamBuffer) {
sendToClient(ws, { type: 'assistant:text', text: session.streamBuffer });
const assistantMsg: Message = {
id: randomUUID(),
timestamp: Date.now(),
@@ -194,7 +195,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
// Flush any remaining streaming buffer
if (session.streamBuffer) {
sendToClient(ws, { type: 'assistant:text', text: session.streamBuffer });
const assistantMsg: Message = {
id: randomUUID(),
timestamp: Date.now(),
@@ -209,7 +210,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
}
sendToClient(ws, { type: 'result', sessionId, cost: event.cost });
session.isGenerating = false;
session.meta.cost.inputTokens += event.cost.inputTokens;
session.meta.cost.outputTokens += event.cost.outputTokens;
@@ -243,11 +244,24 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
async function handleChat(
ws: ServerWebSocket<WSData>,
msg: { prompt: string; displayText?: string; sessionId?: string; model?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[]; thinking?: string; context?: string; contextId?: string }
msg: {
prompt: string;
displayText?: string;
sessionId?: string;
model?: string;
cwd?: string;
cwdRoot?: string;
sandboxed?: boolean;
groupSlug?: string;
attachmentIds?: string[];
thinking?: string;
context?: string;
contextId?: string;
},
): Promise<void> {
const { email, username, userId } = ws.data;
const sessionId = msg.sessionId || randomUUID();
// Use provided model, or fall back to user default, or use system default
let model = msg.model;
let modelSource = 'client-provided';
@@ -262,7 +276,7 @@ async function handleChat(
modelSource = 'system-default';
}
}
logger.info('Model selected for chat', {
sessionId,
model,
@@ -276,41 +290,39 @@ async function handleChat(
}
const homeDir = getHomeDir(email);
const sandboxed = msg.sandboxed ?? false;
const cwd = sandboxed
const cwd = ws.data.sandboxed
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
: resolveHostCwd(msg.cwdRoot, msg.cwd);
const groupSlug = msg.groupSlug || null;
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
session.sandboxed = sandboxed;
session.userId = userId;
sessionManager.attachWs(sessionId, ws);
wsToSessionMap.set(ws as any, sessionId);
sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId });
sendToClient(ws, {
type: 'session:init',
sessionId,
model,
cwd,
context: session.meta.context,
contextId: session.meta.contextId,
});
if (!session.piProcess) {
try {
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
const sandbox = sandboxed ? { userId, username, email, homeDir } : undefined;
// If session has history, save to disk and pass --session for context replay
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) {
if (sandboxed) {
const containerHome = `/home/${username}`;
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
const relativePart = hostPath.slice(sessionsPrefix.length);
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
} else {
spawnOptions = { sessionFile: hostPath };
}
spawnOptions = { sessionFile: hostPath, username };
}
}
if (!spawnOptions) spawnOptions = { username };
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, sandbox, spawnOptions);
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, spawnOptions);
// Null out piProcess when the process dies so next message triggers respawn
const proc = session.piProcess;
@@ -321,7 +333,12 @@ async function handleChat(
}
});
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed, hasSessionFile: !!spawnOptions?.sessionFile });
logger.info('Spawned Pi process for session', {
sessionId,
model,
cwd,
hasSessionFile: !!spawnOptions?.sessionFile,
});
} catch (err) {
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
@@ -360,34 +377,39 @@ async function handleClaudeCodeChat(
ws: ServerWebSocket<WSData>,
sessionId: string,
model: string,
msg: { prompt: string; displayText?: string; groupSlug?: string; context?: string; contextId?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean },
msg: {
prompt: string;
displayText?: string;
groupSlug?: string;
context?: string;
contextId?: string;
cwd?: string;
cwdRoot?: string;
sandboxed?: boolean;
},
): Promise<void> {
const { email, username, userId } = ws.data;
const homeDir = getHomeDir(email);
const sandboxed = msg.sandboxed ?? false;
// Claude Code always operates on the user's data home (not OS home).
// Resolve cwd relative to data directory, then remap for container if sandboxed.
const dataCwd = resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd);
let cwd: string;
if (sandboxed) {
const containerHome = `/home/${username}`;
cwd = dataCwd.startsWith(homeDir)
? `${containerHome}${dataCwd.slice(homeDir.length)}`
: containerHome;
} else {
cwd = dataCwd;
}
const cwd = ws.data.sandboxed
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
: resolveHostCwd(msg.cwdRoot, msg.cwd);
const groupSlug = msg.groupSlug || null;
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
session.sandboxed = sandboxed;
session.userId = userId;
sessionManager.attachWs(sessionId, ws);
wsToSessionMap.set(ws as any, sessionId);
sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId });
sendToClient(ws, {
type: 'session:init',
sessionId,
model,
cwd,
context: session.meta.context,
contextId: session.meta.contextId,
});
// Add user message to session
const userMsg: Message = {
@@ -416,7 +438,6 @@ async function handleClaudeCodeChat(
prompt: msg.prompt,
sessionKey: sessionId,
cwd,
sandboxed,
onEvent,
});
@@ -438,7 +459,7 @@ async function handleClaudeCodeChat(
async function handleResume(
ws: ServerWebSocket<WSData>,
msg: { sessionId: string; cwd?: string; cwdRoot?: string }
msg: { sessionId: string; cwd?: string; cwdRoot?: string },
): Promise<void> {
const { email } = ws.data;
const { sessionId } = msg;
@@ -451,11 +472,11 @@ async function handleResume(
try {
const { meta, messages } = await storage.loadSession(homeDir, sessionId);
session = sessionManager.getOrCreate(sessionId, email, meta.cwd, meta.model);
session.messages = messages;
session.meta = meta;
logger.info('Loaded session from disk', { sessionId, messageCount: messages.length });
} catch (err) {
logger.error('Failed to load session from disk', { sessionId, error: String(err) });
@@ -467,33 +488,40 @@ async function handleResume(
sessionManager.attachWs(sessionId, ws);
wsToSessionMap.set(ws as any, sessionId);
sendToClient(ws, { type: 'session:init', sessionId, model: session.model, cwd: session.cwd, context: session.meta.context, contextId: session.meta.contextId });
sendToClient(ws, {
type: 'session:init',
sessionId,
model: session.model,
cwd: session.cwd,
context: session.meta.context,
contextId: session.meta.contextId,
});
// Spawn fresh Pi process if needed
if (!session.piProcess) {
try {
const homeDir = getHomeDir(email);
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
// If session has history, save to disk and pass --session for context replay
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) {
if (sandbox) {
const containerHome = `/home/${ws.data.username}`;
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
const relativePart = hostPath.slice(sessionsPrefix.length);
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
} else {
spawnOptions = { sessionFile: hostPath };
}
spawnOptions = { sessionFile: hostPath, username: ws.data.username };
}
}
if (!spawnOptions) spawnOptions = { username: ws.data.username };
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, session.userId!, email, onEvent, sandbox, spawnOptions);
session.piProcess = await piBridge.spawnPi(
session.cwd,
session.model,
session.userId!,
email,
onEvent,
spawnOptions,
);
// Null out piProcess when the process dies so next message triggers respawn
const proc = session.piProcess;
@@ -504,7 +532,11 @@ async function handleResume(
}
});
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed, hasSessionFile: !!spawnOptions?.sessionFile });
logger.info('Spawned fresh Pi process for resumed session', {
sessionId,
model: session.model,
hasSessionFile: !!spawnOptions?.sessionFile,
});
} catch (err) {
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
@@ -519,7 +551,7 @@ async function handleResume(
isGenerating: session.isGenerating,
streamingText: session.streamBuffer,
});
logger.info('Session resumed successfully', { sessionId, messageCount: session.messages.length });
} catch (err) {
logger.error('Unexpected error in handleResume', { sessionId, error: String(err) });
@@ -536,7 +568,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
if (session?.piProcess) {
try {
if (session.model === 'claude-code') {
// Claude Code: kill the docker exec process directly
// Claude Code: kill the process directly
session.piProcess.kill();
logger.info('Killed Claude Code process', { sessionId });
} else {
+165 -110
View File
@@ -31,21 +31,24 @@ type ProbeResult = {
};
type PiModelConfig = {
providers: Record<string, {
baseUrl: string;
apiKey?: string;
api: string;
models: {
id: string;
name: string;
reasoning: boolean;
input: string[];
contextWindow: number;
maxTokens: number;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}[];
_officer?: OfficerMeta;
}>;
providers: Record<
string,
{
baseUrl: string;
apiKey?: string;
api: string;
models: {
id: string;
name: string;
reasoning: boolean;
input: string[];
contextWindow: number;
maxTokens: number;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}[];
_officer?: OfficerMeta;
}
>;
};
type OfficerMeta = {
@@ -143,7 +146,7 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<Prob
if (oaiRes) {
if (oaiRes.status === 401 || oaiRes.status === 403) {
const wwwAuth = oaiRes.headers.get('www-authenticate') ?? '';
const authType = wwwAuth.toLowerCase().includes('basic') ? 'basic' as const : 'api-key' as const;
const authType = wwwAuth.toLowerCase().includes('basic') ? ('basic' as const) : ('api-key' as const);
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType };
}
if (oaiRes.ok) {
@@ -152,7 +155,7 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<Prob
if (data.data) {
// LM Studio includes "lm-studio" in model IDs
const isLmStudio = data.data.some((m) => m.id.includes('lm-studio'));
const apiType = isLmStudio ? 'lmstudio' as const : 'openai-compatible' as const;
const apiType = isLmStudio ? ('lmstudio' as const) : ('openai-compatible' as const);
const name = isLmStudio ? 'LM Studio' : 'OpenAI-compatible';
return {
success: true,
@@ -172,7 +175,13 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<Prob
const bareRes = await tryFetch('/models');
if (bareRes) {
if (bareRes.status === 401 || bareRes.status === 403) {
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType: 'api-key' };
return {
success: true,
apiType: 'openai-compatible',
name: 'OpenAI-compatible',
needsAuth: true,
authType: 'api-key',
};
}
if (bareRes.ok) {
try {
@@ -212,7 +221,8 @@ async function fetchModelsFromProvider(
const headers: Record<string, string> = {};
if (lp.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${lp.auth.apiKey}`;
else if (lp.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`;
else if (lp.auth?.type === 'basic')
headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
@@ -223,7 +233,12 @@ async function fetchModelsFromProvider(
const data = await res.json();
if (lp.apiType === 'ollama' && data.models) {
return data.models.map((m: { name: string }) => ({ id: m.name, name: m.name, contextWindow: 128000, maxTokens: 4096 }));
return data.models.map((m: { name: string }) => ({
id: m.name,
name: m.name,
contextWindow: 128000,
maxTokens: 4096,
}));
} else if (data.data) {
return data.data.map((m: { id: string }) => ({ id: m.id, name: m.id, contextWindow: 128000, maxTokens: 4096 }));
}
@@ -242,9 +257,7 @@ async function addLocalProviderToModelsConfig(lp: LocalProvider): Promise<void>
logger.warn('No models found for local provider', { provider: lp.name });
}
const baseUrl = lp.apiType === 'ollama'
? `${lp.url}/v1`
: lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
const baseUrl = lp.apiType === 'ollama' ? `${lp.url}/v1` : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
const config = await readModelsConfig();
config.providers[`officer-local-${lp.id}`] = {
@@ -297,9 +310,7 @@ async function refreshLocalProviders(): Promise<void> {
};
const models = await fetchModelsFromProvider(lp);
const baseUrl = lp.apiType === 'ollama'
? `${lp.url}/v1`
: lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
const baseUrl = lp.apiType === 'ollama' ? `${lp.url}/v1` : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
entry.baseUrl = baseUrl;
entry.apiKey = lp.auth?.type === 'api-key' ? lp.auth.apiKey : 'none';
@@ -350,8 +361,8 @@ export const PROVIDERS: { key: string; piId: string }[] = [
{ key: 'MiniMax', piId: 'minimax' },
{ key: 'Hugging Face', piId: 'huggingface' },
{ key: 'Azure OpenAI', piId: 'azure-openai-responses' },
{ key: 'OpenCode', piId: 'opencode' },
{ key: 'OpenCode Zen', piId: 'zai' },
{ key: 'OpenCode Zen', piId: 'opencode' },
{ key: 'ZAI', piId: 'zai' },
{ key: 'Cerebras', piId: 'cerebras' },
];
@@ -368,9 +379,10 @@ const maskValue = (value: string) => {
piMonoRouter.get('/api-keys', async (ctx) => {
const auth = await readAuthJson();
const keys = PROVIDERS
.filter((p) => auth[p.piId]?.key?.trim())
.map((p) => ({ provider: p.piId, value: maskValue(auth[p.piId]!.key) }));
const keys = PROVIDERS.filter((p) => auth[p.piId]?.key?.trim()).map((p) => ({
provider: p.piId,
value: maskValue(auth[p.piId]!.key),
}));
return ctx.json({ keys });
});
@@ -403,81 +415,114 @@ piMonoRouter.put('/access-policy', async (ctx) => {
return ctx.json(body);
});
const REMOTE_HEALTH_CONFIG: Record<string, {
url: string | ((key: string) => string);
headers: (key: string) => Record<string, string>;
}> = {
const REMOTE_HEALTH_CONFIG: Record<
string,
{
url: string | ((key: string) => string);
headers: (key: string) => Record<string, string>;
}
> = {
openai: { url: 'https://api.openai.com/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
anthropic: { url: 'https://api.anthropic.com/v1/models', headers: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }) },
anthropic: {
url: 'https://api.anthropic.com/v1/models',
headers: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }),
},
google: { url: (k) => `https://generativelanguage.googleapis.com/v1beta/models?key=${k}`, headers: () => ({}) },
groq: { url: 'https://api.groq.com/openai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
mistral: { url: 'https://api.mistral.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
xai: { url: 'https://api.x.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
openrouter: { url: 'https://openrouter.ai/api/v1/auth/key', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
cerebras: { url: 'https://api.cerebras.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
opencode: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
zai: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
opencode: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
zai: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
};
piMonoRouter.get('/api-keys/health', async (ctx) => {
const auth = await readAuthJson();
const results: Record<string, boolean | null> = {};
const checks = PROVIDERS
.filter((p) => auth[p.piId]?.key?.trim())
.map(async (p) => {
const config = REMOTE_HEALTH_CONFIG[p.piId];
if (!config) {
results[p.piId] = null;
return;
}
const key = auth[p.piId]!.key;
const url = typeof config.url === 'function' ? config.url(key) : config.url;
const headers = config.headers(key);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(url, { headers, signal: controller.signal });
results[p.piId] = res.ok;
} catch {
results[p.piId] = false;
} finally {
clearTimeout(timer);
}
});
const checks = PROVIDERS.filter((p) => auth[p.piId]?.key?.trim()).map(async (p) => {
const config = REMOTE_HEALTH_CONFIG[p.piId];
if (!config) {
results[p.piId] = null;
return;
}
const key = auth[p.piId]!.key;
const url = typeof config.url === 'function' ? config.url(key) : config.url;
const headers = config.headers(key);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(url, { headers, signal: controller.signal });
results[p.piId] = res.ok;
} catch {
results[p.piId] = false;
} finally {
clearTimeout(timer);
}
});
await Promise.all(checks);
return ctx.json(results);
});
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
/**
* Resolve the full path to the `pi` binary.
* Checks PATH first, then falls back to the npm global bin directory
* (which may not be in PATH when the server is managed by pm2).
*/
/**
* Finds the Pi package directory (containing package.json).
* Checks: PATH → npm global prefix → ~/.npm-global fallback.
*/
async function resolvePiPackageDir(): Promise<string | null> {
const candidates: string[] = [];
const getPaths = async () => {
// 1. Try PATH
try {
const proc = Bun.spawn(['which', '-a', 'pi'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
const proc = Bun.spawn(['which', 'pi'], { stdout: 'pipe', stderr: 'pipe' });
const output = (await new Response(proc.stdout).text()).trim();
await proc.exited;
if (proc.exitCode !== 0) return { path: null, globalPath: null };
const paths = [...new Set(output.trim().split('\n'))];
const path = paths[0] ?? null;
const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null;
return { path, globalPath };
} catch {
return { path: null, globalPath: null };
if (proc.exitCode === 0 && output) {
// Resolve symlink: bin/pi -> ../lib/node_modules/.../dist/cli.js
const resolved = (await Bun.file(output).exists()) ? output : null;
if (resolved) {
// Walk up from bin to find the package
const npmGlobalLib = join(output, '..', '..', 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent');
candidates.push(npmGlobalLib);
}
}
} catch {}
// 2. Common locations
const home = process.env.HOME ?? '';
candidates.push(
join(home, '.npm-global', 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent'),
'/usr/local/lib/node_modules/@mariozechner/pi-coding-agent',
);
for (const dir of candidates) {
const pkgFile = join(dir, 'package.json');
if (await Bun.file(pkgFile).exists()) return dir;
}
};
return null;
}
/** Read Pi version directly from its package.json — avoids shebang/spawn issues. */
async function getPiVersion(): Promise<{ version: string | null; path: string | null }> {
const dir = await resolvePiPackageDir();
if (!dir) return { version: null, path: null };
try {
const pkg = await Bun.file(join(dir, 'package.json')).json();
return { version: pkg.version ?? null, path: dir };
} catch {
return { version: null, path: dir };
}
}
piMonoRouter.get('/version', async (ctx) => {
try {
const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null });
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
} catch {
return ctx.json({ version: null, path: null, globalPath: null });
}
const { version, path } = await getPiVersion();
return ctx.json({ version, path, globalPath: path });
});
piMonoRouter.post('/install', async (ctx) => {
@@ -486,16 +531,16 @@ piMonoRouter.post('/install', async (ctx) => {
stdout: 'pipe',
stderr: 'pipe',
});
const stderr = await new Response(proc.stderr).text();
await proc.exited;
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500);
}
const versionProc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(versionProc.stdout).text();
await versionProc.exited;
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
const { version, path } = await getPiVersion();
if (!version)
return ctx.json({ version: null, path, globalPath: path, error: 'Installed but package.json not found' }, 500);
return ctx.json({ version, path, globalPath: path });
} catch {
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
}
@@ -505,10 +550,12 @@ piMonoRouter.post('/install', async (ctx) => {
piMonoRouter.get('/local-providers', async (ctx) => {
const providers = await readLocalProviders();
return ctx.json(providers.map((p) => ({
...p,
auth: p.auth ? { type: p.auth.type } : undefined,
})));
return ctx.json(
providers.map((p) => ({
...p,
auth: p.auth ? { type: p.auth.type } : undefined,
})),
);
});
piMonoRouter.post('/local-providers/probe', async (ctx) => {
@@ -519,7 +566,12 @@ piMonoRouter.post('/local-providers/probe', async (ctx) => {
});
piMonoRouter.post('/local-providers', async (ctx) => {
const body = await ctx.req.json<{ url: string; name?: string; apiType: LocalProvider['apiType']; auth?: LocalProvider['auth'] }>();
const body = await ctx.req.json<{
url: string;
name?: string;
apiType: LocalProvider['apiType'];
auth?: LocalProvider['auth'];
}>();
const provider: LocalProvider = {
id: crypto.randomUUID(),
@@ -549,24 +601,27 @@ piMonoRouter.get('/local-providers/health', async (ctx) => {
const providers = await readLocalProviders();
const results: Record<string, boolean> = {};
await Promise.all(providers.map(async (p) => {
const base = p.url.replace(/\/+$/, '');
const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models';
const headers: Record<string, string> = {};
if (p.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${p.auth.apiKey}`;
else if (p.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${p.auth.username}:${p.auth.password}`)}`;
await Promise.all(
providers.map(async (p) => {
const base = p.url.replace(/\/+$/, '');
const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models';
const headers: Record<string, string> = {};
if (p.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${p.auth.apiKey}`;
else if (p.auth?.type === 'basic')
headers['Authorization'] = `Basic ${btoa(`${p.auth.username}:${p.auth.password}`)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
results[p.id] = res.ok;
} catch {
results[p.id] = false;
} finally {
clearTimeout(timer);
}
}));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
results[p.id] = res.ok;
} catch {
results[p.id] = false;
} finally {
clearTimeout(timer);
}
}),
);
return ctx.json(results);
});
@@ -1,79 +0,0 @@
FROM imbios/bun-node:22-slim
RUN apt-get update \
&& apt-get install -y \
python3 python3-pip python3-venv make gcc g++ zsh git curl wget ca-certificates \
sudo gosu locales \
zip unzip tree btop net-tools tmux \
procps psmisc lsof less file man-db \
ripgrep fd-find jq htop sqlite3 \
&& sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen \
&& ln -sf /usr/bin/fdfind /usr/local/bin/fd \
&& apt-get clean
ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
RUN curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz \
&& tar -C /opt -xzf nvim-linux-x86_64.tar.gz \
&& rm nvim-linux-x86_64.tar.gz
ENV PATH="/opt/nvim-linux-x86_64/bin:${PATH}"
RUN git clone --depth 1 https://github.com/LazyVim/starter /opt/lazyvim-starter \
&& rm -rf /opt/lazyvim-starter/.git
WORKDIR /app
COPY pty-sidecar.mjs /app/pty-sidecar.mjs
COPY entrypoint.sh /app/entrypoint.sh
COPY templates /opt/terminal-templates
RUN npm init -y \
&& npm install ws@8.18.1 node-pty@1.1.0
RUN curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
RUN git clone --depth 1 https://github.com/ohmyzsh/ohmyzsh.git /opt/oh-my-zsh
ENV EZA_VERSION=0.18.15
RUN curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/eza_x86_64-unknown-linux-gnu.tar.gz" -o /tmp/eza.tar.gz \
&& tar -xzf /tmp/eza.tar.gz -C /tmp \
&& mv /tmp/eza /usr/local/bin/eza \
&& chmod +x /usr/local/bin/eza \
&& rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man
ENV LAZYGIT_VERSION=0.44.1
RUN curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" -o /tmp/lazygit.tar.gz \
&& tar -xzf /tmp/lazygit.tar.gz -C /tmp \
&& mv /tmp/lazygit /usr/local/bin/lazygit \
&& chmod +x /usr/local/bin/lazygit \
&& rm -rf /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md
ENV GOLANG_VERSION=1.23.6
RUN curl -fsSL "https://go.dev/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tar.gz \
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
&& rm /tmp/go.tar.gz
ENV PATH="/usr/local/go/bin:${PATH}"
ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal \
&& chmod -R a+rw $CARGO_HOME
ENV PATH="/usr/local/cargo/bin:${PATH}"
RUN npm install -g @mariozechner/pi-coding-agent @anthropic-ai/claude-code
# Patch Pi compaction bug: calculateContextTokens crashes when usage is undefined
RUN sed -i '/^export function calculateContextTokens(usage) {$/a\ if (!usage) return 0;' \
/usr/local/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/compaction/compaction.js
WORKDIR /tmp
ENV TERMINAL_PTY_PORT=5337
EXPOSE 5337
ENTRYPOINT ["/app/entrypoint.sh"]
-69
View File
@@ -1,69 +0,0 @@
#!/bin/sh
set -e
USERNAME="${TERMINAL_USER:-officer}"
USER_UID="${TERMINAL_UID:-1000}"
USER_GID="${TERMINAL_GID:-1000}"
# Remove any existing user/group with the target UID/GID
EXISTING_USER=$(getent passwd "$USER_UID" | cut -d: -f1)
if [ -n "$EXISTING_USER" ] && [ "$EXISTING_USER" != "$USERNAME" ]; then
userdel "$EXISTING_USER" 2>/dev/null || true
fi
EXISTING_GROUP=$(getent group "$USER_GID" | cut -d: -f1)
if [ -n "$EXISTING_GROUP" ] && [ "$EXISTING_GROUP" != "$USERNAME" ]; then
groupdel "$EXISTING_GROUP" 2>/dev/null || true
fi
# Create group and user
groupadd -g "$USER_GID" "$USERNAME" 2>/dev/null || true
mkdir -p /home/$USERNAME
useradd -u "$USER_UID" -g "$USER_GID" -s /bin/zsh -d /home/$USERNAME "$USERNAME" 2>/dev/null || true
# Passwordless sudo
echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/terminal-user
chmod 0440 /etc/sudoers.d/terminal-user
# Seed LazyVim config if not present
if [ ! -d /home/$USERNAME/.config/nvim ]; then
mkdir -p /home/$USERNAME/.config
cp -r /opt/lazyvim-starter /home/$USERNAME/.config/nvim
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.config
fi
# Seed shell config files from templates if not present
if [ ! -f /home/$USERNAME/.zshrc ]; then
cp /opt/terminal-templates/.zshrc /home/$USERNAME/.zshrc
chown "$USER_UID:$USER_GID" /home/$USERNAME/.zshrc
fi
if [ ! -f /home/$USERNAME/.tmux.conf ]; then
cp /opt/terminal-templates/.tmux.conf /home/$USERNAME/.tmux.conf
chown "$USER_UID:$USER_GID" /home/$USERNAME/.tmux.conf
fi
if [ ! -f /home/$USERNAME/.config/starship-officer.toml ]; then
mkdir -p /home/$USERNAME/.config
cp /opt/terminal-templates/starship-officer.toml /home/$USERNAME/.config/starship-officer.toml
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.config
fi
if [ ! -d /home/$USERNAME/.oh-my-zsh ]; then
cp -r /opt/oh-my-zsh /home/$USERNAME/.oh-my-zsh
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.oh-my-zsh
fi
mkdir -p /home/$USERNAME/.local/bin
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.local
# Ensure Pi agent sessions directory exists and is writable
mkdir -p /home/$USERNAME/.pi/agent/sessions
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.pi
# Init git repo in home dir so Claude Code skips the workspace trust prompt
if [ ! -d /home/$USERNAME/.git ]; then
gosu "$USER_UID:$USER_GID" git init /home/$USERNAME >/dev/null 2>&1 || true
fi
# Run sidecar as the user
exec gosu "$USER_UID:$USER_GID" node /app/pty-sidecar.mjs
+28 -21
View File
@@ -1,3 +1,6 @@
// Ignore SIGINT — sudo/pty child processes may propagate it
process.on('SIGINT', () => {});
import http from 'node:http';
import { existsSync } from 'node:fs';
import { cp, mkdir } from 'node:fs/promises';
@@ -14,10 +17,8 @@ const run = (cmd, args, opts = {}) =>
});
const __dirname = dirname(fileURLToPath(import.meta.url));
const isDocker = existsSync('/opt/terminal-templates/.zshrc');
const templateDir = isDocker ? '/opt/terminal-templates' : join(__dirname, 'templates');
const ohMyZshSource = isDocker ? '/opt/oh-my-zsh' : null;
const templateDir = join(__dirname, 'templates');
const port = Number(process.env.TERMINAL_PTY_PORT ?? '5337');
const host = process.env.TERMINAL_PTY_HOST ?? '127.0.0.1';
@@ -64,18 +65,7 @@ const ensureUserFiles = async (homeDir) => {
const ohMyZshPath = join(homeDir, '.oh-my-zsh');
if (!existsSync(ohMyZshPath)) {
if (ohMyZshSource && existsSync(ohMyZshSource)) {
await cp(ohMyZshSource, ohMyZshPath, { recursive: true });
} else {
await run('git', ['clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath]);
}
}
if (!isDocker) {
const starshipBin = join(homeDir, '.local', 'bin', 'starship');
if (!existsSync(starshipBin)) {
await run('sh', ['-c', 'curl -sS https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"'], { env: { ...process.env, HOME: homeDir } });
}
await run('git', ['clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath]);
}
};
@@ -145,16 +135,31 @@ wss.on('connection', (ws) => {
const cwd = msg.cwd ?? process.cwd();
const homeDir = msg.homeDir ?? process.cwd();
const userLabel = msg.userLabel ?? 'officer';
const username = msg.username ?? null;
const cols = msg.cols ?? 80;
const rows = msg.rows ?? 24;
const isHost = !!msg.host;
let spawnCommand;
let spawnArgs;
let ptyEnv;
if (isHost) {
// Host session — spawn shell directly as current user
spawnCommand = shell.command;
spawnArgs = shell.args ?? [];
ptyEnv = { ...process.env, TERM: 'xterm-256color', ...(msg.env ?? {}) };
} else if (username) {
// User session — spawn via sudo -u as the target Linux user
spawnCommand = 'sudo';
spawnArgs = ['-u', username, '-i', '/bin/zsh'];
ptyEnv = {
TERM: 'xterm-256color',
};
} else {
const prompt = `${userLabel} in %~ %# `;
const bashPrompt = `${userLabel} \\w \\$ `;
// Fallback — direct spawn with custom env (legacy)
spawnCommand = shell.command;
spawnArgs = shell.args ?? [];
try {
await ensureUserFiles(homeDir);
@@ -171,19 +176,21 @@ wss.on('connection', (ws) => {
USER: userLabel,
LOGNAME: userLabel,
OFFICER_TERMINAL_USER: userLabel,
PROMPT: prompt,
PS1: bashPrompt,
TERM: 'xterm-256color',
};
}
// For username sessions, don't set cwd — sudo -u -i will cd to the user's home.
// node-pty does chdir before exec, so it would fail if the service user can't access the dir.
const ptyCwd = username ? undefined : cwd;
let term;
try {
term = pty.spawn(shell.command, shell.args ?? [], {
term = pty.spawn(spawnCommand, spawnArgs, {
name: 'xterm-256color',
cols,
rows,
cwd,
cwd: ptyCwd,
env: ptyEnv,
});
} catch (err) {
@@ -0,0 +1 @@
skip_global_compinit=1
+4 -1
View File
@@ -1,6 +1,9 @@
# If you come from bash you might have to change your $PATH.
export PATH=$HOME/.local/bin:$PATH
# Skip insecure directory check (system zsh dirs may be group-writable)
ZSH_DISABLE_COMPFIX=true
# Path to your Oh My Zsh installation.
export ZSH="$HOME/.oh-my-zsh"
@@ -22,7 +25,7 @@ source $ZSH/oh-my-zsh.sh
# ============================================================================
# STARSHIP PROMPT
# ============================================================================
if [[ -n "$OFFICER_TERMINAL_USER" ]]; then
if [[ -f "$HOME/.config/starship-officer.toml" ]]; then
export STARSHIP_CONFIG="$HOME/.config/starship-officer.toml"
fi
@@ -1,9 +1,10 @@
format = "$env_var:$hostname $directory $character"
format = "$username:$hostname $directory $character"
[env_var]
variable = "OFFICER_TERMINAL_USER"
format = "[$env_value]($style)"
style = "bold #0891B2"
[username]
show_always = true
format = "[$user]($style)"
style_user = "bold #0891B2"
style_root = "bold red"
[hostname]
ssh_only = false
+75 -363
View File
@@ -1,38 +1,30 @@
import type { ServerWebSocket } from 'bun';
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, PI_CONFIG_DIR, toShellUsername } from '@@/data-path';
import { getUsers } from 'officerdb';
import { generateContainerContext, generateClaudeSettings } from '@@/generate-container-context';
import { getHomeDir } from '@@/data-path';
const ensureDir = (dir: string) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); return dir; };
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
type ShellInfo = { command: string; args: string[]; name: string };
type WSData = {
userId: number;
email: string;
username: string;
role: string;
sandboxed: boolean;
sessionId?: string;
cwd?: string;
cols?: number;
rows?: number;
};
type BridgeSession = {
client: ServerWebSocket<WSData>;
sidecar: WebSocket | null;
dockerId: string;
port: number;
pendingMessages: string[];
};
type ContainerInfo = {
userId: number;
email: string;
dockerId: string;
port: number;
};
const HOST_SIDECAR_PORT = 5338;
const sessions = new Map<ServerWebSocket<WSData>, BridgeSession>();
const containerMapPath = join(getHomeDir(''), '..', 'terminal-containers.json');
let dockerImageReady = false;
let containersCache: Record<string, ContainerInfo> | null = null;
let hostSidecarProcess: ReturnType<typeof import('bun').spawn> | null = null;
const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
@@ -43,14 +35,14 @@ const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
}
};
const connectSidecar = async (port: number): Promise<WebSocket> => {
const connectSidecar = async (): Promise<WebSocket> => {
const delays = [200, 300, 500, 800, 1200, 1600, 2000];
let lastError: Error | null = null;
for (const delay of delays) {
try {
const ws = await new Promise<WebSocket>((resolve, reject) => {
const socket = new WebSocket(`ws://127.0.0.1:${port}`);
const socket = new WebSocket(`ws://127.0.0.1:${HOST_SIDECAR_PORT}`);
const timeout = setTimeout(() => {
try {
socket.close();
@@ -80,224 +72,9 @@ const connectSidecar = async (port: number): Promise<WebSocket> => {
throw lastError ?? new Error('Terminal sidecar connection failed');
};
const ensureDockerImage = () => {
if (dockerImageReady) return;
const dockerPath = Bun.which('docker');
if (!dockerPath) throw new Error('Docker not found');
const tag = 'officer-terminal-sidecar:v1';
const inspect = Bun.spawnSync({ cmd: [dockerPath, 'image', 'inspect', tag], stdout: 'ignore', stderr: 'ignore' });
if (inspect.exitCode === 0) {
dockerImageReady = true;
return;
}
const dockerfilePath = fileURLToPath(new URL('./Dockerfile.terminal-sidecar', import.meta.url));
const build = Bun.spawnSync({
cmd: [dockerPath, 'build', '-f', dockerfilePath, '-t', tag, '.'],
cwd: fileURLToPath(new URL('./', import.meta.url)),
stdout: 'inherit',
stderr: 'inherit',
});
if (build.exitCode !== 0) throw new Error('Failed to build terminal sandbox image');
dockerImageReady = true;
};
// Check whether a container has all expected volume mounts.
// Tests for multiple mount sources — if any is missing, the container should be recreated.
const containerHasExpectedMounts = (dockerId: string): boolean => {
const dockerPath = Bun.which('docker') ?? 'docker';
const result = Bun.spawnSync({
cmd: [dockerPath, 'inspect', '--format', '{{range .Mounts}}{{.Source}}\n{{end}}', dockerId],
stdout: 'pipe',
stderr: 'ignore',
});
if (result.exitCode !== 0) return false;
const mounts = result.stdout.toString();
return mounts.includes(getGlobalSkillsDir()) && mounts.includes('/officer/data') && mounts.includes('.claude');
};
const startDockerSidecar = async (port: number, homeDir: string, userId: number, username: string, email: string, contextFile?: string, settingsFile?: string): Promise<{ dockerId: string }> => {
ensureDockerImage();
const dockerPath = Bun.which('docker') ?? 'docker';
const dockerId = `officer-terminal-${userId}`;
const tag = 'officer-terminal-sidecar:v1';
// Remove stale container with same name if it exists
if (dockerContainerExists(dockerId)) {
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
}
let uid = 1000;
let gid = 1000;
const sidecarAlive = async (): Promise<boolean> => {
try {
const stats = statSync(homeDir);
uid = stats.uid;
gid = stats.gid;
} catch {
// fallback to defaults
}
const containerHome = `/home/${username}`;
const run = Bun.spawnSync({
cmd: [
dockerPath,
'run',
'-d',
'--name',
dockerId,
'--restart',
'unless-stopped',
'--network', 'host',
'-e',
`TERMINAL_PTY_PORT=${port}`,
'-e',
'TERMINAL_PTY_HOST=127.0.0.1',
'-e',
`TERMINAL_USER=${username}`,
'-e',
`TERMINAL_UID=${uid}`,
'-e',
`TERMINAL_GID=${gid}`,
'-e',
`OFFICER_EMAIL=${email}`,
'-v', `${homeDir}:${containerHome}`,
'-v', `${getGlobalSkillsDir()}:/officer/skills:ro`,
'-v', `${getGlobalToolsDir()}:/officer/tools:ro`,
'-v', `${getGlobalExtensionsDir()}:/officer/extensions:ro`,
'-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`,
'-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`,
'-v', `${PI_CONFIG_DIR}:/officer/pi-config:ro`,
'-v', `${PI_CONFIG_DIR}:${containerHome}/.pi/agent`,
'-v', `${ensureDir(join(getHomeDir(email), '.pi', 'agent', 'sessions'))}:${containerHome}/.pi/agent/sessions`,
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
'-v', `${join(DATA_PATH, email)}:/officer/data`,
...(existsSync(join(process.env.HOME ?? '', '.claude')) ? ['-v', `${join(process.env.HOME!, '.claude')}:${containerHome}/.claude`] : []),
...(contextFile && existsSync(contextFile) ? ['-v', `${contextFile}:${containerHome}/.claude/CLAUDE.md:ro`] : []),
...(settingsFile && existsSync(settingsFile) ? ['-v', `${settingsFile}:${containerHome}/.claude/settings.json:ro`] : []),
'-w', containerHome,
tag,
],
stdout: 'inherit',
stderr: 'inherit',
});
if (run.exitCode !== 0) throw new Error('Failed to start terminal sandbox container');
// Wait for entrypoint to finish (user creation, sidecar start)
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
if (await sidecarAlive(port)) return { dockerId };
}
throw new Error('Terminal sidecar did not start in time');
};
const stopDockerSidecar = (dockerId: string) => {
const dockerPath = Bun.which('docker') ?? 'docker';
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
};
const readDockerLogs = (dockerId: string) => {
const dockerPath = Bun.which('docker') ?? 'docker';
const logs = Bun.spawnSync({ cmd: [dockerPath, 'logs', '--tail', '200', dockerId], stdout: 'pipe', stderr: 'pipe' });
if (logs.exitCode !== 0) return '';
return logs.stdout.toString().trim();
};
const loadContainerMap = async (): Promise<Record<string, ContainerInfo>> => {
if (containersCache) return containersCache;
const data = await Bun.file(containerMapPath)
.json()
.catch(() => ({}));
containersCache = data as Record<string, ContainerInfo>;
return containersCache;
};
const saveContainerMap = async (map: Record<string, ContainerInfo>) => {
containersCache = map;
await Bun.write(containerMapPath, JSON.stringify(map, null, 2));
};
const getAvailablePort = (map: Record<string, ContainerInfo>, userId: number) => {
const base = 54000;
const used = new Set(Object.values(map).map((item) => item.port));
let port = base + (userId % 1000);
while (used.has(port)) port += 1;
return port;
};
const dockerContainerExists = (dockerId: string) => {
const dockerPath = Bun.which('docker') ?? 'docker';
const result = Bun.spawnSync({ cmd: [dockerPath, 'ps', '-a', '-q', '-f', `name=${dockerId}`], stdout: 'pipe' });
return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
};
const dockerContainerRunning = (dockerId: string) => {
const dockerPath = Bun.which('docker') ?? 'docker';
const result = Bun.spawnSync({ cmd: [dockerPath, 'ps', '-q', '-f', `name=${dockerId}`], stdout: 'pipe' });
return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
};
const dockerStart = (dockerId: string) => {
const dockerPath = Bun.which('docker') ?? 'docker';
const result = Bun.spawnSync({ cmd: [dockerPath, 'start', dockerId], stdout: 'ignore', stderr: 'ignore' });
return result.exitCode === 0;
};
export const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string, contextFile?: string, settingsFile?: string) => {
// Check if mount sources are stale (e.g. data dir was deleted and Docker recreated them as root)
// Must check BEFORE mkdirSync overwrites them
const skillsDir = getUserSkillsDir(email);
let stale = false;
try {
const s = statSync(skillsDir);
if (s.uid === 0) stale = true;
} catch {
// doesn't exist yet — not stale, will be created below
}
// Ensure user-specific resource dirs exist before mounting (Docker creates them as root if missing)
mkdirSync(skillsDir, { recursive: true });
mkdirSync(getUserToolsDir(email), { recursive: true });
mkdirSync(join(DATA_PATH, email, 'integrations'), { recursive: true });
const map = await loadContainerMap();
const existing = map[email];
if (existing && dockerContainerRunning(existing.dockerId)) {
// Recreate if resource mounts are missing or data dir was recreated (stale mounts)
if (!containerHasExpectedMounts(existing.dockerId) || stale) {
console.log(`[terminal] recreating container for ${email} — mounts stale or missing`);
stopDockerSidecar(existing.dockerId);
} else {
console.log(`[terminal] reusing running container ${existing.dockerId} for ${email} on port ${existing.port}`);
return existing;
}
}
if (existing && dockerContainerExists(existing.dockerId)) {
if (!containerHasExpectedMounts(existing.dockerId) || stale) {
stopDockerSidecar(existing.dockerId);
} else if (dockerStart(existing.dockerId)) {
return existing;
} else {
stopDockerSidecar(existing.dockerId);
}
}
const port = existing?.port ?? getAvailablePort(map, userId);
const docker = await startDockerSidecar(port, homeDir, userId, username, email, contextFile, settingsFile);
const next = { userId, email, dockerId: docker.dockerId, port };
map[email] = next;
await saveContainerMap(map);
return next;
};
const sidecarAlive = async (port: number): Promise<boolean> => {
try {
const res = await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(500) });
const res = await fetch(`http://127.0.0.1:${HOST_SIDECAR_PORT}`, { signal: AbortSignal.timeout(500) });
return res.ok;
} catch {
return false;
@@ -334,135 +111,61 @@ const startHostSidecar = async () => {
};
export const ensureHostSidecar = async () => {
const alive = await sidecarAlive(HOST_SIDECAR_PORT);
const alive = await sidecarAlive();
if (!alive) {
await startHostSidecar();
// Wait for it to come up
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 300));
if (await sidecarAlive(HOST_SIDECAR_PORT)) return;
if (await sidecarAlive()) return;
}
throw new Error('Host sidecar failed to start');
}
};
export const initTerminalSidecars = async () => {
await startHostSidecar();
ensureDockerImage();
const users = await getUsers();
for (const user of users) {
const homeDir = getHomeDir(user.email);
mkdirSync(dirname(homeDir), { recursive: true });
mkdirSync(homeDir, { recursive: true });
const shellUsername = toShellUsername(user.username ?? '', user.email);
const contextFile = generateContainerContext(user.email);
const settingsFile = generateClaudeSettings(user.email, shellUsername);
try {
await ensureDockerContainer(user.email, user.id, homeDir, shellUsername, contextFile, settingsFile);
console.log(`[terminal] sidecar ready for ${user.email}`);
} catch (err) {
console.error(`[terminal] failed to start sidecar for ${user.email}:`, err);
// Sidecar is managed by pm2 — wait for it to be available
for (let i = 0; i < 15; i++) {
if (await sidecarAlive()) {
console.log(`[terminal] host sidecar already running on port ${HOST_SIDECAR_PORT}`);
return;
}
await new Promise((r) => setTimeout(r, 500));
}
console.warn(`[terminal] host sidecar not detected on port ${HOST_SIDECAR_PORT} — terminals will retry on connect`);
};
const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' };
const resolveCwd = (home: string, cwd?: string) => {
if (!cwd || cwd === '~') return home;
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
if (cwd.startsWith('/')) return join(home, cwd.slice(1));
if (cwd.startsWith('/')) return cwd;
return home;
};
export const terminalWebsocket = {
async open(ws: ServerWebSocket<WSData>) {
const { email, username, role, sandboxed } = ws.data;
const isHost = !sandboxed && role === 'Super Admin';
if (!sandboxed && role !== 'Super Admin') {
sendOutput(ws, '\r\n[Permission denied] Host terminal requires Super Admin role.\r\n');
return;
}
console.log(
`[terminal] open: email=${email} username=${username} role=${role} sandboxed=${sandboxed} isHost=${isHost}`,
);
if (!sandboxed) {
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: HOST_SIDECAR_PORT, pendingMessages: [] };
sessions.set(ws, session);
let sidecar: WebSocket | null = null;
try {
sidecar = await connectSidecar(HOST_SIDECAR_PORT);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to connect host sidecar';
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
sessions.delete(ws);
return;
}
session.sidecar = sidecar;
sidecar.addEventListener('message', (ev) => {
try {
if (typeof ev.data === 'string') {
ws.send(ev.data);
} else {
ws.send(new TextDecoder().decode(ev.data));
}
} catch {
// ws already closed
}
});
sidecar.send(
JSON.stringify({
type: 'init',
host: true,
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
homeDir: process.env.HOME,
userLabel: email,
cols: ws.data.cols,
rows: ws.data.rows,
}),
);
for (const msg of session.pendingMessages) sidecar.send(msg);
session.pendingMessages = [];
return;
}
const cwd = getHomeDir(email);
const userRoot = dirname(cwd);
mkdirSync(userRoot, { recursive: true });
mkdirSync(cwd, { recursive: true });
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: 0, pendingMessages: [] };
const session: BridgeSession = { client: ws, sidecar: null, pendingMessages: [] };
sessions.set(ws, session);
let sidecar: WebSocket | null = null;
let info: ContainerInfo | undefined;
try {
info = await ensureDockerContainer(email, ws.data.userId, cwd, username, generateContainerContext(email), generateClaudeSettings(email, username));
sidecar = await connectSidecar(info.port);
sidecar = await connectSidecar();
console.log('[terminal] sidecar connected');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar';
console.error(`[terminal] sidecar connection failed for ${email}:`, message);
console.error('[terminal] sidecar connection failed:', message);
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
if (info) {
const logs = readDockerLogs(info.dockerId);
if (logs) {
sendOutput(ws, `\r\n[Docker logs]\r\n${logs}\r\n`);
}
}
sendOutput(ws, '\r\n[Process exited]\r\n');
if (info) stopDockerSidecar(info.dockerId);
sessions.delete(ws);
return;
}
session.sidecar = sidecar;
session.dockerId = info.dockerId;
session.port = info.port;
sidecar.addEventListener('message', (ev) => {
try {
@@ -476,19 +179,40 @@ export const terminalWebsocket = {
}
});
const containerHome = `/home/${username}`;
sidecar.send(
JSON.stringify({
type: 'init',
sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`,
shell: containerShell,
cwd: resolveCwd(containerHome, ws.data.cwd),
homeDir: containerHome,
userLabel: email,
cols: ws.data.cols,
rows: ws.data.rows,
}),
);
if (isHost) {
// Super Admin host terminal — spawn as the service user directly
sidecar.send(
JSON.stringify({
type: 'init',
host: true,
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
homeDir: process.env.HOME,
userLabel: email,
cols: ws.data.cols,
rows: ws.data.rows,
}),
);
} else {
// User terminal — spawn as the target Linux user via sudo -u
const homeDir = getHomeDir(email);
mkdirSync(dirname(homeDir), { recursive: true });
mkdirSync(homeDir, { recursive: true });
sidecar.send(
JSON.stringify({
type: 'init',
username,
sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`,
cwd: resolveCwd(homeDir, ws.data.cwd),
homeDir,
userLabel: email,
cols: ws.data.cols,
rows: ws.data.rows,
}),
);
}
for (const msg of session.pendingMessages) sidecar.send(msg);
session.pendingMessages = [];
@@ -531,32 +255,20 @@ export const broadcastPanelRefresh = (email: string) => {
const msg = JSON.stringify({ type: 'panel-refresh' });
for (const [ws, session] of sessions) {
if (ws.data.email === email && session.sidecar) {
try { ws.send(msg); } catch { /* ignore */ }
try {
ws.send(msg);
} catch {
/* ignore */
}
}
}
};
export const stopAllContainers = async () => {
// Stop host sidecar
export const stopAllSidecars = async () => {
if (hostSidecarProcess) {
hostSidecarProcess.kill();
await hostSidecarProcess.exited.catch(() => {});
hostSidecarProcess = null;
console.log('[terminal] host sidecar stopped');
}
// Stop all Docker containers
const map = await loadContainerMap();
const entries = Object.entries(map);
if (entries.length === 0) return;
const dockerPath = Bun.which('docker') ?? 'docker';
for (const [email, info] of entries) {
try {
Bun.spawnSync({ cmd: [dockerPath, 'stop', '-t', '2', info.dockerId], stdout: 'ignore', stderr: 'ignore' });
console.log(`[terminal] stopped container ${info.dockerId} (${email})`);
} catch {
// ignore
}
}
};
+147
View File
@@ -0,0 +1,147 @@
import { existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH, getHomeDir, toShellUsername } from '@@/data-path';
import { generateContainerContext, generateClaudeSettings } from '@@/generate-container-context';
const TEMPLATE_DIR = join(import.meta.dir, '../terminal/templates');
const run = (cmd: string[], opts?: { cwd?: string }): boolean => {
const result = Bun.spawnSync({ cmd, stdout: 'ignore', stderr: 'pipe', ...opts });
if (result.exitCode !== 0) {
console.error(`[provision] command failed: ${cmd.join(' ')}`, result.stderr.toString().trim());
}
return result.exitCode === 0;
};
const linuxUserExists = (username: string): boolean => {
const result = Bun.spawnSync({ cmd: ['id', username], stdout: 'ignore', stderr: 'ignore' });
return result.exitCode === 0;
};
const copyTemplate = async (src: string, dest: string) => {
if (existsSync(dest)) return;
const content = await Bun.file(src).text();
await Bun.write(dest, content);
};
export async function provisionLinuxUser(email: string, username: string): Promise<boolean> {
const shellUsername = toShellUsername(username, email);
const homeDir = getHomeDir(email);
const userRoot = join(DATA_PATH, email);
console.log(`[provision] provisioning Linux user ${shellUsername} for ${email}`);
// Ensure data directories exist
mkdirSync(userRoot, { recursive: true });
mkdirSync(homeDir, { recursive: true });
// Create Linux user if not exists
if (!linuxUserExists(shellUsername)) {
const ok = run(['sudo', 'useradd', '-d', homeDir, '-s', '/bin/zsh', '-M', shellUsername]);
if (!ok) {
console.error(`[provision] failed to create Linux user ${shellUsername}`);
return false;
}
console.log(`[provision] created Linux user ${shellUsername}`);
} else {
console.log(`[provision] Linux user ${shellUsername} already exists`);
}
// Set ownership and permissions on user data directory
// chmod 770 so the service user (in the user's group) can read/write for background jobs
run(['sudo', 'chown', '-R', `${shellUsername}:${shellUsername}`, userRoot]);
run(['sudo', 'chmod', '770', userRoot]);
// Add the service user to the new user's group so server jobs can access user data
const serviceUser = process.env.USER ?? '';
if (serviceUser && serviceUser !== shellUsername) {
run(['sudo', 'usermod', '-aG', shellUsername, serviceUser]);
}
// Seed shell config files
await seedShellConfigs(homeDir);
// Generate and write CLAUDE.md + settings.json
const contextFile = generateContainerContext(email);
const claudeDir = join(homeDir, '.claude');
mkdirSync(claudeDir, { recursive: true });
// Copy context to user's .claude dir
const contextContent = await Bun.file(contextFile).text();
await Bun.write(join(claudeDir, 'CLAUDE.md'), contextContent);
const settingsFile = generateClaudeSettings(email, shellUsername);
const settingsContent = await Bun.file(settingsFile).text();
await Bun.write(join(claudeDir, 'settings.json'), settingsContent);
// Fix ownership after seeding
run(['sudo', 'chown', '-R', `${shellUsername}:${shellUsername}`, userRoot]);
console.log(`[provision] provisioning complete for ${shellUsername}`);
return true;
}
async function seedShellConfigs(homeDir: string): Promise<void> {
// .zshenv (must be first — prevents system compinit before oh-my-zsh)
await copyTemplate(join(TEMPLATE_DIR, '.zshenv'), join(homeDir, '.zshenv'));
// .zshrc
await copyTemplate(join(TEMPLATE_DIR, '.zshrc'), join(homeDir, '.zshrc'));
// .tmux.conf
await copyTemplate(join(TEMPLATE_DIR, '.tmux.conf'), join(homeDir, '.tmux.conf'));
// starship config
const configDir = join(homeDir, '.config');
mkdirSync(configDir, { recursive: true });
await copyTemplate(join(TEMPLATE_DIR, 'starship-officer.toml'), join(configDir, 'starship-officer.toml'));
// Oh My Zsh — copy from host install
const ohMyZshDest = join(homeDir, '.oh-my-zsh');
if (!existsSync(ohMyZshDest)) {
const hostOhMyZsh = join(process.env.HOME ?? '', '.oh-my-zsh');
if (existsSync(hostOhMyZsh)) {
Bun.spawnSync({ cmd: ['cp', '-r', hostOhMyZsh, ohMyZshDest], stdout: 'ignore', stderr: 'ignore' });
} else {
Bun.spawnSync({
cmd: ['git', 'clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshDest],
stdout: 'ignore',
stderr: 'ignore',
});
}
}
// LazyVim config
const nvimDir = join(homeDir, '.config', 'nvim');
if (!existsSync(nvimDir)) {
const hostNvim = join(process.env.HOME ?? '', '.config', 'nvim');
if (existsSync(hostNvim)) {
Bun.spawnSync({ cmd: ['cp', '-r', hostNvim, nvimDir], stdout: 'ignore', stderr: 'ignore' });
}
}
// Ensure .local/bin exists
mkdirSync(join(homeDir, '.local', 'bin'), { recursive: true });
// Ensure .pi/agent/sessions exists
mkdirSync(join(homeDir, '.pi', 'agent', 'sessions'), { recursive: true });
}
export function deprovisionLinuxUser(email: string, username: string): boolean {
const shellUsername = toShellUsername(username, email);
console.log(`[provision] deprovisioning Linux user ${shellUsername}`);
if (!linuxUserExists(shellUsername)) {
console.log(`[provision] Linux user ${shellUsername} does not exist, skipping`);
return true;
}
const ok = run(['sudo', 'userdel', shellUsername]);
if (!ok) {
console.error(`[provision] failed to delete Linux user ${shellUsername}`);
return false;
}
console.log(`[provision] deprovisioned Linux user ${shellUsername}`);
return true;
}
+8 -3
View File
@@ -6,6 +6,7 @@ import { sendMail } from 'emailer';
import * as errors from '@@/custom-errors';
import { originMiddleware } from '@@/_middlewares';
import { updateUserHandler } from './update-user';
import { deprovisionLinuxUser } from './provision';
export const usersRouter = createRouter();
usersRouter.use(originMiddleware);
@@ -35,9 +36,10 @@ usersRouter.post('/invite', async (ctx) => {
}
const validRoles = USER_ROLES.filter((r) => r !== 'Super Admin');
const assignedRole = (typeof role === 'string' && validRoles.includes(role as (typeof validRoles)[number]))
? (role as (typeof USER_ROLES)[number])
: ('Member' as const);
const assignedRole =
typeof role === 'string' && validRoles.includes(role as (typeof validRoles)[number])
? (role as (typeof USER_ROLES)[number])
: ('Member' as const);
const existing = await getUserByEmail(email);
if (existing) throw errors.CONFLICT('A user with this email already exists');
@@ -101,6 +103,9 @@ usersRouter.delete('/:id', async (ctx) => {
const target = await getUserById(id);
if (!target) throw errors.NOT_FOUND('User not found');
// Deprovision Linux user before deleting from database
deprovisionLinuxUser(target.email, target.username ?? '');
await deleteUser(id);
return ctx.json({ ok: true });
});