diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index bb18453..3085267 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common'; import { createAgentSession, + DefaultResourceLoader, SessionManager, type AgentSession as PiAgentSession, type AgentSessionEvent, @@ -27,6 +28,27 @@ import type { SessionInfoDto } from './session.dto.js'; export interface AgentSessionOptions { provider?: string; modelId?: string; + /** + * Sandbox working directory for the session. + * File, git, and shell tools will be restricted to this directory. + * Falls back to AGENT_FILE_SANDBOX_DIR env var or process.cwd(). + */ + sandboxDir?: string; + /** + * Platform-level system prompt for this session. + * Merged with skill prompt additions (platform prompt first, then skills). + * Falls back to AGENT_SYSTEM_PROMPT env var when omitted. + */ + systemPrompt?: string; + /** + * Explicit allowlist of tool names available in this session. + * When set, only listed tools are registered with the agent. + * When omitted for non-admin users, falls back to AGENT_USER_TOOLS env var. + * Admins (isAdmin=true) always receive the full tool set unless explicitly restricted. + */ + allowedTools?: string[]; + /** Whether the requesting user has admin privileges. Controls default tool access. */ + isAdmin?: boolean; } export interface AgentSession { @@ -41,6 +63,10 @@ export interface AgentSession { channels: Set; /** System prompt additions injected from enabled prompt-type skills. */ skillPromptAdditions: string[]; + /** Resolved sandbox directory for this session. */ + sandboxDir: string; + /** Tool names available in this session, or null when all tools are available. */ + allowedTools: string[] | null; } @Injectable() @@ -49,8 +75,6 @@ export class AgentService implements OnModuleDestroy { private readonly sessions = new Map(); private readonly creating = new Map>(); - private readonly customTools: ToolDefinition[]; - constructor( @Inject(ProviderService) private readonly providerService: ProviderService, @Inject(BRAIN) private readonly brain: Brain, @@ -59,21 +83,49 @@ export class AgentService implements OnModuleDestroy { @Inject(CoordService) private readonly coordService: CoordService, @Inject(McpClientService) private readonly mcpClientService: McpClientService, @Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService, - ) { - const fileBaseDir = process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd(); - const gitDefaultCwd = process.env['AGENT_GIT_CWD'] ?? process.cwd(); - const shellDefaultCwd = process.env['AGENT_SHELL_CWD'] ?? process.cwd(); + ) {} - this.customTools = [ - ...createBrainTools(brain), - ...createCoordTools(coordService), - ...createMemoryTools(memory, embeddingService.available ? embeddingService : null), - ...createFileTools(fileBaseDir), - ...createGitTools(gitDefaultCwd), - ...createShellTools(shellDefaultCwd), + /** + * Build the full set of custom tools scoped to the given sandbox directory. + * Brain/coord/memory/web tools are stateless with respect to cwd; file/git/shell + * tools receive the resolved sandboxDir so they operate within the sandbox. + */ + private buildToolsForSandbox(sandboxDir: string): ToolDefinition[] { + return [ + ...createBrainTools(this.brain), + ...createCoordTools(this.coordService), + ...createMemoryTools( + this.memory, + this.embeddingService.available ? this.embeddingService : null, + ), + ...createFileTools(sandboxDir), + ...createGitTools(sandboxDir), + ...createShellTools(sandboxDir), ...createWebTools(), ]; - this.logger.log(`Registered ${this.customTools.length} custom tools`); + } + + /** + * Resolve the tool allowlist for a session. + * - Admin users: all tools unless an explicit allowedTools list is passed. + * - Regular users: use allowedTools if provided, otherwise parse AGENT_USER_TOOLS env var. + * Returns null when all tools should be available. + */ + private resolveAllowedTools(isAdmin: boolean, allowedTools?: string[]): string[] | null { + if (allowedTools !== undefined) { + return allowedTools.length === 0 ? [] : allowedTools; + } + if (isAdmin) { + return null; // admins get everything + } + const envTools = process.env['AGENT_USER_TOOLS']; + if (!envTools) { + return null; // no restriction configured + } + return envTools + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); } async createSession(sessionId: string, options?: AgentSessionOptions): Promise { @@ -98,8 +150,15 @@ export class AgentService implements OnModuleDestroy { const providerName = model?.provider ?? 'default'; const modelId = model?.id ?? 'default'; + // Resolve sandbox directory: option > env var > process.cwd() + const sandboxDir = + options?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd(); + + // Resolve allowed tool set + const allowedTools = this.resolveAllowedTools(options?.isAdmin ?? false, options?.allowedTools); + this.logger.log( - `Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId})`, + `Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`, ); // Load skill tools from the catalog @@ -114,21 +173,53 @@ export class AgentService implements OnModuleDestroy { ); } - // Combine static tools with dynamically discovered MCP client tools + // Build per-session tools scoped to the sandbox directory + const sandboxTools = this.buildToolsForSandbox(sandboxDir); + + // Combine static tools with dynamically discovered MCP client tools and skill tools const mcpTools = this.mcpClientService.getToolDefinitions(); - const allCustomTools = [...this.customTools, ...skillMetaTools, ...mcpTools]; + let allCustomTools = [...sandboxTools, ...skillMetaTools, ...mcpTools]; if (mcpTools.length > 0) { this.logger.log(`Attaching ${mcpTools.length} MCP client tool(s) to session ${sessionId}`); } + // Filter tools by allowlist when a restriction is in effect + if (allowedTools !== null) { + const allowedSet = new Set(allowedTools); + const before = allCustomTools.length; + allCustomTools = allCustomTools.filter((t) => allowedSet.has(t.name)); + this.logger.log( + `Tool restriction applied: ${allCustomTools.length}/${before} tools allowed for session ${sessionId}`, + ); + } + + // Build system prompt: platform prompt + skill additions appended + const platformPrompt = options?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined; + const appendSystemPrompt = + promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined; + + // Construct a resource loader that injects the configured system prompt + const resourceLoader = new DefaultResourceLoader({ + cwd: sandboxDir, + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + systemPrompt: platformPrompt, + appendSystemPrompt: appendSystemPrompt, + }); + await resourceLoader.reload(); + let piSession: PiAgentSession; try { const result = await createAgentSession({ sessionManager: SessionManager.inMemory(), modelRegistry: this.providerService.getRegistry(), model: model ?? undefined, + cwd: sandboxDir, tools: [], customTools: allCustomTools, + resourceLoader, }); piSession = result.session; } catch (err) { @@ -162,6 +253,8 @@ export class AgentService implements OnModuleDestroy { promptCount: 0, channels: new Set(), skillPromptAdditions: promptAdditions, + sandboxDir, + allowedTools, }; this.sessions.set(sessionId, session); diff --git a/apps/gateway/src/agent/tools/git-tools.ts b/apps/gateway/src/agent/tools/git-tools.ts index 4c612f3..0680c59 100644 --- a/apps/gateway/src/agent/tools/git-tools.ts +++ b/apps/gateway/src/agent/tools/git-tools.ts @@ -2,12 +2,29 @@ import { Type } from '@sinclair/typebox'; import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; +import { resolve, relative } from 'node:path'; const execAsync = promisify(exec); const GIT_TIMEOUT_MS = 15_000; const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB +/** + * Clamp a user-supplied cwd to within the sandbox directory. + * If the resolved path escapes the sandbox (via ../ or absolute path outside), + * falls back to the sandbox directory itself. + */ +function clampCwd(sandboxDir: string, requestedCwd?: string): string { + if (!requestedCwd) return sandboxDir; + const resolved = resolve(sandboxDir, requestedCwd); + const rel = relative(sandboxDir, resolved); + if (rel.startsWith('..') || rel.startsWith('/')) { + // Escape attempt — fall back to sandbox root + return sandboxDir; + } + return resolved; +} + async function runGit( args: string[], cwd?: string, @@ -41,17 +58,24 @@ async function runGit( } } -export function createGitTools(defaultCwd?: string): ToolDefinition[] { +export function createGitTools(sandboxDir?: string): ToolDefinition[] { + const defaultCwd = sandboxDir ?? process.cwd(); + const gitStatus: ToolDefinition = { name: 'git_status', label: 'Git Status', description: 'Show the working tree status (staged, unstaged, untracked files).', parameters: Type.Object({ - cwd: Type.Optional(Type.String({ description: 'Repository working directory.' })), + cwd: Type.Optional( + Type.String({ + description: 'Repository working directory (relative to sandbox or absolute within it).', + }), + ), }), async execute(_toolCallId, params) { const { cwd } = params as { cwd?: string }; - const result = await runGit(['status', '--short', '--branch'], cwd ?? defaultCwd); + const safeCwd = clampCwd(defaultCwd, cwd); + const result = await runGit(['status', '--short', '--branch'], safeCwd); const text = result.error ? `Error: ${result.error}\n${result.stderr}` : result.stdout || '(no output)'; @@ -71,7 +95,11 @@ export function createGitTools(defaultCwd?: string): ToolDefinition[] { oneline: Type.Optional( Type.Boolean({ description: 'Compact one-line format (default true)' }), ), - cwd: Type.Optional(Type.String({ description: 'Repository working directory.' })), + cwd: Type.Optional( + Type.String({ + description: 'Repository working directory (relative to sandbox or absolute within it).', + }), + ), }), async execute(_toolCallId, params) { const { limit, oneline, cwd } = params as { @@ -79,9 +107,10 @@ export function createGitTools(defaultCwd?: string): ToolDefinition[] { oneline?: boolean; cwd?: string; }; + const safeCwd = clampCwd(defaultCwd, cwd); const args = ['log', `--max-count=${limit ?? 20}`]; if (oneline !== false) args.push('--oneline'); - const result = await runGit(args, cwd ?? defaultCwd); + const result = await runGit(args, safeCwd); const text = result.error ? `Error: ${result.error}\n${result.stderr}` : result.stdout || '(no commits)'; @@ -106,7 +135,11 @@ export function createGitTools(defaultCwd?: string): ToolDefinition[] { path: Type.Optional( Type.String({ description: 'Limit diff to a specific file or directory' }), ), - cwd: Type.Optional(Type.String({ description: 'Repository working directory.' })), + cwd: Type.Optional( + Type.String({ + description: 'Repository working directory (relative to sandbox or absolute within it).', + }), + ), }), async execute(_toolCallId, params) { const { staged, ref, path, cwd } = params as { @@ -115,12 +148,13 @@ export function createGitTools(defaultCwd?: string): ToolDefinition[] { path?: string; cwd?: string; }; + const safeCwd = clampCwd(defaultCwd, cwd); const args = ['diff']; if (staged) args.push('--cached'); if (ref) args.push(ref); args.push('--'); if (path) args.push(path); - const result = await runGit(args, cwd ?? defaultCwd); + const result = await runGit(args, safeCwd); const text = result.error ? `Error: ${result.error}\n${result.stderr}` : result.stdout || '(no diff)'; diff --git a/apps/gateway/src/coord/coord.controller.ts b/apps/gateway/src/coord/coord.controller.ts index e8ba396..4d92eb2 100644 --- a/apps/gateway/src/coord/coord.controller.ts +++ b/apps/gateway/src/coord/coord.controller.ts @@ -1,17 +1,30 @@ import { BadRequestException, + Body, Controller, + Delete, Get, + HttpCode, + HttpStatus, Inject, NotFoundException, Param, + Patch, + Post, Query, UseGuards, } from '@nestjs/common'; import fs from 'node:fs'; import path from 'node:path'; import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; import { CoordService } from './coord.service.js'; +import type { + CreateDbMissionDto, + UpdateDbMissionDto, + CreateMissionTaskDto, + UpdateMissionTaskDto, +} from './coord.dto.js'; /** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */ function findMonorepoRoot(start: string): string { @@ -49,6 +62,8 @@ function resolveAndValidatePath(raw: string | undefined): string { export class CoordController { constructor(@Inject(CoordService) private readonly coordService: CoordService) {} + // ── File-based coord endpoints (legacy) ── + @Get('status') async missionStatus(@Query('projectPath') projectPath?: string) { const resolvedPath = resolveAndValidatePath(projectPath); @@ -70,4 +85,121 @@ export class CoordController { if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`); return detail; } + + // ── DB-backed mission endpoints ── + + @Get('missions') + async listDbMissions(@CurrentUser() user: { id: string }) { + return this.coordService.getMissionsByUser(user.id); + } + + @Get('missions/:id') + async getDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) { + const mission = await this.coordService.getMissionByIdAndUser(id, user.id); + if (!mission) throw new NotFoundException('Mission not found'); + return mission; + } + + @Post('missions') + async createDbMission(@Body() dto: CreateDbMissionDto, @CurrentUser() user: { id: string }) { + return this.coordService.createDbMission({ + name: dto.name, + description: dto.description, + projectId: dto.projectId, + userId: user.id, + phase: dto.phase, + milestones: dto.milestones, + config: dto.config, + status: dto.status, + }); + } + + @Patch('missions/:id') + async updateDbMission( + @Param('id') id: string, + @Body() dto: UpdateDbMissionDto, + @CurrentUser() user: { id: string }, + ) { + const mission = await this.coordService.updateDbMission(id, user.id, dto); + if (!mission) throw new NotFoundException('Mission not found'); + return mission; + } + + @Delete('missions/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) { + const deleted = await this.coordService.deleteDbMission(id, user.id); + if (!deleted) throw new NotFoundException('Mission not found'); + } + + // ── DB-backed mission task endpoints ── + + @Get('missions/:missionId/mission-tasks') + async listMissionTasks( + @Param('missionId') missionId: string, + @CurrentUser() user: { id: string }, + ) { + const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); + if (!mission) throw new NotFoundException('Mission not found'); + return this.coordService.getMissionTasksByMissionAndUser(missionId, user.id); + } + + @Get('missions/:missionId/mission-tasks/:taskId') + async getMissionTask( + @Param('missionId') missionId: string, + @Param('taskId') taskId: string, + @CurrentUser() user: { id: string }, + ) { + const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); + if (!mission) throw new NotFoundException('Mission not found'); + const task = await this.coordService.getMissionTaskByIdAndUser(taskId, user.id); + if (!task) throw new NotFoundException('Mission task not found'); + return task; + } + + @Post('missions/:missionId/mission-tasks') + async createMissionTask( + @Param('missionId') missionId: string, + @Body() dto: CreateMissionTaskDto, + @CurrentUser() user: { id: string }, + ) { + const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); + if (!mission) throw new NotFoundException('Mission not found'); + return this.coordService.createMissionTask({ + missionId, + taskId: dto.taskId, + userId: user.id, + status: dto.status, + description: dto.description, + notes: dto.notes, + pr: dto.pr, + }); + } + + @Patch('missions/:missionId/mission-tasks/:taskId') + async updateMissionTask( + @Param('missionId') missionId: string, + @Param('taskId') taskId: string, + @Body() dto: UpdateMissionTaskDto, + @CurrentUser() user: { id: string }, + ) { + const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); + if (!mission) throw new NotFoundException('Mission not found'); + const updated = await this.coordService.updateMissionTask(taskId, user.id, dto); + if (!updated) throw new NotFoundException('Mission task not found'); + return updated; + } + + @Delete('missions/:missionId/mission-tasks/:taskId') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteMissionTask( + @Param('missionId') missionId: string, + @Param('taskId') taskId: string, + @CurrentUser() user: { id: string }, + ) { + const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); + if (!mission) throw new NotFoundException('Mission not found'); + const deleted = await this.coordService.deleteMissionTask(taskId, user.id); + if (!deleted) throw new NotFoundException('Mission task not found'); + } } diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 56372be..4174322 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -52,6 +52,7 @@ export function TuiApp({ const [availableModels, setAvailableModels] = useState([]); const socketRef = useRef(null); + const currentStreamTextRef = useRef(''); // Fetch available models on mount useEffect(() => { @@ -102,20 +103,22 @@ export function TuiApp({ socket.on('agent:start', () => { setIsStreaming(true); + currentStreamTextRef.current = ''; setCurrentStreamText(''); }); socket.on('agent:text', (data: { text: string }) => { - setCurrentStreamText((prev) => prev + data.text); + currentStreamTextRef.current += data.text; + setCurrentStreamText(currentStreamTextRef.current); }); socket.on('agent:end', () => { - setCurrentStreamText((prev) => { - if (prev) { - setMessages((msgs) => [...msgs, { role: 'assistant', content: prev }]); - } - return ''; - }); + const finalText = currentStreamTextRef.current; + currentStreamTextRef.current = ''; + setCurrentStreamText(''); + if (finalText) { + setMessages((msgs) => [...msgs, { role: 'assistant', content: finalText }]); + } setIsStreaming(false); });