fix pi cwd resolution and super admin home dir for all pi endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 20:31:35 +00:00
co-authored by Claude Opus 4.6
parent 4c2e40a46b
commit 25e8aaaea6
5 changed files with 48 additions and 55 deletions
+7 -5
View File
@@ -6,7 +6,7 @@ import { readSearxngConfig } from '../server-settings/searxng';
import {
PI_CONFIG_DIR,
DATA_PATH,
getHomeDir,
getHomeDirForRole,
getGlobalSkillsDir,
getUserSkillsDir,
getGlobalExtensionsDir,
@@ -237,6 +237,7 @@ async function resolveApiKeyForModel(model: string): Promise<string | null> {
type SpawnPiOptions = {
sessionFile?: string;
username?: string;
role?: string;
};
export async function spawnPi(
@@ -275,7 +276,7 @@ export async function spawnPi(
mkdirSync(cwd, { recursive: true });
}
const homeDir = getHomeDir(email);
const homeDir = getHomeDirForRole(email, options?.role ?? null);
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
const browserRelayEnv = await getBrowserRelayEnv(userId);
const apifyToken = await getApifyToken();
@@ -550,13 +551,14 @@ export function killPi(process: Subprocess): void {
}
/** Build env vars needed by Officer tools when running on the host. */
export async function buildHostToolEnv(userId: number, email: string): Promise<Record<string, string>> {
export async function buildHostToolEnv(userId: number, email: string, role?: string): Promise<Record<string, string>> {
const browserRelayEnv = await getBrowserRelayEnv(userId);
const apifyToken = await getApifyToken();
const homeDir = getHomeDirForRole(email, role ?? null);
return {
HOME: getHomeDir(email),
OFFICER_USER_HOME: getHomeDir(email),
HOME: homeDir,
OFFICER_USER_HOME: homeDir,
OFFICER_USER_ROOT: join(DATA_PATH, email),
OFFICER_RESOURCES: buildResourcesEnv(),
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
+13 -13
View File
@@ -4,7 +4,7 @@ import * as storage from './storage';
import { readLocalProviders } from '../server-settings/pi-mono';
import { readSttConfig } from '../server-settings/stt';
import { listPiModels } from './list-models';
import { getHomeDir } from '../../data-path';
import { getHomeDirForRole } from '../../data-path';
import { resolveBaseCwd } from './websocket';
import { logger } from './logger';
@@ -50,8 +50,8 @@ 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 userHome = getHomeDirForRole(user.email, user.role);
const filterCwd = body.cwd ? resolveBaseCwd(user.email, user.role, body.cwd) : null;
const contextFilter = body.context
? { context: body.context as string, contextId: body.contextId as string | undefined }
: undefined;
@@ -83,7 +83,7 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
return ctx.json({ error: 'Session ID required' }, 400);
}
const userHome = getHomeDir(user.email);
const userHome = getHomeDirForRole(user.email, user.role);
try {
// Try loading from root first
@@ -150,7 +150,7 @@ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => {
return ctx.json({ error: 'Title is required and must be a string' }, 400);
}
const userHome = getHomeDir(user.email);
const userHome = getHomeDirForRole(user.email, user.role);
try {
// Find the session (root or in group)
@@ -207,7 +207,7 @@ piRestRouter.delete('/pi/sessions/:sessionId', async (ctx: Context) => {
return ctx.json({ error: 'Session ID required' }, 400);
}
const userHome = getHomeDir(user.email);
const userHome = getHomeDirForRole(user.email, user.role);
try {
// Find the session (root or in group)
@@ -266,7 +266,7 @@ piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
const contextFilter = body.context
? { context: body.context as string, contextId: body.contextId as string | undefined }
: undefined;
const userHome = getHomeDir(user.email);
const userHome = getHomeDirForRole(user.email, user.role);
try {
const allSessions = await storage.listUserSessions(userHome, contextFilter);
@@ -311,7 +311,7 @@ piRestRouter.get('/pi/sessions/search', async (ctx: Context) => {
return ctx.json({ error: 'Query parameter required' }, 400);
}
const userHome = getHomeDir(user.email);
const userHome = getHomeDirForRole(user.email, user.role);
try {
const results = await storage.searchSessions(userHome, query);
@@ -342,7 +342,7 @@ piRestRouter.post('/pi/groups', async (ctx: Context) => {
return ctx.json({ error: 'Slug is required and must be a string' }, 400);
}
const userHome = getHomeDir(user.email);
const userHome = getHomeDirForRole(user.email, user.role);
try {
// Check if group already exists
@@ -407,7 +407,7 @@ piRestRouter.get('/pi/groups', async (ctx: Context) => {
return ctx.json({ error: 'Unauthorized' }, 401);
}
const userHome = getHomeDir(user.email);
const userHome = getHomeDirForRole(user.email, user.role);
try {
const groups = await storage.listGroups(userHome);
@@ -447,7 +447,7 @@ piRestRouter.patch('/pi/groups/:groupSlug', async (ctx: Context) => {
return ctx.json({ error: 'No valid updates provided' }, 400);
}
const userHome = getHomeDir(user.email);
const userHome = getHomeDirForRole(user.email, user.role);
try {
const updatedGroup = await storage.updateGroupMeta(userHome, groupSlug, updates);
@@ -476,7 +476,7 @@ piRestRouter.delete('/pi/groups/:groupSlug', async (ctx: Context) => {
return ctx.json({ error: 'Group slug required' }, 400);
}
const userHome = getHomeDir(user.email);
const userHome = getHomeDirForRole(user.email, user.role);
try {
await storage.deleteGroup(userHome, groupSlug);
@@ -509,7 +509,7 @@ piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => {
return ctx.json({ error: 'groupSlug must be a string or null' }, 400);
}
const userHome = getHomeDir(user.email);
const userHome = getHomeDirForRole(user.email, user.role);
try {
// Find the session in root or any group
+18 -32
View File
@@ -5,9 +5,8 @@ import { sessionManager } from './session-manager';
import * as storage from './storage';
import * as piBridge from './pi-bridge';
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { join, resolve } from 'path';
import { homedir } from 'os';
import { getHomeDir } from '../../../servers/data-path';
import { join } from 'path';
import { getHomeDirForRole } from '../../../servers/data-path';
import { getUserSettings } from 'officerdb';
import { logger } from './logger';
@@ -36,25 +35,16 @@ type WSData = {
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
const resolveSandboxedCwd = (email: string, cwdRoot?: string, cwd?: string) => {
const root = !cwdRoot || cwdRoot === 'home' ? getHomeDir(email) : getHomeDir(email);
const resolveCwd = (email: string, role: string, cwd?: string) => {
const root = getHomeDirForRole(email, role);
if (!cwd || cwd === '~') return root;
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
if (cwd.startsWith('/')) return join(root, cwd.slice(1));
return root;
};
const resolveHostCwd = (cwdRoot?: string, cwd?: string) => {
if (cwdRoot === 'officer.dev') return resolve(process.cwd(), '..');
const root = homedir();
if (!cwd || cwd === '~') return root;
if (cwd.startsWith('/')) return cwd;
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
return join(root, cwd);
};
export const resolveBaseCwd = (email: string, cwdRoot?: string, cwd?: string) => {
return resolveHostCwd(cwdRoot, cwd);
export const resolveBaseCwd = (email: string, role: string, cwd?: string) => {
return resolveCwd(email, role, cwd);
};
const wsToSessionMap = new WeakMap<any, string>();
@@ -289,10 +279,8 @@ async function handleChat(
return handleClaudeCodeChat(ws, sessionId, model, msg);
}
const homeDir = getHomeDir(email);
const cwd = ws.data.sandboxed
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
: resolveHostCwd(msg.cwdRoot, msg.cwd);
const homeDir = getHomeDirForRole(email, ws.data.role);
const cwd = resolveCwd(email, ws.data.role, msg.cwd);
const groupSlug = msg.groupSlug || null;
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
session.userId = userId;
@@ -312,15 +300,15 @@ async function handleChat(
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
// If session has history, save to disk and pass --session for context replay
let spawnOptions: { sessionFile?: string; username?: string } | undefined;
let spawnOptions: { sessionFile?: string; username?: string; role?: 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) {
spawnOptions = { sessionFile: hostPath, username };
spawnOptions = { sessionFile: hostPath, username, role: ws.data.role };
}
}
if (!spawnOptions) spawnOptions = { username };
if (!spawnOptions) spawnOptions = { username, role: ws.data.role };
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, spawnOptions);
@@ -389,11 +377,9 @@ async function handleClaudeCodeChat(
},
): Promise<void> {
const { email, username, userId } = ws.data;
const homeDir = getHomeDir(email);
const homeDir = getHomeDirForRole(email, ws.data.role);
const cwd = ws.data.sandboxed
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
: resolveHostCwd(msg.cwdRoot, msg.cwd);
const cwd = resolveCwd(email, ws.data.role, msg.cwd);
const groupSlug = msg.groupSlug || null;
@@ -468,7 +454,7 @@ async function handleResume(
let session = sessionManager.getSession(sessionId);
if (!session) {
const homeDir = getHomeDir(email);
const homeDir = getHomeDirForRole(email, ws.data.role);
try {
const { meta, messages } = await storage.loadSession(homeDir, sessionId);
@@ -500,19 +486,19 @@ async function handleResume(
// Spawn fresh Pi process if needed
if (!session.piProcess) {
try {
const homeDir = getHomeDir(email);
const homeDir = getHomeDirForRole(email, ws.data.role);
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; username?: string } | undefined;
let spawnOptions: { sessionFile?: string; username?: string; role?: 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) {
spawnOptions = { sessionFile: hostPath, username: ws.data.username };
spawnOptions = { sessionFile: hostPath, username: ws.data.username, role: ws.data.role };
}
}
if (!spawnOptions) spawnOptions = { username: ws.data.username };
if (!spawnOptions) spawnOptions = { username: ws.data.username, role: ws.data.role };
session.piProcess = await piBridge.spawnPi(
session.cwd,
+7 -5
View File
@@ -4,7 +4,7 @@ import type { PiEvent, MessageCost, Message } from '@@/api/pi/types';
import { sessionManager } from '@@/api/pi/session-manager';
import * as storage from '@@/api/pi/storage';
import * as piBridge from '@@/api/pi/pi-bridge';
import { getHomeDir } from '@@/data-path';
import { getHomeDirForRole } from '@@/data-path';
import { getUserSettings } from 'officerdb';
import { logger } from '@@/api/pi/logger';
import { sendClaudeCode, clearClaudeCodeSession } from './send-claude-code';
@@ -21,6 +21,7 @@ type SendAndAwaitParams = {
context: string;
contextId: string;
model?: string;
role?: string;
};
type SendAndAwaitResult = {
@@ -132,7 +133,7 @@ function createDispatcher(sessionId: string): (event: PiEvent) => void {
async function doSend(sessionId: string, params: SendAndAwaitParams): Promise<SendAndAwaitResult> {
const { userId, email, username, prompt, context, contextId } = params;
const homeDir = getHomeDir(email);
const homeDir = getHomeDirForRole(email, params.role ?? null);
const cwd = homeDir;
// Resolve model: explicit param > !model override > user default > existing session > system default
@@ -294,15 +295,16 @@ async function doSend(sessionId: string, params: SendAndAwaitParams): Promise<Se
(async () => {
try {
if (!session.piProcess) {
let spawnOptions: { sessionFile?: string; username?: string } | undefined;
const role = params.role;
let spawnOptions: { sessionFile?: string; username?: string; role?: 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) {
spawnOptions = { sessionFile: hostPath, username };
spawnOptions = { sessionFile: hostPath, username, role };
}
}
if (!spawnOptions) spawnOptions = { username };
if (!spawnOptions) spawnOptions = { username, role };
const dispatcher = createDispatcher(sessionId);
session.piProcess = await piBridge.spawnPi(cwd, model!, userId, email, dispatcher, spawnOptions);
+3
View File
@@ -31,6 +31,9 @@ export const getArchivedSessionDir = (email: string, sessionId: string) =>
export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
export const getHomeDirForRole = (email: string, role: string | null): string =>
role === 'Super Admin' && process.env.HOME_DIR ? process.env.HOME_DIR : getHomeDir(email);
export const getUserPiConfigDir = (email: string) => join(DATA_PATH, email, 'home', '.pi', 'agent');
export const getUserSettingsDir = (email: string) => join(DATA_PATH, email, 'settings');