diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index 64d1cd9c..9d9fbe20 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -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 { 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> { +export async function buildHostToolEnv(userId: number, email: string, role?: string): Promise> { 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'), diff --git a/src/servers/api/pi/rest.ts b/src/servers/api/pi/rest.ts index 50a150e0..67ed18af 100644 --- a/src/servers/api/pi/rest.ts +++ b/src/servers/api/pi/rest.ts @@ -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 diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index 72a5f1b4..b750dd18 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -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(); @@ -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 { 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, diff --git a/src/servers/channels/send-and-await.ts b/src/servers/channels/send-and-await.ts index 65830d2b..c875ae1f 100644 --- a/src/servers/channels/send-and-await.ts +++ b/src/servers/channels/send-and-await.ts @@ -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 { 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 { 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); diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 75eb9387..9e50031d 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -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');