diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4dbc7022..ee361a65 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -131,6 +131,7 @@ export { createAgentSchema, createAgentHireSchema, updateAgentSchema, + updateAgentInstructionsPathSchema, createAgentKeySchema, wakeAgentSchema, resetAgentSessionSchema, @@ -140,6 +141,7 @@ export { type CreateAgent, type CreateAgentHire, type UpdateAgent, + type UpdateAgentInstructionsPath, type CreateAgentKey, type WakeAgent, type ResetAgentSession, diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 800d5eef..8a0450cb 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -59,6 +59,13 @@ export const updateAgentSchema = createAgentSchema export type UpdateAgent = z.infer; +export const updateAgentInstructionsPathSchema = z.object({ + path: z.string().trim().min(1).nullable(), + adapterConfigKey: z.string().trim().min(1).optional(), +}); + +export type UpdateAgentInstructionsPath = z.infer; + export const createAgentKeySchema = z.object({ name: z.string().min(1).default("default"), }); diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 0f752597..352c8995 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -9,6 +9,7 @@ export { createAgentSchema, createAgentHireSchema, updateAgentSchema, + updateAgentInstructionsPathSchema, createAgentKeySchema, wakeAgentSchema, resetAgentSessionSchema, @@ -18,6 +19,7 @@ export { type CreateAgent, type CreateAgentHire, type UpdateAgent, + type UpdateAgentInstructionsPath, type CreateAgentKey, type WakeAgent, type ResetAgentSession, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 4b1148bf..1325d84d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,5 +1,6 @@ import { Router, type Request } from "express"; import { randomUUID } from "node:crypto"; +import path from "node:path"; import type { Db } from "@paperclip/db"; import { agents as agentsTable, companies, heartbeatRuns } from "@paperclip/db"; import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; @@ -10,6 +11,7 @@ import { resetAgentSessionSchema, testAdapterEnvironmentSchema, updateAgentPermissionsSchema, + updateAgentInstructionsPathSchema, wakeAgentSchema, updateAgentSchema, } from "@paperclip/shared"; @@ -24,13 +26,19 @@ import { logActivity, secretService, } from "../services/index.js"; -import { forbidden } from "../errors.js"; +import { forbidden, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; import { runClaudeLogin } from "@paperclip/adapter-claude-local/server"; export function agentRoutes(db: Db) { + const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { + claude_local: "instructionsFilePath", + codex_local: "instructionsFilePath", + }; + const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); + const router = Router(); const svc = agentService(db); const access = accessService(db); @@ -123,6 +131,45 @@ export function agentRoutes(db: Db) { return value as Record; } + function asNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record) { + const trimmed = candidatePath.trim(); + if (path.isAbsolute(trimmed)) return trimmed; + + const cwd = asNonEmptyString(adapterConfig.cwd); + if (!cwd) { + throw unprocessable( + "Relative instructions path requires adapterConfig.cwd to be set to an absolute path", + ); + } + if (!path.isAbsolute(cwd)) { + throw unprocessable("adapterConfig.cwd must be an absolute path to resolve relative instructions path"); + } + return path.resolve(cwd, trimmed); + } + + async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) { + assertCompanyAccess(req, targetAgent.companyId); + if (req.actor.type === "board") return; + if (!req.actor.agentId) throw forbidden("Agent authentication required"); + + const actorAgent = await svc.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { + throw forbidden("Agent key cannot access another company"); + } + if (actorAgent.id === targetAgent.id) return; + + const chainOfCommand = await svc.getChainOfCommand(targetAgent.id); + if (chainOfCommand.some((manager) => manager.id === actorAgent.id)) return; + + throw forbidden("Only the target agent or an ancestor manager can update instructions path"); + } + function summarizeAgentUpdateDetails(patch: Record) { const changedTopLevelKeys = Object.keys(patch).sort(); const details: Record = { changedTopLevelKeys }; @@ -661,6 +708,83 @@ export function agentRoutes(db: Db) { res.json(agent); }); + router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => { + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Agent not found" }); + return; + } + + await assertCanManageInstructionsPath(req, existing); + + const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; + const explicitKey = asNonEmptyString(req.body.adapterConfigKey); + const defaultKey = DEFAULT_INSTRUCTIONS_PATH_KEYS[existing.adapterType] ?? null; + const adapterConfigKey = explicitKey ?? defaultKey; + if (!adapterConfigKey) { + res.status(422).json({ + error: `No default instructions path key for adapter type '${existing.adapterType}'. Provide adapterConfigKey.`, + }); + return; + } + + const nextAdapterConfig: Record = { ...existingAdapterConfig }; + if (req.body.path === null) { + delete nextAdapterConfig[adapterConfigKey]; + } else { + nextAdapterConfig[adapterConfigKey] = resolveInstructionsFilePath(req.body.path, existingAdapterConfig); + } + + const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + existing.companyId, + nextAdapterConfig, + { strictMode: strictSecretsMode }, + ); + const actor = getActorInfo(req); + const agent = await svc.update( + id, + { adapterConfig: normalizedAdapterConfig }, + { + recordRevision: { + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + source: "instructions_path_patch", + }, + }, + ); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + + const updatedAdapterConfig = asRecord(agent.adapterConfig) ?? {}; + const pathValue = asNonEmptyString(updatedAdapterConfig[adapterConfigKey]); + + await logActivity(db, { + companyId: agent.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "agent.instructions_path_updated", + entityType: "agent", + entityId: agent.id, + details: { + adapterConfigKey, + path: pathValue, + cleared: req.body.path === null, + }, + }); + + res.json({ + agentId: agent.id, + adapterType: agent.adapterType, + adapterConfigKey, + path: pathValue, + }); + }); + router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); @@ -682,6 +806,12 @@ export function agentRoutes(db: Db) { res.status(422).json({ error: "adapterConfig must be an object" }); return; } + const changingInstructionsPath = Object.keys(adapterConfig).some((key) => + KNOWN_INSTRUCTIONS_PATH_KEYS.has(key), + ); + if (changingInstructionsPath) { + await assertCanManageInstructionsPath(req, existing); + } patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, adapterConfig,