diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index a3466372..1e605638 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -31,15 +31,17 @@ export const AGENT_CONFIG_DIR = join(homedir(), '.pi', 'agent'); export const SEED_PATH = resolve(import.meta.dir, '../../seed'); -// The managed home under DATA_PATH — what provisioning seeds and what the generated Claude config -// points at. Distinct from the owner's real login home below. +// The managed home under DATA_PATH. A remnant of the first architecture, where every user ran inside +// their own Docker container and this was that container's home — seeded by provisioning, described to +// the agent by a generated CLAUDE.md. Both of those are gone, and nothing executes here any more: +// terminals, chats and task runs all use getOwnerHomeDir below. It survives only as that function's +// fallback for when HOME_DIR is unset, and in pipeline-executor. export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home'); // Where the owner's sessions actually run: their real login home when HOME_DIR is set, so platform // terminals/chats/tasks share config and credentials with the shell they use outside Officer. export const getOwnerHomeDir = (email: string): string => process.env.HOME_DIR ?? getHomeDir(email); -export const getUserPiConfigDir = (email: string) => join(DATA_PATH, email, 'home', '.pi', 'agent'); export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'attachments', 'tmp'); @@ -53,7 +55,12 @@ export const getEmailDbPath = (ownerEmail: string, accountEmail: string) => export const getEmailAttachmentCacheDir = (ownerEmail: string) => join(getEmailAccountsDir(ownerEmail), 'attachment_cache'); -/** Derive a valid Linux username from a display username or email. */ +// Sanitises a display username or email into a bare, lowercase, shell-safe token. The name and the +// 32-char Linux limit are the last trace of the per-container architecture, where this really did name +// a Linux user inside the user's container. Nothing creates a Linux user now — the value is carried +// through the websocket/job payloads and ends up only as a claim inside the signed task token, so this +// is a sanitiser rather than an account name. Left in place because unpicking it means changing what +// goes into that token and into WSData, which is a wider change than a cleanup. export const toShellUsername = (username: string, email: string): string => { const raw = username || email.split('@')[0]!; // Replace invalid chars, lowercase, truncate to 32 chars diff --git a/src/servers/generate-container-context.ts b/src/servers/generate-container-context.ts deleted file mode 100644 index 60a09c4f..00000000 --- a/src/servers/generate-container-context.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { itemsDir, getHomeDir, DATA_PATH } from '@@/data-path'; - -// Whatever the host's settings.json holds is preserved verbatim; we only add the two permission keys below. -type ClaudeSettings = Record; - -type FrontmatterEntry = { name: string; description: string }; - -function parseFrontmatter(content: string): Record { - const match = content.match(/^---\n([\s\S]*?)\n---/); - if (!match) return {}; - const fields: Record = {}; - for (const line of match[1]!.split('\n')) { - const m = line.match(/^(\w+):\s*(.+)/); - if (m) fields[m[1]!] = m[2]!.trim(); - } - return fields; -} - -function scanDir(dir: string, metaFile: string): FrontmatterEntry[] { - if (!existsSync(dir)) return []; - const entries: FrontmatterEntry[] = []; - for (const name of readdirSync(dir, { withFileTypes: true })) { - if (!name.isDirectory()) continue; - const filePath = join(dir, name.name, metaFile); - if (!existsSync(filePath)) continue; - const fm = parseFrontmatter(readFileSync(filePath, 'utf-8')); - if (fm.name || fm.label) { - entries.push({ name: fm.name ?? fm.label ?? name.name, description: fm.description ?? '' }); - } - } - return entries; -} - -function dedup(entries: FrontmatterEntry[]): FrontmatterEntry[] { - const seen = new Map(); - for (const e of entries) seen.set(e.name, e); - return [...seen.values()]; -} - -function formatList(entries: FrontmatterEntry[]): string { - if (entries.length === 0) return 'None configured.\n'; - return entries.map((e) => `- **${e.name}**${e.description ? ` — ${e.description}` : ''}`).join('\n') + '\n'; -} - -export function generateContainerContext(email: string): string { - const toolsDir = itemsDir('tools'); - const skillsDir = itemsDir('skills'); - const tasksDir = itemsDir('tasks'); - const tools = dedup(scanDir(toolsDir, 'TOOL.md')); - const skills = dedup(scanDir(skillsDir, 'SKILL.md')); - const tasks = dedup(scanDir(tasksDir, 'TASK.md')); - - const userDataDir = join(DATA_PATH, email); - - const content = `# Officer — User Environment - -This is an isolated Linux user environment managed by the Officer platform. - -## Directory Layout - -| Path | Contents | -|------|----------| -| \`~\` | User home directory (read-write) | -| \`~/Projects/\` | User projects | -| \`~/Downloads/\` | Downloaded files | -| \`${toolsDir}/\` | Tools | -| \`${skillsDir}/\` | Reference skills | -| \`${userDataDir}/\` | User data (emails.db, attachments, etc.) | - -## Available Tools - -Tools are callable capabilities used by the Officer AI agent (Pi). Each tool has a \`TOOL.md\` with documentation and an \`index.ts\` that exports an \`execute()\` function. Read individual tool docs at \`${toolsDir}//TOOL.md\`. - -${formatList(tools)} -## Available Skills - -Skills are reference documentation that the AI agent uses to understand APIs and CLIs. - -${formatList(skills)} -## Available Tasks - -Tasks are predefined instruction sets the AI agent can execute. - -${formatList(tasks)} -## Creating New Tools - -Create a directory in \`${toolsDir}//\` with two files: - -**TOOL.md** — Frontmatter metadata + markdown documentation: -\`\`\`yaml ---- -name: my_tool -description: What it does and when the agent should use it. -version: 1 -language: typescript -inputs: - param: - type: string - description: What this parameter is for. ---- -# My Tool -Usage documentation here. -\`\`\` - -**index.ts** — Must export an \`execute\` function: -\`\`\`typescript -type ToolResult = { content: Array<{ type: string; text: string }>; isError?: boolean }; - -export async function execute(_toolCallId: string, params: Record): Promise { - return { content: [{ type: 'text', text: 'Done' }] }; -} -\`\`\` - -## Environment Variables - -| Variable | Description | -|----------|-------------| -| \`OFFICER_EMAIL_DB\` | Path to email SQLite database | -| \`PI_TOOLS_DIRS\` | Tool discovery paths (colon-separated) | -| \`PI_SEARXNG_URL\` | Search engine URL | -`; - - const contextDir = join(DATA_PATH, email, '.container-context'); - mkdirSync(contextDir, { recursive: true }); - const filePath = join(contextDir, 'CLAUDE.md'); - writeFileSync(filePath, content); - return filePath; -} - -export function generateClaudeSettings(email: string, username?: string): string { - const hostSettingsPath = join(process.env.HOME ?? '', '.claude', 'settings.json'); - let settings: ClaudeSettings = {}; - try { - settings = JSON.parse(readFileSync(hostSettingsPath, 'utf-8')) as ClaudeSettings; - } catch { - // no host settings - } - - // A Stop hook used to be injected here, curling /api/hooks/claude-done so the file browser would refresh - // when the terminal's Claude finished. It never fired: this writes to the MANAGED home under DATA_PATH, - // while HOME_DIR points terminals at the owner's real login home, where Claude actually reads its - // settings. The chat UI — the destination for agent work — refreshes those panels itself from - // onTurnComplete, with no hook, no HTTP round trip and no unauthenticated endpoint. - settings.defaultMode = 'bypassPermissions'; - settings.skipDangerousModePermissionPrompt = true; - - const contextDir = join(DATA_PATH, email, '.container-context'); - mkdirSync(contextDir, { recursive: true }); - const filePath = join(contextDir, 'settings.json'); - writeFileSync(filePath, JSON.stringify(settings, null, 2)); - - // Pre-seed trust and skip-permissions in .claude.json so interactive Claude Code skips all prompts - if (username) { - const homeDir = getHomeDir(email); - const claudeJsonPath = join(homeDir, '.claude.json'); - let claudeJson: Record = {}; - try { - claudeJson = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')) as Record; - } catch { - // no existing config - } - const projects = (claudeJson.projects ?? {}) as Record>; - const projectKey = homeDir; - if (!projects[projectKey]) projects[projectKey] = {}; - projects[projectKey]!.hasTrustDialogAccepted = true; - claudeJson.projects = projects; - writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2)); - } - - return filePath; -} diff --git a/src/servers/sidecar/claude/user-instance.ts b/src/servers/sidecar/claude/user-instance.ts index 3e7205ed..dafc7ad2 100644 --- a/src/servers/sidecar/claude/user-instance.ts +++ b/src/servers/sidecar/claude/user-instance.ts @@ -68,7 +68,12 @@ loadState(); // ── MCP config ── function generateMcpConfig(): string { - const contextDir = join(DATA_PATH, email!, '.container-context'); + // Was `.container-context`, from the architecture where each user ran inside their own Docker + // container and this directory described that container to the agent. Nothing about it is + // container-related now — it holds exactly one file, the MCP server config handed to the CLI. The + // path is written here and consumed through the return value, so nothing else reads it and the + // rename costs nothing; an old `.container-context` directory left on disk is inert. + const contextDir = join(DATA_PATH, email!, 'agent-config'); mkdirSync(contextDir, { recursive: true }); const userRoot = join(DATA_PATH, email!);