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 { import {
PI_CONFIG_DIR, PI_CONFIG_DIR,
DATA_PATH, DATA_PATH,
getHomeDir, getHomeDirForRole,
getGlobalSkillsDir, getGlobalSkillsDir,
getUserSkillsDir, getUserSkillsDir,
getGlobalExtensionsDir, getGlobalExtensionsDir,
@@ -237,6 +237,7 @@ async function resolveApiKeyForModel(model: string): Promise<string | null> {
type SpawnPiOptions = { type SpawnPiOptions = {
sessionFile?: string; sessionFile?: string;
username?: string; username?: string;
role?: string;
}; };
export async function spawnPi( export async function spawnPi(
@@ -275,7 +276,7 @@ export async function spawnPi(
mkdirSync(cwd, { recursive: true }); mkdirSync(cwd, { recursive: true });
} }
const homeDir = getHomeDir(email); const homeDir = getHomeDirForRole(email, options?.role ?? null);
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':'); const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
const browserRelayEnv = await getBrowserRelayEnv(userId); const browserRelayEnv = await getBrowserRelayEnv(userId);
const apifyToken = await getApifyToken(); 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. */ /** 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 browserRelayEnv = await getBrowserRelayEnv(userId);
const apifyToken = await getApifyToken(); const apifyToken = await getApifyToken();
const homeDir = getHomeDirForRole(email, role ?? null);
return { return {
HOME: getHomeDir(email), HOME: homeDir,
OFFICER_USER_HOME: getHomeDir(email), OFFICER_USER_HOME: homeDir,
OFFICER_USER_ROOT: join(DATA_PATH, email), OFFICER_USER_ROOT: join(DATA_PATH, email),
OFFICER_RESOURCES: buildResourcesEnv(), OFFICER_RESOURCES: buildResourcesEnv(),
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), 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 { readLocalProviders } from '../server-settings/pi-mono';
import { readSttConfig } from '../server-settings/stt'; import { readSttConfig } from '../server-settings/stt';
import { listPiModels } from './list-models'; import { listPiModels } from './list-models';
import { getHomeDir } from '../../data-path'; import { getHomeDirForRole } from '../../data-path';
import { resolveBaseCwd } from './websocket'; import { resolveBaseCwd } from './websocket';
import { logger } from './logger'; import { logger } from './logger';
@@ -50,8 +50,8 @@ piRestRouter.post('/pi/sessions', async (ctx: Context) => {
} }
const body = await ctx.req.json().catch(() => ({})); const body = await ctx.req.json().catch(() => ({}));
const userHome = getHomeDir(user.email); const userHome = getHomeDirForRole(user.email, user.role);
const filterCwd = body.cwd ? resolveBaseCwd(user.email, body.cwdRoot, body.cwd) : null; const filterCwd = body.cwd ? resolveBaseCwd(user.email, user.role, body.cwd) : null;
const contextFilter = body.context const contextFilter = body.context
? { context: body.context as string, contextId: body.contextId as string | undefined } ? { context: body.context as string, contextId: body.contextId as string | undefined }
: undefined; : undefined;
@@ -83,7 +83,7 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => {
return ctx.json({ error: 'Session ID required' }, 400); return ctx.json({ error: 'Session ID required' }, 400);
} }
const userHome = getHomeDir(user.email); const userHome = getHomeDirForRole(user.email, user.role);
try { try {
// Try loading from root first // 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); 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 { try {
// Find the session (root or in group) // 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); return ctx.json({ error: 'Session ID required' }, 400);
} }
const userHome = getHomeDir(user.email); const userHome = getHomeDirForRole(user.email, user.role);
try { try {
// Find the session (root or in group) // Find the session (root or in group)
@@ -266,7 +266,7 @@ piRestRouter.delete('/pi/sessions', async (ctx: Context) => {
const contextFilter = body.context const contextFilter = body.context
? { context: body.context as string, contextId: body.contextId as string | undefined } ? { context: body.context as string, contextId: body.contextId as string | undefined }
: undefined; : undefined;
const userHome = getHomeDir(user.email); const userHome = getHomeDirForRole(user.email, user.role);
try { try {
const allSessions = await storage.listUserSessions(userHome, contextFilter); 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); return ctx.json({ error: 'Query parameter required' }, 400);
} }
const userHome = getHomeDir(user.email); const userHome = getHomeDirForRole(user.email, user.role);
try { try {
const results = await storage.searchSessions(userHome, query); 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); 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 { try {
// Check if group already exists // Check if group already exists
@@ -407,7 +407,7 @@ piRestRouter.get('/pi/groups', async (ctx: Context) => {
return ctx.json({ error: 'Unauthorized' }, 401); return ctx.json({ error: 'Unauthorized' }, 401);
} }
const userHome = getHomeDir(user.email); const userHome = getHomeDirForRole(user.email, user.role);
try { try {
const groups = await storage.listGroups(userHome); 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); return ctx.json({ error: 'No valid updates provided' }, 400);
} }
const userHome = getHomeDir(user.email); const userHome = getHomeDirForRole(user.email, user.role);
try { try {
const updatedGroup = await storage.updateGroupMeta(userHome, groupSlug, updates); 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); return ctx.json({ error: 'Group slug required' }, 400);
} }
const userHome = getHomeDir(user.email); const userHome = getHomeDirForRole(user.email, user.role);
try { try {
await storage.deleteGroup(userHome, groupSlug); 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); 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 { try {
// Find the session in root or any group // 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 storage from './storage';
import * as piBridge from './pi-bridge'; import * as piBridge from './pi-bridge';
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code'; import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { join, resolve } from 'path'; import { join } from 'path';
import { homedir } from 'os'; import { getHomeDirForRole } from '../../../servers/data-path';
import { getHomeDir } from '../../../servers/data-path';
import { getUserSettings } from 'officerdb'; import { getUserSettings } from 'officerdb';
import { logger } from './logger'; import { logger } from './logger';
@@ -36,25 +35,16 @@ type WSData = {
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
const resolveSandboxedCwd = (email: string, cwdRoot?: string, cwd?: string) => { const resolveCwd = (email: string, role: string, cwd?: string) => {
const root = !cwdRoot || cwdRoot === 'home' ? getHomeDir(email) : getHomeDir(email); const root = getHomeDirForRole(email, role);
if (!cwd || cwd === '~') return root; if (!cwd || cwd === '~') return root;
if (cwd.startsWith('~/')) return join(root, cwd.slice(2)); if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
if (cwd.startsWith('/')) return join(root, cwd.slice(1)); 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); return join(root, cwd);
}; };
export const resolveBaseCwd = (email: string, cwdRoot?: string, cwd?: string) => { export const resolveBaseCwd = (email: string, role: string, cwd?: string) => {
return resolveHostCwd(cwdRoot, cwd); return resolveCwd(email, role, cwd);
}; };
const wsToSessionMap = new WeakMap<any, string>(); const wsToSessionMap = new WeakMap<any, string>();
@@ -289,10 +279,8 @@ async function handleChat(
return handleClaudeCodeChat(ws, sessionId, model, msg); return handleClaudeCodeChat(ws, sessionId, model, msg);
} }
const homeDir = getHomeDir(email); const homeDir = getHomeDirForRole(email, ws.data.role);
const cwd = ws.data.sandboxed const cwd = resolveCwd(email, ws.data.role, msg.cwd);
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
: resolveHostCwd(msg.cwdRoot, msg.cwd);
const groupSlug = msg.groupSlug || null; const groupSlug = msg.groupSlug || null;
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId); const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
session.userId = userId; session.userId = userId;
@@ -312,15 +300,15 @@ async function handleChat(
const onEvent = createEventHandler(sessionId, model, cwd, homeDir); const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
// If session has history, save to disk and pass --session for context replay // 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) { if (session.messages.length > 0) {
await storage.saveSession(homeDir, sessionId, session.meta, session.messages); await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
const hostPath = await storage.getSessionFilePath(homeDir, sessionId); const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
if (hostPath) { 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); session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, spawnOptions);
@@ -389,11 +377,9 @@ async function handleClaudeCodeChat(
}, },
): Promise<void> { ): Promise<void> {
const { email, username, userId } = ws.data; const { email, username, userId } = ws.data;
const homeDir = getHomeDir(email); const homeDir = getHomeDirForRole(email, ws.data.role);
const cwd = ws.data.sandboxed const cwd = resolveCwd(email, ws.data.role, msg.cwd);
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
: resolveHostCwd(msg.cwdRoot, msg.cwd);
const groupSlug = msg.groupSlug || null; const groupSlug = msg.groupSlug || null;
@@ -468,7 +454,7 @@ async function handleResume(
let session = sessionManager.getSession(sessionId); let session = sessionManager.getSession(sessionId);
if (!session) { if (!session) {
const homeDir = getHomeDir(email); const homeDir = getHomeDirForRole(email, ws.data.role);
try { try {
const { meta, messages } = await storage.loadSession(homeDir, sessionId); const { meta, messages } = await storage.loadSession(homeDir, sessionId);
@@ -500,19 +486,19 @@ async function handleResume(
// Spawn fresh Pi process if needed // Spawn fresh Pi process if needed
if (!session.piProcess) { if (!session.piProcess) {
try { try {
const homeDir = getHomeDir(email); const homeDir = getHomeDirForRole(email, ws.data.role);
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir); const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
// If session has history, save to disk and pass --session for context replay // 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) { if (session.messages.length > 0) {
await storage.saveSession(homeDir, sessionId, session.meta, session.messages); await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
const hostPath = await storage.getSessionFilePath(homeDir, sessionId); const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
if (hostPath) { 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.piProcess = await piBridge.spawnPi(
session.cwd, 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 { sessionManager } from '@@/api/pi/session-manager';
import * as storage from '@@/api/pi/storage'; import * as storage from '@@/api/pi/storage';
import * as piBridge from '@@/api/pi/pi-bridge'; import * as piBridge from '@@/api/pi/pi-bridge';
import { getHomeDir } from '@@/data-path'; import { getHomeDirForRole } from '@@/data-path';
import { getUserSettings } from 'officerdb'; import { getUserSettings } from 'officerdb';
import { logger } from '@@/api/pi/logger'; import { logger } from '@@/api/pi/logger';
import { sendClaudeCode, clearClaudeCodeSession } from './send-claude-code'; import { sendClaudeCode, clearClaudeCodeSession } from './send-claude-code';
@@ -21,6 +21,7 @@ type SendAndAwaitParams = {
context: string; context: string;
contextId: string; contextId: string;
model?: string; model?: string;
role?: string;
}; };
type SendAndAwaitResult = { type SendAndAwaitResult = {
@@ -132,7 +133,7 @@ function createDispatcher(sessionId: string): (event: PiEvent) => void {
async function doSend(sessionId: string, params: SendAndAwaitParams): Promise<SendAndAwaitResult> { async function doSend(sessionId: string, params: SendAndAwaitParams): Promise<SendAndAwaitResult> {
const { userId, email, username, prompt, context, contextId } = params; const { userId, email, username, prompt, context, contextId } = params;
const homeDir = getHomeDir(email); const homeDir = getHomeDirForRole(email, params.role ?? null);
const cwd = homeDir; const cwd = homeDir;
// Resolve model: explicit param > !model override > user default > existing session > system default // 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 () => { (async () => {
try { try {
if (!session.piProcess) { 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) { if (session.messages.length > 0) {
await storage.saveSession(homeDir, sessionId, session.meta, session.messages); await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
const hostPath = await storage.getSessionFilePath(homeDir, sessionId); const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
if (hostPath) { if (hostPath) {
spawnOptions = { sessionFile: hostPath, username }; spawnOptions = { sessionFile: hostPath, username, role };
} }
} }
if (!spawnOptions) spawnOptions = { username }; if (!spawnOptions) spawnOptions = { username, role };
const dispatcher = createDispatcher(sessionId); const dispatcher = createDispatcher(sessionId);
session.piProcess = await piBridge.spawnPi(cwd, model!, userId, email, dispatcher, spawnOptions); 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 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 getUserPiConfigDir = (email: string) => join(DATA_PATH, email, 'home', '.pi', 'agent');
export const getUserSettingsDir = (email: string) => join(DATA_PATH, email, 'settings'); export const getUserSettingsDir = (email: string) => join(DATA_PATH, email, 'settings');