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:
@@ -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.
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user