diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index ecefbfba42..47cdbc01d7 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -208,6 +208,11 @@ async function api(baseUrl: string, pathname: string, init?: RequestInit): Pr return text ? JSON.parse(text) as T : (null as T); } +function isPortableAgent(agent: { metadata?: Record | null }) { + const marker = agent.metadata?.paperclipBuiltInAgent; + return typeof marker !== "object" || marker === null; +} + async function runCliJson( args: string[], opts: TestPaperclipEnv & { apiBase?: string; includeConfigArg?: boolean }, @@ -560,7 +565,7 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { expect(importedExisting.company.action).toBe("unchanged"); expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true); - const twiceImportedAgents = await api>( + const twiceImportedAgents = await api | null }>>( apiBase, `/api/companies/${importedNew.company.id}/agents`, ); @@ -573,9 +578,10 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => { `/api/companies/${importedNew.company.id}/issues`, ); const twiceImportedMatchingIssues = twiceImportedIssues.filter((issue) => issue.title === sourceIssue.title); + const twiceImportedPortableAgents = twiceImportedAgents.filter(isPortableAgent); - expect(twiceImportedAgents).toHaveLength(2); - expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2); + expect(twiceImportedPortableAgents).toHaveLength(2); + expect(new Set(twiceImportedPortableAgents.map((agent) => agent.name)).size).toBe(2); expect(twiceImportedProjects).toHaveLength(2); expect(twiceImportedMatchingIssues).toHaveLength(2); expect(new Set(twiceImportedMatchingIssues.map((issue) => issue.identifier)).size).toBe(2); diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts index c9be4bfea7..f1d8ee0530 100644 --- a/cli/src/__tests__/company.test.ts +++ b/cli/src/__tests__/company.test.ts @@ -377,6 +377,7 @@ describe("renderCompanyImportPreview", () => { adapterConfig: {}, runtimeConfig: {}, permissions: {}, + permissionGrants: [], budgetMonthlyCents: 0, metadata: null, }, @@ -597,6 +598,7 @@ describe("import selection catalog", () => { adapterConfig: {}, runtimeConfig: {}, permissions: {}, + permissionGrants: [], budgetMonthlyCents: 0, metadata: null, }, @@ -757,6 +759,7 @@ describe("default adapter overrides", () => { adapterConfig: {}, runtimeConfig: {}, permissions: {}, + permissionGrants: [], budgetMonthlyCents: 0, metadata: null, }, @@ -776,6 +779,7 @@ describe("default adapter overrides", () => { adapterConfig: {}, runtimeConfig: {}, permissions: {}, + permissionGrants: [], budgetMonthlyCents: 0, metadata: null, }, diff --git a/doc/LOW-TRUST-PRESETS.md b/doc/LOW-TRUST-PRESETS.md index 7a876c94b0..476af516cb 100644 --- a/doc/LOW-TRUST-PRESETS.md +++ b/doc/LOW-TRUST-PRESETS.md @@ -37,6 +37,10 @@ changes that behavior. Low-trust containment instead limits what the low-trust agent can read or mutate through the Paperclip API and prevents raw untrusted output from being automatically promoted into higher-trust agent context. +Low-trust agents cannot read or mutate agent configuration, instruction bundles, +or company skill configuration through direct grants. Configuration changes from +low-trust work must go through higher-trust review and promotion paths instead. + ## Runtime Containment Managed `low_trust_review` runs fail closed unless Paperclip can enforce the diff --git a/docs/built-in-agents.md b/docs/built-in-agents.md new file mode 100644 index 0000000000..90acac315f --- /dev/null +++ b/docs/built-in-agents.md @@ -0,0 +1,144 @@ +# Built-in Agents + +Built-in agents are first-party, company-scoped agents that Paperclip can resolve by a stable registry key. They are normal rows in `agents`, but they carry immutable metadata under `metadata.paperclipBuiltInAgent` so services can find them without hardcoding a database id. + +The first built-ins are `briefs` and `learning`. Operators can provision them from the API without going through board hire approval, but the route still requires the same `agents:create` permission as normal agent creation. + +## Runtime Model + +The subsystem has four layers: + +- Registry: `server/src/services/built-in-agents.ts` defines the static `BuiltInAgentDefinition` list. +- Marker: `server/src/services/built-in-agent-metadata.ts` reads and writes `metadata.paperclipBuiltInAgent`. +- Provisioning service: `builtInAgentService(db)` finds, creates, updates, resets, and requires built-ins per company. +- Routes: `server/src/routes/built-in-agents.ts` exposes list, provision, and reset APIs. + +Built-in agent state is derived from the marked agent row: + +- `not_provisioned`: no active marked row exists for the company/key. +- `needs_setup`: a row exists, but adapter config is incomplete for the adapter type. +- `ready`: adapter config is complete and the agent is not paused. +- `paused`: the marked row is paused. Scheduled/background work should log the paused warning and skip queueing work. + +Use `builtInAgentService(db).requireBuiltInAgent(companyId, key)` from backend features that need a built-in agent before scheduling work. It throws HTTP 412 with `code: "built_in_agent_not_configured"` for missing or incomplete agents. Paused agents return the agent plus a `built_in_agent_paused` warning so callers can pass the warning through to logs or API responses without treating the agent as ready for scheduling. + +## API + +All routes are company-scoped: + +- `GET /api/companies/:companyId/built-in-agents` + Lists registry definitions with current company state. +- `POST /api/companies/:companyId/built-in-agents/:key/provision` + Creates or configures the built-in for the company. Body accepts optional `adapterType` and `adapterConfig`. +- `POST /api/companies/:companyId/built-in-agents/:key/reset` + Restores registry-owned display/default fields on the marked row while preserving operator adapter setup. + +Provision and reset require `agents:create`. Provision intentionally skips `requireBoardApprovalForNewAgents` because built-ins are registry-owned system capacity, not ad hoc hires. + +## Add a New Built-in Agent + +1. Add a definition in `DEFINITIONS` inside `server/src/services/built-in-agents.ts`. +2. Pick a stable lowercase `key` using only letters, numbers, `_`, and `-`. Do not rename keys after release. +3. Set `displayName`, `shortPurpose`, `defaultInstructions`, `defaultRole`, and at least one `featureKeys` entry. +4. Set `allowedAdapterTypes` to the smallest set that actually works for this built-in. +5. Decide whether the built-in needs a nonzero `defaultBudgetMonthlyCents`. +6. Add or update tests in `server/src/__tests__/built-in-agents.test.ts`. +7. If the built-in is surfaced in UI or docs, add those changes in the same PR. +8. Run the focused tests from the repo root with `pnpm --filter @paperclipai/server exec vitest run src/__tests__/built-in-agents.test.ts src/__tests__/built-in-agent-routes.test.ts`. + +Do not write built-in markers directly through generic agent create/update routes. The agent service rejects marker add, remove, and mutation unless the built-in service explicitly opts in. + +## Worked Example: `digest` + +Hypothetical registry diff: + +```diff + const DEFINITIONS = validateBuiltInAgentDefinitions([ + { + key: "learning", + displayName: "Learning Agent", + featureKeys: ["learning"], + shortPurpose: "Maintains reusable company learning from completed work and recurring patterns.", + defaultInstructions: + "You are Paperclip's built-in Learning agent. Extract durable lessons from completed work, preserve useful patterns, and keep learning artifacts grounded in source context.", + defaultRole: "general", + allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"], + defaultBudgetMonthlyCents: 0, + }, ++ { ++ key: "digest", ++ displayName: "Digest Agent", ++ featureKeys: ["digest"], ++ shortPurpose: "Summarizes recent company activity into a board-readable digest.", ++ defaultInstructions: ++ "You are Paperclip's built-in Digest agent. Produce short, sourced summaries of recent company activity, decisions, blockers, and next actions.", ++ defaultRole: "general", ++ allowedAdapterTypes: ["codex_local", "claude_local", "process"], ++ defaultBudgetMonthlyCents: 0, ++ }, + ]); +``` + +Add focused test coverage: + +```ts +expect(listBuiltInAgentDefinitions().map((definition) => definition.key).sort()).toEqual([ + "briefs", + "digest", + "learning", +]); +``` + +If a background job needs the agent: + +```ts +const { agent, warning } = await builtInAgentService(db).requireBuiltInAgent(companyId, "digest"); +if (warning) { + logger.info({ warning }, "Skipping digest work because built-in agent is paused"); + return; +} + +await heartbeatService(db).wakeup(agent.id, { + source: "automation", + triggerDetail: "system", + reason: "Generate company digest", +}); +``` + +If the agent is missing or not configured, the helper throws: + +```json +{ + "error": "Built-in agent is not configured: digest", + "code": "built_in_agent_not_configured", + "details": { + "code": "built_in_agent_not_configured", + "key": "digest", + "status": "needs_setup", + "agentId": "..." + } +} +``` + +## PR Checklist + +- Registry definition has a stable key and at least one feature key. +- `allowedAdapterTypes` is intentionally narrow. +- Provisioning does not require board hire approval. +- Generic agent create/update cannot forge or remove the marker. +- Routes remain company-scoped and write activity for mutations. +- Background consumers use `requireBuiltInAgent(companyId, key)` instead of open-coding marker lookup. +- Paused built-ins skip scheduled/background work and leave an inspectable log or warning. +- Focused tests pass: + +```sh +pnpm --filter @paperclipai/server exec vitest run src/__tests__/built-in-agents.test.ts src/__tests__/built-in-agent-routes.test.ts +``` + +## Operational Notes + +- One active built-in row per company/key is allowed. Duplicate active markers are treated as a conflict and must be repaired manually. +- Terminated built-in rows are ignored for lookup; provisioning can create a replacement. +- `reset` restores registry-owned defaults but preserves adapter setup so operators do not lose local model or command configuration. +- Unknown marker keys are ignored during startup reconciliation. This prevents removed experimental built-ins from breaking server boot. +- Feature code should treat 412 `built_in_agent_not_configured` as an operator setup problem, not as a 500. diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index 5cabb4d0be..8f9efddb9b 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -42,7 +42,7 @@ afterEach(async () => { const cleanup = cleanups.pop(); await cleanup?.(); } -}); +}, 60_000); if (!embeddedPostgresSupport.supported) { console.warn( diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index 5a5a82697a..a9616ec91b 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -617,6 +617,110 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { 20_000, ); + it( + "replays the built-in managed resources migration after the legacy 0136 journal entry", + async () => { + const connectionString = await createTempDatabase(); + + await applyPendingMigrations(connectionString); + + const builtInResourcesHash = await migrationHash( + "0140_built_in_managed_resources.sql", + ); + const legacyBuiltInResourcesHash = createHash("sha256") + .update("legacy 0136_built_in_managed_resources.sql") + .digest("hex"); + + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + await sql.unsafe( + `DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${builtInResourcesHash}'`, + ); + await sql.unsafe( + ` + INSERT INTO "drizzle"."__drizzle_migrations" ("hash", "created_at") + VALUES ('${legacyBuiltInResourcesHash}', 1783555200000) + `, + ); + await sql.unsafe(` + ALTER TABLE "built_in_managed_resources" + DROP CONSTRAINT IF EXISTS "built_in_managed_resources_company_id_companies_id_fk" + `); + await sql.unsafe(`DROP INDEX IF EXISTS "built_in_managed_resources_company_idx"`); + await sql.unsafe(`DROP INDEX IF EXISTS "built_in_managed_resources_resource_idx"`); + await sql.unsafe(`DROP INDEX IF EXISTS "built_in_managed_resources_company_bundle_resource_uq"`); + } finally { + await sql.end(); + } + + const pendingState = await inspectMigrations(connectionString); + expect(pendingState).toMatchObject({ + status: "needsMigrations", + pendingMigrations: ["0140_built_in_managed_resources.sql"], + reason: "pending-migrations", + }); + + await applyPendingMigrations(connectionString); + + const finalState = await inspectMigrations(connectionString); + expect(finalState.status).toBe("upToDate"); + + const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const rows = await verifySql.unsafe<{ + foreign_key_exists: boolean; + company_index_exists: boolean; + resource_index_exists: boolean; + unique_index_exists: boolean; + }[]>(` + SELECT + EXISTS ( + SELECT 1 + FROM "pg_constraint" c + JOIN "pg_class" t ON t.oid = c.conrelid + JOIN "pg_namespace" n ON n.oid = t.relnamespace + WHERE n.nspname = 'public' + AND t.relname = 'built_in_managed_resources' + AND c.conname = 'built_in_managed_resources_company_id_companies_id_fk' + ) AS "foreign_key_exists", + EXISTS ( + SELECT 1 + FROM "pg_class" c + JOIN "pg_namespace" n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind = 'i' + AND c.relname = 'built_in_managed_resources_company_idx' + ) AS "company_index_exists", + EXISTS ( + SELECT 1 + FROM "pg_class" c + JOIN "pg_namespace" n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind = 'i' + AND c.relname = 'built_in_managed_resources_resource_idx' + ) AS "resource_index_exists", + EXISTS ( + SELECT 1 + FROM "pg_class" c + JOIN "pg_namespace" n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind = 'i' + AND c.relname = 'built_in_managed_resources_company_bundle_resource_uq' + ) AS "unique_index_exists" + `); + expect(rows[0]).toEqual({ + foreign_key_exists: true, + company_index_exists: true, + resource_index_exists: true, + unique_index_exists: true, + }); + } finally { + await verifySql.end(); + } + }, + 20_000, + ); + it( "replays migration 0134 without bumping issue updated_at for inbox archives", async () => { diff --git a/packages/db/src/migrations/0140_built_in_managed_resources.sql b/packages/db/src/migrations/0140_built_in_managed_resources.sql new file mode 100644 index 0000000000..2af683d089 --- /dev/null +++ b/packages/db/src/migrations/0140_built_in_managed_resources.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS "built_in_managed_resources" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "bundle_key" text NOT NULL, + "resource_kind" text NOT NULL, + "resource_key" text NOT NULL, + "resource_id" uuid NOT NULL, + "stock_version" text NOT NULL, + "stock_hash" text NOT NULL, + "defaults_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM "pg_constraint" c + JOIN "pg_class" t ON t.oid = c.conrelid + JOIN "pg_namespace" n ON n.oid = t.relnamespace + WHERE n.nspname = 'public' + AND t.relname = 'built_in_managed_resources' + AND c.conname = 'built_in_managed_resources_company_id_companies_id_fk' + ) THEN + ALTER TABLE "built_in_managed_resources" + ADD CONSTRAINT "built_in_managed_resources_company_id_companies_id_fk" + FOREIGN KEY ("company_id") REFERENCES "companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "built_in_managed_resources_company_idx" + ON "built_in_managed_resources" ("company_id"); +--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "built_in_managed_resources_resource_idx" + ON "built_in_managed_resources" ("resource_kind", "resource_id"); +--> statement-breakpoint + +CREATE UNIQUE INDEX IF NOT EXISTS "built_in_managed_resources_company_bundle_resource_uq" + ON "built_in_managed_resources" ("company_id", "bundle_key", "resource_kind", "resource_key"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 1526989ee3..3703625b25 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -967,6 +967,13 @@ "when": 1783555203000, "tag": "0139_skill_studio_run_templates", "breakpoints": true + }, + { + "idx": 140, + "version": "7", + "when": 1783555300000, + "tag": "0140_built_in_managed_resources", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/built_in_managed_resources.ts b/packages/db/src/schema/built_in_managed_resources.ts new file mode 100644 index 0000000000..8c04da34c7 --- /dev/null +++ b/packages/db/src/schema/built_in_managed_resources.ts @@ -0,0 +1,31 @@ +import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +export const builtInManagedResources = pgTable( + "built_in_managed_resources", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id") + .notNull() + .references(() => companies.id, { onDelete: "cascade" }), + bundleKey: text("bundle_key").notNull(), + resourceKind: text("resource_kind").notNull(), + resourceKey: text("resource_key").notNull(), + resourceId: uuid("resource_id").notNull(), + stockVersion: text("stock_version").notNull(), + stockHash: text("stock_hash").notNull(), + defaultsJson: jsonb("defaults_json").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIdx: index("built_in_managed_resources_company_idx").on(table.companyId), + resourceIdx: index("built_in_managed_resources_resource_idx").on(table.resourceKind, table.resourceId), + companyBundleResourceUq: uniqueIndex("built_in_managed_resources_company_bundle_resource_uq").on( + table.companyId, + table.bundleKey, + table.resourceKind, + table.resourceKey, + ), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 7d1180376c..aadaaf63db 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -6,6 +6,7 @@ export { cloudUpstreamConnections, cloudUpstreamRuns } from "./cloud_upstreams.j export { instanceUserRoles } from "./instance_user_roles.js"; export { userSidebarPreferences } from "./user_sidebar_preferences.js"; export { agents } from "./agents.js"; +export { builtInManagedResources } from "./built_in_managed_resources.js"; export { agentMemberships } from "./agent_memberships.js"; export { boardApiKeys } from "./board_api_keys.js"; export { cliAuthChallenges } from "./cli_auth_challenges.js"; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index aab701a670..287ae7835b 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -849,7 +849,10 @@ export type JoinRequestStatus = (typeof JOIN_REQUEST_STATUSES)[number]; export const PERMISSION_KEYS = [ "agents:create", + "agents:configure", + "agents:suggest-changes", "skills:create", + "skills:suggest-changes", "environments:manage", "users:invite", "users:manage_permissions", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9502219f3e..261e6301db 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1163,6 +1163,9 @@ export { agentSkillSyncSchema, type AgentSkillSync, createAgentSchema, + builtInAgentEmptyMutationSchema, + builtInAgentProvisionSchema, + builtInAgentResetSchema, createAgentHireSchema, updateAgentSchema, agentInstructionsBundleModeSchema, @@ -1182,6 +1185,8 @@ export { agentPermissionsSchema, updateAgentPermissionsSchema, type CreateAgent, + type BuiltInAgentProvision, + type BuiltInAgentReset, type CreateAgentHire, type UpdateAgent, type UpdateAgentInstructionsBundle, diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index fd2bb5b1c1..1434ab733a 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -1,6 +1,6 @@ import type { AgentEnvConfig } from "./secrets.js"; import type { RoutineVariable } from "./routine.js"; -import type { IssueCommentAuthorType } from "../constants.js"; +import type { IssueCommentAuthorType, PermissionKey } from "../constants.js"; import type { IssueCommentMetadata, IssueCommentPresentation } from "./issue.js"; export interface CompanyPortabilityInclude { @@ -145,6 +145,10 @@ export interface CompanyPortabilityAgentManifestEntry { adapterConfig: Record; runtimeConfig: Record; permissions: Record; + permissionGrants: Array<{ + permissionKey: PermissionKey; + scope: Record | null; + }>; budgetMonthlyCents: number; metadata: Record | null; } diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 0a00c8cbe1..79cbbd3d7a 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -55,6 +55,7 @@ export interface InstanceExperimentalSettings { enableExperimentalFileViewer: boolean; enableCloudSync: boolean; enableExternalObjects: boolean; + enableBuiltInAgents: boolean; enableGoalsSidebarLink: boolean; enableServerInfoDebugView: boolean; autoRestartDevServerWhenIdle: boolean; diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 5133896310..a0d38cd921 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -88,6 +88,24 @@ export const createAgentSchema = z.object({ export type CreateAgent = z.infer; +export const builtInAgentProvisionSchema = z.object({ + adapterType: agentAdapterTypeSchema.optional(), + adapterConfig: adapterConfigSchema.optional(), + budgetMonthlyCents: z.number().int().nonnegative().optional(), +}).strict(); + +export type BuiltInAgentProvision = z.infer; + +export const builtInAgentEmptyMutationSchema = z.object({}).strict().default({}); + +export type BuiltInAgentEmptyMutation = z.infer; + +export const builtInAgentResetSchema = z.object({ + resources: z.array(z.enum(["agent", "instructions", "skill", "routine"])).optional(), +}).strict().default({}); + +export type BuiltInAgentReset = z.infer; + export const createAgentHireSchema = createAgentSchema.extend({ sourceIssueId: z.string().uuid().optional().nullable(), sourceIssueIds: z.array(z.string().uuid()).optional(), diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index d6d81d964e..8a1e97a6cc 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { PERMISSION_KEYS } from "../constants.js"; import { MAX_COMPANY_ATTACHMENT_MAX_BYTES } from "../constants.js"; import { issueCommentAuthorTypeSchema, @@ -70,6 +71,10 @@ export const portabilityAgentManifestEntrySchema = z.object({ adapterConfig: z.record(z.string(), z.unknown()), runtimeConfig: z.record(z.string(), z.unknown()), permissions: z.record(z.string(), z.unknown()), + permissionGrants: z.array(z.object({ + permissionKey: z.enum(PERMISSION_KEYS), + scope: z.record(z.string(), z.unknown()).nullable().default(null), + })).default([]), budgetMonthlyCents: z.number().int().nonnegative(), metadata: z.record(z.string(), z.unknown()).nullable(), }); diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index b76d6dee12..eb610f933a 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -247,6 +247,9 @@ export { export { createAgentSchema, + builtInAgentEmptyMutationSchema, + builtInAgentProvisionSchema, + builtInAgentResetSchema, createAgentHireSchema, updateAgentSchema, agentRuntimeConfigSchema, @@ -267,6 +270,8 @@ export { agentPermissionsSchema, updateAgentPermissionsSchema, type CreateAgent, + type BuiltInAgentProvision, + type BuiltInAgentReset, type CreateAgentHire, type UpdateAgent, type UpdateAgentInstructionsBundle, diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts index 34e6174894..eb2117a0c0 100644 --- a/packages/shared/src/validators/instance.test.ts +++ b/packages/shared/src/validators/instance.test.ts @@ -29,6 +29,12 @@ describe("instance experimental settings validators", () => { expect(settings.enableWorktreeRunExecution).toBe(false); }); + it("defaults built-in agents off", () => { + const settings = instanceExperimentalSettingsSchema.parse({}); + + expect(settings.enableBuiltInAgents).toBe(false); + }); + it("accepts worktree run execution patches", () => { expect( patchInstanceExperimentalSettingsSchema.parse({ @@ -68,4 +74,14 @@ describe("instance experimental settings validators", () => { enableGoalsSidebarLink: true, }); }); + + it("accepts built-in agents patches", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableBuiltInAgents: true, + }), + ).toEqual({ + enableBuiltInAgents: true, + }); + }); }); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 158edeffe7..bdd0a53a65 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -49,6 +49,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableExperimentalFileViewer: z.boolean().default(false), enableCloudSync: z.boolean().default(false), enableExternalObjects: z.boolean().default(false), + enableBuiltInAgents: z.boolean().default(false), enableGoalsSidebarLink: z.boolean().default(false), enableServerInfoDebugView: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), diff --git a/packages/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md b/packages/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md new file mode 100644 index 0000000000..c6786939d7 --- /dev/null +++ b/packages/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md @@ -0,0 +1,202 @@ +--- +name: reflection-coach +description: Reflect on another agent's recent execution record, name evidence-backed patterns, and propose the smallest durable change to their AGENTS.md, a reusable skill, or a tool description — as a reviewable, interaction-gated proposal, never a same-run hot-swap. +key: paperclipai/bundled/paperclip-operations/reflection-coach +recommendedForRoles: + - manager + - general +tags: + - paperclip + - reflection + - coaching + - agents + - skills +--- + +# Reflection Coach + +You are coaching another agent. You are **not** that agent. Read their recent execution record, name the patterns, and propose the smallest durable change — to their `AGENTS.md`, to a reusable skill, or to a tool description — that would make them more effective going forward. + +This skill runs **on a target agent** and produces a reviewable proposal. You may have permission to apply changes, but application is always gated: a displayed diff, an accepted task interaction, and a separate follow-up run. You never propose and apply in the same run. + +Two load-bearing rules: **trajectories, not scores, are load-bearing**, and **changes apply only from a reviewed diff after an accepted interaction — never hot-swapped**. + +## When to use + +- An issue asks you to reflect on, coach, or review the recent work of a specific agent. +- A routine (e.g. `recent-agent-reflection`) hands you a bounded set of agents to review. +- Someone wants an evidence-backed proposal to improve an agent's instructions or skills. + +## When not to use + +- The target agent id is your own. Refuse — no self-reflection. +- You are asked to rewrite product code or shared infra. That is out of scope. +- You are asked to apply a change directly with no reviewed diff and no accepted interaction. Refuse and name the gate. + +## Inputs + +Required: + +- `targetAgentId` — the agent you are coaching. Never coach yourself. +- `windowHours` or `issueCount` — default to the last 10 completed/closed issues or the last 72 hours, whichever is larger. Cap at 25 issues to stay within budget. + +Optional: + +- `focus` — free-text hint ("verification misses", "late escalations"). Bias clustering toward this axis if given. +- `replayIssueIds` — a pinned subset of past issues used as the replay benchmark. If absent, pick 3–5 representative recent issues from the window. + +## Hard guardrails + +Every proposal must satisfy all of these: + +- **No same-run apply.** Discovery and application are separate runs. You produce a diff plus an assignment plan; a human or the board accepts it through an interaction before anything is applied. +- **Size caps.** Skills ≤ 15KB. Tool descriptions ≤ 500 chars. `AGENTS.md` may grow by **at most +20%** per proposal. Want more? Split proposals. +- **Trajectory-backed or drop it.** Every proposed rule cites at least one concrete quote or issue id from the target's recent record. No evidence, no rule. +- **Not your code.** Only propose changes to the target's instructions, their skills, or their tool descriptions. Never to code they do not own or to shared infra. +- **Benchmark-gated.** Name the replay cases the proposal must still resolve. If a rule would have broken a past success, drop it. +- **No reflection on yourself.** If `targetAgentId == PAPERCLIP_AGENT_ID`, refuse and ask for another coach. + +## Procedure + +### 1) Confirm target and scope + +```sh +curl -sS "$PAPERCLIP_API_URL/api/agents/" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" +``` + +Record `name`, `role`, `reportsTo`, `adapterType`, `adapterConfig.instructionsFilePath` (where `AGENTS.md` lives), and current assigned skills via `GET /api/agents//skills`. Refuse and exit if `targetAgentId == $PAPERCLIP_AGENT_ID`. + +### 2) Pull the recent record + +```sh +curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=&status=done,in_review,blocked&limit=25" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" +``` + +For each issue, pull the trajectory substrate — the issue body and its comments: + +```sh +curl -sS "$PAPERCLIP_API_URL/api/issues/" -H "Authorization: Bearer $PAPERCLIP_API_KEY" +curl -sS "$PAPERCLIP_API_URL/api/issues//comments" -H "Authorization: Bearer $PAPERCLIP_API_KEY" +``` + +Keep status transitions, blocker reasons, reviewer comments, approval outcomes, human corrections, and PR-link comments. Comments are the closest thing Paperclip has to an execution trace — treat them as first-class evidence. + +### 3) Read the target's current guardrails + +Before proposing anything, read what already exists so you don't restate it: + +- Their `AGENTS.md` at `adapterConfig.instructionsFilePath`. +- Their assigned skills (from step 1). +- Any `MEMORY.md` / `memory/` files in their cwd if the adapter uses para-memory-files. + +If a rule you were about to propose is already present, drop it. A failure pattern *despite* an existing rule is a different finding — record it as "existing rule X is not being followed" and propose how to make it stick (move to a skill, add a negative example, strengthen the trigger), not a duplicate. + +### 4) Cluster the failures + +Name each cluster from this taxonomy: + +- **verifier-miss** — agent claimed done; reviewer rejected. +- **avoidable-rework** — same issue reopened more than once. +- **stale-context** — acted on an assumption already falsified in-thread. +- **instruction-miss** — violated an existing rule in `AGENTS.md`. +- **late-escalation** — stayed blocked too long without escalating. +- **human-correction** — a user explicitly said to do X differently. +- **tool-misuse** — hit the same tool-error pattern repeatedly. +- **scope-creep** — changes beyond task scope. + +For each cluster keep a list of `(issueId, commentId, one-line evidence quote)` tuples. **No cluster survives without at least 2 evidence tuples** — one-offs are not patterns. + +### 5) Route each cluster to a target surface + +- **Agent-specific, narrow, cheap to state** → `AGENTS.md` update. E.g. "always re-run failing tests before marking in_review." +- **Generalizable, multi-step procedure with when-to-use logic** → new or updated reusable skill. +- **Both** → update/create the skill AND add a pointer line in `AGENTS.md` so the agent knows when to reach for it. Common case for non-obvious procedures. +- **Tool description** → only if the failure was "agent didn't know when to use tool X" and a ≤500-char description change fixes it. + +Sanity check reuse honestly: a rule that applies to all coders belongs in a shared skill; a "reusable skill" that only fits one role belongs in that agent's `AGENTS.md`. + +### 6) Draft the proposal document + +Create a document attached to the **reflection issue** (never the target's issues). One section per cluster: + +```markdown +## Cluster: + +**Pattern (1 sentence, quotable):** +**Root cause hypothesis:** +**Evidence (≥2):** +- [PAP-NNN](/PAP/issues/PAP-NNN) — "" +- [PAP-MMM](/PAP/issues/PAP-MMM) — "" + +**Proposed change:** +- Target surface: AGENTS.md | skill: | both | tool-description: +- Diff (inline, minimal, ≤20% AGENTS.md growth / ≤15KB skill): + ```diff + ... + ``` + +**Expected still-passes (replay):** +- [PAP-XXX](/PAP/issues/PAP-XXX), [PAP-YYY](/PAP/issues/PAP-YYY) + +**Why this change, not something bigger:** +(1–2 sentences on why you didn't rewrite more.) +``` + +### 7) Write the actual drafts (files, not just prose) + +- **Skill surface** — draft a full `SKILL.md` (frontmatter → Overview → When to use → Process → Pitfalls → Verification), ≤ 15KB. Put it under `drafts//SKILL.md` and attach it to the reflection issue. +- **AGENTS.md surface** — write a unified diff against the target's current `AGENTS.md`. Do not rewrite the whole file; quote 1–3 lines of context per change. Keep total growth ≤ +20%; split if you can't. + +### 8) Benchmark-gate the proposal + +For each pinned replay issue, ask: "If this rule had been in effect, would the agent still have succeeded?" Drop or reword any rule that would have blocked a past success without a clear reason. Record the walk in "Expected still-passes." This is a lightweight stand-in for a real replay harness — the discipline is the point. + +### 9) Publish and request acceptance + +From a reflection issue (assigned to the target's manager or the requester): + +1. Attach the proposal document: `PUT /api/issues/{issueId}/documents/reflection-proposal`. +2. If a draft skill was written, commit it under `skills//` (or attach it) and link it in the proposal. +3. Open the acceptance gate with a task interaction on the reflection issue. Mutations that change instructions, skills, or tool descriptions must use `request_confirmation`, show the diff in `payload.detailsMarkdown`, set `continuationPolicy: wake_assignee_on_accept`, and include the exact `payload.target.key` listed below. +4. Leave a comment summarizing: target agent, window, clusters found, surfaces touched, link to the proposal, link to the interaction, and the next-step owner. + +Server-enforced mutation target keys: + +- Agent instructions: `agent::instructions` +- Agent/tool description fields: `agent::profile` +- Existing company skill: `skill:` +- New local company skill by slug: `skill-slug:` +- Imported or catalog skill source: `skill-import:` +- Project workspace skill scan: `skills:scan-projects` + +### 10) Apply only after acceptance, in a follow-up run + +When the interaction resolves **accepted**, apply the change in a *separate* run: + +- **AGENTS.md** — update the target's managed instruction file exactly as the accepted diff specified. +- **Skill** — install/update the skill in the company library, then `POST /api/agents//skills/sync` when the target should receive it. +- **Tool description** — update the target agent's description/profile field that the accepted diff named. + +The server rejects Reflection Coach mutations unless the accepted `request_confirmation` was created by Reflection Coach in a previous run, has a displayed diff, and is bound to the resource by one of the target keys above. If the interaction was rejected or is still pending, apply nothing. If you were asked to apply without a reviewed diff and an accepted interaction, refuse and name the gate — no-same-run-apply is load-bearing. + +## Pitfalls + +- **Scoring without trajectories.** Don't say "failed 3 times" without quoting the failures. Scores alone collapse improvement rate. +- **Proposing the bigger rewrite.** Your job is the smallest change that would have prevented the cluster. Bigger feels impressive; it isn't. +- **Duplicating rules the agent already has.** Read `AGENTS.md` + assigned skills first. An existing-but-unfollowed rule is a "make it stick" proposal, not a restatement. +- **Applying in the discovery run.** Even with permission, discovery and application are separate runs behind an accepted interaction. +- **Silently expanding scope.** The +20% cap exists because every new rule competes for attention. Four small proposals beat one big rewrite. +- **Promising runtime value.** You are not improving the agent mid-session. This is offline, diff-reviewed, interaction-gated. + +## Verification (self-check before publishing) + +- [ ] `targetAgentId != $PAPERCLIP_AGENT_ID` +- [ ] Each cluster has ≥2 evidence tuples with a linked issue + verbatim quote +- [ ] Each proposal names the target surface explicitly and includes the diff (not just prose) +- [ ] `AGENTS.md` growth ≤ 20%, skills ≤ 15KB, tool descriptions ≤ 500 chars +- [ ] Replay set has ≥3 past issues the rules still pass against +- [ ] Proposal document linked from the reflection issue +- [ ] An acceptance interaction (showing the diff) is open before any mutation +- [ ] No claim that the target has already "been updated" before acceptance + follow-up run diff --git a/packages/skills-catalog/generated/catalog.json b/packages/skills-catalog/generated/catalog.json index 826e04409e..791750aa2a 100644 --- a/packages/skills-catalog/generated/catalog.json +++ b/packages/skills-catalog/generated/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "packageName": "@paperclipai/skills-catalog", "packageVersion": "0.3.1", - "generatedAt": "2026-07-07T12:23:29.613Z", + "generatedAt": "2026-07-09T15:02:07.574Z", "skills": [ { "id": "paperclipai:bundled:docs:doc-maintenance", @@ -73,6 +73,41 @@ ], "contentHash": "sha256:88dc13560371fb364963782cb4f6eeb4090fcde92ee3774479428ed6b90e11c1" }, + { + "id": "paperclipai:bundled:paperclip-operations:reflection-coach", + "key": "paperclipai/bundled/paperclip-operations/reflection-coach", + "kind": "bundled", + "category": "paperclip-operations", + "slug": "reflection-coach", + "name": "reflection-coach", + "description": "Reflect on another agent's recent execution record, name evidence-backed patterns, and propose the smallest durable change to their AGENTS.md, a reusable skill, or a tool description — as a reviewable, interaction-gated proposal, never a same-run hot-swap.", + "path": "catalog/bundled/paperclip-operations/reflection-coach", + "entrypoint": "SKILL.md", + "trustLevel": "markdown_only", + "compatibility": "compatible", + "defaultInstall": false, + "recommendedForRoles": [ + "manager", + "general" + ], + "requires": [], + "tags": [ + "paperclip", + "reflection", + "coaching", + "agents", + "skills" + ], + "files": [ + { + "path": "SKILL.md", + "kind": "skill", + "sizeBytes": 11903, + "sha256": "ca167eac8d1e89cadc8009b61a204368507e7edb7f2da0688fea6dba8223e189" + } + ], + "contentHash": "sha256:20381a898f05ceb668e305708dd33a03ce36aef0e00b923c01ae20ab785f04d2" + }, { "id": "paperclipai:bundled:paperclip-operations:task-planning", "key": "paperclipai/bundled/paperclip-operations/task-planning", diff --git a/packages/skills-catalog/src/shipped-catalog.test.ts b/packages/skills-catalog/src/shipped-catalog.test.ts index 431e20c962..1a29bbb473 100644 --- a/packages/skills-catalog/src/shipped-catalog.test.ts +++ b/packages/skills-catalog/src/shipped-catalog.test.ts @@ -5,6 +5,7 @@ import { catalogManifest, catalogSkills, resolveCatalogSkillRef } from "./index. const EXPECTED_BUNDLED_KEYS = [ "paperclipai/bundled/docs/doc-maintenance", "paperclipai/bundled/paperclip-operations/issue-triage", + "paperclipai/bundled/paperclip-operations/reflection-coach", "paperclipai/bundled/paperclip-operations/task-planning", "paperclipai/bundled/product/paperclip-capsules", "paperclipai/bundled/product/wireframe", diff --git a/server/src/__tests__/adapter-model-refresh-routes.test.ts b/server/src/__tests__/adapter-model-refresh-routes.test.ts index 68553e9d31..a328a16c55 100644 --- a/server/src/__tests__/adapter-model-refresh-routes.test.ts +++ b/server/src/__tests__/adapter-model-refresh-routes.test.ts @@ -80,6 +80,7 @@ function registerModuleMocks() { agentInstructionsService: () => mockAgentInstructionsService, accessService: () => mockAccessService, approvalService: () => mockApprovalService, + builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }), companySkillService: () => mockCompanySkillService, budgetService: () => mockBudgetService, heartbeatService: () => mockHeartbeatService, diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index 40a7266ac6..f17158ce37 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -69,6 +69,7 @@ vi.mock("../services/index.js", () => ({ agentInstructionsService: () => mockAgentInstructionsService, accessService: () => mockAccessService, approvalService: () => mockApprovalService, + builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }), companySkillService: () => mockCompanySkillService, budgetService: () => mockBudgetService, heartbeatService: () => mockHeartbeatService, @@ -94,6 +95,7 @@ function registerModuleMocks() { agentInstructionsService: () => mockAgentInstructionsService, accessService: () => mockAccessService, approvalService: () => mockApprovalService, + builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }), companySkillService: () => mockCompanySkillService, budgetService: () => mockBudgetService, heartbeatService: () => mockHeartbeatService, diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts index 765f0c507f..738d2ebf20 100644 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -182,6 +182,7 @@ vi.mock("../services/index.js", () => ({ agentInstructionsService: () => mockAgentInstructionsService, accessService: () => mockAccessService, approvalService: () => mockApprovalService, + builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }), companySkillService: () => mockCompanySkillService, budgetService: () => mockBudgetService, heartbeatService: () => mockHeartbeatService, diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 6d36a820d3..5fca425834 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -8,6 +8,10 @@ const mockAgentService = vi.hoisted(() => ({ resolveByReference: vi.fn(), })); +const mockBuiltInAgentService = vi.hoisted(() => ({ + ensureCompanyDefaultAgentGrants: vi.fn(), +})); + const mockAgentInstructionsService = vi.hoisted(() => ({ getBundle: vi.fn(), readFile: vi.fn(), @@ -42,6 +46,7 @@ vi.mock("../services/index.js", () => ({ agentInstructionsService: () => mockAgentInstructionsService, accessService: () => mockAccessService, approvalService: () => ({}), + builtInAgentService: () => mockBuiltInAgentService, companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), budgetService: () => ({}), environmentService: () => mockEnvironmentService, @@ -73,6 +78,7 @@ function registerModuleMocks() { agentInstructionsService: () => mockAgentInstructionsService, accessService: () => mockAccessService, approvalService: () => ({}), + builtInAgentService: () => mockBuiltInAgentService, companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), budgetService: () => ({}), heartbeatService: () => ({}), @@ -98,7 +104,17 @@ function registerModuleMocks() { })); } -async function createApp() { +function boardActor() { + return { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; +} + +async function createApp(actor: Record = boardActor()) { const [{ agentRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/agents.js"), vi.importActual("../middleware/index.js"), @@ -106,13 +122,7 @@ async function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = { - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }; + (req as any).actor = actor; next(); }); app.use("/api", agentRoutes({} as any)); @@ -166,6 +176,21 @@ function makeAgent() { }; } +function makeReflectionCoachAgent(overrides: Record = {}) { + return { + ...makeAgent(), + id: "22222222-2222-4222-8222-222222222222", + name: "Reflection Coach", + metadata: { + paperclipBuiltInAgent: { + key: "reflection-coach", + featureKeys: ["reflection-coach"], + }, + }, + ...overrides, + }; +} + describe("agent instructions bundle routes", () => { beforeEach(() => { vi.resetModules(); @@ -174,6 +199,7 @@ describe("agent instructions bundle routes", () => { vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.clearAllMocks(); + mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockResolvedValue(0); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type })); mockAccessService.decide.mockResolvedValue({ @@ -259,6 +285,110 @@ describe("agent instructions bundle routes", () => { expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled(); }); + it("denies non-privileged agents from reading peer instructions bundles", async () => { + mockAgentService.getById.mockImplementation(async (id: string) => { + if (id === "agent-reader") { + return { + ...makeAgent(), + id: "agent-reader", + name: "Reader", + permissions: { canCreateAgents: false }, + }; + } + return makeAgent(); + }); + mockAccessService.decide.mockResolvedValue({ + allowed: false, + reason: "deny_no_grant", + explanation: "Missing permission: agents:configure or agents:suggest-changes.", + }); + + const res = await requestApp( + await createApp({ + type: "agent", + agentId: "agent-reader", + companyId: "company-1", + source: "agent_key", + }), + (baseUrl) => request(baseUrl) + .get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle"), + ); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toContain("Missing permission"); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "agent_config:read", + resource: { + type: "agent", + companyId: "company-1", + agentId: "11111111-1111-4111-8111-111111111111", + }, + })); + expect(mockAgentInstructionsService.getBundle).not.toHaveBeenCalled(); + }); + + it("allows agents to read their own instructions bundles", async () => { + const res = await requestApp( + await createApp({ + type: "agent", + agentId: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + source: "agent_key", + }), + (baseUrl) => request(baseUrl) + .get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle"), + ); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled(); + }); + + it("allows agents with suggest grants to read peer instructions bundles", async () => { + mockAccessService.decide.mockResolvedValue({ + allowed: true, + reason: "allow_explicit_grant", + explanation: "Allowed by explicit grant agents:suggest-changes.", + grant: { + principalType: "agent", + principalId: "coach-agent", + permissionKey: "agents:suggest-changes", + scope: null, + }, + }); + mockAgentService.getById.mockImplementation(async (id: string) => { + if (id === "coach-agent") { + return makeReflectionCoachAgent({ id: "coach-agent" }); + } + return makeAgent(); + }); + + const res = await requestApp( + await createApp({ + type: "agent", + agentId: "coach-agent", + companyId: "company-1", + source: "agent_key", + }), + (baseUrl) => request(baseUrl) + .get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file") + .query({ path: "AGENTS.md" }), + ); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "agent_config:read", + resource: { + type: "agent", + companyId: "company-1", + agentId: "11111111-1111-4111-8111-111111111111", + }, + })); + expect(mockAgentInstructionsService.readFile).toHaveBeenCalledWith( + expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }), + "AGENTS.md", + ); + }); + it("writes a bundle file and persists compatibility config", async () => { const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1") diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index b17235b5e8..f611dfa66e 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -63,6 +63,7 @@ function registerModuleMocks() { hasPermission: vi.fn(async () => true), }), approvalService: () => ({}), + builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }), companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), budgetService: () => ({}), heartbeatService: () => mockHeartbeatService, diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 9aeda84ac5..1822baee2d 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -51,6 +51,10 @@ const mockAgentService = vi.hoisted(() => ({ resolveByReference: vi.fn(), })); +const mockBuiltInAgentService = vi.hoisted(() => ({ + ensureCompanyDefaultAgentGrants: vi.fn(), +})); + const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), decide: vi.fn(), @@ -195,6 +199,7 @@ function registerModuleMocks() { agentInstructionsService: () => mockAgentInstructionsService, accessService: () => mockAccessService, approvalService: () => mockApprovalService, + builtInAgentService: () => mockBuiltInAgentService, companySkillService: () => mockCompanySkillService, budgetService: () => mockBudgetService, heartbeatService: () => mockHeartbeatService, @@ -309,6 +314,7 @@ describe.sequential("agent permission routes", () => { mockAgentService.updatePermissions.mockReset(); mockAgentService.getChainOfCommand.mockReset(); mockAgentService.resolveByReference.mockReset(); + mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockReset(); mockAccessService.canUser.mockReset(); mockAccessService.decide.mockReset(); mockAccessService.hasPermission.mockReset(); @@ -354,6 +360,7 @@ describe.sequential("agent permission routes", () => { }); mockAgentService.update.mockResolvedValue(baseAgent); mockAgentService.updatePermissions.mockResolvedValue(baseAgent); + mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockResolvedValue(0); mockAccessService.canUser.mockResolvedValue(true); mockAccessService.decide.mockImplementation(async (input: { action?: string }) => { const allowed = Boolean(await mockAccessService.canUser()); @@ -866,6 +873,7 @@ describe.sequential("agent permission routes", () => { true, "agent-admin-user", ); + expect(mockBuiltInAgentService.ensureCompanyDefaultAgentGrants).toHaveBeenCalledWith(companyId); }); it("rejects direct agent creation when new agents require board approval", async () => { @@ -1699,11 +1707,10 @@ describe.sequential("agent permission routes", () => { expect(res.status).toBe(200); }); - it("denies an agent actor without agents:create when reading peer config", async () => { - // Agent actors must still pass the agents:create gate (explicit - // grant OR canCreateAgents permission on the agent record). A peer - // agent in the same company without that permission must not be - // able to read another agent's configuration. + it("denies an agent actor without configure or suggest grants when reading peer config", async () => { + // Agent actors must pass the agent configuration read ladder. A peer + // agent in the same company without agents:configure or + // agents:suggest-changes must not read another agent's configuration. const peerAgentId = "33333333-3333-4333-8333-333333333333"; const peerAgent = { ...baseAgent, id: peerAgentId }; mockAgentService.getById.mockImplementation(async (id: string) => { @@ -1713,7 +1720,11 @@ describe.sequential("agent permission routes", () => { } return null; }); - mockAccessService.hasPermission.mockResolvedValue(false); + mockAccessService.decide.mockResolvedValue({ + allowed: false, + reason: "deny_no_grant", + explanation: "Missing permission: agents:configure or agents:suggest-changes.", + }); const app = await createApp({ type: "agent", @@ -1726,11 +1737,15 @@ describe.sequential("agent permission routes", () => { const res = await request(app).get(`/api/agents/${peerAgentId}/configuration`); expect(res.status).toBe(403); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "agent_config:read", + resource: { type: "company", companyId }, + })); }); - it("allows an agent actor with agents:create grant to read peer config", async () => { - // When an agent actor has an explicit agents:create grant in the - // access service, the read gate must let them through. + it("allows an agent actor with agents:suggest-changes grant to read peer config", async () => { + // Suggest-tier authority implies read access so the agent can prepare a + // consented diff without receiving direct change authority. const peerAgentId = "44444444-4444-4444-8444-444444444444"; const peerAgent = { ...baseAgent, id: peerAgentId }; mockAgentService.getById.mockImplementation(async (id: string) => { @@ -1740,11 +1755,17 @@ describe.sequential("agent permission routes", () => { } return null; }); - mockAccessService.hasPermission.mockImplementation( - async (_companyId: string, _principalType: string, principalId: string, key: string) => { - return principalId === agentId && key === "agents:create"; + mockAccessService.decide.mockResolvedValue({ + allowed: true, + reason: "allow_explicit_grant", + explanation: "Allowed by explicit grant agents:suggest-changes.", + grant: { + principalType: "agent", + principalId: agentId, + permissionKey: "agents:suggest-changes", + scope: null, }, - ); + }); const app = await createApp({ type: "agent", @@ -1757,6 +1778,10 @@ describe.sequential("agent permission routes", () => { const res = await request(app).get(`/api/agents/${peerAgentId}/configuration`); expect(res.status).toBe(200); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "agent_config:read", + resource: { type: "company", companyId }, + })); }); }); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index e1f02e7170..83352c4189 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -85,6 +85,7 @@ vi.mock("../services/index.js", () => ({ agentInstructionsService: () => mockAgentInstructionsService, accessService: () => mockAccessService, approvalService: () => mockApprovalService, + builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }), companySkillService: () => mockCompanySkillService, budgetService: () => mockBudgetService, environmentService: () => mockEnvironmentService, @@ -123,6 +124,7 @@ function registerModuleMocks() { agentInstructionsService: () => mockAgentInstructionsService, accessService: () => mockAccessService, approvalService: () => mockApprovalService, + builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }), companySkillService: () => mockCompanySkillService, budgetService: () => mockBudgetService, heartbeatService: () => mockHeartbeatService, @@ -695,6 +697,7 @@ describe.sequential("agent skill routes", () => { instructionsFilePath: `/tmp/${createdAgentId}/instructions/AGENTS.md`, }), }), + expect.objectContaining({ allowPendingApprovalConfigUpdate: true }), ); expect(mockAgentService.update.mock.calls.at(-1)?.[1]).not.toMatchObject({ adapterConfig: expect.objectContaining({ diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index 40730c051b..dc3e7ce445 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -45,6 +45,7 @@ vi.mock("../services/index.js", () => ({ agentInstructionsService: () => ({}), accessService: () => mockAccessService, approvalService: () => ({}), + builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }), companySkillService: () => ({ listRuntimeSkillEntries: vi.fn(async () => []), resolveRequestedSkillKeys: vi.fn(async () => []), diff --git a/server/src/__tests__/agents-pending-approval-config.test.ts b/server/src/__tests__/agents-pending-approval-config.test.ts new file mode 100644 index 0000000000..3bb906334a --- /dev/null +++ b/server/src/__tests__/agents-pending-approval-config.test.ts @@ -0,0 +1,158 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { + agents, + approvals, + activityLog, + budgetPolicies, + companies, + createDb, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { agentService } from "../services/agents.ts"; +import { approvalService } from "../services/approvals.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +function issuePrefix(id: string) { + return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`; +} + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres pending approval agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("pending approval agent config integrity", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-pending-agent-config-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(budgetPolicies); + await db.delete(approvals); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompany() { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: issuePrefix(companyId), + requireBoardApprovalForNewAgents: true, + }); + return companyId; + } + + it("freezes generic pending hire config and reapplies the approval snapshot on activation", async () => { + const companyId = await seedCompany(); + const agentSvc = agentService(db); + const approvalSvc = approvalService(db); + const pending = await agentSvc.create(companyId, { + name: "Pending Coder", + role: "engineer", + title: "Software Engineer", + icon: "code", + capabilities: "Writes code", + adapterType: "process", + adapterConfig: { command: "echo safe" }, + runtimeConfig: { maxConcurrentRuns: 1 }, + budgetMonthlyCents: 1234, + metadata: { source: "hire-form" }, + status: "pending_approval", + spentMonthlyCents: 0, + permissions: {}, + lastHeartbeatAt: null, + }); + const approval = await approvalSvc.create(companyId, { + type: "hire_agent", + requestedByAgentId: null, + requestedByUserId: "board-user", + status: "pending", + payload: { + name: "Pending Coder", + role: "engineer", + title: "Software Engineer", + icon: "code", + reportsTo: null, + capabilities: "Writes code", + adapterType: "process", + adapterConfig: { command: "echo safe" }, + runtimeConfig: { maxConcurrentRuns: 1 }, + budgetMonthlyCents: 1234, + metadata: { source: "hire-form" }, + agentId: pending.id, + }, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + updatedAt: new Date(), + }); + + await expect(agentSvc.update(pending.id, { + name: "Tampered Coder", + adapterConfig: { command: "echo malicious" }, + runtimeConfig: { maxConcurrentRuns: 99 }, + })).rejects.toMatchObject({ + status: 409, + details: { + code: "pending_approval_agent_config_frozen", + agentId: pending.id, + fields: ["name", "adapterConfig", "runtimeConfig"], + }, + }); + await expect(agentSvc.updatePermissions(pending.id, { + canCreateAgents: true, + })).rejects.toMatchObject({ + status: 409, + details: { + code: "pending_approval_agent_config_frozen", + agentId: pending.id, + fields: ["permissions"], + }, + }); + + await db + .update(agents) + .set({ + name: "Tampered Coder", + adapterConfig: { command: "echo malicious" }, + runtimeConfig: { maxConcurrentRuns: 99 }, + metadata: { source: "tampered" }, + }) + .where(eq(agents.id, pending.id)); + + await approvalSvc.approve(approval.id, "board-user", "Approved generic hire"); + + await expect(agentSvc.getById(pending.id)).resolves.toMatchObject({ + status: "idle", + name: "Pending Coder", + role: "engineer", + title: "Software Engineer", + icon: "code", + capabilities: "Writes code", + adapterType: "process", + adapterConfig: { command: "echo safe" }, + runtimeConfig: { maxConcurrentRuns: 1 }, + budgetMonthlyCents: 1234, + metadata: { source: "hire-form" }, + }); + }); +}); diff --git a/server/src/__tests__/approvals-service.test.ts b/server/src/__tests__/approvals-service.test.ts index aebff09f59..e3411d89dd 100644 --- a/server/src/__tests__/approvals-service.test.ts +++ b/server/src/__tests__/approvals-service.test.ts @@ -101,7 +101,7 @@ describe("approvalService resolution idempotency", () => { const result = await svc.approve("approval-1", "board", "ship it"); expect(result.applied).toBe(true); - expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1"); + expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1", approved.payload); expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1); }); diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 213b192e47..111843a0f2 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -12,7 +12,7 @@ import { principalPermissionGrants, projects, } from "@paperclipai/db"; -import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared"; +import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -100,7 +100,7 @@ async function grantAgentPermission( db: ReturnType, companyId: string, agentId: string, - permissionKey: "tasks:assign" | "tasks:assign_scope", + permissionKey: PermissionKey, scope: Record | null = null, ) { await db.insert(companyMemberships).values({ @@ -141,7 +141,7 @@ async function grantUserPermission( db: ReturnType, companyId: string, userId: string, - permissionKey: "tasks:assign" | "tasks:assign_scope", + permissionKey: PermissionKey, scope: Record | null = null, ) { await db.insert(companyMemberships).values({ @@ -224,23 +224,35 @@ describeEmbeddedPostgres("authorization service", () => { expect(decision.explanation).toContain("Allowed by explicit grant tasks:assign"); }); - it("allows agent grants for agent configuration decisions", async () => { - const company = await createCompany(db, "AgentGrant"); + it("allows suggest grants to read peer agent configuration", async () => { + const company = await createCompany(db, "AgentReadGrant"); const actorAgent = await createAgent(db, company.id); const targetAgent = await createAgent(db, company.id); - await db.insert(companyMemberships).values({ - companyId: company.id, - principalType: "agent", - principalId: actorAgent.id, - status: "active", - membershipRole: "member", + await grantAgentPermission(db, company.id, actorAgent.id, "agents:suggest-changes"); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "agent_config:read", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, }); - await db.insert(principalPermissionGrants).values({ - companyId: company.id, - principalType: "agent", - principalId: actorAgent.id, - permissionKey: "agents:create", - grantedByUserId: null, + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_explicit_grant", + grant: { + principalType: "agent", + principalId: actorAgent.id, + permissionKey: "agents:suggest-changes", + }, + }); + }); + + it("falls back to the direct config-read grant decision when a suggest read grant is scoped away", async () => { + const company = await createCompany(db, "AgentReadScopedSuggestGrant"); + const actorAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "agents:suggest-changes", { + projectId: randomUUID(), }); const decision = await authorizationService(db).decide({ @@ -249,8 +261,129 @@ describeEmbeddedPostgres("authorization service", () => { resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, }); - expect(decision.allowed).toBe(true); - expect(decision.grant?.permissionKey).toBe("agents:create"); + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_missing_grant", + explanation: "Missing permission: agents:configure.", + }); + }); + + it("enforces direct or consented suggest grants for agent configuration changes", async () => { + const company = await createCompany(db, "AgentChangeGrant"); + const directAgent = await createAgent(db, company.id); + const suggestAgent = await createAgent(db, company.id); + const noGrantAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, directAgent.id, "agents:configure"); + await grantAgentPermission(db, company.id, suggestAgent.id, "agents:suggest-changes"); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: noGrantAgent.id, + status: "active", + membershipRole: "member", + }); + + const authz = authorizationService(db); + await expect(authz.decide({ + actor: { type: "agent", agentId: directAgent.id, companyId: company.id, source: "agent_key" }, + action: "agent_config:update", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + scope: { requiresChangeGrant: true }, + })).resolves.toMatchObject({ + allowed: true, + reason: "allow_direct_change", + grant: { permissionKey: "agents:configure" }, + }); + + await expect(authz.decide({ + actor: { type: "agent", agentId: suggestAgent.id, companyId: company.id, source: "agent_key" }, + action: "agent_config:update", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + scope: { requiresChangeGrant: true }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_missing_consent", + grant: { permissionKey: "agents:suggest-changes" }, + }); + + await expect(authz.decide({ + actor: { type: "agent", agentId: suggestAgent.id, companyId: company.id, source: "agent_key" }, + action: "agent_config:update", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + scope: { requiresChangeGrant: true, consentedChange: true }, + })).resolves.toMatchObject({ + allowed: true, + reason: "allow_consented_change", + grant: { permissionKey: "agents:suggest-changes" }, + }); + + await expect(authz.decide({ + actor: { type: "agent", agentId: noGrantAgent.id, companyId: company.id, source: "agent_key" }, + action: "agent_config:update", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + scope: { requiresChangeGrant: true }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_no_grant", + }); + }); + + it("enforces direct or consented suggest grants for skill configuration changes", async () => { + const company = await createCompany(db, "SkillChangeGrant"); + const directAgent = await createAgent(db, company.id); + const suggestAgent = await createAgent(db, company.id); + const noGrantAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, directAgent.id, "skills:create"); + await grantAgentPermission(db, company.id, suggestAgent.id, "skills:suggest-changes"); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: noGrantAgent.id, + status: "active", + membershipRole: "member", + }); + + const authz = authorizationService(db); + await expect(authz.decide({ + actor: { type: "agent", agentId: directAgent.id, companyId: company.id, source: "agent_key" }, + action: "skill_config:update", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ + allowed: true, + reason: "allow_direct_change", + grant: { permissionKey: "skills:create" }, + }); + + await expect(authz.decide({ + actor: { type: "agent", agentId: suggestAgent.id, companyId: company.id, source: "agent_key" }, + action: "skill_config:update", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_missing_consent", + grant: { permissionKey: "skills:suggest-changes" }, + }); + + await expect(authz.decide({ + actor: { type: "agent", agentId: suggestAgent.id, companyId: company.id, source: "agent_key" }, + action: "skill_config:update", + resource: { type: "company", companyId: company.id }, + scope: { consentedChange: true }, + })).resolves.toMatchObject({ + allowed: true, + reason: "allow_consented_change", + grant: { permissionKey: "skills:suggest-changes" }, + }); + + await expect(authz.decide({ + actor: { type: "agent", agentId: noGrantAgent.id, companyId: company.id, source: "agent_key" }, + action: "skill_config:update", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_no_grant", + }); }); it("denies cross-company agent decisions before grant evaluation", async () => { @@ -605,6 +738,69 @@ describeEmbeddedPostgres("authorization service", () => { })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); }); + it("blocks low-trust configuration actions before evaluating explicit change grants", async () => { + const company = await createCompany(db, "LowTrustConfigGrants"); + const project = await createProject(db, company.id, "Allowed"); + const targetAgent = await createAgent(db, company.id); + const actorAgent = await createAgent(db, company.id, { + role: "ceo", + permissions: { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { + trustBoundary: { + mode: LOW_TRUST_REVIEW_PRESET, + companyId: company.id, + projectIds: [project.id], + allowedAgentIds: [targetAgent.id], + }, + }, + }, + }); + await grantAgentPermission(db, company.id, actorAgent.id, "agents:configure"); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + permissionKey: "skills:create", + grantedByUserId: null, + }); + + const authz = authorizationService(db); + const actor = { type: "agent" as const, agentId: actorAgent.id, companyId: company.id, source: "agent_key" as const }; + + await expect(authz.decide({ + actor, + action: "agent:read", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + })).resolves.toMatchObject({ allowed: true, reason: "allow_low_trust_boundary" }); + + await expect(authz.decide({ + actor, + action: "agent_config:read", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); + + await expect(authz.decide({ + actor, + action: "agent_config:read", + resource: { type: "agent", companyId: company.id, agentId: actorAgent.id }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); + + await expect(authz.decide({ + actor, + action: "agent_config:update", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + scope: { requiresChangeGrant: true, consentedChange: true }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); + + await expect(authz.decide({ + actor, + action: "skill_config:update", + resource: { type: "company", companyId: company.id }, + scope: { consentedChange: true }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); + }); + it("denies simple-mode assignment when the target agent requires protected-assignment approval", async () => { const company = await createCompany(db, "ProtectedAssignment"); const actorAgent = await createAgent(db, company.id, { role: "engineer" }); diff --git a/server/src/__tests__/built-in-agent-routes.test.ts b/server/src/__tests__/built-in-agent-routes.test.ts new file mode 100644 index 0000000000..acae6704dc --- /dev/null +++ b/server/src/__tests__/built-in-agent-routes.test.ts @@ -0,0 +1,442 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const companyId = "22222222-2222-4222-8222-222222222222"; +const agentId = "11111111-1111-4111-8111-111111111111"; + +const mockAccessService = vi.hoisted(() => ({ + decide: vi.fn(), + canUser: vi.fn(), +})); + +const mockInstanceSettingsService = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + +const mockBuiltInAgentService = vi.hoisted(() => ({ + list: vi.fn(), + get: vi.fn(), + ensure: vi.fn(), + provision: vi.fn(), + reset: vi.fn(), + enableRoutineSchedule: vi.fn(), + disableRoutineSchedule: vi.fn(), + runRoutine: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +function allowDecision() { + return { + allowed: true, + action: "agents:create", + reason: "allow_explicit_grant", + explanation: "Allowed.", + }; +} + +function denyDecision() { + return { + allowed: false, + action: "agents:create", + reason: "deny_missing_grant", + explanation: "Missing permission: agents:create.", + }; +} + +function builtInState(overrides: Record = {}) { + return { + definition: { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["briefs"], + shortPurpose: "Prepares concise operational briefs.", + defaultInstructions: "Write briefs.", + defaultRole: "general", + allowedAdapterTypes: ["codex_local"], + }, + status: "ready", + agentId, + agent: { + id: agentId, + companyId, + name: "Briefs Agent", + role: "general", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }, + pauseReason: null, + ...overrides, + }; +} + +function registerModuleMocks() { + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + instanceSettingsService: () => mockInstanceSettingsService, + logActivity: mockLogActivity, + })); + vi.doMock("../services/built-in-agents.js", () => ({ + builtInAgentService: () => mockBuiltInAgentService, + })); +} + +async function createApp(actor: Record) { + const [{ builtInAgentRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/built-in-agents.js"), + vi.importActual("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", builtInAgentRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("built-in agent routes", () => { + beforeEach(() => { + vi.resetModules(); + registerModuleMocks(); + vi.clearAllMocks(); + mockAccessService.decide.mockResolvedValue(allowDecision()); + mockAccessService.canUser.mockResolvedValue(true); + mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBuiltInAgents: true }); + mockBuiltInAgentService.list.mockResolvedValue([builtInState()]); + mockBuiltInAgentService.get.mockResolvedValue(builtInState()); + mockBuiltInAgentService.ensure.mockResolvedValue(builtInState()); + mockBuiltInAgentService.provision.mockResolvedValue({ state: builtInState(), approval: null }); + mockBuiltInAgentService.reset.mockResolvedValue(builtInState()); + mockBuiltInAgentService.enableRoutineSchedule.mockResolvedValue(builtInState()); + mockBuiltInAgentService.disableRoutineSchedule.mockResolvedValue(builtInState()); + mockBuiltInAgentService.runRoutine.mockResolvedValue({ id: "routine-run-1", source: "manual", status: "queued" }); + }); + + it("lists built-in agent state for actors with company access", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).get(`/api/companies/${companyId}/built-in-agents`); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockBuiltInAgentService.list).toHaveBeenCalledWith(companyId); + expect(res.body).toEqual([expect.objectContaining({ status: "ready", agentId })]); + expect(res.body[0].agent.adapterConfig).toEqual({}); + }); + + it("denies list requests outside the actor company boundary", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: ["33333333-3333-4333-8333-333333333333"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).get(`/api/companies/${companyId}/built-in-agents`); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(mockBuiltInAgentService.list).not.toHaveBeenCalled(); + }); + + it("returns 404 and does not load built-in state when the experimental flag is disabled", async () => { + mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBuiltInAgents: false }); + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app).get(`/api/companies/${companyId}/built-in-agents`); + + expect(res.status, JSON.stringify(res.body)).toBe(404); + expect(res.body.error).toContain("Built-in agents are not enabled"); + expect(mockBuiltInAgentService.list).not.toHaveBeenCalled(); + }); + + it("provisions through the agents:create gate and passes optional adapter overrides", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/briefs/provision`) + .send({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" }, budgetMonthlyCents: 5000 }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAccessService.decide).toHaveBeenCalledWith({ + actor: expect.objectContaining({ type: "board" }), + action: "agents:create", + resource: { type: "company", companyId }, + }); + expect(mockBuiltInAgentService.provision).toHaveBeenCalledWith( + companyId, + "briefs", + { + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + budgetMonthlyCents: 5000, + }, + { requestedByAgentId: null, requestedByUserId: "board-user" }, + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, + actorType: "user", + actorId: "board-user", + action: "built_in_agent.provision_requested", + entityId: agentId, + })); + }); + + it("denies provision when agents:create is not allowed", async () => { + mockAccessService.decide.mockResolvedValue(denyDecision()); + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/briefs/provision`) + .send({ adapterType: "codex_local" }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.details).toMatchObject({ reason: "deny_missing_grant" }); + expect(mockBuiltInAgentService.ensure).not.toHaveBeenCalled(); + expect(mockBuiltInAgentService.provision).not.toHaveBeenCalled(); + }); + + it("rejects provision bodies with unknown fields", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/briefs/provision`) + .send({ adapterType: "codex_local", unexpected: true }); + + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(mockBuiltInAgentService.ensure).not.toHaveBeenCalled(); + expect(mockBuiltInAgentService.provision).not.toHaveBeenCalled(); + }); + + it("enables a built-in routine schedule through the board tasks:assign gate", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/enable`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAccessService.canUser).toHaveBeenCalledWith(companyId, "board-user", "tasks:assign"); + expect(mockBuiltInAgentService.enableRoutineSchedule).toHaveBeenCalledWith( + companyId, + "reflection-coach", + "recent-agent-reflection", + { agentId: null, userId: "board-user", runId: null }, + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "built_in_agent.routine_schedule_enabled", + entityId: agentId, + details: expect.objectContaining({ routineKey: "recent-agent-reflection" }), + })); + }); + + it("disables a built-in routine schedule", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/disable`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockBuiltInAgentService.disableRoutineSchedule).toHaveBeenCalledWith( + companyId, + "reflection-coach", + "recent-agent-reflection", + { agentId: null, userId: "board-user", runId: null }, + ); + }); + + it("triggers a built-in routine manual run", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/run`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(202); + expect(res.body).toMatchObject({ id: "routine-run-1", source: "manual" }); + expect(mockBuiltInAgentService.runRoutine).toHaveBeenCalledWith( + companyId, + "reflection-coach", + "recent-agent-reflection", + { agentId: null, userId: "board-user", runId: null }, + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "built_in_agent.routine_run_triggered", + details: expect.objectContaining({ routineRunId: "routine-run-1" }), + })); + }); + + it("denies built-in routine controls when tasks:assign is not allowed", async () => { + mockAccessService.canUser.mockResolvedValue(false); + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/run`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(mockBuiltInAgentService.runRoutine).not.toHaveBeenCalled(); + }); + + it("denies agent actors from controlling built-in routines", async () => { + const app = await createApp({ + type: "agent", + agentId: "manager-agent", + companyId, + source: "agent_key", + runId: "55555555-5555-4555-8555-555555555555", + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/run`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toContain("Only board operators can control built-in routines."); + expect(mockAccessService.canUser).not.toHaveBeenCalled(); + expect(mockBuiltInAgentService.runRoutine).not.toHaveBeenCalled(); + }); + + it("returns pending hire approvals instead of provisioning immediately when company policy requires it", async () => { + const approval = { + id: "approval-1", + status: "pending", + type: "hire_agent", + }; + mockBuiltInAgentService.provision.mockResolvedValue({ + state: builtInState({ + status: "pending_approval", + agent: { ...builtInState().agent, status: "pending_approval" }, + }), + approval, + }); + const app = await createApp({ + type: "agent", + agentId: "manager-agent", + companyId, + source: "agent_key", + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/briefs/provision`) + .send({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }); + + expect(res.status, JSON.stringify(res.body)).toBe(202); + expect(res.body.status).toBe("pending_approval"); + expect(res.body.approval).toMatchObject({ id: "approval-1", status: "pending", type: "hire_agent" }); + expect(mockBuiltInAgentService.ensure).not.toHaveBeenCalled(); + expect(mockBuiltInAgentService.provision).toHaveBeenCalledWith( + companyId, + "briefs", + { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }, + { requestedByAgentId: "manager-agent", requestedByUserId: null }, + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, + actorType: "agent", + actorId: "manager-agent", + action: "approval.created", + entityId: "approval-1", + })); + }); + + it("resets registry defaults through the same agents:create gate", async () => { + const app = await createApp({ + type: "agent", + agentId: "manager-agent", + companyId, + source: "agent_key", + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/briefs/reset`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockBuiltInAgentService.reset).toHaveBeenCalledWith(companyId, "briefs", {}); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, + actorType: "agent", + actorId: "manager-agent", + agentId: "manager-agent", + action: "built_in_agent.reset", + entityId: agentId, + })); + }); + + it("denies agent actors from provisioning across company boundaries", async () => { + const app = await createApp({ + type: "agent", + agentId: "manager-agent", + companyId: "33333333-3333-4333-8333-333333333333", + source: "agent_key", + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/built-in-agents/briefs/reset`) + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(mockAccessService.decide).not.toHaveBeenCalled(); + expect(mockBuiltInAgentService.reset).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts new file mode 100644 index 0000000000..b42ef25822 --- /dev/null +++ b/server/src/__tests__/built-in-agents.test.ts @@ -0,0 +1,1210 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { + activityLog, + agentConfigRevisions, + agents, + approvals, + budgetPolicies, + builtInManagedResources, + companies, + companyMemberships, + companySkillVersions, + companySkills, + createDb, + issueThreadInteractions, + issues, + principalPermissionGrants, + routines, + routineTriggers, +} from "@paperclipai/db"; +import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { HttpError } from "../errors.ts"; +import { agentInstructionsService } from "../services/agent-instructions.ts"; +import { agentService } from "../services/agents.ts"; +import { approvalService } from "../services/approvals.ts"; +import { + builtInAgentService, + deriveBuiltInAgentStatus, + listBuiltInAgentDefinitions, + reconcileBuiltInAgentsOnStartup, + validateBuiltInAgentDefinitions, +} from "../services/built-in-agents.ts"; +import { readBuiltInAgentMarker, withBuiltInAgentMarker } from "../services/built-in-agent-metadata.ts"; +import { issueThreadInteractionService } from "../services/issue-thread-interactions.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +function issuePrefix(id: string) { + return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`; +} + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres built-in agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("built-in agents", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-built-in-agents-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(routineTriggers); + await db.delete(routines); + await db.delete(issueThreadInteractions); + await db.delete(issues); + await db.delete(builtInManagedResources); + await db.delete(companySkillVersions); + await db.delete(companySkills); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(agentConfigRevisions); + await db.delete(activityLog); + await db.delete(approvals); + await db.delete(agents); + await db.delete(budgetPolicies); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function permissionKeysForAgent(agentId: string) { + const grants = await db + .select() + .from(principalPermissionGrants) + .where(eq(principalPermissionGrants.principalId, agentId)); + return grants.map((grant) => grant.permissionKey).sort(); + } + + async function seedCompany(options: { requireApproval?: boolean } = {}) { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: issuePrefix(companyId), + defaultResponsibleUserId: "responsible-user", + requireBoardApprovalForNewAgents: options.requireApproval ?? true, + }); + return companyId; + } + + it("validates the static registry and rejects invalid definitions", () => { + expect(listBuiltInAgentDefinitions().map((definition) => definition.key).sort()).toEqual(["briefs", "learning", "reflection-coach"]); + expect(() => validateBuiltInAgentDefinitions([ + { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["briefs"], + shortPurpose: "One", + defaultInstructions: "Do work", + defaultRole: "general", + }, + { + key: "briefs", + displayName: "Duplicate", + featureKeys: ["duplicate"], + shortPurpose: "Two", + defaultInstructions: "Do work", + defaultRole: "general", + }, + ])).toThrow("Duplicate built-in agent key"); + expect(() => validateBuiltInAgentDefinitions([ + { + key: "Bad Key", + displayName: "Bad", + featureKeys: ["bad"], + shortPurpose: "Bad", + defaultInstructions: "Bad", + defaultRole: "general", + }, + ])).toThrow("Invalid built-in agent key"); + }); + + it("lazily provisions one agent per company/key and updates the same row on setup", async () => { + const companyId = await seedCompany(); + const svc = builtInAgentService(db); + + const created = await svc.ensure(companyId, "briefs"); + expect(created.status).toBe("needs_setup"); + expect(created.agentId).toBeTruthy(); + expect(created.agent).toMatchObject({ + companyId, + name: "Briefs Agent", + adapterConfig: {}, + status: "idle", + }); + expect(readBuiltInAgentMarker(created.agent?.metadata)).toEqual({ + key: "briefs", + featureKeys: ["briefs"], + }); + + const configured = await svc.ensure(companyId, "briefs", { + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }); + expect(configured.status).toBe("ready"); + expect(configured.agentId).toBe(created.agentId); + expect(configured.agent).toMatchObject({ + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }); + + const reconciled = await svc.ensure(companyId, "briefs"); + expect(reconciled.status).toBe("ready"); + expect(reconciled.agent).toMatchObject({ + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }); + + const rows = await db.select().from(agents).where(eq(agents.companyId, companyId)); + expect(rows).toHaveLength(1); + }); + + it("routes policy-gated built-in provisioning through a pending hire approval", async () => { + const companyId = await seedCompany(); + const builtIns = builtInAgentService(db); + + const result = await builtIns.provision(companyId, "briefs", { + adapterType: "process", + adapterConfig: { command: "echo safe" }, + budgetMonthlyCents: 5000, + }, { requestedByUserId: "board-user" }); + + expect(result.state).toMatchObject({ + status: "pending_approval", + agent: { + companyId, + name: "Briefs Agent", + status: "pending_approval", + adapterType: "process", + adapterConfig: { command: "echo safe" }, + budgetMonthlyCents: 5000, + }, + }); + expect(result.approval).toMatchObject({ + companyId, + type: "hire_agent", + status: "pending", + requestedByUserId: "board-user", + requestedByAgentId: null, + payload: { + name: "Briefs Agent", + role: "general", + adapterType: "process", + adapterConfig: { command: "echo safe" }, + budgetMonthlyCents: 5000, + agentId: result.state.agentId, + sourceBuiltInAgentKey: "briefs", + featureKeys: ["briefs"], + }, + }); + + const rowsBeforeApproval = await db.select().from(agents).where(eq(agents.companyId, companyId)); + expect(rowsBeforeApproval).toHaveLength(1); + expect(rowsBeforeApproval[0]).toMatchObject({ status: "pending_approval" }); + + await expect(builtIns.requireBuiltInAgent(companyId, "briefs")).rejects.toMatchObject({ + status: 412, + details: { code: "built_in_agent_not_configured", status: "pending_approval" }, + }); + + await expect(agentService(db).update(result.state.agentId!, { + adapterType: "process", + adapterConfig: { command: "echo tampered" }, + })).rejects.toMatchObject({ + status: 409, + details: { + code: "pending_approval_agent_config_frozen", + agentId: result.state.agentId, + fields: ["adapterConfig"], + }, + }); + + await expect(builtIns.provision(companyId, "briefs", { + budgetMonthlyCents: 7500, + })).rejects.toMatchObject({ + status: 409, + details: { + code: "built_in_agent_pending_approval", + key: "briefs", + agentId: result.state.agentId, + }, + }); + + await db + .update(agents) + .set({ + adapterType: "process", + adapterConfig: { command: "echo tampered" }, + }) + .where(eq(agents.id, result.state.agentId!)); + + await approvalService(db).approve(result.approval!.id, "board-user", "Approved built-in agent"); + + await expect(builtIns.get(companyId, "briefs")).resolves.toMatchObject({ + status: "ready", + agentId: result.state.agentId, + agent: { status: "idle", adapterType: "process", adapterConfig: { command: "echo safe" }, budgetMonthlyCents: 5000 }, + }); + }); + + it("blocks policy-gated built-in reconfiguration instead of applying adapter overrides immediately", async () => { + const companyId = await seedCompany(); + const builtIns = builtInAgentService(db); + const ready = await builtIns.ensure(companyId, "briefs", { + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }); + + await expect(builtIns.provision(companyId, "briefs", { + adapterType: "process", + adapterConfig: { command: "echo bypass" }, + })).rejects.toMatchObject({ + status: 409, + details: { + code: "built_in_agent_reconfiguration_requires_approval", + key: "briefs", + agentId: ready.agentId, + }, + }); + + await expect(builtIns.get(companyId, "briefs")).resolves.toMatchObject({ + status: "ready", + agentId: ready.agentId, + agent: { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }, + }); + }); + + it("rejects adapter types outside the built-in definition allowlist", async () => { + const companyId = await seedCompany(); + + await expect(builtInAgentService(db).ensure(companyId, "briefs", { + adapterType: "http", + adapterConfig: { url: "https://example.test/webhook" }, + })).rejects.toMatchObject({ + status: 422, + details: { + code: "built_in_agent_adapter_not_allowed", + key: "briefs", + allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"], + }, + }); + }); + + it("recovers an orphaned marked row instead of creating a duplicate", async () => { + const companyId = await seedCompany(); + const orphanId = randomUUID(); + await db.insert(agents).values({ + id: orphanId, + companyId, + name: "Old Briefs", + role: "general", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + metadata: withBuiltInAgentMarker({ source: "orphan" }, { key: "briefs", featureKeys: ["briefs"] }), + }); + + const state = await builtInAgentService(db).ensure(companyId, "briefs"); + + expect(state.status).toBe("ready"); + expect(state.agentId).toBe(orphanId); + const rows = await db.select().from(agents).where(eq(agents.companyId, companyId)); + expect(rows).toHaveLength(1); + }); + + it("derives not_provisioned, needs_setup, ready, and paused states", async () => { + const companyId = await seedCompany(); + const builtIns = builtInAgentService(db); + + await expect(builtIns.get(companyId, "learning")).resolves.toMatchObject({ status: "not_provisioned" }); + + const needsSetup = await builtIns.ensure(companyId, "learning"); + expect(needsSetup.status).toBe("needs_setup"); + expect(deriveBuiltInAgentStatus(needsSetup.agent)).toBe("needs_setup"); + + const ready = await builtIns.ensure(companyId, "learning", { + adapterType: "claude_local", + adapterConfig: { model: "claude-sonnet-4" }, + }); + expect(ready.status).toBe("ready"); + + await agentService(db).pause(ready.agentId!, "manual"); + await expect(builtIns.get(companyId, "learning")).resolves.toMatchObject({ + status: "paused", + agentId: ready.agentId, + pauseReason: "manual", + }); + }); + + it("requires configured built-ins with typed precondition failures and paused warnings", async () => { + const companyId = await seedCompany(); + const builtIns = builtInAgentService(db); + + await expect(builtIns.requireBuiltInAgent(companyId, "briefs")).rejects.toMatchObject({ + status: 412, + details: { + code: "built_in_agent_not_configured", + key: "briefs", + status: "not_provisioned", + agentId: null, + }, + }); + + const needsSetup = await builtIns.ensure(companyId, "briefs"); + await expect(builtIns.requireBuiltInAgent(companyId, "briefs")).rejects.toMatchObject({ + status: 412, + details: { + code: "built_in_agent_not_configured", + key: "briefs", + status: "needs_setup", + agentId: needsSetup.agentId, + }, + }); + + const ready = await builtIns.ensure(companyId, "briefs", { + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }); + await expect(builtIns.requireBuiltInAgent(companyId, "briefs")).resolves.toMatchObject({ + agent: { id: ready.agentId }, + warning: null, + }); + + await agentService(db).pause(ready.agentId!, "maintenance"); + await expect(builtIns.requireBuiltInAgent(companyId, "briefs")).resolves.toMatchObject({ + agent: { id: ready.agentId }, + warning: { + code: "built_in_agent_paused", + key: "briefs", + agentId: ready.agentId, + pauseReason: "maintenance", + }, + }); + }); + + it("resets marked agents back to registry display defaults without replacing adapter setup", async () => { + const companyId = await seedCompany(); + const builtIns = builtInAgentService(db); + const ready = await builtIns.ensure(companyId, "briefs", { + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }); + + await agentService(db).update(ready.agentId!, { + name: "Custom Briefs", + role: "engineer", + title: "Custom", + capabilities: "Custom purpose", + }); + + const reset = await builtIns.reset(companyId, "briefs"); + + expect(reset).toMatchObject({ + status: "ready", + agentId: ready.agentId, + agent: { + name: "Briefs Agent", + role: "general", + title: null, + capabilities: "Prepares concise operational briefs for the board and agent company.", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }, + }); + }); + + it("auto-provisions a paused Reflection Coach bundle with skill sync and a disabled routine", async () => { + const companyId = await seedCompany({ requireApproval: false }); + const root = await agentService(db).create(companyId, { + name: "CEO", + role: "ceo", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4", apiKey: "do-not-copy" }, + runtimeConfig: {}, + permissions: {}, + }); + + const result = await reconcileBuiltInAgentsOnStartup(db); + expect(result.autoEnsured).toBeGreaterThanOrEqual(1); + expect(result.defaultGrantsEnsured).toBeGreaterThanOrEqual(4); + + const rootGrantKeys = await permissionKeysForAgent(root.id); + expect(rootGrantKeys).toEqual(expect.arrayContaining(["agents:configure", "skills:create"])); + expect(rootGrantKeys).not.toContain("agents:suggest-changes"); + expect(rootGrantKeys).not.toContain("skills:suggest-changes"); + + const state = await builtInAgentService(db).get(companyId, "reflection-coach"); + expect(state).toMatchObject({ + status: "paused", + agent: { + companyId, + name: "Reflection Coach", + role: "general", + title: "Reflection Coach", + icon: "eye", + adapterType: "codex_local", + permissions: { + canCreateAgents: false, + canCreateSkills: false, + }, + }, + }); + expect(state.agent?.adapterConfig).toMatchObject({ + instructionsBundleMode: "managed", + instructionsEntryFile: "AGENTS.md", + }); + expect(state.agent?.adapterConfig).not.toMatchObject({ model: "gpt-5.4", apiKey: "do-not-copy" }); + expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus])).toEqual([ + ["instructions", "stock_current"], + ["skill", "stock_current"], + ["routine", "stock_current"], + ]); + expect(state.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({ + resourceId: expect.any(String), + scheduleEnabled: false, + }); + + const agentRows = await db.select().from(agents).where(eq(agents.companyId, companyId)); + expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1); + + const [skill] = await db + .select() + .from(companySkills) + .where(eq(companySkills.key, "paperclipai/bundled/paperclip-operations/reflection-coach")); + expect(skill).toMatchObject({ + key: "paperclipai/bundled/paperclip-operations/reflection-coach", + slug: "reflection-coach", + }); + expect(readPaperclipSkillSyncPreference(state.agent!.adapterConfig as Record).desiredSkills).toContain( + "paperclipai/bundled/paperclip-operations/reflection-coach", + ); + + const [routine] = await db.select().from(routines).where(eq(routines.companyId, companyId)); + expect(routine).toMatchObject({ + title: "Review recent agent trajectories for coaching proposals", + status: "paused", + assigneeAgentId: state.agentId, + }); + const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, routine!.id)); + expect(trigger).toMatchObject({ + kind: "schedule", + enabled: false, + cronExpression: "0 9 * * 1", + timezone: "UTC", + }); + const coachGrantKeys = await permissionKeysForAgent(state.agentId!); + expect(coachGrantKeys).toEqual(expect.arrayContaining(["agents:suggest-changes", "skills:suggest-changes"])); + expect(coachGrantKeys).not.toContain("agents:configure"); + expect(coachGrantKeys).not.toContain("skills:create"); + }); + + it("recreates missing managed resource bindings idempotently during concurrent reconcile", async () => { + const companyId = await seedCompany({ requireApproval: false }); + await agentService(db).create(companyId, { + name: "CEO", + role: "ceo", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + }); + const builtIns = builtInAgentService(db); + await builtIns.ensure(companyId, "reflection-coach"); + await db.delete(builtInManagedResources).where(eq(builtInManagedResources.companyId, companyId)); + + const states = await Promise.all([ + builtIns.ensure(companyId, "reflection-coach"), + builtIns.ensure(companyId, "reflection-coach"), + ]); + + expect(states).toHaveLength(2); + for (const state of states) { + expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus])).toEqual([ + ["instructions", "stock_current"], + ["skill", "stock_current"], + ["routine", "stock_current"], + ]); + } + const bindings = await db + .select() + .from(builtInManagedResources) + .where(eq(builtInManagedResources.companyId, companyId)); + expect(bindings).toHaveLength(3); + expect(new Set(bindings.map((binding) => + `${binding.bundleKey}:${binding.resourceKind}:${binding.resourceKey}` + )).size).toBe(3); + }); + + it("preserves new-agent approval gates during automatic Reflection Coach provisioning", async () => { + const companyId = await seedCompany({ requireApproval: true }); + const root = await agentService(db).create(companyId, { + name: "CEO", + role: "ceo", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + }); + const mutationPolicy = { + requiresDisplayedDiff: true, + requiresAcceptedTaskInteraction: true, + applyInSeparateFollowUpRun: true, + }; + + const result = await reconcileBuiltInAgentsOnStartup(db); + + expect(result).toMatchObject({ + autoEnsured: 1, + pendingApprovals: 1, + }); + const state = await builtInAgentService(db).get(companyId, "reflection-coach"); + expect(state).toMatchObject({ + status: "pending_approval", + agent: { + companyId, + name: "Reflection Coach", + status: "pending_approval", + reportsTo: root.id, + budgetMonthlyCents: 0, + permissions: { + builtInMutationPolicy: mutationPolicy, + }, + }, + }); + expect(state.resources.map((resource) => resource.stockStatus)).toEqual(["missing", "missing", "missing"]); + + const [approval] = await db.select().from(approvals).where(eq(approvals.companyId, companyId)); + expect(approval).toMatchObject({ + type: "hire_agent", + status: "pending", + payload: { + agentId: state.agentId, + sourceBuiltInAgentKey: "reflection-coach", + featureKeys: ["reflection-coach"], + reportsTo: root.id, + permissions: expect.objectContaining({ + builtInMutationPolicy: mutationPolicy, + }), + }, + }); + + const pendingReconcile = await reconcileBuiltInAgentsOnStartup(db); + expect(pendingReconcile.pendingApprovals).toBe(1); + const stillPending = await builtInAgentService(db).get(companyId, "reflection-coach"); + expect(stillPending).toMatchObject({ + status: "pending_approval", + agent: { + adapterConfig: {}, + reportsTo: root.id, + status: "pending_approval", + }, + }); + expect(stillPending.resources.map((resource) => resource.stockStatus)).toEqual([ + "missing", + "missing", + "missing", + ]); + + await approvalService(db).approve(approval.id, "board-user", "Approved Reflection Coach"); + const approvedState = await builtInAgentService(db).get(companyId, "reflection-coach"); + expect(approvedState).toMatchObject({ + agent: { + reportsTo: root.id, + permissions: { + builtInMutationPolicy: mutationPolicy, + }, + }, + }); + expect(approvedState.resources.map((resource) => resource.stockStatus)).toEqual([ + "stock_current", + "stock_current", + "stock_current", + ]); + + await reconcileBuiltInAgentsOnStartup(db); + const agentRows = await db.select().from(agents).where(eq(agents.companyId, companyId)); + expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1); + const approvalRows = await db.select().from(approvals).where(eq(approvals.companyId, companyId)); + expect(approvalRows).toHaveLength(1); + }); + + it("preserves Reflection Coach instruction drift on reconcile and restores it on reset", async () => { + const companyId = await seedCompany(); + const builtIns = builtInAgentService(db); + const created = await builtIns.ensure(companyId, "reflection-coach"); + const instructions = agentInstructionsService(); + + await instructions.writeFile(created.agent!, "AGENTS.md", "# Custom Reflection Coach\n\nOperator edit.\n"); + + const reconciled = await builtIns.ensure(companyId, "reflection-coach"); + const drift = reconciled.resources.find((resource) => resource.resourceKind === "instructions"); + expect(drift).toMatchObject({ + stockStatus: "operator_modified", + updateAvailable: true, + resetAvailable: true, + changedFiles: ["AGENTS.md"], + }); + await expect(instructions.readFile(reconciled.agent!, "AGENTS.md")).resolves.toMatchObject({ + content: "# Custom Reflection Coach\n\nOperator edit.\n", + }); + + const reset = await builtIns.reset(companyId, "reflection-coach"); + expect(reset.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({ + stockStatus: "stock_current", + resetAvailable: false, + }); + const resetFile = await instructions.readFile(reset.agent!, "AGENTS.md"); + expect(resetFile.content).toContain("Reflection Coach"); + expect(resetFile.content).not.toContain("Operator edit."); + }); + + it("blocks deleting a built-in agent", async () => { + const companyId = await seedCompany(); + const state = await builtInAgentService(db).ensure(companyId, "briefs"); + + await expect(agentService(db).remove(state.agentId!)).rejects.toMatchObject({ + status: 409, + details: { + code: "built_in_agent_undeletable", + key: "briefs", + }, + }); + }); + + it("prevents direct marker add, remove, or mutation", async () => { + const companyId = await seedCompany(); + const builtIn = await builtInAgentService(db).ensure(companyId, "briefs"); + const normal = await agentService(db).create(companyId, { + name: "Normal", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + }); + + await expect(agentService(db).create(companyId, { + name: "Spoof", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }), + })).rejects.toMatchObject({ status: 409, details: { code: "built_in_agent_marker_readonly" } }); + + await expect(agentService(db).update(normal.id, { + metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }), + })).rejects.toMatchObject({ status: 409, details: { code: "built_in_agent_marker_readonly" } }); + + await expect(agentService(db).update(builtIn.agentId!, { + metadata: { other: "metadata" }, + })).rejects.toMatchObject({ status: 409, details: { code: "built_in_agent_marker_readonly" } }); + + await expect(agentService(db).update(builtIn.agentId!, { + metadata: withBuiltInAgentMarker({}, { key: "learning", featureKeys: ["learning"] }), + })).rejects.toMatchObject({ status: 409, details: { code: "built_in_agent_marker_readonly" } }); + + await expect(agentService(db).update(builtIn.agentId!, { + metadata: withBuiltInAgentMarker({ note: "allowed" }, { key: "briefs", featureKeys: ["briefs"] }), + })).resolves.toMatchObject({ + id: builtIn.agentId, + metadata: { + note: "allowed", + paperclipBuiltInAgent: { key: "briefs", featureKeys: ["briefs"] }, + }, + }); + }); + + it("repairs display/default drift for marked rows during startup reconciliation", async () => { + const companyId = await seedCompany(); + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Old Name", + role: "engineer", + title: "Old title", + capabilities: "Old purpose", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["old-briefs"] }), + }); + + const result = await reconcileBuiltInAgentsOnStartup(db); + expect(result).toMatchObject({ unknown: 0, duplicates: 0 }); + expect(result.scanned).toBeGreaterThanOrEqual(1); + expect(result.reconciled).toBeGreaterThanOrEqual(1); + + const [row] = await db.select().from(agents).where(eq(agents.id, agentId)); + expect(row).toMatchObject({ + name: "Briefs Agent", + role: "general", + title: null, + capabilities: "Prepares concise operational briefs for the board and agent company.", + }); + expect(readBuiltInAgentMarker(row?.metadata)).toEqual({ key: "briefs", featureKeys: ["briefs"] }); + }); + + it("reports duplicate active instances for a company/key", async () => { + const companyId = await seedCompany(); + await db.insert(agents).values([ + { + id: randomUUID(), + companyId, + name: "Briefs One", + role: "general", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }), + }, + { + id: randomUUID(), + companyId, + name: "Briefs Two", + role: "general", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }), + }, + ]); + + await expect(builtInAgentService(db).ensure(companyId, "briefs")).rejects.toMatchObject({ + status: 409, + details: { + code: "built_in_agent_duplicate_instance", + key: "briefs", + }, + } satisfies Partial); + }); + + it("automatically materializes the Reflection Coach bundle without enabling background work", async () => { + const companyId = await seedCompany(); + const root = await agentService(db).create(companyId, { + name: "CEO", + role: "ceo", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + }); + + const state = await builtInAgentService(db).ensure(companyId, "reflection-coach"); + + expect(state.agent).toMatchObject({ + companyId, + name: "Reflection Coach", + title: "Reflection Coach", + icon: "eye", + reportsTo: root.id, + adapterType: "codex_local", + budgetMonthlyCents: 0, + }); + expect(state.status).toBe("paused"); + expect(readBuiltInAgentMarker(state.agent?.metadata)).toEqual({ + key: "reflection-coach", + featureKeys: ["reflection-coach"], + }); + expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus])).toEqual([ + ["instructions", "stock_current"], + ["skill", "stock_current"], + ["routine", "stock_current"], + ]); + const reported = await builtInAgentService(db).get(companyId, "reflection-coach"); + const reportedRoutine = reported.resources.find((resource) => resource.resourceKind === "routine"); + expect(reportedRoutine).toMatchObject({ + stockStatus: "stock_current", + updateAvailable: false, + resetAvailable: false, + }); + expect(reportedRoutine?.currentHash).toBe(reportedRoutine?.stockHash); + + const [skill] = await db + .select() + .from(companySkills) + .where(eq(companySkills.key, "paperclipai/bundled/paperclip-operations/reflection-coach")); + expect(skill).toMatchObject({ + key: "paperclipai/bundled/paperclip-operations/reflection-coach", + slug: "reflection-coach", + }); + expect(readPaperclipSkillSyncPreference(state.agent!.adapterConfig).desiredSkills).toContain(skill!.key); + + const [routine] = await db.select().from(routines).where(eq(routines.companyId, companyId)); + expect(routine).toMatchObject({ + title: "Review recent agent trajectories for coaching proposals", + status: "paused", + assigneeAgentId: state.agentId, + originKind: "built_in_agent_bundle", + originId: "reflection-coach:recent-agent-reflection", + }); + const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, routine!.id)); + expect(trigger).toMatchObject({ + kind: "schedule", + enabled: false, + cronExpression: "0 9 * * 1", + timezone: "UTC", + }); + + const grantKeys = await permissionKeysForAgent(state.agentId!); + expect(grantKeys).toEqual(expect.arrayContaining(["agents:suggest-changes", "skills:suggest-changes"])); + expect(grantKeys).not.toContain("tasks:assign"); + expect(grantKeys).not.toContain("agents:configure"); + expect(grantKeys).not.toContain("skills:create"); + }); + + it("controls the Reflection Coach routine schedule without enabling it by default", async () => { + const companyId = await seedCompany(); + await agentService(db).create(companyId, { + name: "CEO", + role: "ceo", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + }); + const builtIns = builtInAgentService(db); + const created = await builtIns.ensure(companyId, "reflection-coach"); + expect(created.status).toBe("paused"); + expect(created.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({ + stockStatus: "stock_current", + scheduleEnabled: false, + }); + + const enabled = await builtIns.enableRoutineSchedule( + companyId, + "reflection-coach", + "recent-agent-reflection", + { userId: "board-user" }, + ); + expect(enabled.status).toBe("needs_setup"); + expect(enabled.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({ + stockStatus: "stock_current", + scheduleEnabled: true, + }); + const [enabledRoutine] = await db.select().from(routines).where(eq(routines.companyId, companyId)); + const [enabledTrigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, enabledRoutine!.id)); + expect(enabledRoutine).toMatchObject({ status: "active" }); + expect(enabledTrigger).toMatchObject({ enabled: true }); + + const disabled = await builtIns.disableRoutineSchedule( + companyId, + "reflection-coach", + "recent-agent-reflection", + { userId: "board-user" }, + ); + expect(disabled.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({ + stockStatus: "stock_current", + scheduleEnabled: false, + }); + const [disabledRoutine] = await db.select().from(routines).where(eq(routines.id, enabledRoutine!.id)); + const [disabledTrigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.id, enabledTrigger!.id)); + expect(disabledRoutine).toMatchObject({ status: "paused" }); + expect(disabledTrigger).toMatchObject({ enabled: false }); + }); + + it("surfaces pending Reflection Coach proposal interactions on the routine resource", async () => { + const companyId = await seedCompany(); + await agentService(db).create(companyId, { + name: "CEO", + role: "ceo", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + }); + const created = await builtInAgentService(db).ensure(companyId, "reflection-coach"); + const proposalIssueId = randomUUID(); + await db.insert(issues).values({ + id: proposalIssueId, + companyId, + title: "Review Reflection Coach proposal", + status: "in_review", + priority: "medium", + identifier: `${issuePrefix(companyId)}-42`, + issueNumber: 42, + assigneeAgentId: created.agentId, + createdByAgentId: created.agentId, + }); + const interactionId = randomUUID(); + await db.insert(issueThreadInteractions).values({ + id: interactionId, + companyId, + issueId: proposalIssueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Review proposed coaching change", + summary: "Accept or reject the proposed update.", + createdByAgentId: created.agentId, + payload: { + version: 1, + prompt: "Accept the proposed coaching change?", + acceptLabel: "Accept", + rejectLabel: "Reject", + }, + }); + + const state = await builtInAgentService(db).get(companyId, "reflection-coach"); + + expect(state.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({ + pendingUpdateInteractionId: interactionId, + pendingUpdateIssueId: proposalIssueId, + pendingUpdateIssueIdentifier: `${issuePrefix(companyId)}-42`, + }); + }); + + it("gates Reflection Coach proposal mutations until an accepted follow-up apply step", async () => { + const companyId = await seedCompany(); + const agentsSvc = agentService(db); + await agentsSvc.create(companyId, { + name: "CEO", + role: "ceo", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + }); + const target = await agentsSvc.create(companyId, { + name: "Target Coder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + runtimeConfig: {}, + permissions: {}, + }); + const created = await builtInAgentService(db).ensure(companyId, "reflection-coach"); + const coach = created.agent!; + const instructionsSvc = agentInstructionsService(); + const originalInstructions = "# Target Coder\n\nWork from the assigned issue.\n"; + const prepared = await instructionsSvc.writeFile(target, "AGENTS.md", originalInstructions); + let persistedTarget = (await agentsSvc.update(target.id, { adapterConfig: prepared.adapterConfig }))!; + + const interactionsSvc = issueThreadInteractionService(db); + const applyAcceptedProposalFollowUp = async (input: { + interactionId: string; + nextInstructions: string; + }) => { + const interaction = await interactionsSvc.getById(input.interactionId); + if (interaction?.kind !== "request_confirmation" || interaction.status !== "accepted") { + return false; + } + const written = await instructionsSvc.writeFile(persistedTarget, "AGENTS.md", input.nextInstructions); + persistedTarget = (await agentsSvc.update(persistedTarget.id, { adapterConfig: written.adapterConfig }))!; + return true; + }; + const readTargetInstructions = async () => + (await instructionsSvc.readFile(persistedTarget, "AGENTS.md")).content; + + const proposalIssueId = randomUUID(); + await db.insert(issues).values({ + id: proposalIssueId, + companyId, + title: "Review Reflection Coach proposal", + status: "in_review", + priority: "medium", + identifier: `${issuePrefix(companyId)}-43`, + issueNumber: 43, + assigneeUserId: "board-user", + createdByAgentId: coach.id, + }); + const acceptedInstructions = `${originalInstructions}\nWhen finishing, name the exact verification command.\n`; + const acceptedProposal = await interactionsSvc.create({ + id: proposalIssueId, + companyId, + }, { + kind: "request_confirmation", + continuationPolicy: "wake_assignee_on_accept", + title: "Review proposed coaching change", + summary: "Accept or reject the proposed instruction diff.", + payload: { + version: 1, + prompt: "Apply this Reflection Coach instruction diff in a follow-up run?", + acceptLabel: "Accept", + rejectLabel: "Reject", + detailsMarkdown: [ + "```diff", + " # Target Coder", + "", + " Work from the assigned issue.", + "+When finishing, name the exact verification command.", + "```", + ].join("\n"), + target: { + type: "custom", + key: `agent:${target.id}:instructions`, + revisionId: "proposal-v1", + label: "Target Coder AGENTS.md diff", + }, + }, + }, { + agentId: coach.id, + }); + + const accepted = await interactionsSvc.acceptInteraction( + { id: proposalIssueId, companyId, goalId: null, projectId: null }, + acceptedProposal.id, + {}, + { userId: "board-user" }, + ); + + expect(accepted.interaction).toMatchObject({ + id: acceptedProposal.id, + kind: "request_confirmation", + status: "accepted", + }); + expect(accepted.continuationIssue).toMatchObject({ + id: proposalIssueId, + assigneeAgentId: coach.id, + assigneeUserId: null, + status: "todo", + }); + expect(await readTargetInstructions()).toBe(originalInstructions); + + await expect(applyAcceptedProposalFollowUp({ + interactionId: acceptedProposal.id, + nextInstructions: acceptedInstructions, + })).resolves.toBe(true); + expect(await readTargetInstructions()).toBe(acceptedInstructions); + + const rejectedProposal = await interactionsSvc.create({ + id: proposalIssueId, + companyId, + }, { + kind: "request_confirmation", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: "reflection-coach:proposal-v2", + title: "Review rejected coaching change", + summary: "Rejecting this diff must not mutate the target instructions.", + payload: { + version: 1, + prompt: "Apply this rejected Reflection Coach instruction diff?", + acceptLabel: "Accept", + rejectLabel: "Reject", + detailsMarkdown: [ + "```diff", + "+This rejected line must not be applied.", + "```", + ].join("\n"), + target: { + type: "custom", + key: `agent:${target.id}:instructions`, + revisionId: "proposal-v2", + label: "Target Coder AGENTS.md rejected diff", + }, + }, + }, { + agentId: coach.id, + }); + + const rejected = await interactionsSvc.rejectInteraction( + { id: proposalIssueId, companyId }, + rejectedProposal.id, + { reason: "Not the right rule." }, + { userId: "board-user" }, + ); + + expect(rejected).toMatchObject({ + id: rejectedProposal.id, + kind: "request_confirmation", + status: "rejected", + result: expect.objectContaining({ + outcome: "rejected", + reason: "Not the right rule.", + }), + }); + await expect(applyAcceptedProposalFollowUp({ + interactionId: rejectedProposal.id, + nextInstructions: `${acceptedInstructions}\nThis rejected line must not be applied.\n`, + })).resolves.toBe(false); + expect(await readTargetInstructions()).toBe(acceptedInstructions); + }); + + it("preserves Reflection Coach stock drift until explicit reset", async () => { + const companyId = await seedCompany(); + const created = await builtInAgentService(db).ensure(companyId, "reflection-coach"); + const agent = created.agent!; + + const instructionsSvc = agentInstructionsService(); + await instructionsSvc.writeFile(agent, "AGENTS.md", "# Custom Reflection Coach\n\nDo not overwrite me.\n"); + await db + .update(companySkills) + .set({ markdown: "---\nname: reflection-coach\n---\n\n# Custom skill\n" }) + .where(eq(companySkills.companyId, companyId)); + await db + .update(routines) + .set({ title: "DRIFTED BY TEST - do not clobber" }) + .where(eq(routines.companyId, companyId)); + + const drifted = await builtInAgentService(db).ensure(companyId, "reflection-coach"); + expect(drifted.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({ + stockStatus: "operator_modified", + resetAvailable: true, + }); + expect(drifted.resources.find((resource) => resource.resourceKind === "skill")).toMatchObject({ + stockStatus: "operator_modified", + resetAvailable: true, + }); + expect(drifted.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({ + stockStatus: "operator_modified", + resetAvailable: true, + }); + expect((await instructionsSvc.readFile(drifted.agent!, "AGENTS.md")).content).toContain("Do not overwrite me."); + const [preservedRoutine] = await db.select().from(routines).where(eq(routines.companyId, companyId)); + expect(preservedRoutine?.title).toBe("DRIFTED BY TEST - do not clobber"); + + const reset = await builtInAgentService(db).reset(companyId, "reflection-coach", { + resources: ["instructions", "routine"], + }); + expect(reset.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({ + stockStatus: "stock_current", + resetAvailable: false, + }); + expect(reset.resources.find((resource) => resource.resourceKind === "skill")).toMatchObject({ + stockStatus: "operator_modified", + resetAvailable: true, + }); + expect(reset.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({ + stockStatus: "stock_current", + resetAvailable: false, + }); + expect((await instructionsSvc.readFile(reset.agent!, "AGENTS.md")).content).toContain("You are Reflection Coach"); + const [resetRoutine] = await db.select().from(routines).where(eq(routines.companyId, companyId)); + expect(resetRoutine?.title).toBe("Review recent agent trajectories for coaching proposals"); + }); +}); diff --git a/server/src/__tests__/change-consent-gate.test.ts b/server/src/__tests__/change-consent-gate.test.ts new file mode 100644 index 0000000000..65a173f0dd --- /dev/null +++ b/server/src/__tests__/change-consent-gate.test.ts @@ -0,0 +1,249 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { + agents, + companies, + createDb, + heartbeatRuns, + issueThreadInteractions, + issues, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { + changeConsentGateService, + skillChangeTargetKey, +} from "../services/change-consent-gate.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("changeConsentGateService", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-reflection-coach-gate-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issueThreadInteractions); + await db.delete(issues); + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedGateFixture() { + const companyId = randomUUID(); + const coachId = randomUUID(); + const sourceRunId = randomUUID(); + const proposalIssueId = randomUUID(); + const skillId = randomUUID(); + const targetKey = skillChangeTargetKey(skillId); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + defaultResponsibleUserId: "board-user", + }); + await db.insert(agents).values({ + id: coachId, + companyId, + name: "Reflection Coach", + role: "general", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: { canCreateSkills: true }, + }); + await db.insert(heartbeatRuns).values({ + id: sourceRunId, + companyId, + agentId: coachId, + status: "succeeded", + }); + await db.insert(issues).values({ + id: proposalIssueId, + companyId, + title: "Review Reflection Coach proposal", + status: "in_review", + priority: "medium", + identifier: "PAP-1", + issueNumber: 1, + createdByAgentId: coachId, + }); + + return { companyId, coachId, sourceRunId, proposalIssueId, skillId, targetKey }; + } + + it("rejects Reflection Coach skill mutation without an accepted bound interaction", async () => { + const { companyId, coachId, targetKey } = await seedGateFixture(); + + await expect(changeConsentGateService(db).assertConsented({ + companyId, + actorAgentId: coachId, + actorRunId: randomUUID(), + targetKeys: [targetKey], + })).rejects.toMatchObject({ + status: 403, + details: { code: "reflection_coach_mutation_gate_required" }, + }); + }); + + it("rejects accepted interactions from the same run as the apply mutation", async () => { + const { companyId, coachId, sourceRunId, proposalIssueId, targetKey } = await seedGateFixture(); + await db.insert(issueThreadInteractions).values({ + id: randomUUID(), + companyId, + issueId: proposalIssueId, + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee_on_accept", + sourceRunId, + createdByAgentId: coachId, + payload: { + version: 1, + prompt: "Apply this Reflection Coach skill diff?", + detailsMarkdown: "```diff\n+Tighten the workflow.\n```", + target: { type: "custom", key: targetKey, revisionId: "proposal-v1" }, + }, + result: { version: 1, outcome: "accepted" }, + resolvedByUserId: "board-user", + resolvedAt: new Date(), + }); + + await expect(changeConsentGateService(db).assertConsented({ + companyId, + actorAgentId: coachId, + actorRunId: sourceRunId, + targetKeys: [targetKey], + })).rejects.toMatchObject({ + status: 403, + details: { code: "reflection_coach_mutation_gate_required" }, + }); + }); + + it("allows a previous-run accepted interaction with a displayed diff for the bound target", async () => { + const { companyId, coachId, sourceRunId, proposalIssueId, targetKey } = await seedGateFixture(); + const interactionId = randomUUID(); + await db.insert(issueThreadInteractions).values({ + id: interactionId, + companyId, + issueId: proposalIssueId, + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee_on_accept", + sourceRunId, + createdByAgentId: coachId, + payload: { + version: 1, + prompt: "Apply this Reflection Coach skill diff?", + detailsMarkdown: "```diff\n+Tighten the workflow.\n```", + target: { type: "custom", key: targetKey, revisionId: "proposal-v1" }, + }, + result: { version: 1, outcome: "accepted" }, + resolvedByUserId: "board-user", + resolvedAt: new Date(), + }); + const actorRunId = randomUUID(); + + await expect(changeConsentGateService(db).assertConsented({ + companyId, + actorAgentId: coachId, + actorRunId, + targetKeys: [targetKey], + })).resolves.toBe(true); + + const [stored] = await db + .select({ result: issueThreadInteractions.result }) + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)); + + expect(stored?.result).toMatchObject({ + consumedByRunId: actorRunId, + outcome: "accepted", + version: 1, + }); + expect((stored?.result as { consumedAt?: unknown } | undefined)?.consumedAt).toEqual(expect.any(String)); + }); + + it("rejects reusing an accepted interaction after it is consumed by a mutation", async () => { + const { companyId, coachId, sourceRunId, proposalIssueId, targetKey } = await seedGateFixture(); + await db.insert(issueThreadInteractions).values({ + id: randomUUID(), + companyId, + issueId: proposalIssueId, + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee_on_accept", + sourceRunId, + createdByAgentId: coachId, + payload: { + version: 1, + prompt: "Apply this Reflection Coach skill diff?", + detailsMarkdown: "```diff\n+Tighten the workflow.\n```", + target: { type: "custom", key: targetKey, revisionId: "proposal-v1" }, + }, + result: { version: 1, outcome: "accepted" }, + resolvedByUserId: "board-user", + resolvedAt: new Date(), + }); + + await expect(changeConsentGateService(db).assertConsented({ + companyId, + actorAgentId: coachId, + actorRunId: randomUUID(), + targetKeys: [targetKey], + })).resolves.toBe(true); + + await expect(changeConsentGateService(db).assertConsented({ + companyId, + actorAgentId: coachId, + actorRunId: randomUUID(), + targetKeys: [targetKey], + })).rejects.toMatchObject({ + status: 403, + details: { code: "reflection_coach_mutation_gate_required" }, + }); + }); + + it("allows legacy Reflection Coach target keys for durable accepted interactions", async () => { + const { companyId, coachId, sourceRunId, proposalIssueId, skillId, targetKey } = await seedGateFixture(); + await db.insert(issueThreadInteractions).values({ + id: randomUUID(), + companyId, + issueId: proposalIssueId, + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee_on_accept", + sourceRunId, + createdByAgentId: coachId, + payload: { + version: 1, + prompt: "Apply this Reflection Coach skill diff?", + detailsMarkdown: "```diff\n+Tighten the workflow.\n```", + target: { type: "custom", key: `reflection-coach:company-skill:${skillId}`, revisionId: "proposal-v1" }, + }, + result: { version: 1, outcome: "accepted" }, + resolvedByUserId: "board-user", + resolvedAt: new Date(), + }); + + await expect(changeConsentGateService(db).assertConsented({ + companyId, + actorAgentId: coachId, + actorRunId: randomUUID(), + targetKeys: [targetKey], + })).resolves.toBe(true); + }); +}); diff --git a/server/src/__tests__/companies-service.test.ts b/server/src/__tests__/companies-service.test.ts index 2301b33b02..8dd76866af 100644 --- a/server/src/__tests__/companies-service.test.ts +++ b/server/src/__tests__/companies-service.test.ts @@ -3,18 +3,28 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { and, eq } from "drizzle-orm"; import { activityLog, + agentConfigRevisions, agents, agentWakeupRequests, + builtInManagedResources, companies, + companySkillVersions, + companySkills, + companyMemberships, createDb, heartbeatRunEvents, heartbeatRuns, + principalPermissionGrants, + routines, + routineTriggers, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { companyService } from "../services/companies.js"; +import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js"; +import { reconcileBuiltInAgentsOnStartup } from "../services/built-in-agents.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -35,11 +45,19 @@ describeEmbeddedPostgres("companyService", () => { }, 20_000); afterEach(async () => { + await db.delete(routineTriggers); + await db.delete(routines); + await db.delete(builtInManagedResources); + await db.delete(companySkillVersions); + await db.delete(companySkills); await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); + await db.delete(agentConfigRevisions); await db.delete(activityLog); await db.delete(agents); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); await db.delete(companies); }); @@ -63,6 +81,50 @@ describeEmbeddedPostgres("companyService", () => { expect(rows.map((row) => row.issuePrefix).sort()).toEqual(["ARO", "AROA"]); }); + it("auto-provisions one paused Reflection Coach bundle for a freshly created company", async () => { + const created = await companyService(db).create({ + name: "Fresh Company", + }); + + const agentRows = await db.select().from(agents).where(eq(agents.companyId, created.id)); + const reflectionRows = agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach"); + expect(reflectionRows).toHaveLength(1); + expect(reflectionRows[0]).toMatchObject({ + name: "Reflection Coach", + status: "paused", + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + }); + + const [skill] = await db + .select() + .from(companySkills) + .where(and( + eq(companySkills.companyId, created.id), + eq(companySkills.key, "paperclipai/bundled/paperclip-operations/reflection-coach"), + )); + expect(skill).toMatchObject({ + slug: "reflection-coach", + }); + + const [routine] = await db.select().from(routines).where(eq(routines.companyId, created.id)); + expect(routine).toMatchObject({ + status: "paused", + assigneeAgentId: reflectionRows[0]!.id, + originKind: "built_in_agent_bundle", + originId: "reflection-coach:recent-agent-reflection", + }); + const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, routine!.id)); + expect(trigger).toMatchObject({ + kind: "schedule", + enabled: false, + }); + + await reconcileBuiltInAgentsOnStartup(db); + const afterReconcileRows = await db.select().from(agents).where(eq(agents.companyId, created.id)); + expect(afterReconcileRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1); + }); + it("archives companies by pausing runnable agents and cancelling active runs", async () => { const companyId = randomUUID(); const runningAgentId = randomUUID(); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index f4283a0910..b7f76b7ff8 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -480,6 +480,55 @@ describe("company portability", () => { expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent."); }); + it("exports agent permission grants through the Paperclip extension and manifest", async () => { + const db = { + select: vi.fn((selection: Record) => ({ + from: vi.fn(() => ({ + where: vi.fn(async () => { + if (!selection.permissionKey) return []; + return [ + { + principalId: "agent-1", + permissionKey: "agents:suggest-changes", + scope: null, + }, + { + principalId: "agent-1", + permissionKey: "skills:create", + scope: { targetAgentIds: ["agent-1"] }, + }, + ]; + }), + })), + })), + }; + const portability = companyPortabilityService(db as any); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + const extension = asTextFile(exported.files[".paperclip.yaml"]); + expect(extension).toContain("permissionGrants:"); + expect(extension).toContain('permissionKey: "agents:suggest-changes"'); + expect(extension).toContain('permissionKey: "skills:create"'); + expect(exported.manifest.agents.find((agent) => agent.slug === "claudecoder")?.permissionGrants).toEqual([ + { + permissionKey: "agents:suggest-changes", + scope: null, + }, + { + permissionKey: "skills:create", + scope: { targetAgentIds: ["agent-1"] }, + }, + ]); + }); + it("exports hire approval policy only when approval is required", async () => { const portability = companyPortabilityService({} as any); @@ -1559,6 +1608,90 @@ describe("company portability", () => { ); }); + it("imports agent permission grants from package metadata", async () => { + const portability = companyPortabilityService({} as any); + agentSvc.list.mockResolvedValue([]); + agentSvc.create.mockImplementation(async (_companyId: string, input: Record) => ({ + id: "agent-imported", + name: input.name, + adapterType: input.adapterType, + adapterConfig: input.adapterConfig, + runtimeConfig: input.runtimeConfig, + status: input.status, + })); + + await portability.importBundle({ + source: { + type: "inline", + files: { + "COMPANY.md": [ + "---", + "name: Import", + "includes:", + " - agents/coder/AGENTS.md", + "---", + "", + ].join("\n"), + "agents/coder/AGENTS.md": [ + "---", + "name: Coder", + "slug: coder", + "kind: agent", + "---", + "", + "# Coder", + "", + ].join("\n"), + ".paperclip.yaml": [ + "schema: paperclip/v1", + "agents:", + " coder:", + " adapter:", + " type: process", + " config: {}", + " permissionGrants:", + " - permissionKey: agents:suggest-changes", + " - permissionKey: skills:create", + " scope:", + " targetAgentIds:", + " - agent-imported", + "", + ].join("\n"), + }, + }, + include: { + company: false, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "existing_company", + companyId: "company-1", + }, + collisionStrategy: "rename", + }, "user-1"); + + expect(accessSvc.setPrincipalPermission).toHaveBeenCalledWith( + "company-1", + "agent", + "agent-imported", + "agents:suggest-changes", + true, + "user-1", + null, + ); + expect(accessSvc.setPrincipalPermission).toHaveBeenCalledWith( + "company-1", + "agent", + "agent-imported", + "skills:create", + true, + "user-1", + { targetAgentIds: ["agent-imported"] }, + ); + }); + it("removes import secrets created before a later import failure", async () => { const portability = companyPortabilityService({} as any); agentSvc.list.mockResolvedValue([]); @@ -1959,6 +2092,123 @@ describe("company portability", () => { ]); }); + it("skips built-in managed agents and routines during export", async () => { + const portability = companyPortabilityService({} as any); + + agentSvc.list.mockResolvedValue([ + { + id: "agent-1", + name: "ClaudeCoder", + status: "idle", + role: "engineer", + title: "Software Engineer", + icon: "code", + reportsTo: null, + capabilities: "Writes code", + adapterType: "claude_local", + adapterConfig: { promptTemplate: "You are ClaudeCoder." }, + runtimeConfig: { heartbeat: { intervalSec: 3600 } }, + budgetMonthlyCents: 0, + permissions: { canCreateAgents: false }, + metadata: null, + }, + { + id: "agent-built-in", + name: "Reflection Coach", + status: "paused", + role: "coach", + title: "Reflection Coach", + icon: "sparkles", + reportsTo: null, + capabilities: "Reviews trajectories", + adapterType: "codex_local", + adapterConfig: { promptTemplate: "You coach agents." }, + runtimeConfig: {}, + budgetMonthlyCents: 0, + permissions: {}, + metadata: { + paperclipBuiltInAgent: { + key: "reflection-coach", + featureKeys: ["recent-agent-reflection"], + }, + }, + }, + ]); + routineSvc.list.mockResolvedValue([ + { + id: "routine-built-in", + companyId: "company-1", + projectId: null, + goalId: null, + parentIssueId: null, + title: "Review recent agent trajectories for coaching proposals", + description: "Review recent agent work and propose coaching follow-ups.", + assigneeAgentId: "agent-built-in", + priority: "medium", + status: "paused", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + originKind: "built_in_agent_bundle", + originId: "reflection-coach:recent-agent-reflection", + originFingerprint: null, + triggers: [ + { + id: "trigger-built-in", + companyId: "company-1", + routineId: "routine-built-in", + kind: "schedule", + label: "Weekly review", + enabled: false, + cronExpression: "0 9 * * 1", + timezone: "UTC", + nextRunAt: null, + lastFiredAt: null, + publicId: "public-built-in", + secretId: "secret-built-in", + signingMode: null, + replayWindowSec: null, + lastRotatedAt: null, + lastResult: null, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + lastRun: null, + activeIssue: null, + }, + ]); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: true, + issues: true, + skills: false, + }, + }); + + expect(exported.files["agents/claudecoder/AGENTS.md"]).toBeDefined(); + expect(exported.files["agents/reflection-coach/AGENTS.md"]).toBeUndefined(); + expect(exported.files["tasks/review-recent-agent-trajectories-for-coaching-proposals/TASK.md"]).toBeUndefined(); + expect(exported.manifest.agents.map((agent) => agent.slug)).toEqual(["claudecoder"]); + expect(exported.manifest.issues).toEqual([]); + expect(exported.warnings).toContain("Skipped 1 built-in managed agent from export."); + expect(exported.warnings).toContain("Skipped 1 built-in managed routine from export."); + }); + it("imports recurring task packages as routines instead of one-time issues", async () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 8b64c3551c..1bd34a526b 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -8,6 +8,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), })); @@ -74,6 +75,33 @@ const mockCatalogService = vi.hoisted(() => ({ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackSkillImported = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); +const mockReflectionCoachMutationGate = vi.hoisted(() => ({ + assertConsented: vi.fn(), +})); + +function allowSkillChangeDecision(reason = "allow_direct_change") { + return { + allowed: true, + action: "skill_config:update", + reason, + explanation: "Allowed.", + grant: { + principalType: "agent", + principalId: "agent-1", + permissionKey: reason === "allow_consented_change" ? "skills:suggest-changes" : "skills:create", + scope: null, + }, + }; +} + +function denySkillChangeDecision(reason = "deny_no_grant", explanation = "Missing permission: skills:create or skills:suggest-changes.") { + return { + allowed: false, + action: "skill_config:update", + reason, + explanation, + }; +} function registerModuleMocks() { vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); @@ -105,6 +133,16 @@ function registerModuleMocks() { vi.doMock("../services/skills-catalog.js", () => mockCatalogService); + vi.doMock("../services/change-consent-gate.js", async () => { + const actual = await vi.importActual( + "../services/change-consent-gate.js", + ); + return { + ...actual, + changeConsentGateService: () => mockReflectionCoachMutationGate, + }; + }); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -141,6 +179,7 @@ describe("company skill mutation permissions", () => { vi.doUnmock("../services/agents.js"); vi.doUnmock("../services/company-skills.js"); vi.doUnmock("../services/skills-catalog.js"); + vi.doUnmock("../services/change-consent-gate.js"); vi.doUnmock("../services/index.js"); vi.doUnmock("../routes/company-skills.js"); vi.doUnmock("../routes/authz.js"); @@ -589,7 +628,9 @@ describe("company skill mutation permissions", () => { }); mockLogActivity.mockResolvedValue(undefined); mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.decide.mockResolvedValue(allowSkillChangeDecision()); mockAccessService.hasPermission.mockResolvedValue(false); + mockReflectionCoachMutationGate.assertConsented.mockResolvedValue(undefined); }); it("allows local board operators to mutate company skills", async () => { @@ -647,7 +688,10 @@ describe("company skill mutation permissions", () => { .send({}) .expect(200); - expect(mockAccessService.canUser).toHaveBeenCalledWith("company-1", "board-user", "skills:create"); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "skill_config:update", + resource: { type: "company", companyId: "company-1" }, + })); expect(mockAccessService.canUser).not.toHaveBeenCalledWith("company-1", "board-user", "agents:create"); expect(mockCompanySkillService.createLocalSkill).toHaveBeenCalled(); expect(mockCompanySkillService.importFromSource).toHaveBeenCalled(); @@ -659,7 +703,7 @@ describe("company skill mutation permissions", () => { }); it("blocks board users without skills:create from mutating company skills", async () => { - mockAccessService.canUser.mockResolvedValue(false); + mockAccessService.decide.mockResolvedValue(denySkillChangeDecision()); const res = await request(await createApp({ type: "board", @@ -672,8 +716,11 @@ describe("company skill mutation permissions", () => { .send({ source: "https://github.com/vercel-labs/agent-browser" }); expect(res.status, JSON.stringify(res.body)).toBe(403); - expect(res.body.error).toBe("Missing permission: skills:create"); - expect(mockAccessService.canUser).toHaveBeenCalledWith("company-1", "board-user", "skills:create"); + expect(res.body.error).toBe("Missing permission: skills:create or skills:suggest-changes."); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "skill_config:update", + resource: { type: "company", companyId: "company-1" }, + })); expect(mockAccessService.canUser).not.toHaveBeenCalledWith("company-1", "board-user", "agents:create"); expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); }); @@ -919,7 +966,8 @@ describe("company skill mutation permissions", () => { }); }); - it("blocks same-company agents with skill creation disabled from mutating company skills", async () => { + it("blocks same-company agents without skill change grants from mutating company skills", async () => { + mockAccessService.decide.mockResolvedValue(denySkillChangeDecision()); mockAgentService.getById.mockResolvedValue({ id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", @@ -936,8 +984,11 @@ describe("company skill mutation permissions", () => { .send({ source: "https://github.com/vercel-labs/agent-browser" }); expect(res.status, JSON.stringify(res.body)).toBe(403); - expect(res.body.error).toBe("Missing permission: skills:create"); - expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "skills:create"); + expect(res.body.error).toBe("Missing permission: skills:create or skills:suggest-changes."); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "skill_config:update", + resource: { type: "company", companyId: "company-1" }, + })); expect(mockAccessService.hasPermission).not.toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "agents:create"); expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); }); @@ -1124,11 +1175,12 @@ describe("company skill mutation permissions", () => { }); }); - it("allows agents with canCreateSkills to mutate company skills", async () => { + it("allows agents with direct skills:create grants to mutate company skills", async () => { + mockAccessService.decide.mockResolvedValue(allowSkillChangeDecision("allow_direct_change")); mockAgentService.getById.mockResolvedValue({ id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", - permissions: { canCreateSkills: true }, + permissions: { canCreateSkills: false }, }); const res = await request(await createApp({ @@ -1141,13 +1193,81 @@ describe("company skill mutation permissions", () => { .send({ source: "https://github.com/vercel-labs/agent-browser" }); expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "skill_config:update", + resource: { type: "company", companyId: "company-1" }, + })); + expect(mockReflectionCoachMutationGate.assertConsented).not.toHaveBeenCalled(); expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( "company-1", "https://github.com/vercel-labs/agent-browser", ); }); - it("allows same-company agents with missing skill creation permission to mutate company skills", async () => { + it("rejects suggest-tier skill mutations when the consent gate is not satisfied", async () => { + const { forbidden } = await import("../errors.js"); + mockAccessService.decide.mockResolvedValue(denySkillChangeDecision( + "deny_missing_consent", + "Permission skills:suggest-changes requires accepted change consent before applying this mutation.", + )); + mockReflectionCoachMutationGate.assertConsented.mockRejectedValue(forbidden("gate required", { + code: "reflection_coach_mutation_gate_required", + })); + + const res = await request(await createApp({ + type: "agent", + agentId: "reflection-coach", + companyId: "company-1", + runId: "run-apply", + })) + .post("/api/companies/company-1/skills") + .send({ name: "Reflection Draft", slug: "reflection-draft", markdown: "# Draft" }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Permission skills:suggest-changes requires accepted change consent before applying this mutation."); + expect(mockReflectionCoachMutationGate.assertConsented).toHaveBeenCalledWith({ + companyId: "company-1", + actorAgentId: "reflection-coach", + actorRunId: "run-apply", + targetKeys: ["skill-slug:reflection-draft"], + }); + expect(mockCompanySkillService.createLocalSkill).not.toHaveBeenCalled(); + }); + + it("does not convert consent gate service failures into authorization denials", async () => { + mockAccessService.decide.mockResolvedValue(denySkillChangeDecision( + "deny_missing_consent", + "Permission skills:suggest-changes requires accepted change consent before applying this mutation.", + )); + mockReflectionCoachMutationGate.assertConsented.mockRejectedValue(new Error("database unavailable")); + + const res = await request(await createApp({ + type: "agent", + agentId: "reflection-coach", + companyId: "company-1", + runId: "run-apply", + })) + .post("/api/companies/company-1/skills") + .send({ name: "Reflection Draft", slug: "reflection-draft", markdown: "# Draft" }); + + expect(res.status, JSON.stringify(res.body)).toBe(500); + expect(res.body.error).toBe("Internal server error"); + expect(mockReflectionCoachMutationGate.assertConsented).toHaveBeenCalledWith({ + companyId: "company-1", + actorAgentId: "reflection-coach", + actorRunId: "run-apply", + targetKeys: ["skill-slug:reflection-draft"], + }); + expect(mockCompanySkillService.createLocalSkill).not.toHaveBeenCalled(); + }); + + it("allows suggest-tier skill mutations after accepted change consent", async () => { + mockAccessService.decide + .mockResolvedValueOnce(denySkillChangeDecision( + "deny_missing_consent", + "Permission skills:suggest-changes requires accepted change consent before applying this mutation.", + )) + .mockResolvedValueOnce(allowSkillChangeDecision("allow_consented_change")); mockAgentService.getById.mockResolvedValue({ id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", @@ -1164,57 +1284,29 @@ describe("company skill mutation permissions", () => { .send({ source: "https://github.com/vercel-labs/agent-browser" }); expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockReflectionCoachMutationGate.assertConsented).toHaveBeenCalledWith({ + companyId: "company-1", + actorAgentId: "55555555-5555-4555-8555-555555555555", + actorRunId: "run-1", + targetKeys: ["skill-import:https://github.com/vercel-labs/agent-browser"], + }); + expect(mockAccessService.decide).toHaveBeenLastCalledWith(expect.objectContaining({ + action: "skill_config:update", + resource: { type: "company", companyId: "company-1" }, + scope: { consentedChange: true }, + })); expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( "company-1", "https://github.com/vercel-labs/agent-browser", ); }); - it("allows agents with explicit skills:create grants to mutate company skills", async () => { + it("blocks same-company agents without skill change or suggest grants", async () => { + mockAccessService.decide.mockResolvedValue(denySkillChangeDecision()); mockAgentService.getById.mockResolvedValue({ id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", - permissions: { canCreateSkills: false }, - }); - mockAccessService.hasPermission.mockImplementation(async ( - _companyId: string, - _principalType: string, - _principalId: string, - key: string, - ) => { - return key === "skills:create"; - }); - - const res = await request(await createApp({ - type: "agent", - agentId: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - runId: "run-1", - })) - .post("/api/companies/company-1/skills/import") - .send({ source: "https://github.com/vercel-labs/agent-browser" }); - - expect(res.status, JSON.stringify(res.body)).toBe(201); - expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "skills:create"); - expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( - "company-1", - "https://github.com/vercel-labs/agent-browser", - ); - }); - - it("does not allow explicit agents:create grants to mutate company skills", async () => { - mockAgentService.getById.mockResolvedValue({ - id: "55555555-5555-4555-8555-555555555555", - companyId: "company-1", - permissions: { canCreateSkills: false }, - }); - mockAccessService.hasPermission.mockImplementation(async ( - _companyId: string, - _principalType: string, - _principalId: string, - key: string, - ) => { - return key === "agents:create"; + permissions: {}, }); const res = await request(await createApp({ @@ -1227,8 +1319,44 @@ describe("company skill mutation permissions", () => { .send({ source: "https://github.com/vercel-labs/agent-browser" }); expect(res.status, JSON.stringify(res.body)).toBe(403); - expect(res.body.error).toBe("Missing permission: skills:create"); - expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "skills:create"); + expect(res.body.error).toBe("Missing permission: skills:create or skills:suggest-changes."); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "skill_config:update", + resource: { type: "company", companyId: "company-1" }, + })); + expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); + }); + + it("does not allow explicit agents:create grants to mutate company skills", async () => { + mockAccessService.decide.mockResolvedValue(denySkillChangeDecision()); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + permissions: { canCreateSkills: false }, + }); + mockAccessService.hasPermission.mockImplementation(async ( + _companyId: string, + _principalType: string, + _principalId: string, + key: string, + ) => key === "agents:create"); + + const res = await request(await createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + runId: "run-1", + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://github.com/vercel-labs/agent-browser" }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Missing permission: skills:create or skills:suggest-changes."); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "skill_config:update", + resource: { type: "company", companyId: "company-1" }, + })); + expect(mockAccessService.hasPermission).not.toHaveBeenCalledWith("company-1", "agent", "agent-1", "agents:create"); expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/heartbeat-retry-scheduling.test.ts b/server/src/__tests__/heartbeat-retry-scheduling.test.ts index 80b05e5ef4..26e9fd664d 100644 --- a/server/src/__tests__/heartbeat-retry-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-retry-scheduling.test.ts @@ -93,6 +93,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { await db.delete(issueRelations); await db.delete(issues); await db.delete(activityLog); + await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); await db.delete(agentRuntimeState); diff --git a/server/src/__tests__/heartbeat-worktree-suppression.test.ts b/server/src/__tests__/heartbeat-worktree-suppression.test.ts index 58daa176c5..3a3144dcef 100644 --- a/server/src/__tests__/heartbeat-worktree-suppression.test.ts +++ b/server/src/__tests__/heartbeat-worktree-suppression.test.ts @@ -38,19 +38,23 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; - function isHeartbeatRunEventFkError(error: unknown) { + function isHeartbeatRunDependentFkError(error: unknown) { const message = error instanceof Error ? `${error.message} ${String(error.cause ?? "")}` : String(error); - return message.includes("heartbeat_run_events_run_id_heartbeat_runs_id_fk"); + return ( + message.includes("heartbeat_run_events_run_id_heartbeat_runs_id_fk") || + message.includes("activity_log_run_id_heartbeat_runs_id_fk") + ); } - async function deleteHeartbeatRunsWithEvents() { + async function deleteHeartbeatRunsWithDependents() { for (let attempt = 0; attempt < 5; attempt += 1) { await db.delete(heartbeatRunEvents); + await db.delete(activityLog); try { await db.delete(heartbeatRuns); return; } catch (error) { - if (!isHeartbeatRunEventFkError(error) || attempt === 4) throw error; + if (!isHeartbeatRunDependentFkError(error) || attempt === 4) throw error; await new Promise((resolve) => setTimeout(resolve, 25)); } } @@ -67,7 +71,7 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => { await db.delete(documentRevisions); await db.delete(documents); await db.delete(activityLog); - await deleteHeartbeatRunsWithEvents(); + await deleteHeartbeatRunsWithDependents(); await db.delete(agentWakeupRequests); await db.delete(issues); await db.delete(agentRuntimeState); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 2c717b557e..337551b270 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -82,6 +82,7 @@ describe("instance settings routes", () => { enableExperimentalFileViewer: false, enableCloudSync: false, enableExternalObjects: false, + enableBuiltInAgents: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, @@ -105,6 +106,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: false, enableCloudSync: false, enableExternalObjects: false, + enableBuiltInAgents: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, @@ -127,6 +129,7 @@ describe("instance settings routes", () => { enableExperimentalFileViewer: true, enableCloudSync: true, enableExternalObjects: false, + enableBuiltInAgents: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, @@ -155,6 +158,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: true, enableCloudSync: true, enableExternalObjects: false, + enableBuiltInAgents: true, enableGoalsSidebarLink: false, enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: false, @@ -211,6 +215,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: false, enableCloudSync: false, enableExternalObjects: false, + enableBuiltInAgents: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, @@ -309,6 +314,24 @@ describe("instance settings routes", () => { }); }); + it("allows local board users to update built-in agents", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableBuiltInAgents: true }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableBuiltInAgents: true, + }); + }); + it("allows local board users to update the goals sidebar link", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 5f2ca06995..e7b45a2a44 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -10,6 +10,7 @@ describe("instance settings service", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableBuiltInAgents: true, enableGoalsSidebarLink: true, enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, @@ -28,6 +29,7 @@ describe("instance settings service", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableBuiltInAgents: true, enableGoalsSidebarLink: true, enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, @@ -98,4 +100,10 @@ describe("instance settings service", () => { normalizeExperimentalSettings({ enableConferenceRoomChat: "yes" }).enableConferenceRoomChat, ).toBe(false); }); + + it("defaults enableBuiltInAgents to false for empty and legacy stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableBuiltInAgents).toBe(false); + expect(normalizeExperimentalSettings({}).enableBuiltInAgents).toBe(false); + expect(normalizeExperimentalSettings({ enableExternalObjects: true }).enableBuiltInAgents).toBe(false); + }); }); diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts index 0e05562fa9..7b7fa63e01 100644 --- a/server/src/__tests__/invite-join-grants.test.ts +++ b/server/src/__tests__/invite-join-grants.test.ts @@ -68,6 +68,7 @@ describe("human invite roles", () => { it("maps owner to the full management grant set", () => { expect(grantsForHumanRole("owner")).toEqual([ { permissionKey: "agents:create", scope: null }, + { permissionKey: "agents:configure", scope: null }, { permissionKey: "skills:create", scope: null }, { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, @@ -80,6 +81,7 @@ describe("human invite roles", () => { it("maps admin to management grants including environment management", () => { expect(grantsForHumanRole("admin")).toEqual([ { permissionKey: "agents:create", scope: null }, + { permissionKey: "agents:configure", scope: null }, { permissionKey: "skills:create", scope: null }, { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index d8a893f171..f8fe6b53e8 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -13,6 +13,7 @@ import { approvals, assets, companies, + companyMemberships, companySkills, createDb, documentAnnotationComments, @@ -31,6 +32,7 @@ import { issues, issueThreadInteractions, issueWorkProducts, + principalPermissionGrants, projects, } from "@paperclipai/db"; import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared"; @@ -68,22 +70,31 @@ async function waitFor(condition: () => boolean | Promise, timeoutMs = throw new Error("Timed out waiting for condition"); } +function isHeartbeatCleanupFkError(error: unknown) { + const message = error instanceof Error ? `${error.message} ${String(error.cause ?? "")}` : String(error); + return ( + message.includes("heartbeat_run_events_run_id_heartbeat_runs_id_fk") || + message.includes("activity_log_run_id_heartbeat_runs_id_fk") || + message.includes("heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk") + ); +} + async function deleteHeartbeatRunsAndWakeupsAfterActivityLogDrains(db: Db) { - let lastError: unknown = null; for (let attempt = 0; attempt < 10; attempt += 1) { - await db.delete(activityLog); await db.delete(heartbeatRunEvents); + await db.delete(activityLog); try { await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); return; } catch (error) { - lastError = error; + if (!isHeartbeatCleanupFkError(error) || attempt === 9) { + throw error; + } await new Promise((resolve) => setTimeout(resolve, 25)); } } - throw lastError; } function expectNoCanary(value: unknown, ...markers: string[]) { @@ -645,6 +656,8 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => await deleteHeartbeatRunsAndWakeupsAfterActivityLogDrains(db); await db.delete(issues); await db.delete(agentRuntimeState); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); await db.delete(agents); await db.delete(projects); await db.delete(companySkills); @@ -774,6 +787,29 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => it("restricts low-trust self inspection without changing standard-agent visibility", async () => { const fixture = await seedLowTrustFixture(db); + await db.insert(companyMemberships).values({ + companyId: fixture.company.id, + principalType: "agent", + principalId: fixture.agents.lowTrust.id, + status: "active", + membershipRole: "member", + }); + await db.insert(principalPermissionGrants).values([ + { + companyId: fixture.company.id, + principalType: "agent", + principalId: fixture.agents.lowTrust.id, + permissionKey: "agents:configure", + grantedByUserId: null, + }, + { + companyId: fixture.company.id, + principalType: "agent", + principalId: fixture.agents.lowTrust.id, + permissionKey: "skills:create", + grantedByUserId: null, + }, + ]); const lowTrustRes = await request(createApp(db, agentActor(fixture))).get("/api/agents/me"); expect(lowTrustRes.status, JSON.stringify(lowTrustRes.body)).toBe(200); @@ -802,6 +838,16 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => expect(lowTrustSelfByIdRes.body).not.toHaveProperty("access"); expectNoCanary(lowTrustSelfByIdRes.body, fixture.canaries.agentConfig); + const lowTrustPeerConfigRes = await request(createApp(db, agentActor(fixture))) + .get(`/api/agents/${fixture.agents.collaborator.id}/configuration`); + expect(lowTrustPeerConfigRes.status, JSON.stringify(lowTrustPeerConfigRes.body)).toBe(403); + expectNoCanary(lowTrustPeerConfigRes.body, fixture.canaries.agentConfig); + + const lowTrustSelfBundleRes = await request(createApp(db, agentActor(fixture))) + .get(`/api/agents/${fixture.agents.lowTrust.id}/instructions-bundle`); + expect(lowTrustSelfBundleRes.status, JSON.stringify(lowTrustSelfBundleRes.body)).toBe(403); + expectNoCanary(lowTrustSelfBundleRes.body, fixture.canaries.agentConfig); + const standardActor = agentActor(fixture, fixture.agents.standard.id); const standardRes = await request(createApp(db, { ...standardActor, runId: null })).get("/api/agents/me"); expect(standardRes.status, JSON.stringify(standardRes.body)).toBe(200); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 52b3658780..9736c25694 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -19,6 +19,7 @@ const apiPrefixes: Record = { "assets.ts": "/api", "auth.ts": "/api/auth", "board-chat.ts": "/api", + "built-in-agents.ts": "/api", "cloud-upstreams.ts": "/api", "companies.ts": "/api/companies", "company-skills.ts": "/api", diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index e4420e0bb6..9bf650f74f 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -218,6 +218,12 @@ vi.mock("../services/index.js", () => ({ failed: 0, seededAgentIds: [], })), + reconcileBuiltInAgentsOnStartup: vi.fn(async () => ({ + scanned: 0, + reconciled: 0, + unknown: 0, + duplicates: 0, + })), reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })), resolveHeartbeatSchedulingSuppression: resolveHeartbeatSchedulingSuppressionMock, routineService: routineServiceFactoryMock, diff --git a/server/src/app.ts b/server/src/app.ts index 3ee97f25f8..566baa7104 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -14,6 +14,7 @@ import { applyTrustProxy, parseTrustProxyEnv } from "./middleware/trust-proxy.js import { healthRoutes } from "./routes/health.js"; import { companyRoutes } from "./routes/companies.js"; import { companySkillRoutes } from "./routes/company-skills.js"; +import { builtInAgentRoutes } from "./routes/built-in-agents.js"; import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; @@ -226,6 +227,7 @@ export async function createApp( api.use("/companies", companyRoutes(db, opts.storageService)); api.use(llmRoutes(db)); api.use(companySkillRoutes(db)); + api.use(builtInAgentRoutes(db)); api.use(teamsCatalogRoutes(db)); api.use(agentRoutes(db, { pluginWorkerManager: workerManager })); api.use(assetRoutes(db, opts.storageService)); diff --git a/server/src/built-ins/agents/reflection-coach/AGENTS.md b/server/src/built-ins/agents/reflection-coach/AGENTS.md new file mode 100644 index 0000000000..3aaf98954b --- /dev/null +++ b/server/src/built-ins/agents/reflection-coach/AGENTS.md @@ -0,0 +1,47 @@ +You are Reflection Coach, a built-in operational coach at Paperclip. + +When you wake up, follow the Paperclip heartbeat procedure. Work only on issues assigned to you. Always leave a task comment before exiting a heartbeat. + +Your job is to run reflection loops on other agents and propose the smallest durable improvement to how they operate. When an issue asks you to reflect on a target agent, use the `reflection-coach` skill as your operating procedure. + +## Core responsibilities + +- Read the target agent's recent completed, in-review, and blocked issue trajectories, including comments, status changes, reviewer feedback, approvals, and blockers. +- Read the target agent's current AGENTS.md and assigned skills before proposing anything. +- Cluster repeated failure or improvement patterns only when they are backed by concrete issue/comment evidence. +- Propose the smallest durable change: an AGENTS.md diff, a reusable skill draft/update, a tool-description change, or a combination. +- Publish a proposal document with evidence, minimal diffs, and replay cases, and request acceptance before any change to another agent's surfaces is applied. + +## Hard boundaries + +- Never reflect on yourself. If the target agent id equals your own `PAPERCLIP_AGENT_ID`, refuse and ask for another coach. +- Never hot-swap production instructions or edit another agent's live configuration in the same run that discovers the pattern. Discovery and application are always separate runs. +- Do not score agents without trajectory evidence. Every proposed rule needs linked issue/comment evidence or it is dropped. +- Keep proposals small: AGENTS.md growth at most +20% per proposal, skills at most 15KB, tool descriptions at most 500 characters. Split larger ideas into multiple proposals. +- Do not rewrite product code or shared infrastructure as part of a reflection task. Your output is the coaching proposal, the diff, and the approval path. + +## Applying changes (permission is gated, not automatic) + +You may be granted permission to create and update skills, update agent AGENTS.md/instruction files, or assign follow-up proposal issues. Permission is not enough by itself; every actual mutation is gated: + +- Show the exact proposed diff before you change anything. Instructions, skills, and tool descriptions are only ever changed from a reviewed diff, never from a verbal summary. +- Gate every instruction, skill, or tool-description change behind a `request_confirmation` interaction so the user or board explicitly accepts or rejects it first. The interaction must show the diff in `payload.detailsMarkdown`, use `continuationPolicy: wake_assignee_on_accept`, and bind `payload.target.key` to the exact resource you will mutate. +- Apply an accepted change only in a separate follow-up run after the interaction resolves. Never propose and apply in the same run. +- If asked to "just apply it" without a reviewed diff and an accepted interaction, refuse politely and name this gate. No-same-run-apply is a load-bearing property of this loop. + +Server-enforced target keys: + +- `agent::instructions` +- `agent::profile` +- `skill:` +- `skill-slug:` +- `skill-import:` +- `skills:scan-projects` + +## Execution contract + +- Start concrete work in the same heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. +- Leave durable progress in comments, issue documents, or draft files, with a clear next action owner. +- Use child issues for long or parallel delegated work instead of polling. +- If blocked, mark the issue blocked and name the unblock owner and exact action needed. +- Respect budget, pause/cancel, approval gates, execution policy stages, and company boundaries. diff --git a/server/src/built-ins/agents/reflection-coach/routines/recent-agent-reflection.md b/server/src/built-ins/agents/reflection-coach/routines/recent-agent-reflection.md new file mode 100644 index 0000000000..db73f36e2b --- /dev/null +++ b/server/src/built-ins/agents/reflection-coach/routines/recent-agent-reflection.md @@ -0,0 +1,77 @@ +--- +routineKey: recent-agent-reflection +title: Review recent agent trajectories for coaching proposals +description: Bounded reflection sweep over recently active agents that produces evidence-backed coaching proposals only. Never mutates another agent's live instructions, skills, or tool descriptions without an accepted task interaction. +assigneeRef: + resourceKind: agent + resourceKey: reflection-coach +status: paused +priority: medium +concurrencyPolicy: coalesce_if_active +catchUpPolicy: skip_missed +variables: + - name: lookbackDays + label: Lookback window (days) + type: number + defaultValue: 7 + required: false + options: [] + - name: maxTargetAgents + label: Max target agents per run + type: number + defaultValue: 8 + required: false + options: [] + - name: targetAgentMode + label: Target selection mode + type: select + defaultValue: recent_active + required: false + options: + - recent_active + - all + - explicit + - name: excludeAgentIds + label: Agent ids to exclude (comma-separated) + type: string + defaultValue: null + required: false + options: [] +triggers: + - kind: schedule + label: Weekly reflection sweep + enabled: false + cronExpression: "0 9 * * 1" + timezone: UTC + signingMode: none + replayWindowSec: 0 +issueTemplate: + surfaceVisibility: normal +--- + +# Recent agent reflection sweep + +This routine is **paused by default** and spends no tokens until an operator enables its schedule or triggers a manual run. When it runs, it produces coaching proposals only. + +## What this run must do + +1. Select target agents using `{{targetAgentMode}}`: + - `recent_active` — agents with completed/in-review/blocked issue activity within the last `{{lookbackDays}}` days. + - `all` — every non-terminated agent in the company. + - `explicit` — only agents named in the run inputs. + Cap the set at `{{maxTargetAgents}}`. Drop any agent id listed in `{{excludeAgentIds}}`, and always drop your own `PAPERCLIP_AGENT_ID` (no self-reflection). +2. For each selected target, run the `reflection-coach` skill as the operating procedure: pull recent trajectories, read current AGENTS.md and assigned skills, cluster evidence-backed patterns, and draft the smallest durable change. +3. Produce, per target agent, a proposal document with clustered patterns, linked issue/comment evidence, minimal diffs, and replay cases. Create a follow-up proposal issue when a change is worth carrying forward. + +## Hard limits for this routine + +- Proposal-only. This routine must not edit any agent's live AGENTS.md, skill assignments, or tool descriptions directly. +- Any actual instruction/skill/tool-description change requires a displayed diff and an **accepted** `request_confirmation` task interaction, applied only in a separate follow-up run. +- Mutation confirmations must bind the exact resource key they will apply, using `agent::instructions`, `agent::profile`, `skill:`, `skill-slug:`, `skill-import:`, or `skills:scan-projects`. +- Keep every read company-scoped. Do not cross company boundaries. +- Every proposed rule needs linked issue/comment evidence or it is dropped. No scoring without trajectories. +- Respect the size caps: AGENTS.md +20% max per proposal, skills 15KB max, tool descriptions 500 chars max. + +## Output + +A single bounded routine issue that links one proposal document (or follow-up proposal issue) per reviewed target agent, plus a summary comment listing: agents reviewed, window, clusters found, surfaces proposed, and the next-step owner for each accepted-or-pending change. diff --git a/server/src/index.ts b/server/src/index.ts index 3056d02b86..e547bd9d97 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -42,6 +42,7 @@ import { environmentCustomImageService, heartbeatService, instanceSettingsService, + reconcileBuiltInAgentsOnStartup, reconcileCloudUpstreamRunsOnStartup, reconcileCodexLocalManagedHomesOnStartup, reconcilePersistedRuntimeServicesOnStartup, @@ -779,6 +780,19 @@ export async function startServer(): Promise { logger.error({ err }, "startup reconciliation of codex_local managed homes failed"); }); + void reconcileBuiltInAgentsOnStartup(db as any) + .then((result) => { + if (result.reconciled > 0 || result.unknown > 0 || result.duplicates > 0 || result.autoEnsured > 0) { + logger.warn( + result, + "startup reconciliation of built-in agents complete", + ); + } + }) + .catch((err) => { + logger.error({ err }, "startup reconciliation of built-in agents failed"); + }); + // Force the instance onto the Kubernetes sandbox provider when configured via // env (PAPERCLIP_EXECUTION_MODE=kubernetes). Runs BEFORE the heartbeat resumes // queued runs so the policy + managed k8s environments are in place. A bad diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index d3bbdcee52..2d7c5a3e70 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -41,6 +41,7 @@ import { agentInstructionsService, accessService, approvalService, + builtInAgentService, companySkillService, budgetService, heartbeatService, @@ -52,7 +53,7 @@ import { syncInstructionsBundleConfigFromFilePath, workspaceOperationService, } from "../services/index.js"; -import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; +import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { assertNoAgentHostWorkspaceCommandMutation, @@ -99,6 +100,13 @@ import { recoveryService } from "../services/recovery/service.js"; import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js"; import { readObject } from "../lib/objects.js"; import { listInvalidOrgChainDescendantIds } from "../services/agent-invokability.js"; +import { + AGENT_PROFILE_CHANGE_CONSENT_FIELDS, + agentInstructionsChangeTargetKey, + agentProfileChangeTargetKey, + changeConsentGateService, + touchesAgentProfileChangeConsentFields, +} from "../services/change-consent-gate.js"; const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000; const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024; @@ -689,30 +697,22 @@ export function agentRoutes( // read-only operation available to any board (human) member of the // company. Responses go through `redactAgentConfiguration` so secrets // are never exposed. Mutations and environment probes still gate on - // agents:create via assertCanCreateAgentsForCompany / assertCanUpdateAgent. + // agents:create or agents:configure via the mutating route helpers. // - // For AGENT actors we keep the previous, stricter gate: an agent must - // either have an explicit `agents:create` grant or the legacy - // `canCreateAgents` permission on its own record. Agents are - // non-human principals — they should not be able to introspect peer - // agents' configurations just by virtue of being in the same company. + // For AGENT actors we keep a stricter gate: an agent must have either + // agents:configure or agents:suggest-changes before it can inspect peer + // agent configuration for a proposed diff. assertCompanyAccess(req, companyId); if (req.actor.type === "agent") { - if (!req.actor.agentId) throw forbidden("Agent authentication required"); - const actorAgent = await svc.getById(req.actor.agentId); - if (!actorAgent || actorAgent.companyId !== companyId) { - throw forbidden("Agent key cannot access another company"); + const decision = await access.decide({ + actor: req.actor, + action: "agent_config:read", + resource: { type: "company", companyId }, + }); + if (!decision.allowed) { + throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } - const allowedByGrant = await access.hasPermission( - companyId, - "agent", - actorAgent.id, - "agents:create", - ); - if (!allowedByGrant && !canCreateAgents(actorAgent)) { - throw forbidden("Missing permission: can create agents"); - } - return actorAgent; + return req.actor.agentId ? await svc.getById(req.actor.agentId) : null; } return null; } @@ -732,26 +732,21 @@ export function agentRoutes( async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { // Mirrors assertCanReadConfigurations but returns a boolean instead of - // throwing. Board actors only need company access; agent actors must - // still pass the agents:create gate (explicit grant or canCreateAgents - // on their own record) so peer agents cannot snoop each others' - // configurations. + // throwing. Board actors only need company access; agent actors must pass + // the agent configuration read grant ladder so peer agents cannot snoop + // each others' configurations. try { assertCompanyAccess(req, companyId); } catch { return false; } if (req.actor.type === "board") return true; - if (!req.actor.agentId) return false; - const actorAgent = await svc.getById(req.actor.agentId); - if (!actorAgent || actorAgent.companyId !== companyId) return false; - const allowedByGrant = await access.hasPermission( - companyId, - "agent", - actorAgent.id, - "agents:create", - ); - return allowedByGrant || canCreateAgents(actorAgent); + const decision = await access.decide({ + actor: req.actor, + action: "agent_config:read", + resource: { type: "company", companyId }, + }); + return decision.allowed; } async function buildSkippedWakeupResponse( @@ -832,7 +827,7 @@ export function agentRoutes( throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } - async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) { + async function assertCanReadAgent(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); if (req.actor.type === "board") { await assertCanReadConfigurations(req, targetAgent.companyId); @@ -844,6 +839,14 @@ export function agentRoutes( if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { throw forbidden("Agent key cannot access another company"); } + const decision = await access.decide({ + actor: req.actor, + action: "agent_config:read", + resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id }, + }); + if (decision.allowed) return; + + throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } function assertKnownAdapterType(type: string | null | undefined): string { @@ -1271,7 +1274,9 @@ export function agentRoutes( delete nextAdapterConfig.bootstrapPromptTemplate; if (!hadLegacyPrompt) return agent; - const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig }); + const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig }, { + allowPendingApprovalConfigUpdate: true, + }); return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig }; } @@ -1286,7 +1291,9 @@ export function agentRoutes( delete nextAdapterConfig.promptTemplate; delete nextAdapterConfig.bootstrapPromptTemplate; - const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig }); + const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig }, { + allowPendingApprovalConfigUpdate: true, + }); return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig }; } @@ -1302,14 +1309,70 @@ export function agentRoutes( } } - async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) { + async function assertCanApplyProtectedAgentChange( + req: Request, + targetAgent: { id: string; companyId: string }, + targetKeys: string[], + ) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type !== "board") { - throw forbidden( - "Only board-authenticated callers can manage instructions path or bundle configuration", - ); + const changeScope = { requiresChangeGrant: true }; + const decision = await access.decide({ + actor: req.actor, + action: "agent_config:update", + resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id }, + scope: changeScope, + }); + if (decision.allowed) { + return; } - await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); + + if (decision.reason === "deny_missing_consent" && req.actor.type === "agent" && targetKeys.length > 0) { + try { + await changeConsentGateService(db).assertConsented({ + companyId: targetAgent.companyId, + actorAgentId: req.actor.agentId, + actorRunId: req.actor.runId ?? null, + targetKeys, + }); + } catch (err) { + if (err instanceof HttpError && err.status === 403) { + throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + } + throw err; + } + + const consentedDecision = await access.decide({ + actor: req.actor, + action: "agent_config:update", + resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id }, + scope: { ...changeScope, consentedChange: true }, + }); + if (consentedDecision.allowed) { + return; + } + throw forbidden(consentedDecision.explanation, authorizationDeniedDetails(consentedDecision)); + } + + throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + } + + async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) { + await assertCanApplyProtectedAgentChange( + req, + targetAgent, + [agentInstructionsChangeTargetKey(targetAgent.id)], + ); + } + + async function assertCanApplyAgentProfileChange( + req: Request, + targetAgent: { id: string; companyId: string }, + ) { + await assertCanApplyProtectedAgentChange( + req, + targetAgent, + [agentProfileChangeTargetKey(targetAgent.id)], + ); } function assertNoAgentInstructionsConfigMutation( @@ -2472,6 +2535,7 @@ export function agentRoutes( agent.id, req.actor.type === "board" ? (req.actor.userId ?? null) : null, ); + await builtInAgentService(db).ensureCompanyDefaultAgentGrants(companyId); if (agent.budgetMonthlyCents > 0) { await budgets.upsertPolicy( @@ -2800,7 +2864,7 @@ export function agentRoutes( res.status(404).json({ error: "Agent not found" }); return; } - await assertCanUpdateAgent(req, existing); + assertCompanyAccess(req, existing.companyId); if (hasOwn(req.body as object, "permissions")) { res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" }); @@ -2912,6 +2976,15 @@ export function agentRoutes( }, ); } + const touchesProfileFields = touchesAgentProfileChangeConsentFields(patchData); + const profileOnlyChange = touchesProfileFields && Object.keys(patchData).every((key) => + (AGENT_PROFILE_CHANGE_CONSENT_FIELDS as readonly string[]).includes(key), + ); + if (profileOnlyChange) { + await assertCanApplyAgentProfileChange(req, existing); + } else { + await assertCanUpdateAgent(req, existing); + } const actor = getActorInfo(req); const agent = await svc.update(id, patchData, { diff --git a/server/src/routes/built-in-agents.ts b/server/src/routes/built-in-agents.ts new file mode 100644 index 0000000000..9c16ba6f53 --- /dev/null +++ b/server/src/routes/built-in-agents.ts @@ -0,0 +1,318 @@ +import { Router, type Request } from "express"; +import type { Db } from "@paperclipai/db"; +import { builtInAgentEmptyMutationSchema, builtInAgentProvisionSchema, builtInAgentResetSchema } from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { forbidden, notFound } from "../errors.js"; +import { accessService, instanceSettingsService, logActivity } from "../services/index.js"; +import { builtInAgentService } from "../services/built-in-agents.js"; +import { authorizationDeniedDetails } from "../services/authorization.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import type { BuiltInAgentState } from "../services/built-in-agents.js"; + +const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function formatScheduleLabel(trigger: { cronExpression: string; timezone: string } | undefined) { + if (!trigger) return "Weekly schedule"; + const parts = trigger.cronExpression.trim().split(/\s+/); + const [minute, hour, , , dayOfWeek] = parts; + const weekdayIndex = dayOfWeek ? Number(dayOfWeek) : Number.NaN; + if ( + parts.length === 5 + && /^\d+$/.test(minute ?? "") + && /^\d+$/.test(hour ?? "") + && Number.isInteger(weekdayIndex) + && weekdayIndex >= 0 + && weekdayIndex < WEEKDAY_LABELS.length + ) { + return `Weekly · ${WEEKDAY_LABELS[weekdayIndex]} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} ${trigger.timezone}`; + } + return `Weekly · ${trigger.timezone}`; +} + +function redactBuiltInAgentListState(state: BuiltInAgentState): BuiltInAgentState { + const definition = { + ...state.definition, + defaultInstructions: state.definition.defaultInstructions ? "[file-backed]" : "", + bundle: state.definition.bundle + ? { + stockVersion: state.definition.bundle.stockVersion, + instructions: { + entryFile: state.definition.bundle.instructions.entryFile, + files: Object.keys(state.definition.bundle.instructions.files), + }, + skill: { + skillKey: state.definition.bundle.skill.skillKey, + displayName: state.definition.bundle.skill.displayName, + slug: state.definition.bundle.skill.slug, + canonicalKey: state.definition.bundle.skill.canonicalKey, + files: Object.keys(state.definition.bundle.skill.files), + }, + routine: { + routineKey: state.definition.bundle.routine.routineKey, + title: state.definition.bundle.routine.title, + status: state.definition.bundle.routine.status, + triggerCount: state.definition.bundle.routine.triggers.length, + scheduleLabel: formatScheduleLabel(state.definition.bundle.routine.triggers[0]), + }, + } + : undefined, + } as BuiltInAgentState["definition"]; + if (!state.agent) return { ...state, definition }; + return { + ...state, + definition, + agent: { + ...state.agent, + adapterConfig: {}, + runtimeConfig: {}, + }, + }; +} + +export function builtInAgentRoutes(db: Db) { + const router = Router(); + const access = accessService(db); + const svc = builtInAgentService(db); + const settings = instanceSettingsService(db); + + async function assertBuiltInAgentsEnabled() { + const experimental = await settings.getExperimental(); + if (experimental.enableBuiltInAgents !== true) { + throw notFound("Built-in agents are not enabled"); + } + } + + async function assertCanProvisionBuiltInAgents(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + const decision = await access.decide({ + actor: req.actor, + action: "agents:create", + resource: { type: "company", companyId }, + }); + if (decision.allowed) return; + throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + } + + async function assertCanControlBuiltInRoutine(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + if (req.actor.type !== "board") { + throw forbidden("Only board operators can control built-in routines."); + } + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign"); + if (!allowed) { + throw forbidden("Missing permission: tasks:assign"); + } + } + + async function logBuiltInAgentMutation( + req: Request, + input: { + companyId: string; + action: + | "built_in_agent.provision_requested" + | "built_in_agent.reconcile" + | "built_in_agent.reset" + | "built_in_agent.routine_schedule_enabled" + | "built_in_agent.routine_schedule_disabled" + | "built_in_agent.routine_run_triggered" + | "approval.created"; + key: string; + agentId: string | null; + status: string; + approvalId?: string | null; + routineKey?: string | null; + routineRunId?: string | null; + }, + ) { + const actor = getActorInfo(req); + await logActivity(db, { + companyId: input.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + action: input.action, + entityType: input.action === "approval.created" ? "approval" : "agent", + entityId: input.action === "approval.created" ? input.approvalId ?? input.key : input.agentId ?? input.key, + ...(actor.agentId ? { agentId: actor.agentId } : {}), + ...(actor.runId ? { runId: actor.runId } : {}), + details: { + key: input.key, + status: input.status, + approvalId: input.approvalId ?? null, + routineKey: input.routineKey ?? null, + routineRunId: input.routineRunId ?? null, + }, + }); + } + + router.get("/companies/:companyId/built-in-agents", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + await assertBuiltInAgentsEnabled(); + const states = await svc.list(companyId); + res.json(states.map(redactBuiltInAgentListState)); + }); + + router.get("/companies/:companyId/built-in-agents/:key/status", async (req, res) => { + const companyId = req.params.companyId as string; + const key = req.params.key as string; + assertCompanyAccess(req, companyId); + await assertBuiltInAgentsEnabled(); + res.json(redactBuiltInAgentListState(await svc.get(companyId, key))); + }); + + router.post("/companies/:companyId/built-in-agents/:key/reconcile", validate(builtInAgentEmptyMutationSchema), async (req, res) => { + const companyId = req.params.companyId as string; + const key = req.params.key as string; + await assertBuiltInAgentsEnabled(); + await assertCanProvisionBuiltInAgents(req, companyId); + const state = await svc.ensure(companyId, key); + await logBuiltInAgentMutation(req, { + companyId, + action: "built_in_agent.reconcile", + key, + agentId: state.agentId, + status: state.status, + }); + res.json(redactBuiltInAgentListState(state)); + }); + + router.post( + "/companies/:companyId/built-in-agents/:key/provision", + validate(builtInAgentProvisionSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const key = req.params.key as string; + await assertBuiltInAgentsEnabled(); + await assertCanProvisionBuiltInAgents(req, companyId); + const actor = getActorInfo(req); + const result = await svc.provision(companyId, key, req.body, { + requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, + requestedByUserId: actor.actorType === "user" ? actor.actorId : null, + }); + const { state, approval } = result; + await logBuiltInAgentMutation(req, { + companyId, + action: "built_in_agent.provision_requested", + key, + agentId: state.agentId, + status: state.status, + }); + if (approval) { + await logBuiltInAgentMutation(req, { + companyId, + action: "approval.created", + key, + agentId: state.agentId, + status: approval.status, + approvalId: approval.id, + }); + } + res.status(approval ? 202 : 200).json(redactBuiltInAgentListState({ ...state, approval })); + }, + ); + + router.post("/companies/:companyId/built-in-agents/:key/reset", validate(builtInAgentResetSchema), async (req, res) => { + const companyId = req.params.companyId as string; + const key = req.params.key as string; + await assertBuiltInAgentsEnabled(); + await assertCanProvisionBuiltInAgents(req, companyId); + const state = await svc.reset(companyId, key, req.body); + await logBuiltInAgentMutation(req, { + companyId, + action: "built_in_agent.reset", + key, + agentId: state.agentId, + status: state.status, + }); + res.json(redactBuiltInAgentListState(state)); + }); + + router.post( + "/companies/:companyId/built-in-agents/:key/routines/:routineKey/enable", + validate(builtInAgentEmptyMutationSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const key = req.params.key as string; + const routineKey = req.params.routineKey as string; + await assertBuiltInAgentsEnabled(); + assertCompanyAccess(req, companyId); + await assertCanControlBuiltInRoutine(req, companyId); + const actor = getActorInfo(req); + const state = await svc.enableRoutineSchedule(companyId, key, routineKey, { + agentId: actor.actorType === "agent" ? actor.actorId : null, + userId: actor.actorType === "user" ? actor.actorId : null, + runId: actor.runId ?? null, + }); + await logBuiltInAgentMutation(req, { + companyId, + action: "built_in_agent.routine_schedule_enabled", + key, + agentId: state.agentId, + status: state.status, + routineKey, + }); + res.json(redactBuiltInAgentListState(state)); + }, + ); + + router.post( + "/companies/:companyId/built-in-agents/:key/routines/:routineKey/disable", + validate(builtInAgentEmptyMutationSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const key = req.params.key as string; + const routineKey = req.params.routineKey as string; + await assertBuiltInAgentsEnabled(); + assertCompanyAccess(req, companyId); + await assertCanControlBuiltInRoutine(req, companyId); + const actor = getActorInfo(req); + const state = await svc.disableRoutineSchedule(companyId, key, routineKey, { + agentId: actor.actorType === "agent" ? actor.actorId : null, + userId: actor.actorType === "user" ? actor.actorId : null, + runId: actor.runId ?? null, + }); + await logBuiltInAgentMutation(req, { + companyId, + action: "built_in_agent.routine_schedule_disabled", + key, + agentId: state.agentId, + status: state.status, + routineKey, + }); + res.json(redactBuiltInAgentListState(state)); + }, + ); + + router.post( + "/companies/:companyId/built-in-agents/:key/routines/:routineKey/run", + validate(builtInAgentEmptyMutationSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const key = req.params.key as string; + const routineKey = req.params.routineKey as string; + await assertBuiltInAgentsEnabled(); + assertCompanyAccess(req, companyId); + const current = await svc.get(companyId, key); + await assertCanControlBuiltInRoutine(req, companyId); + const actor = getActorInfo(req); + const run = await svc.runRoutine(companyId, key, routineKey, { + agentId: actor.actorType === "agent" ? actor.actorId : null, + userId: actor.actorType === "user" ? actor.actorId : null, + runId: actor.runId ?? null, + }); + await logBuiltInAgentMutation(req, { + companyId, + action: "built_in_agent.routine_run_triggered", + key, + agentId: current.agentId, + status: current.status, + routineKey, + routineRunId: run.id, + }); + res.status(202).json(run); + }, + ); + + return router; +} diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index a5eb2545a9..b18a5aec38 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -31,9 +31,17 @@ import { listCatalogSkillsOrEmpty, readCatalogSkillFile, } from "../services/skills-catalog.js"; -import { forbidden } from "../errors.js"; +import { forbidden, HttpError } from "../errors.js"; import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js"; import { getTelemetryClient } from "../telemetry.js"; +import { authorizationDeniedDetails } from "../services/authorization.js"; +import { + changeConsentGateService, + skillChangeTargetKey, + skillImportChangeTargetKey, + skillSlugChangeTargetKey, + skillsScanProjectsChangeTargetKey, +} from "../services/change-consent-gate.js"; type SkillTelemetryInput = { key: string; @@ -51,11 +59,6 @@ export function companySkillRoutes(db: Db) { const issues = issueService(db); const heartbeat = heartbeatService(db); - function canCreateSkills(agent: { permissions: Record | null | undefined }) { - if (!agent.permissions || typeof agent.permissions !== "object") return true; - return (agent.permissions as Record).canCreateSkills !== false; - } - function asString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -98,37 +101,65 @@ export function companySkillRoutes(db: Db) { return { type: "system" as const }; } - async function assertCanMutateCompanySkills(req: Request, companyId: string) { + function skillMutationTargets(input: { + skillId?: string | null; + slug?: unknown; + source?: unknown; + catalogSkillId?: unknown; + scanProjects?: boolean; + }) { + const targetKeys: string[] = []; + const skillId = asString(input.skillId); + const slug = asString(input.slug); + const source = asString(input.source); + const catalogSkillId = asString(input.catalogSkillId); + if (skillId) targetKeys.push(skillChangeTargetKey(skillId)); + if (slug) targetKeys.push(skillSlugChangeTargetKey(slug)); + if (source) targetKeys.push(skillImportChangeTargetKey(source)); + if (catalogSkillId) targetKeys.push(skillImportChangeTargetKey(catalogSkillId)); + if (input.scanProjects) targetKeys.push(skillsScanProjectsChangeTargetKey()); + return targetKeys; + } + + async function assertCanMutateCompanySkills(req: Request, companyId: string, targetKeys: string[] = []) { assertCompanyAccess(req, companyId); + const decision = await access.decide({ + actor: req.actor, + action: "skill_config:update", + resource: { type: "company", companyId }, + }); + if (decision.allowed) { + return; + } - if (req.actor.type === "board") { - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const allowed = await access.canUser(companyId, req.actor.userId, "skills:create"); - if (!allowed) { - throw forbidden("Missing permission: skills:create"); + if (decision.reason === "deny_missing_consent" && req.actor.type === "agent" && targetKeys.length > 0) { + try { + await changeConsentGateService(db).assertConsented({ + companyId, + actorAgentId: req.actor.agentId, + actorRunId: req.actor.runId ?? null, + targetKeys, + }); + } catch (err) { + if (err instanceof HttpError && err.status === 403) { + throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + } + throw err; } - return; + + const consentedDecision = await access.decide({ + actor: req.actor, + action: "skill_config:update", + resource: { type: "company", companyId }, + scope: { consentedChange: true }, + }); + if (consentedDecision.allowed) { + return; + } + throw forbidden(consentedDecision.explanation, { reason: consentedDecision.reason }); } - if (!req.actor.agentId) { - throw forbidden("Agent authentication required"); - } - - const actorAgent = await agents.getById(req.actor.agentId); - if (!actorAgent || actorAgent.companyId !== companyId) { - throw forbidden("Agent key cannot access another company"); - } - - if (canCreateSkills(actorAgent)) { - return; - } - - const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "skills:create"); - if (allowedByGrant) { - return; - } - - throw forbidden("Missing permission: skills:create"); + throw forbidden(decision.explanation, { reason: decision.reason }); } async function assertCanStartSkillTestRuns(req: Request, companyId: string) { @@ -608,7 +639,7 @@ export function companySkillRoutes(db: Db) { async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; - await assertCanMutateCompanySkills(req, companyId); + await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId })); const result = await svc.createVersion(companyId, skillId, req.body, skillActor(req)); const actor = getActorInfo(req); await logActivity(db, { @@ -676,7 +707,10 @@ export function companySkillRoutes(db: Db) { async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; - await assertCanMutateCompanySkills(req, companyId); + await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ + skillId, + slug: req.body.slug, + })); const result = await svc.forkSkill(companyId, skillId, req.body, skillActor(req)); const actor = getActorInfo(req); await logActivity(db, { @@ -806,7 +840,9 @@ export function companySkillRoutes(db: Db) { validate(companySkillCreateSchema), async (req, res) => { const companyId = req.params.companyId as string; - await assertCanMutateCompanySkills(req, companyId); + await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ + slug: req.body.slug, + })); const result = await svc.createLocalSkill(companyId, req.body, skillActor(req)); const actor = getActorInfo(req); @@ -835,7 +871,7 @@ export function companySkillRoutes(db: Db) { async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; - await assertCanMutateCompanySkills(req, companyId); + await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId })); const result = await svc.updateSkill(companyId, skillId, req.body); const actor = getActorInfo(req); @@ -865,7 +901,7 @@ export function companySkillRoutes(db: Db) { async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; - await assertCanMutateCompanySkills(req, companyId); + await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId })); const result = await svc.updateFile( companyId, skillId, @@ -929,8 +965,8 @@ export function companySkillRoutes(db: Db) { validate(companySkillImportSchema), async (req, res) => { const companyId = req.params.companyId as string; - await assertCanMutateCompanySkills(req, companyId); const source = String(req.body.source ?? ""); + await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ source })); const result = await svc.importFromSource(companyId, source); const actor = getActorInfo(req); @@ -969,7 +1005,10 @@ export function companySkillRoutes(db: Db) { validate(companySkillInstallCatalogSchema), async (req, res) => { const companyId = req.params.companyId as string; - await assertCanMutateCompanySkills(req, companyId); + await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ + catalogSkillId: req.body.catalogSkillId, + slug: req.body.slug, + })); const result = await svc.installFromCatalog(companyId, req.body); const actor = getActorInfo(req); @@ -1001,7 +1040,7 @@ export function companySkillRoutes(db: Db) { validate(companySkillProjectScanRequestSchema), async (req, res) => { const companyId = req.params.companyId as string; - await assertCanMutateCompanySkills(req, companyId); + await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ scanProjects: true })); const result = await svc.scanProjectWorkspaces(companyId, req.body); const actor = getActorInfo(req); @@ -1032,7 +1071,7 @@ export function companySkillRoutes(db: Db) { router.delete("/companies/:companyId/skills/:skillId", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; - await assertCanMutateCompanySkills(req, companyId); + await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId })); const result = await svc.deleteSkill(companyId, skillId); if (!result) { res.status(404).json({ error: "Skill not found" }); @@ -1063,7 +1102,7 @@ export function companySkillRoutes(db: Db) { async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; - await assertCanMutateCompanySkills(req, companyId); + await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId })); const result = await svc.auditSkill(companyId, skillId); if (!result) { res.status(404).json({ error: "Skill not found" }); @@ -1099,7 +1138,7 @@ export function companySkillRoutes(db: Db) { async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; - await assertCanMutateCompanySkills(req, companyId); + await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId })); const before = await svc.getById(companyId, skillId); const result = await svc.installUpdate(companyId, skillId, req.body); if (!result) { @@ -1139,7 +1178,7 @@ export function companySkillRoutes(db: Db) { async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; - await assertCanMutateCompanySkills(req, companyId); + await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId })); const before = await svc.getById(companyId, skillId); const result = await svc.resetSkill(companyId, skillId, req.body); if (!result) { diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 940047f29f..c637677c9f 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -1,6 +1,7 @@ export { healthRoutes } from "./health.js"; export { companyRoutes } from "./companies.js"; export { companySkillRoutes } from "./company-skills.js"; +export { builtInAgentRoutes } from "./built-in-agents.js"; export { teamsCatalogRoutes } from "./teams-catalog.js"; export { agentRoutes } from "./agents.js"; export { projectRoutes } from "./projects.js"; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 8c34c562ed..91710f71d7 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -10,6 +10,8 @@ import { updateAgentInstructionsBundleSchema, upsertAgentInstructionsFileSchema, createAgentKeySchema, + builtInAgentEmptyMutationSchema, + builtInAgentProvisionSchema, wakeAgentSchema, resetAgentSessionSchema, agentSkillSyncSchema, @@ -1144,6 +1146,105 @@ for (const route of [ // ─── Agents ────────────────────────────────────────────────────────────────── +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/built-in-agents", + tags: ["agents"], + summary: "List built-in agent provisioning state", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/built-in-agents/{key}/status", + tags: ["agents"], + summary: "Get built-in agent bundle status", + request: { params: z.object({ companyId: z.string(), key: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/built-in-agents/{key}/reconcile", + tags: ["agents"], + summary: "Reconcile built-in agent managed resources", + request: { + params: z.object({ companyId: z.string(), key: z.string() }), + body: jsonBody(builtInAgentEmptyMutationSchema), + }, + responses: { + 200: r.ok(), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 409: r.conflict, + 422: r.unprocessable, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/built-in-agents/{key}/provision", + tags: ["agents"], + summary: "Provision a built-in agent", + request: { + params: z.object({ companyId: z.string(), key: z.string() }), + body: jsonBody(builtInAgentProvisionSchema), + }, + responses: { + 200: r.ok(), + 202: r.ok(), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 409: r.conflict, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/built-in-agents/{key}/reset", + tags: ["agents"], + summary: "Reset a built-in agent", + request: { params: z.object({ companyId: z.string(), key: z.string() }) }, + responses: { + 200: r.ok(), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 409: r.conflict, + }, +}); + +for (const route of [ + ["enable", "Enable a built-in routine schedule", 200], + ["disable", "Disable a built-in routine schedule", 200], + ["run", "Run a built-in routine once", 202], +] as const) { + registry.registerPath({ + method: "post", + path: `/api/companies/{companyId}/built-in-agents/{key}/routines/{routineKey}/${route[0]}`, + tags: ["agents"], + summary: route[1], + request: { + params: z.object({ companyId: z.string(), key: z.string(), routineKey: z.string() }), + body: jsonBody(builtInAgentEmptyMutationSchema), + }, + responses: { + [route[2]]: r.ok(), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 409: r.conflict, + 422: r.unprocessable, + }, + }); +} + registry.registerPath({ method: "get", path: "/api/companies/{companyId}/agents", diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index ac98371573..51546b911c 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -30,6 +30,10 @@ import { syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js"; import { normalizeAgentPermissions } from "./agent-permissions.js"; import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js"; import { secretService } from "./secrets.js"; +import { + builtInAgentMarkersEqual, + readBuiltInAgentMarker, +} from "./built-in-agent-metadata.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -43,6 +47,7 @@ const CONFIG_REVISION_FIELDS = [ "name", "role", "title", + "icon", "reportsTo", "capabilities", "adapterType", @@ -65,6 +70,12 @@ interface RevisionMetadata { interface UpdateAgentOptions { recordRevision?: RevisionMetadata; + allowBuiltInAgentMetadata?: boolean; + allowPendingApprovalConfigUpdate?: boolean; +} + +interface CreateAgentOptions { + allowBuiltInAgentMetadata?: boolean; } interface AgentShortnameRow { @@ -104,6 +115,7 @@ function buildConfigSnapshot( name: row.name, role: row.role, title: row.title, + icon: row.icon, reportsTo: row.reportsTo, capabilities: row.capabilities, adapterType: row.adapterType, @@ -126,6 +138,50 @@ function hasConfigPatchFields(data: Partial) { return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field)); } +function changedPendingApprovalConfigFields( + existing: typeof agents.$inferSelect, + data: Partial, +) { + return CONFIG_REVISION_FIELDS.filter((field) => + Object.prototype.hasOwnProperty.call(data, field) && !jsonEqual(data[field], existing[field]), + ); +} + +function configPatchFromApprovalPayload(payload: Record) { + const patch: Partial = {}; + if (typeof payload.name === "string") patch.name = payload.name; + if (typeof payload.role === "string") patch.role = payload.role; + if (Object.prototype.hasOwnProperty.call(payload, "title")) { + patch.title = typeof payload.title === "string" ? payload.title : null; + } + if (Object.prototype.hasOwnProperty.call(payload, "icon")) { + patch.icon = typeof payload.icon === "string" ? payload.icon : null; + } + if (Object.prototype.hasOwnProperty.call(payload, "reportsTo")) { + patch.reportsTo = typeof payload.reportsTo === "string" ? payload.reportsTo : null; + } + if (Object.prototype.hasOwnProperty.call(payload, "capabilities")) { + patch.capabilities = typeof payload.capabilities === "string" ? payload.capabilities : null; + } + if (typeof payload.adapterType === "string") patch.adapterType = payload.adapterType; + if (isPlainRecord(payload.adapterConfig)) patch.adapterConfig = payload.adapterConfig; + if (isPlainRecord(payload.runtimeConfig)) patch.runtimeConfig = payload.runtimeConfig; + if (Object.prototype.hasOwnProperty.call(payload, "defaultEnvironmentId")) { + patch.defaultEnvironmentId = + typeof payload.defaultEnvironmentId === "string" ? payload.defaultEnvironmentId : null; + } + if (typeof payload.budgetMonthlyCents === "number") { + patch.budgetMonthlyCents = payload.budgetMonthlyCents; + } + if (Object.prototype.hasOwnProperty.call(payload, "metadata")) { + patch.metadata = isPlainRecord(payload.metadata) ? payload.metadata : null; + } + if (isPlainRecord(payload.permissions)) { + patch.permissions = payload.permissions; + } + return patch; +} + function parseFiniteNumberLike(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value !== "string") return null; @@ -390,6 +446,21 @@ export function agentService(db: Db) { }); } + function assertBuiltInAgentMetadataMutationAllowed( + beforeMetadata: unknown, + afterMetadata: unknown, + options?: { allowBuiltInAgentMetadata?: boolean }, + ) { + if (options?.allowBuiltInAgentMetadata) return; + const beforeMarker = readBuiltInAgentMarker(beforeMetadata); + const afterMarker = readBuiltInAgentMarker(afterMetadata); + if (builtInAgentMarkersEqual(beforeMarker, afterMarker)) return; + throw conflict("Built-in agent marker is managed by Paperclip and cannot be edited directly", { + code: "built_in_agent_marker_readonly", + key: beforeMarker?.key ?? afterMarker?.key ?? null, + }); + } + async function updateAgent( id: string, data: Partial, @@ -409,6 +480,16 @@ export function agentService(db: Db) { ) { throw conflict("Pending approval agents cannot be activated directly"); } + if (existing.status === "pending_approval" && !options?.allowPendingApprovalConfigUpdate) { + const changedFields = changedPendingApprovalConfigFields(existing as typeof agents.$inferSelect, data); + if (changedFields.length > 0) { + throw conflict("Pending approval agent configuration cannot be changed before board approval", { + code: "pending_approval_agent_config_frozen", + agentId: id, + fields: changedFields, + }); + } + } if (data.reportsTo !== undefined) { if (data.reportsTo) { @@ -425,6 +506,10 @@ export function agentService(db: Db) { } } + if (Object.prototype.hasOwnProperty.call(data, "metadata")) { + assertBuiltInAgentMetadataMutationAllowed(existing.metadata, data.metadata, options); + } + const normalizedPatch = { ...data } as Partial; if (data.permissions !== undefined) { const role = (data.role ?? existing.role) as string; @@ -501,7 +586,8 @@ export function agentService(db: Db) { getById, - create: async (companyId: string, data: Omit) => { + create: async (companyId: string, data: Omit, options?: CreateAgentOptions) => { + assertBuiltInAgentMetadataMutationAllowed(null, data.metadata, options); if (data.reportsTo) { await ensureManager(companyId, data.reportsTo); } @@ -645,6 +731,14 @@ export function agentService(db: Db) { remove: async (id: string) => { const existing = await getById(id); if (!existing) return null; + const builtInMarker = readBuiltInAgentMarker(existing.metadata); + if (builtInMarker) { + throw conflict("Built-in agents cannot be deleted; pause them instead", { + code: "built_in_agent_undeletable", + key: builtInMarker.key, + featureKeys: builtInMarker.featureKeys, + }); + } return db.transaction(async (tx) => { await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id)); @@ -675,12 +769,32 @@ export function agentService(db: Db) { }); }, - activatePendingApproval: async (id: string) => { + activatePendingApproval: async (id: string, approvedPayload?: Record | null) => { const activatedAgent = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; + const existing = await agentService(txDb).getById(id); + if (!existing || existing.status !== "pending_approval") return null; + const approvedPatch = approvedPayload ? configPatchFromApprovalPayload(approvedPayload) : {}; + let patch = { ...approvedPatch } as Partial; + if ( + Object.prototype.hasOwnProperty.call(patch, "adapterConfig") && + isPlainRecord(patch.adapterConfig) + ) { + patch.adapterConfig = await secretService(txDb).normalizeAdapterConfigForPersistence( + existing.companyId, + patch.adapterConfig, + { adapterType: (patch.adapterType ?? existing.adapterType) as string }, + ); + } + if (patch.permissions !== undefined) { + patch.permissions = normalizeAgentPermissions( + patch.permissions, + (patch.role ?? existing.role) as string, + ); + } const updated = await tx .update(agents) - .set({ status: "idle", updatedAt: new Date() }) + .set({ ...patch, status: "idle", updatedAt: new Date() }) .where(and(eq(agents.id, id), eq(agents.status, "pending_approval"))) .returning() .then((rows) => rows[0] ?? null); @@ -704,6 +818,13 @@ export function agentService(db: Db) { updatePermissions: async (id: string, permissions: Record & { canCreateAgents: boolean }) => { const existing = await getById(id); if (!existing) return null; + if (existing.status === "pending_approval") { + throw conflict("Pending approval agent permissions cannot be changed before board approval", { + code: "pending_approval_agent_config_frozen", + agentId: id, + fields: ["permissions"], + }); + } const updated = await db .update(agents) diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index 5917270cad..a8a2ebac0f 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -24,6 +24,13 @@ export function approvalService(db: Db) { }; } + async function reconcileApprovedBuiltInAgent(companyId: string, payload: Record) { + const sourceBuiltInAgentKey = typeof payload.sourceBuiltInAgentKey === "string" ? payload.sourceBuiltInAgentKey : null; + if (!sourceBuiltInAgentKey) return; + const { builtInAgentService } = await import("./built-in-agents.js"); + await builtInAgentService(db).ensure(companyId, sourceBuiltInAgentKey); + } + async function getExistingApproval(id: string) { const existing = await db .select() @@ -128,7 +135,8 @@ export function approvalService(db: Db) { const payload = updated.payload as Record; const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; if (payloadAgentId) { - await agentsSvc.activatePendingApproval(payloadAgentId); + await agentsSvc.activatePendingApproval(payloadAgentId, payload); + await reconcileApprovedBuiltInAgent(updated.companyId, payload); hireApprovedAgentId = payloadAgentId; } else { const created = await agentsSvc.create(updated.companyId, { diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 247b3d5cdc..03e80e45fa 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -56,6 +56,7 @@ export type AuthorizationAction = | PermissionKey | "agent_config:read" | "agent_config:update" + | "skill_config:update" | "agent:read" | "agent:wake" | "company_scope:read" @@ -93,6 +94,8 @@ export type AuthorizationDecision = { | "allow_local_board" | "allow_instance_admin" | "allow_explicit_grant" + | "allow_direct_change" + | "allow_consented_change" | "allow_legacy_agent_creator" | "allow_issue_mention_grant" | "allow_self" @@ -104,6 +107,8 @@ export type AuthorizationDecision = { | "deny_company_boundary" | "deny_missing_membership" | "deny_missing_grant" + | "deny_missing_consent" + | "deny_no_grant" | "deny_policy_restricted" | "deny_low_trust_boundary" | "deny_scope" @@ -125,7 +130,9 @@ function companyIdForResource(resource: AuthorizationResource) { } function permissionForAction(action: AuthorizationAction): PermissionKey | null { - if (action === "agent_config:read" || action === "agent_config:update") return "agents:create"; + if (action === "agent_config:read" || action === "agent_config:update" || action === "skill_config:update") { + return null; + } if ( action === "agent:read" || action === "agent:wake" || @@ -466,6 +473,10 @@ function activeResponsibleUserCanAuthorizeIssueAction( ); } +function scopeBoolean(scope: Record | null | undefined, key: string) { + return scope?.[key] === true; +} + export function authorizationDeniedDetails(decision: AuthorizationDecision) { return { ...(decision.code ? { code: decision.code } : {}), @@ -878,6 +889,9 @@ export function authorizationService(db: Db) { if ( input.action === "company_scope:read" || + input.action === "agent_config:read" || + input.action === "agent_config:update" || + input.action === "skill_config:update" || input.action === "runtime:manage" || input.action === "secrets:read" ) { @@ -1305,6 +1319,94 @@ export function authorizationService(db: Db) { return broadDecision; } + async function decideWithAgentConfigReadGrant( + principalType: PrincipalType, + principalId: string, + ): Promise { + const configureDecision = await decidePrincipalGrant({ + companyId, + principalType, + principalId, + action: input.action, + permissionKey: "agents:configure", + scope: input.scope, + }); + if (configureDecision.allowed || configureDecision.reason === "deny_missing_membership") { + return configureDecision; + } + + const suggestDecision = await decidePrincipalGrant({ + companyId, + principalType, + principalId, + action: input.action, + permissionKey: "agents:suggest-changes", + scope: input.scope, + }); + if (suggestDecision.allowed || suggestDecision.reason === "deny_missing_grant") { + return suggestDecision; + } + return configureDecision; + } + + async function decideWithProtectedChangeGrants( + principalType: PrincipalType, + principalId: string, + keys: { direct: PermissionKey; suggest: PermissionKey }, + ): Promise { + const directDecision = await decidePrincipalGrant({ + companyId, + principalType, + principalId, + action: input.action, + permissionKey: keys.direct, + scope: input.scope, + }); + if (directDecision.allowed) { + return allow({ + action: input.action, + reason: "allow_direct_change", + explanation: `Allowed by direct change permission ${keys.direct}.`, + grant: directDecision.grant, + }); + } + if (directDecision.reason === "deny_missing_membership") return directDecision; + + const suggestDecision = await decidePrincipalGrant({ + companyId, + principalType, + principalId, + action: input.action, + permissionKey: keys.suggest, + scope: input.scope, + }); + if (suggestDecision.allowed) { + if (scopeBoolean(input.scope, "consentedChange")) { + return allow({ + action: input.action, + reason: "allow_consented_change", + explanation: `Allowed by suggest permission ${keys.suggest} after accepted change consent.`, + grant: suggestDecision.grant, + }); + } + return deny({ + action: input.action, + reason: "deny_missing_consent", + explanation: `Permission ${keys.suggest} requires accepted change consent before applying this mutation.`, + grant: suggestDecision.grant, + }); + } + if (suggestDecision.reason === "deny_missing_membership") return suggestDecision; + if (directDecision.reason === "deny_scope") return directDecision; + if (suggestDecision.reason === "deny_scope") return suggestDecision; + + return deny({ + action: input.action, + reason: "deny_no_grant", + explanation: `Missing permission: ${keys.direct} or ${keys.suggest}.`, + }); + } + async function denyForAssignmentPolicyIfNeeded( policyEffect: AssignmentPolicyEffect, ): Promise { @@ -1465,6 +1567,21 @@ export function authorizationService(db: Db) { if (policyEffect.kind === "restricted") return denyRestrictedAssignmentPolicy(policyEffect); return grantDecision; } + if (input.action === "agent_config:read") { + return decideWithAgentConfigReadGrant("user", input.actor.userId); + } + if (input.action === "agent_config:update") { + return decideWithProtectedChangeGrants("user", input.actor.userId, { + direct: "agents:configure", + suggest: "agents:suggest-changes", + }); + } + if (input.action === "skill_config:update") { + return decideWithProtectedChangeGrants("user", input.actor.userId, { + direct: "skills:create", + suggest: "skills:suggest-changes", + }); + } return decidePrincipalGrant({ companyId, principalType: "user", @@ -1641,7 +1758,8 @@ export function authorizationService(db: Db) { if ( input.action === "agent_config:update" && input.resource.type === "agent" && - input.resource.agentId === actorAgentId + input.resource.agentId === actorAgentId && + !scopeBoolean(input.scope, "requiresChangeGrant") ) { return allow({ action: input.action, @@ -1650,6 +1768,31 @@ export function authorizationService(db: Db) { }); } + if (input.action === "agent_config:read") { + if (input.resource.type === "agent" && input.resource.agentId === actorAgentId) { + return allow({ + action: input.action, + reason: "allow_self", + explanation: "Allowed because the actor is reading its own agent configuration.", + }); + } + return decideWithAgentConfigReadGrant("agent", actorAgentId); + } + + if (input.action === "agent_config:update") { + return decideWithProtectedChangeGrants("agent", actorAgentId, { + direct: "agents:configure", + suggest: "agents:suggest-changes", + }); + } + + if (input.action === "skill_config:update") { + return decideWithProtectedChangeGrants("agent", actorAgentId, { + direct: "skills:create", + suggest: "skills:suggest-changes", + }); + } + if (permissionKey) { const grantDecision = await decidePrincipalGrant({ companyId, @@ -1664,8 +1807,6 @@ export function authorizationService(db: Db) { if ( (input.action === "agents:create" || - input.action === "agent_config:read" || - input.action === "agent_config:update" || input.action === "tasks:manage_active_checkouts") && canCreateAgentsLegacy(actorAgent) ) { diff --git a/server/src/services/built-in-agent-metadata.ts b/server/src/services/built-in-agent-metadata.ts new file mode 100644 index 0000000000..d0e64fca58 --- /dev/null +++ b/server/src/services/built-in-agent-metadata.ts @@ -0,0 +1,45 @@ +export const BUILT_IN_AGENT_METADATA_KEY = "paperclipBuiltInAgent"; + +export interface BuiltInAgentMarker { + key: string; + featureKeys: string[]; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeFeatureKeys(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const featureKeys = value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); + return featureKeys.length === value.length ? featureKeys : null; +} + +export function readBuiltInAgentMarker(metadata: unknown): BuiltInAgentMarker | null { + if (!isPlainRecord(metadata)) return null; + const marker = metadata[BUILT_IN_AGENT_METADATA_KEY]; + if (!isPlainRecord(marker)) return null; + const key = marker.key; + const featureKeys = normalizeFeatureKeys(marker.featureKeys); + if (typeof key !== "string" || key.trim().length === 0 || !featureKeys) return null; + return { key, featureKeys }; +} + +export function withBuiltInAgentMarker( + metadata: Record | null | undefined, + marker: BuiltInAgentMarker, +): Record { + return { + ...(metadata ?? {}), + [BUILT_IN_AGENT_METADATA_KEY]: { + key: marker.key, + featureKeys: [...marker.featureKeys], + }, + }; +} + +export function builtInAgentMarkersEqual(left: BuiltInAgentMarker | null, right: BuiltInAgentMarker | null) { + if (!left && !right) return true; + if (!left || !right) return false; + return left.key === right.key && JSON.stringify(left.featureKeys) === JSON.stringify(right.featureKeys); +} diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts new file mode 100644 index 0000000000..e7191f3817 --- /dev/null +++ b/server/src/services/built-in-agents.ts @@ -0,0 +1,1669 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; +import { and, desc, eq, ne } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents, builtInManagedResources, companies, issueThreadInteractions, issues, routines, routineTriggers } from "@paperclipai/db"; +import { syncRoutineVariablesWithTemplate } from "@paperclipai/shared"; +import type { Agent, Approval, CompanySkill, PermissionKey, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared"; +import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; +import { logActivity } from "./activity-log.js"; +import { agentInstructionsService } from "./agent-instructions.js"; +import { agentService } from "./agents.js"; +import { approvalService } from "./approvals.js"; +import { + readBuiltInAgentMarker, + withBuiltInAgentMarker, +} from "./built-in-agent-metadata.js"; +import { companySkillService } from "./company-skills.js"; +import { routineService } from "./routines.js"; +import { accessService } from "./access.js"; + +export type BuiltInAgentStatus = "not_provisioned" | "pending_approval" | "needs_setup" | "ready" | "paused"; + +export interface BuiltInAgentDefinition { + key: string; + displayName: string; + featureKeys: string[]; + shortPurpose: string; + defaultInstructions: string; + defaultRole: string; + defaultTitle?: string | null; + defaultIcon?: string | null; + defaultPermissions?: Record; + defaultStatus?: "idle" | "paused"; + defaultManager?: "single_root_agent" | null; + allowedAdapterTypes?: string[]; + defaultBudgetMonthlyCents?: number; + bundle?: BuiltInAgentBundleDefinition; +} + +export interface BuiltInAgentState { + definition: BuiltInAgentDefinition; + status: BuiltInAgentStatus; + agentId: string | null; + agent: Agent | null; + pauseReason: string | null; + resources: BuiltInManagedResourceState[]; + approval?: Approval | null; +} + +export interface BuiltInAgentProvisionInput { + adapterType?: string; + adapterConfig?: Record; + budgetMonthlyCents?: number; +} + +export interface BuiltInAgentProvisionActor { + requestedByAgentId?: string | null; + requestedByUserId?: string | null; +} + +export interface BuiltInAgentProvisionResult { + state: BuiltInAgentState; + approval: Approval | null; +} + +export type BuiltInManagedResourceKind = "instructions" | "skill" | "routine"; +export type BuiltInManagedResourceStockStatus = + | "missing" + | "stock_current" + | "stock_update_available" + | "operator_modified"; + +export interface BuiltInManagedResourceState { + resourceKind: BuiltInManagedResourceKind; + resourceKey: string; + resourceId: string | null; + stockVersion: string; + stockHash: string; + currentHash: string | null; + stockStatus: BuiltInManagedResourceStockStatus; + updateAvailable: boolean; + resetAvailable: boolean; + changedFiles?: string[]; + scheduleEnabled?: boolean; + pendingUpdateInteractionId?: string | null; + pendingUpdateIssueId?: string | null; + pendingUpdateIssueIdentifier?: string | null; +} + +export interface BuiltInAgentBundleDefinition { + stockVersion: string; + instructions: { + entryFile: string; + files: Record; + }; + skill: { + skillKey: string; + displayName: string; + slug: string; + canonicalKey: string; + files: Record; + }; + routine: { + routineKey: string; + title: string; + description: string; + status: "active" | "paused"; + priority: "critical" | "high" | "medium" | "low"; + concurrencyPolicy: "always_enqueue" | "coalesce_if_active" | "skip_if_active"; + catchUpPolicy: "enqueue_missed_with_cap" | "skip_missed"; + variables: RoutineVariable[]; + triggers: Array<{ + kind: "schedule"; + label: string | null; + enabled: boolean; + cronExpression: string; + timezone: string; + }>; + }; +} + +export interface RequiredBuiltInAgentWarning { + code: "built_in_agent_paused"; + key: string; + agentId: string; + message: string; + pauseReason: string | null; +} + +export interface RequiredBuiltInAgent { + definition: BuiltInAgentDefinition; + agent: Agent; + warning: RequiredBuiltInAgentWarning | null; +} + +const BUILT_IN_AGENT_KEY_PATTERN = /^[a-z][a-z0-9_-]*$/; + +const BUILT_INS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../built-ins/agents"); + +function readBuiltInText(relativePath: string) { + return readFileSync(path.join(BUILT_INS_DIR, relativePath), "utf8"); +} + +const REFLECTION_COACH_INSTRUCTIONS = readBuiltInText("reflection-coach/AGENTS.md"); +const REFLECTION_COACH_ROUTINE = readBuiltInText("reflection-coach/routines/recent-agent-reflection.md"); +const REFLECTION_COACH_SKILL = readFileSync( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../packages/skills-catalog/catalog/bundled/paperclip-operations/reflection-coach/SKILL.md", + ), + "utf8", +); + +const DEFINITIONS = validateBuiltInAgentDefinitions([ + { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["briefs"], + shortPurpose: "Prepares concise operational briefs for the board and agent company.", + defaultInstructions: + "You are Paperclip's built-in Briefs agent. Produce concise, sourced operational briefs that help the board understand current company work, risks, and next actions.", + defaultRole: "general", + allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"], + defaultBudgetMonthlyCents: 0, + }, + { + key: "learning", + displayName: "Learning Agent", + featureKeys: ["learning"], + shortPurpose: "Maintains reusable company learning from completed work and recurring patterns.", + defaultInstructions: + "You are Paperclip's built-in Learning agent. Extract durable lessons from completed work, preserve useful patterns, and keep learning artifacts grounded in source context.", + defaultRole: "general", + allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"], + defaultBudgetMonthlyCents: 0, + }, + { + key: "reflection-coach", + displayName: "Reflection Coach", + featureKeys: ["reflection-coach"], + shortPurpose: + "Runs evidence-backed reflection loops on recent agent work, proposes small instruction and skill improvements, and requests approval before changes are applied.", + defaultInstructions: REFLECTION_COACH_INSTRUCTIONS, + defaultRole: "general", + defaultTitle: "Reflection Coach", + defaultIcon: "eye", + defaultPermissions: { + canCreateAgents: false, + canCreateSkills: false, + builtInMutationPolicy: { + requiresDisplayedDiff: true, + requiresAcceptedTaskInteraction: true, + applyInSeparateFollowUpRun: true, + }, + }, + defaultStatus: "paused", + defaultManager: "single_root_agent", + allowedAdapterTypes: ["claude_local", "codex_local", "gemini_local", "opencode_local", "process"], + defaultBudgetMonthlyCents: 0, + bundle: { + stockVersion: "2026-07-08", + instructions: { + entryFile: "AGENTS.md", + files: { + "AGENTS.md": REFLECTION_COACH_INSTRUCTIONS, + }, + }, + skill: { + skillKey: "reflection-coach", + displayName: "Reflection Coach", + slug: "reflection-coach", + canonicalKey: "paperclipai/bundled/paperclip-operations/reflection-coach", + files: { + "reflection-coach/SKILL.md": REFLECTION_COACH_SKILL, + }, + }, + routine: { + routineKey: "recent-agent-reflection", + title: "Review recent agent trajectories for coaching proposals", + description: REFLECTION_COACH_ROUTINE, + status: "paused", + priority: "medium", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [ + { name: "lookbackDays", label: "Lookback days", type: "number", defaultValue: 7, required: true, options: [] }, + { name: "maxTargetAgents", label: "Max target agents", type: "number", defaultValue: 8, required: true, options: [] }, + { + name: "targetAgentMode", + label: "Target agent mode", + type: "select", + defaultValue: "recent_active", + required: true, + options: ["recent_active", "recent_blocked", "recent_completed"], + }, + { name: "excludeAgentIds", label: "Excluded agent ids", type: "text", defaultValue: "", required: false, options: [] }, + ], + triggers: [ + { + kind: "schedule", + label: "Weekly reflection review", + enabled: false, + cronExpression: "0 9 * * 1", + timezone: "UTC", + }, + ], + }, + }, + }, +]); + +const DEFINITIONS_BY_KEY = new Map(DEFINITIONS.map((definition) => [definition.key, definition])); + +const ROOT_AGENT_DEFAULT_CHANGE_GRANTS: PermissionKey[] = ["agents:configure", "skills:create"]; +const BUILT_IN_AGENT_DEFAULT_GRANTS: Record = { + "reflection-coach": ["agents:suggest-changes", "skills:suggest-changes"], +}; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown) { + return typeof value === "string" && value.trim().length > 0; +} + +function uniqueNonEmptyStrings(values: string[]) { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const normalized = value.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function stockHash(value: unknown) { + return `sha256:${createHash("sha256").update(stableJson(value)).digest("hex")}`; +} + +function changedFileList(currentFiles: Record, stockFiles: Record) { + const paths = new Set([...Object.keys(currentFiles), ...Object.keys(stockFiles)]); + return [...paths] + .filter((filePath) => (currentFiles[filePath] ?? null) !== (stockFiles[filePath] ?? null)) + .sort((left, right) => left.localeCompare(right)); +} + +function resourceStatus(input: { + resourceId: string | null; + currentHash: string | null; + bindingStockHash: string | null; + latestStockHash: string; +}): BuiltInManagedResourceStockStatus { + if (!input.resourceId || !input.currentHash) return "missing"; + if (input.currentHash === input.latestStockHash) return "stock_current"; + if (input.bindingStockHash && input.currentHash === input.bindingStockHash) { + return "stock_update_available"; + } + return "operator_modified"; +} + +function stockState(input: { + resourceKind: BuiltInManagedResourceKind; + resourceKey: string; + resourceId: string | null; + stockVersion: string; + latestStockHash: string; + currentHash: string | null; + bindingStockHash: string | null; + changedFiles?: string[]; + scheduleEnabled?: boolean; + pendingUpdateInteractionId?: string | null; + pendingUpdateIssueId?: string | null; + pendingUpdateIssueIdentifier?: string | null; +}): BuiltInManagedResourceState { + const status = resourceStatus({ + resourceId: input.resourceId, + currentHash: input.currentHash, + bindingStockHash: input.bindingStockHash, + latestStockHash: input.latestStockHash, + }); + return { + resourceKind: input.resourceKind, + resourceKey: input.resourceKey, + resourceId: input.resourceId, + stockVersion: input.stockVersion, + stockHash: input.latestStockHash, + currentHash: input.currentHash, + stockStatus: status, + updateAvailable: status === "stock_update_available" || status === "operator_modified", + resetAvailable: status !== "stock_current", + ...(input.changedFiles && input.changedFiles.length > 0 ? { changedFiles: input.changedFiles } : {}), + ...(input.scheduleEnabled !== undefined ? { scheduleEnabled: input.scheduleEnabled } : {}), + ...(input.pendingUpdateInteractionId !== undefined + ? { pendingUpdateInteractionId: input.pendingUpdateInteractionId } + : {}), + ...(input.pendingUpdateIssueId !== undefined ? { pendingUpdateIssueId: input.pendingUpdateIssueId } : {}), + ...(input.pendingUpdateIssueIdentifier !== undefined + ? { pendingUpdateIssueIdentifier: input.pendingUpdateIssueIdentifier } + : {}), + }; +} + +export function validateBuiltInAgentDefinitions(definitions: BuiltInAgentDefinition[]) { + const seenKeys = new Set(); + for (const definition of definitions) { + if (!BUILT_IN_AGENT_KEY_PATTERN.test(definition.key)) { + throw new Error(`Invalid built-in agent key: ${definition.key}`); + } + if (seenKeys.has(definition.key)) { + throw new Error(`Duplicate built-in agent key: ${definition.key}`); + } + seenKeys.add(definition.key); + if (!definition.displayName.trim()) { + throw new Error(`Built-in agent ${definition.key} requires a displayName`); + } + if (!definition.shortPurpose.trim()) { + throw new Error(`Built-in agent ${definition.key} requires a shortPurpose`); + } + if (!definition.defaultInstructions.trim()) { + throw new Error(`Built-in agent ${definition.key} requires defaultInstructions`); + } + if (!definition.defaultRole.trim()) { + throw new Error(`Built-in agent ${definition.key} requires a defaultRole`); + } + if (uniqueNonEmptyStrings(definition.featureKeys).length !== definition.featureKeys.length) { + throw new Error(`Built-in agent ${definition.key} featureKeys must be unique non-empty strings`); + } + if (definition.featureKeys.length === 0) { + throw new Error(`Built-in agent ${definition.key} requires at least one featureKey`); + } + if ( + definition.allowedAdapterTypes + && uniqueNonEmptyStrings(definition.allowedAdapterTypes).length !== definition.allowedAdapterTypes.length + ) { + throw new Error(`Built-in agent ${definition.key} allowedAdapterTypes must be unique non-empty strings`); + } + if ( + definition.defaultBudgetMonthlyCents !== undefined + && (!Number.isInteger(definition.defaultBudgetMonthlyCents) || definition.defaultBudgetMonthlyCents < 0) + ) { + throw new Error(`Built-in agent ${definition.key} defaultBudgetMonthlyCents must be a non-negative integer`); + } + if (definition.bundle) { + if (!definition.bundle.stockVersion.trim()) { + throw new Error(`Built-in agent ${definition.key} bundle requires a stockVersion`); + } + if (!definition.bundle.instructions.files[definition.bundle.instructions.entryFile]) { + throw new Error(`Built-in agent ${definition.key} bundle instructions require the entry file`); + } + if (!definition.bundle.skill.files[`${definition.bundle.skill.slug}/SKILL.md`]) { + throw new Error(`Built-in agent ${definition.key} bundle skill requires SKILL.md`); + } + if (!definition.bundle.routine.description.trim()) { + throw new Error(`Built-in agent ${definition.key} bundle routine requires a description`); + } + } + } + return definitions.map((definition) => ({ + ...definition, + featureKeys: [...definition.featureKeys], + allowedAdapterTypes: definition.allowedAdapterTypes ? [...definition.allowedAdapterTypes] : undefined, + bundle: definition.bundle ? { + ...definition.bundle, + instructions: { + ...definition.bundle.instructions, + files: { ...definition.bundle.instructions.files }, + }, + skill: { + ...definition.bundle.skill, + files: { ...definition.bundle.skill.files }, + }, + routine: { + ...definition.bundle.routine, + variables: definition.bundle.routine.variables.map((variable) => ({ ...variable, options: [...variable.options] })), + triggers: definition.bundle.routine.triggers.map((trigger) => ({ ...trigger })), + }, + } : undefined, + })); +} + +export function listBuiltInAgentDefinitions() { + return DEFINITIONS.map((definition) => ({ ...definition, featureKeys: [...definition.featureKeys] })); +} + +export function getBuiltInAgentDefinition(key: string) { + return DEFINITIONS_BY_KEY.get(key) ?? null; +} + +export function requireBuiltInAgentDefinition(key: string) { + const definition = getBuiltInAgentDefinition(key); + if (!definition) throw notFound(`Built-in agent definition not found: ${key}`); + return definition; +} + +function defaultAdapterType(definition: BuiltInAgentDefinition) { + return definition.allowedAdapterTypes?.[0] ?? "process"; +} + +function normalizeAdapterType(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function selectPreferredAdapterType( + definition: BuiltInAgentDefinition, + usage: Array<{ adapterType: string; count: number }>, +) { + const fallback = defaultAdapterType(definition); + const preference = definition.allowedAdapterTypes ?? []; + if (preference.length === 0) return fallback; + + const rank = new Map(preference.map((adapterType, index) => [adapterType, index])); + let selected: { adapterType: string; count: number; rank: number } | null = null; + for (const entry of usage) { + const adapterRank = rank.get(entry.adapterType); + if (adapterRank === undefined) continue; + if (!selected || entry.count > selected.count || (entry.count === selected.count && adapterRank < selected.rank)) { + selected = { ...entry, rank: adapterRank }; + } + } + return selected?.adapterType ?? fallback; +} + +function assertAdapterAllowed(definition: BuiltInAgentDefinition, adapterType: string) { + if (definition.allowedAdapterTypes && !definition.allowedAdapterTypes.includes(adapterType)) { + throw unprocessable(`Adapter type ${adapterType} is not allowed for built-in agent ${definition.key}`, { + code: "built_in_agent_adapter_not_allowed", + key: definition.key, + allowedAdapterTypes: definition.allowedAdapterTypes, + }); + } +} + +function hasCompleteAdapterConfig(adapterType: string, adapterConfig: unknown) { + if (!isPlainRecord(adapterConfig)) return false; + if (["process", "command"].includes(adapterType)) { + return nonEmptyString(adapterConfig.command) || nonEmptyString(adapterConfig.script); + } + if (adapterType === "http") { + return nonEmptyString(adapterConfig.url) || nonEmptyString(adapterConfig.endpoint) || nonEmptyString(adapterConfig.webhookUrl); + } + if (adapterType === "openclaw_gateway" || adapterType === "hermes_gateway") { + return nonEmptyString(adapterConfig.baseUrl) || nonEmptyString(adapterConfig.url); + } + return nonEmptyString(adapterConfig.model); +} + +export function deriveBuiltInAgentStatus(agent: Pick | null): BuiltInAgentStatus { + if (!agent) return "not_provisioned"; + if (agent.status === "pending_approval") return "pending_approval"; + if (agent.status === "paused" || agent.pausedAt) return "paused"; + return hasCompleteAdapterConfig(agent.adapterType, agent.adapterConfig) ? "ready" : "needs_setup"; +} + +function builtInMetadata(definition: BuiltInAgentDefinition, existing?: Record | null) { + return withBuiltInAgentMarker(existing, { + key: definition.key, + featureKeys: definition.featureKeys, + }); +} + +function definitionPatch(definition: BuiltInAgentDefinition, input: BuiltInAgentProvisionInput = {}) { + const adapterType = input.adapterType ?? defaultAdapterType(definition); + assertAdapterAllowed(definition, adapterType); + return { + name: definition.displayName, + role: definition.defaultRole, + title: definition.defaultTitle ?? null, + icon: definition.defaultIcon ?? null, + capabilities: definition.shortPurpose, + adapterType, + adapterConfig: input.adapterConfig ?? {}, + permissions: definition.defaultPermissions ?? {}, + budgetMonthlyCents: input.budgetMonthlyCents ?? definition.defaultBudgetMonthlyCents ?? 0, + }; +} + +function builtInAgentNotConfiguredError(state: BuiltInAgentState) { + return new HttpError(412, `Built-in agent is not configured: ${state.definition.key}`, { + code: "built_in_agent_not_configured", + key: state.definition.key, + status: state.status, + agentId: state.agentId, + featureKeys: state.definition.featureKeys, + }); +} + +function hasProvisionSetupInput(input: BuiltInAgentProvisionInput) { + return input.adapterType !== undefined || input.adapterConfig !== undefined || input.budgetMonthlyCents !== undefined; +} + +function rowIsBuiltInAgent(row: typeof agents.$inferSelect, key: string) { + const marker = readBuiltInAgentMarker(row.metadata); + return marker?.key === key; +} + +export function builtInAgentService(db: Db) { + const agentSvc = agentService(db); + const accessSvc = accessService(db); + const approvalSvc = approvalService(db); + const instructionsSvc = agentInstructionsService(); + const skillSvc = companySkillService(db); + const routineSvc = routineService(db); + + async function findSingleRootManager(companyId: string) { + const roots = await db + .select() + .from(agents) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); + const nonBuiltInRoots = roots.filter((agent) => !readBuiltInAgentMarker(agent.metadata) && !agent.reportsTo); + return nonBuiltInRoots.length === 1 ? nonBuiltInRoots[0]!.id : null; + } + + async function ensureAgentDefaultGrants(companyId: string, agentId: string, grantKeys: PermissionKey[]) { + if (grantKeys.length === 0) return 0; + await accessSvc.ensureMembership(companyId, "agent", agentId, "member", "active"); + let ensured = 0; + for (const permissionKey of grantKeys) { + await accessSvc.setPrincipalPermission(companyId, "agent", agentId, permissionKey, true, null); + ensured += 1; + } + return ensured; + } + + async function ensureBuiltInAgentDefaultGrants(agent: Agent, definition: BuiltInAgentDefinition) { + if (agent.status === "pending_approval" || agent.status === "terminated") return 0; + return ensureAgentDefaultGrants( + agent.companyId, + agent.id, + BUILT_IN_AGENT_DEFAULT_GRANTS[definition.key] ?? [], + ); + } + + async function ensureRootAgentDefaultChangeGrants(companyId: string) { + const rows = await db + .select() + .from(agents) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); + const rootCeoRows = rows.filter((agent) => + !readBuiltInAgentMarker(agent.metadata) && + !agent.reportsTo && + agent.role.trim().toLowerCase() === "ceo" && + agent.status !== "pending_approval" + ); + if (rootCeoRows.length !== 1) return 0; + return ensureAgentDefaultGrants(companyId, rootCeoRows[0]!.id, ROOT_AGENT_DEFAULT_CHANGE_GRANTS); + } + + async function ensureCompanyDefaultAgentGrants(companyId: string) { + let ensured = await ensureRootAgentDefaultChangeGrants(companyId); + for (const definition of DEFINITIONS) { + const agent = await findSingleAgent(companyId, definition); + if (!agent) continue; + ensured += await ensureBuiltInAgentDefaultGrants(agent as Agent, definition); + } + return ensured; + } + + async function defaultProvisionInput(companyId: string, definition: BuiltInAgentDefinition, input: BuiltInAgentProvisionInput) { + if (input.adapterType || input.adapterConfig) return input; + if (!definition.bundle) return input; + const rows = await db + .select({ + adapterType: agents.adapterType, + adapterConfig: agents.adapterConfig, + }) + .from(agents) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); + const candidate = rows.find((row) => + definition.allowedAdapterTypes?.includes(row.adapterType) + && hasCompleteAdapterConfig(row.adapterType, row.adapterConfig) + ); + if (!candidate) return input; + return { + ...input, + adapterType: candidate.adapterType, + adapterConfig: {}, + }; + } + + async function getManagedResourceBinding( + companyId: string, + bundleKey: string, + resourceKind: BuiltInManagedResourceKind, + resourceKey: string, + ) { + return db + .select() + .from(builtInManagedResources) + .where(and( + eq(builtInManagedResources.companyId, companyId), + eq(builtInManagedResources.bundleKey, bundleKey), + eq(builtInManagedResources.resourceKind, resourceKind), + eq(builtInManagedResources.resourceKey, resourceKey), + )) + .then((rows) => rows[0] ?? null); + } + + async function upsertManagedResourceBinding(input: { + companyId: string; + bundleKey: string; + resourceKind: BuiltInManagedResourceKind; + resourceKey: string; + resourceId: string; + stockVersion: string; + stockHash: string; + defaultsJson: Record; + }) { + const now = new Date(); + return db + .insert(builtInManagedResources) + .values(input) + .onConflictDoUpdate({ + target: [ + builtInManagedResources.companyId, + builtInManagedResources.bundleKey, + builtInManagedResources.resourceKind, + builtInManagedResources.resourceKey, + ], + set: { + resourceId: input.resourceId, + stockVersion: input.stockVersion, + stockHash: input.stockHash, + defaultsJson: input.defaultsJson, + updatedAt: now, + }, + }) + .returning() + .then((rows) => rows[0] ?? null); + } + + async function currentInstructionFiles(agent: Agent, bundle: BuiltInAgentBundleDefinition) { + const currentFiles: Record = {}; + for (const filePath of Object.keys(bundle.instructions.files)) { + try { + currentFiles[filePath] = (await instructionsSvc.readFile(agent, filePath)).content; + } catch { + currentFiles[filePath] = null; + } + } + return currentFiles; + } + + async function materializeInstructions(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset") { + const bundle = definition.bundle!; + const stock = stockHash(bundle.instructions.files); + const binding = await getManagedResourceBinding(agent.companyId, definition.key, "instructions", "AGENTS.md"); + const currentFiles = await currentInstructionFiles(agent, bundle); + const currentHash = Object.values(currentFiles).some((value) => value === null) ? null : stockHash(currentFiles); + const currentState = stockState({ + resourceKind: "instructions", + resourceKey: "AGENTS.md", + resourceId: agent.id, + stockVersion: bundle.stockVersion, + latestStockHash: stock, + currentHash, + bindingStockHash: binding?.stockHash ?? null, + changedFiles: changedFileList(currentFiles, bundle.instructions.files), + }); + + const shouldWrite = + mode === "reset" + || currentState.stockStatus === "missing" + || currentState.stockStatus === "stock_update_available"; + if (!shouldWrite) { + if (!binding && currentHash === stock) { + await upsertManagedResourceBinding({ + companyId: agent.companyId, + bundleKey: definition.key, + resourceKind: "instructions", + resourceKey: "AGENTS.md", + resourceId: agent.id, + stockVersion: bundle.stockVersion, + stockHash: stock, + defaultsJson: { + entryFile: bundle.instructions.entryFile, + files: Object.keys(bundle.instructions.files), + }, + }); + } + return currentState; + } + + const materialized = await instructionsSvc.materializeManagedBundle(agent, bundle.instructions.files, { + entryFile: bundle.instructions.entryFile, + replaceExisting: true, + clearLegacyPromptTemplate: true, + }); + const updated = await agentSvc.update(agent.id, { + adapterConfig: materialized.adapterConfig, + }, { + allowBuiltInAgentMetadata: true, + recordRevision: { source: `built-in-bundle:${mode}:instructions` }, + }); + if (!updated) throw notFound("Built-in agent not found"); + await upsertManagedResourceBinding({ + companyId: agent.companyId, + bundleKey: definition.key, + resourceKind: "instructions", + resourceKey: "AGENTS.md", + resourceId: agent.id, + stockVersion: bundle.stockVersion, + stockHash: stock, + defaultsJson: { + entryFile: bundle.instructions.entryFile, + files: Object.keys(bundle.instructions.files), + }, + }); + return stockState({ + resourceKind: "instructions", + resourceKey: "AGENTS.md", + resourceId: agent.id, + stockVersion: bundle.stockVersion, + latestStockHash: stock, + currentHash: stock, + bindingStockHash: stock, + }); + } + + async function getCurrentSkillFiles(companyId: string, skill: CompanySkill | null, bundle: BuiltInAgentBundleDefinition) { + const currentFiles: Record = {}; + const stockFiles = bundle.skill.files; + for (const packagePath of Object.keys(stockFiles)) { + const relativePath = packagePath.split("/").slice(1).join("/") || "SKILL.md"; + if (!skill) { + currentFiles[packagePath] = null; + continue; + } + if (relativePath === "SKILL.md") { + currentFiles[packagePath] = skill.markdown; + continue; + } + try { + currentFiles[packagePath] = (await skillSvc.readFile(companyId, skill.id, relativePath))?.content ?? null; + } catch { + currentFiles[packagePath] = null; + } + } + return currentFiles; + } + + async function importBundledSkill(companyId: string, definition: BuiltInAgentDefinition) { + const results = await skillSvc.importPackageFiles(companyId, definition.bundle!.skill.files, { onConflict: "replace" }); + const imported = results.find((result) => result.skill.key === definition.bundle!.skill.canonicalKey)?.skill + ?? results[0]?.skill + ?? await skillSvc.getByKey(companyId, definition.bundle!.skill.canonicalKey); + if (!imported) throw notFound("Built-in bundled skill was not imported"); + return imported; + } + + async function syncBundledSkillToAgent(agent: Agent, skill: CompanySkill) { + const desired = readPaperclipSkillSyncPreference(agent.adapterConfig as Record).desiredSkillEntries; + const nextDesired = [ + ...desired.filter((entry) => entry.key !== skill.key), + { key: skill.key, versionId: skill.currentVersionId ?? null }, + ]; + const adapterConfig = writePaperclipSkillSyncPreference(agent.adapterConfig as Record, nextDesired); + const updated = await agentSvc.update(agent.id, { adapterConfig }, { + allowBuiltInAgentMetadata: true, + recordRevision: { source: "built-in-bundle:skill-sync" }, + }); + if (!updated) throw notFound("Built-in agent not found"); + return updated as Agent; + } + + async function materializeSkill(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset") { + const bundle = definition.bundle!; + const stock = stockHash(bundle.skill.files); + const binding = await getManagedResourceBinding(agent.companyId, definition.key, "skill", bundle.skill.skillKey); + const boundSkill = binding ? await skillSvc.getById(agent.companyId, binding.resourceId) : null; + const existingByKey = await skillSvc.getByKey(agent.companyId, bundle.skill.canonicalKey); + const skill = boundSkill ?? existingByKey; + const currentFiles = await getCurrentSkillFiles(agent.companyId, skill, bundle); + const currentHash = skill && Object.values(currentFiles).every((value) => value !== null) + ? stockHash(currentFiles) + : null; + const currentState = stockState({ + resourceKind: "skill", + resourceKey: bundle.skill.skillKey, + resourceId: skill?.id ?? null, + stockVersion: bundle.stockVersion, + latestStockHash: stock, + currentHash, + bindingStockHash: binding?.stockHash ?? null, + changedFiles: changedFileList(currentFiles, bundle.skill.files), + }); + + const shouldWrite = + mode === "reset" + || currentState.stockStatus === "missing" + || currentState.stockStatus === "stock_update_available"; + const nextSkill = shouldWrite ? await importBundledSkill(agent.companyId, definition) : skill!; + await upsertManagedResourceBinding({ + companyId: agent.companyId, + bundleKey: definition.key, + resourceKind: "skill", + resourceKey: bundle.skill.skillKey, + resourceId: nextSkill.id, + stockVersion: bundle.stockVersion, + stockHash: shouldWrite ? stock : binding?.stockHash ?? stock, + defaultsJson: { + canonicalKey: bundle.skill.canonicalKey, + slug: bundle.skill.slug, + files: Object.keys(bundle.skill.files), + }, + }); + await syncBundledSkillToAgent(agent, nextSkill); + return stockState({ + resourceKind: "skill", + resourceKey: bundle.skill.skillKey, + resourceId: nextSkill.id, + stockVersion: bundle.stockVersion, + latestStockHash: stock, + currentHash: shouldWrite ? stock : currentHash, + bindingStockHash: shouldWrite ? stock : binding?.stockHash ?? stock, + changedFiles: shouldWrite ? [] : changedFileList(currentFiles, bundle.skill.files), + }); + } + + function normalizeRoutineVariablesForHash(input: { + title: string; + description?: string | null; + variables?: RoutineVariable[] | null; + }) { + return syncRoutineVariablesWithTemplate( + [input.title, input.description ?? ""], + input.variables ?? [], + ).map((variable) => ({ + name: variable.name, + label: variable.label ?? null, + type: variable.type ?? "text", + defaultValue: variable.defaultValue ?? null, + required: variable.required ?? true, + options: variable.options ?? [], + })); + } + + function normalizeRoutineTriggersForHash( + triggers: Array<{ + kind: string; + label?: string | null; + cronExpression?: string | null; + timezone?: string | null; + }>, + ) { + return triggers + .filter((trigger) => trigger.kind === "schedule") + .map((trigger) => ({ + kind: "schedule", + label: trigger.label ?? null, + cronExpression: trigger.cronExpression ?? "", + timezone: trigger.timezone ?? "UTC", + })) + .sort((left, right) => stableJson(left).localeCompare(stableJson(right))); + } + + function routineDefaultsHash( + routine: Pick< + BuiltInAgentBundleDefinition["routine"], + "title" | "description" | "priority" | "concurrencyPolicy" | "catchUpPolicy" | "variables" + >, + triggers: Array<{ + kind: string; + label?: string | null; + cronExpression?: string | null; + timezone?: string | null; + }>, + ) { + return stockHash({ + title: routine.title, + description: routine.description ?? "", + priority: routine.priority, + concurrencyPolicy: routine.concurrencyPolicy, + catchUpPolicy: routine.catchUpPolicy, + variables: normalizeRoutineVariablesForHash(routine), + triggers: normalizeRoutineTriggersForHash(triggers), + }); + } + + async function getRoutineByBinding(companyId: string, definition: BuiltInAgentDefinition) { + const binding = await getManagedResourceBinding(companyId, definition.key, "routine", definition.bundle!.routine.routineKey); + const routine = binding + ? await db + .select() + .from(routines) + .where(and(eq(routines.companyId, companyId), eq(routines.id, binding.resourceId))) + .then((rows) => rows[0] as Routine | undefined ?? null) + : await db + .select() + .from(routines) + .where(and( + eq(routines.companyId, companyId), + eq(routines.originKind, "built_in_agent_bundle"), + eq(routines.originId, `${definition.key}:${definition.bundle!.routine.routineKey}`), + )) + .then((rows) => rows[0] as Routine | undefined ?? null); + const triggers = routine + ? await db + .select() + .from(routineTriggers) + .where(eq(routineTriggers.routineId, routine.id)) + .then((rows) => rows as RoutineTrigger[]) + : []; + return { binding, routine, triggers }; + } + + function routineScheduleEnabled(routine: Routine | null, triggers: RoutineTrigger[]) { + return Boolean( + routine?.status === "active" + && triggers.some((trigger) => trigger.kind === "schedule" && trigger.enabled), + ); + } + + async function pendingUpdateProposal(agent: Agent | null) { + if (!agent) return null; + return db + .select({ + interactionId: issueThreadInteractions.id, + issueId: issues.id, + issueIdentifier: issues.identifier, + }) + .from(issueThreadInteractions) + .innerJoin(issues, eq(issueThreadInteractions.issueId, issues.id)) + .where(and( + eq(issueThreadInteractions.companyId, agent.companyId), + eq(issueThreadInteractions.createdByAgentId, agent.id), + eq(issueThreadInteractions.kind, "request_confirmation"), + eq(issueThreadInteractions.status, "pending"), + )) + .orderBy(desc(issueThreadInteractions.createdAt), desc(issueThreadInteractions.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + + function withRoutineControls( + state: BuiltInManagedResourceState, + input: { + routine: Routine | null; + triggers: RoutineTrigger[]; + proposal: Awaited>; + }, + ) { + if (state.resourceKind !== "routine") return state; + return { + ...state, + scheduleEnabled: routineScheduleEnabled(input.routine, input.triggers), + pendingUpdateInteractionId: input.proposal?.interactionId ?? null, + pendingUpdateIssueId: input.proposal?.issueId ?? null, + pendingUpdateIssueIdentifier: input.proposal?.issueIdentifier ?? null, + }; + } + + async function requireBundleRoutine(companyId: string, key: string, routineKey: string) { + const definition = requireBuiltInAgentDefinition(key); + if (!definition.bundle || definition.bundle.routine.routineKey !== routineKey) { + throw notFound("Built-in routine not found"); + } + await ensureCompany(companyId); + const agent = await findSingleAgent(companyId, definition); + if (!agent) throw notFound("Built-in agent is not provisioned"); + const current = await getRoutineByBinding(companyId, definition); + if (!current.routine) throw notFound("Built-in routine not found"); + const schedule = current.triggers.find((trigger) => trigger.kind === "schedule") ?? null; + return { definition, agent, routine: current.routine, triggers: current.triggers, schedule }; + } + + async function ensureBuiltInAgentAssignable(agent: Agent) { + if (agent.status !== "paused") return agent; + const resumed = await agentSvc.resume(agent.id); + if (!resumed) throw notFound("Built-in agent not found"); + return resumed as Agent; + } + + async function createOrResetRoutine(agent: Agent, definition: BuiltInAgentDefinition, existing: Routine | null, mode: "reconcile" | "reset") { + const routine = definition.bundle!.routine; + const actor = { agentId: null, userId: "built-in-bundles" }; + const nextRoutine = existing + ? await routineSvc.update(existing.id, { + title: routine.title, + description: routine.description, + assigneeAgentId: agent.id, + priority: routine.priority, + status: routine.status, + concurrencyPolicy: routine.concurrencyPolicy, + catchUpPolicy: routine.catchUpPolicy, + variables: routine.variables, + }, actor) + : await routineSvc.create(agent.companyId, { + title: routine.title, + description: routine.description, + assigneeAgentId: agent.id, + priority: routine.priority, + status: routine.status, + concurrencyPolicy: routine.concurrencyPolicy, + catchUpPolicy: routine.catchUpPolicy, + variables: routine.variables, + }, actor); + if (!nextRoutine) throw notFound("Built-in routine not found"); + await db + .update(routines) + .set({ + originKind: "built_in_agent_bundle", + originId: `${definition.key}:${routine.routineKey}`, + updatedAt: new Date(), + }) + .where(eq(routines.id, nextRoutine.id)); + + const currentTriggers = await db + .select() + .from(routineTriggers) + .where(eq(routineTriggers.routineId, nextRoutine.id)) + .then((rows) => rows as RoutineTrigger[]); + const firstSchedule = currentTriggers.find((trigger) => trigger.kind === "schedule"); + const stockTrigger = routine.triggers[0]; + if (stockTrigger && firstSchedule) { + await routineSvc.updateTrigger(firstSchedule.id, { + label: stockTrigger.label, + enabled: stockTrigger.enabled, + cronExpression: stockTrigger.cronExpression, + timezone: stockTrigger.timezone, + }, actor); + } else if (stockTrigger) { + await routineSvc.createTrigger(nextRoutine.id, { + kind: "schedule", + label: stockTrigger.label, + enabled: stockTrigger.enabled, + cronExpression: stockTrigger.cronExpression, + timezone: stockTrigger.timezone, + }, actor); + } + await logActivity(db, { + companyId: agent.companyId, + actorType: "system", + actorId: "built-in-bundles", + action: mode === "reset" ? "built_in_agent.routine_reset" : "built_in_agent.routine_reconciled", + entityType: "routine", + entityId: nextRoutine.id, + details: { + key: definition.key, + routineKey: routine.routineKey, + status: routine.status, + }, + }); + return nextRoutine; + } + + async function materializeRoutine(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset") { + const bundle = definition.bundle!; + const stock = routineDefaultsHash(bundle.routine, bundle.routine.triggers); + const { binding, routine, triggers } = await getRoutineByBinding(agent.companyId, definition); + const currentHash = routine ? routineDefaultsHash({ + title: routine.title, + description: routine.description ?? "", + priority: routine.priority as "critical" | "high" | "medium" | "low", + concurrencyPolicy: routine.concurrencyPolicy as "always_enqueue" | "coalesce_if_active" | "skip_if_active", + catchUpPolicy: routine.catchUpPolicy as "enqueue_missed_with_cap" | "skip_missed", + variables: routine.variables ?? [], + }, triggers) : null; + const currentState = stockState({ + resourceKind: "routine", + resourceKey: bundle.routine.routineKey, + resourceId: routine?.id ?? null, + stockVersion: bundle.stockVersion, + latestStockHash: stock, + currentHash, + bindingStockHash: binding?.stockHash ?? null, + }); + const shouldWrite = + mode === "reset" + || currentState.stockStatus === "missing" + || currentState.stockStatus === "stock_update_available"; + const nextRoutine = shouldWrite + ? await createOrResetRoutine(agent, definition, routine, mode) + : routine!; + await upsertManagedResourceBinding({ + companyId: agent.companyId, + bundleKey: definition.key, + resourceKind: "routine", + resourceKey: bundle.routine.routineKey, + resourceId: nextRoutine.id, + stockVersion: bundle.stockVersion, + stockHash: shouldWrite ? stock : binding?.stockHash ?? stock, + defaultsJson: { + title: bundle.routine.title, + status: bundle.routine.status, + triggerCount: bundle.routine.triggers.length, + }, + }); + const next = await getRoutineByBinding(agent.companyId, definition); + return withRoutineControls(stockState({ + resourceKind: "routine", + resourceKey: bundle.routine.routineKey, + resourceId: nextRoutine.id, + stockVersion: bundle.stockVersion, + latestStockHash: stock, + currentHash: shouldWrite ? stock : currentHash, + bindingStockHash: shouldWrite ? stock : binding?.stockHash ?? stock, + }), { + routine: next.routine, + triggers: next.triggers, + proposal: await pendingUpdateProposal(agent), + }); + } + + async function bundleResourceStates(companyId: string, definition: BuiltInAgentDefinition, agent: Agent | null) { + if (!definition.bundle || !agent) return []; + const bundle = definition.bundle; + const [instructionBinding, skillBinding, routineBinding] = await Promise.all([ + getManagedResourceBinding(companyId, definition.key, "instructions", "AGENTS.md"), + getManagedResourceBinding(companyId, definition.key, "skill", bundle.skill.skillKey), + getManagedResourceBinding(companyId, definition.key, "routine", bundle.routine.routineKey), + ]); + const instructionFiles = await currentInstructionFiles(agent, bundle); + const skill = skillBinding + ? await skillSvc.getById(companyId, skillBinding.resourceId) + : await skillSvc.getByKey(companyId, bundle.skill.canonicalKey); + const skillFiles = await getCurrentSkillFiles(companyId, skill, bundle); + const { routine, triggers } = await getRoutineByBinding(companyId, definition); + const proposal = await pendingUpdateProposal(agent); + const instructionHash = Object.values(instructionFiles).every((value) => value !== null) + ? stockHash(instructionFiles) + : null; + const skillHash = skill && Object.values(skillFiles).every((value) => value !== null) + ? stockHash(skillFiles) + : null; + const routineHash = routine ? routineDefaultsHash({ + title: routine.title, + description: routine.description ?? "", + priority: routine.priority as "critical" | "high" | "medium" | "low", + concurrencyPolicy: routine.concurrencyPolicy as "always_enqueue" | "coalesce_if_active" | "skip_if_active", + catchUpPolicy: routine.catchUpPolicy as "enqueue_missed_with_cap" | "skip_missed", + variables: routine.variables ?? [], + }, triggers) : null; + return [ + stockState({ + resourceKind: "instructions", + resourceKey: "AGENTS.md", + resourceId: agent.id, + stockVersion: bundle.stockVersion, + latestStockHash: stockHash(bundle.instructions.files), + currentHash: instructionHash, + bindingStockHash: instructionBinding?.stockHash ?? null, + changedFiles: changedFileList(instructionFiles, bundle.instructions.files), + }), + stockState({ + resourceKind: "skill", + resourceKey: bundle.skill.skillKey, + resourceId: skill?.id ?? null, + stockVersion: bundle.stockVersion, + latestStockHash: stockHash(bundle.skill.files), + currentHash: skillHash, + bindingStockHash: skillBinding?.stockHash ?? null, + changedFiles: changedFileList(skillFiles, bundle.skill.files), + }), + withRoutineControls(stockState({ + resourceKind: "routine", + resourceKey: bundle.routine.routineKey, + resourceId: routine?.id ?? null, + stockVersion: bundle.stockVersion, + latestStockHash: routineDefaultsHash(bundle.routine, bundle.routine.triggers), + currentHash: routineHash, + bindingStockHash: routineBinding?.stockHash ?? null, + }), { routine, triggers, proposal }), + ]; + } + + async function reconcileBundleResources( + agent: Agent, + definition: BuiltInAgentDefinition, + mode: "reconcile" | "reset", + resources?: Array<"instructions" | "skill" | "routine">, + ) { + if (!definition.bundle) return []; + const selected = new Set(resources ?? ["instructions", "skill", "routine"]); + const existingStates = await bundleResourceStates(agent.companyId, definition, agent); + const byKind = new Map(existingStates.map((state) => [state.resourceKind, state])); + const instruction = selected.has("instructions") + ? await materializeInstructions(agent, definition, mode) + : byKind.get("instructions")!; + const refreshedAgent = await agentSvc.getById(agent.id) as Agent | null; + if (!refreshedAgent) throw notFound("Built-in agent not found"); + const skill = selected.has("skill") + ? await materializeSkill(refreshedAgent, definition, mode) + : byKind.get("skill")!; + const refreshedAfterSkill = await agentSvc.getById(agent.id) as Agent | null; + if (!refreshedAfterSkill) throw notFound("Built-in agent not found"); + const routine = selected.has("routine") + ? await materializeRoutine(refreshedAfterSkill, definition, mode) + : byKind.get("routine")!; + return [instruction, skill, routine]; + } + + async function ensureCompany(companyId: string) { + const company = await db + .select({ + id: companies.id, + requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents, + }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + if (!company) throw notFound("Company not found"); + return company; + } + + async function findMarkedRows(companyId: string, key: string) { + const rows = await db + .select() + .from(agents) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); + return rows + .filter((row) => rowIsBuiltInAgent(row, key)) + .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime() || left.id.localeCompare(right.id)); + } + + async function findSingleAgent(companyId: string, definition: BuiltInAgentDefinition) { + const markedRows = await findMarkedRows(companyId, definition.key); + if (markedRows.length > 1) { + throw conflict(`Multiple built-in agents found for ${definition.key}`, { + code: "built_in_agent_duplicate_instance", + key: definition.key, + agentIds: markedRows.map((row) => row.id), + }); + } + if (markedRows.length === 0) return null; + const agent = await agentSvc.getById(markedRows[0]!.id); + return agent as Agent | null; + } + + async function state( + definition: BuiltInAgentDefinition, + agent: Agent | null, + resources?: BuiltInManagedResourceState[], + ): Promise { + return { + definition, + status: deriveBuiltInAgentStatus(agent), + agentId: agent?.id ?? null, + agent, + pauseReason: agent?.pauseReason ?? null, + resources: resources ?? await bundleResourceStates(agent?.companyId ?? "", definition, agent), + }; + } + + async function get(companyId: string, key: string) { + const definition = requireBuiltInAgentDefinition(key); + await ensureCompany(companyId); + return state(definition, await findSingleAgent(companyId, definition)); + } + + async function ensure(companyId: string, key: string, input: BuiltInAgentProvisionInput = {}) { + const definition = requireBuiltInAgentDefinition(key); + await ensureCompany(companyId); + const existing = await findSingleAgent(companyId, definition); + const existingPendingApproval = existing?.status === "pending_approval"; + const preserveExistingAdapter = Boolean( + existing + && !existingPendingApproval + && input.adapterType === undefined + && input.adapterConfig === undefined + && hasCompleteAdapterConfig(existing.adapterType, existing.adapterConfig), + ); + const resolvedInput = existingPendingApproval || preserveExistingAdapter + ? input + : await defaultProvisionInput(companyId, definition, input); + if (existing) { + const patch: Partial = { + metadata: builtInMetadata(definition, existing.metadata), + }; + if ( + !existingPendingApproval + && (resolvedInput.adapterType !== undefined || resolvedInput.adapterConfig !== undefined) + ) { + const adapterType = resolvedInput.adapterType ?? existing.adapterType; + assertAdapterAllowed(definition, adapterType); + patch.adapterType = adapterType; + patch.adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig; + } + if (!existingPendingApproval && resolvedInput.budgetMonthlyCents !== undefined) { + patch.budgetMonthlyCents = resolvedInput.budgetMonthlyCents; + } + if ( + !existingPendingApproval + && definition.defaultManager === "single_root_agent" + && !existing.reportsTo + ) { + const reportsTo = await findSingleRootManager(companyId); + if (reportsTo) patch.reportsTo = reportsTo; + } + const updated = await agentSvc.update(existing.id, patch, { + allowBuiltInAgentMetadata: true, + recordRevision: { source: "built-in-agent:ensure" }, + }); + if (!updated) throw notFound("Built-in agent not found"); + if (existingPendingApproval) { + return state(definition, updated as Agent); + } + await ensureBuiltInAgentDefaultGrants(updated as Agent, definition); + const resources = await reconcileBundleResources(updated as Agent, definition, "reconcile"); + return state(definition, await agentSvc.getById(existing.id) as Agent, resources); + } + + const reportsTo = definition.defaultManager === "single_root_agent" + ? await findSingleRootManager(companyId) + : null; + const created = await agentSvc.create(companyId, { + ...definitionPatch(definition, resolvedInput), + status: definition.defaultStatus ?? "idle", + pauseReason: definition.defaultStatus === "paused" ? "Built-in Reflection Coach is disabled until explicitly configured." : null, + pausedAt: definition.defaultStatus === "paused" ? new Date() : null, + reportsTo, + metadata: builtInMetadata(definition), + runtimeConfig: {}, + permissions: definition.defaultPermissions ?? {}, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + }, { allowBuiltInAgentMetadata: true }) as Agent; + + await logActivity(db, { + companyId, + actorType: "system", + actorId: "built-in-agents", + action: "built_in_agent.provisioned", + entityType: "agent", + entityId: created.id, + details: { + key: definition.key, + featureKeys: definition.featureKeys, + status: deriveBuiltInAgentStatus(created), + }, + }); + + await ensureBuiltInAgentDefaultGrants(created, definition); + const resources = await reconcileBundleResources(created, definition, "reconcile"); + return state(definition, await agentSvc.getById(created.id) as Agent, resources); + } + + async function provision( + companyId: string, + key: string, + input: BuiltInAgentProvisionInput = {}, + actor: BuiltInAgentProvisionActor = {}, + ): Promise { + const definition = requireBuiltInAgentDefinition(key); + const company = await ensureCompany(companyId); + if (!company.requireBoardApprovalForNewAgents) { + return { state: await ensure(companyId, key, input), approval: null }; + } + + const existing = await findSingleAgent(companyId, definition); + if (existing) { + if (existing.status === "pending_approval") { + if (hasProvisionSetupInput(input)) { + throw conflict("Built-in agent setup is already pending board approval.", { + code: "built_in_agent_pending_approval", + key: definition.key, + agentId: existing.id, + }); + } + const approval = await approvalSvc.findOpenHireApprovalForAgent(companyId, existing.id); + return { + state: await state(definition, existing), + approval: approval as Approval | null, + }; + } + + if (input.adapterType !== undefined || input.adapterConfig !== undefined) { + throw conflict("Built-in agent adapter changes require board approval before they can be applied.", { + code: "built_in_agent_reconfiguration_requires_approval", + key: definition.key, + agentId: existing.id, + }); + } + + return { state: await state(definition, existing), approval: null }; + } + + const reportsTo = definition.defaultManager === "single_root_agent" + ? await findSingleRootManager(companyId) + : null; + const pending = await agentSvc.create(companyId, { + ...definitionPatch(definition, input), + status: "pending_approval", + reportsTo, + metadata: builtInMetadata(definition), + runtimeConfig: {}, + permissions: definition.defaultPermissions ?? {}, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + }, { allowBuiltInAgentMetadata: true }) as Agent; + + const approval = await approvalSvc.create(companyId, { + type: "hire_agent", + requestedByAgentId: actor.requestedByAgentId ?? null, + requestedByUserId: actor.requestedByUserId ?? null, + status: "pending", + payload: { + name: pending.name, + role: pending.role, + title: pending.title, + icon: pending.icon, + reportsTo: pending.reportsTo, + capabilities: pending.capabilities, + adapterType: pending.adapterType, + adapterConfig: pending.adapterConfig, + runtimeConfig: pending.runtimeConfig, + permissions: pending.permissions, + budgetMonthlyCents: pending.budgetMonthlyCents, + metadata: pending.metadata, + agentId: pending.id, + sourceBuiltInAgentKey: definition.key, + featureKeys: definition.featureKeys, + }, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + updatedAt: new Date(), + }) as Approval; + + return { state: await state(definition, pending), approval }; + } + + async function list(companyId: string) { + await ensureCompany(companyId); + return Promise.all(DEFINITIONS.map(async (definition) => state(definition, await findSingleAgent(companyId, definition)))); + } + + async function reconcileDefinitionDefaults(companyId: string, key: string) { + const definition = requireBuiltInAgentDefinition(key); + await ensureCompany(companyId); + const existing = await findSingleAgent(companyId, definition); + if (!existing) return state(definition, null); + const patch = { + name: definition.displayName, + role: definition.defaultRole, + title: definition.defaultTitle ?? null, + icon: definition.defaultIcon ?? null, + capabilities: definition.shortPurpose, + metadata: builtInMetadata(definition, existing.metadata), + }; + const updated = await agentSvc.update(existing.id, patch, { + allowBuiltInAgentMetadata: true, + recordRevision: { source: "built-in-agent:reconcile-defaults" }, + }); + if (!updated) throw notFound("Built-in agent not found"); + await ensureBuiltInAgentDefaultGrants(updated as Agent, definition); + return state(definition, updated as Agent); + } + + async function reset(companyId: string, key: string, input: { resources?: Array<"agent" | "instructions" | "skill" | "routine"> } = {}) { + const definition = requireBuiltInAgentDefinition(key); + const resetAgentDefaults = !input.resources || input.resources.includes("agent"); + const current = resetAgentDefaults + ? await reconcileDefinitionDefaults(companyId, key) + : await get(companyId, key); + if (!current.agent || !definition.bundle) return current; + const selectedBundleResources = input.resources?.filter( + (resource): resource is "instructions" | "skill" | "routine" => resource !== "agent", + ); + const resources = await reconcileBundleResources( + current.agent, + definition, + "reset", + input.resources ? selectedBundleResources ?? [] : undefined, + ); + return state(definition, await agentSvc.getById(current.agent.id) as Agent, resources); + } + + async function setRoutineSchedule( + companyId: string, + key: string, + routineKey: string, + enabled: boolean, + actor: { agentId?: string | null; userId?: string | null; runId?: string | null } = {}, + ) { + const { definition, agent, routine, schedule } = await requireBundleRoutine(companyId, key, routineKey); + if (!schedule) throw notFound("Built-in routine schedule not found"); + if (enabled) { + await ensureBuiltInAgentAssignable(agent); + await routineSvc.update(routine.id, { status: "active" }, actor); + await routineSvc.updateTrigger(schedule.id, { enabled: true }, actor); + } else { + await routineSvc.updateTrigger(schedule.id, { enabled: false }, actor); + await routineSvc.update(routine.id, { status: "paused" }, actor); + } + return state(definition, await agentSvc.getById(agent.id) as Agent); + } + + async function runRoutine( + companyId: string, + key: string, + routineKey: string, + actor: { agentId?: string | null; userId?: string | null; runId?: string | null } = {}, + ) { + const { agent, routine } = await requireBundleRoutine(companyId, key, routineKey); + await ensureBuiltInAgentAssignable(agent); + return routineSvc.runRoutine(routine.id, { source: "manual" }, actor); + } + + async function requireBuiltInAgent(companyId: string, key: string): Promise { + const current = await get(companyId, key); + if (!current.agent) throw builtInAgentNotConfiguredError(current); + if (current.status === "ready") { + return { definition: current.definition, agent: current.agent, warning: null }; + } + if (current.status === "paused") { + return { + definition: current.definition, + agent: current.agent, + warning: { + code: "built_in_agent_paused", + key: current.definition.key, + agentId: current.agent.id, + message: `Built-in agent ${current.definition.key} is paused; scheduled/background work should be skipped.`, + pauseReason: current.pauseReason, + }, + }; + } + throw builtInAgentNotConfiguredError(current); + } + + async function autoProvisionBundledAgents(companyId: string) { + const company = await ensureCompany(companyId); + let autoEnsured = 0; + let pendingApprovals = 0; + for (const definition of DEFINITIONS.filter((entry) => entry.bundle)) { + if (company.requireBoardApprovalForNewAgents) { + const result = await provision(companyId, definition.key); + if (result.approval) pendingApprovals += 1; + } else { + await ensure(companyId, definition.key); + } + autoEnsured += 1; + } + const defaultGrantsEnsured = await ensureCompanyDefaultAgentGrants(companyId); + return { autoEnsured, pendingApprovals, defaultGrantsEnsured }; + } + + return { + definitions: listBuiltInAgentDefinitions, + get, + ensure, + provision, + list, + reset, + enableRoutineSchedule: ( + companyId: string, + key: string, + routineKey: string, + actor?: { agentId?: string | null; userId?: string | null; runId?: string | null }, + ) => setRoutineSchedule(companyId, key, routineKey, true, actor), + disableRoutineSchedule: ( + companyId: string, + key: string, + routineKey: string, + actor?: { agentId?: string | null; userId?: string | null; runId?: string | null }, + ) => setRoutineSchedule(companyId, key, routineKey, false, actor), + runRoutine, + requireBuiltInAgent, + autoProvisionBundledAgents, + ensureCompanyDefaultAgentGrants, + reconcileDefinitionDefaults, + }; +} + +export async function reconcileBuiltInAgentsOnStartup(db: Db) { + const svc = builtInAgentService(db); + const companyRows = await db + .select({ id: companies.id }) + .from(companies); + let autoEnsured = 0; + let pendingApprovals = 0; + let defaultGrantsEnsured = 0; + for (const company of companyRows) { + const result = await svc.autoProvisionBundledAgents(company.id); + autoEnsured += result.autoEnsured; + pendingApprovals += result.pendingApprovals; + defaultGrantsEnsured += result.defaultGrantsEnsured; + } + const rows = await db + .select({ + companyId: agents.companyId, + metadata: agents.metadata, + status: agents.status, + }) + .from(agents) + .where(ne(agents.status, "terminated")); + const seen = new Set(); + let scanned = 0; + let reconciled = 0; + let unknown = 0; + let duplicates = 0; + + for (const row of rows) { + const marker = readBuiltInAgentMarker(row.metadata); + if (!marker) continue; + scanned += 1; + if (!getBuiltInAgentDefinition(marker.key)) { + unknown += 1; + continue; + } + const instanceKey = `${row.companyId}:${marker.key}`; + if (seen.has(instanceKey)) { + duplicates += 1; + continue; + } + seen.add(instanceKey); + await svc.reconcileDefinitionDefaults(row.companyId, marker.key); + reconciled += 1; + } + + return { scanned, reconciled, unknown, duplicates, autoEnsured, pendingApprovals, defaultGrantsEnsured }; +} diff --git a/server/src/services/change-consent-gate.ts b/server/src/services/change-consent-gate.ts new file mode 100644 index 0000000000..8523f04bca --- /dev/null +++ b/server/src/services/change-consent-gate.ts @@ -0,0 +1,232 @@ +import type { Db } from "@paperclipai/db"; +import { issueThreadInteractions } from "@paperclipai/db"; +import { and, desc, eq, or, sql } from "drizzle-orm"; +import type { RequestConfirmationPayload, RequestConfirmationResult } from "@paperclipai/shared"; +import { forbidden } from "../errors.js"; + +export const AGENT_PROFILE_CHANGE_CONSENT_FIELDS = ["name", "role", "title", "capabilities"] as const; + +type ConsumedRequestConfirmationResult = RequestConfirmationResult & { + consumedAt?: string | null; + consumedByRunId?: string | null; +}; + +export function agentInstructionsChangeTargetKey(agentId: string) { + return `agent:${agentId}:instructions`; +} + +export function agentProfileChangeTargetKey(agentId: string) { + return `agent:${agentId}:profile`; +} + +export function skillChangeTargetKey(skillId: string) { + return `skill:${skillId}`; +} + +export function skillSlugChangeTargetKey(slug: string) { + return `skill-slug:${slug}`; +} + +export function skillImportChangeTargetKey(source: string) { + return `skill-import:${source}`; +} + +export function skillsScanProjectsChangeTargetKey() { + return "skills:scan-projects"; +} + +export function touchesAgentProfileChangeConsentFields(patchData: Record) { + return AGENT_PROFILE_CHANGE_CONSENT_FIELDS.some((key) => + Object.prototype.hasOwnProperty.call(patchData, key), + ); +} + +function readNonEmptyString(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function payloadHasDisplayedDiff(payload: RequestConfirmationPayload) { + const details = readNonEmptyString(payload.detailsMarkdown); + if (!details) return false; + if (/```diff\b/i.test(details)) return true; + return /(^|\n)[+-][^\n]+/.test(details); +} + +function requestConfirmationResultConsumed(result: RequestConfirmationResult | null) { + const consumed = result as ConsumedRequestConfirmationResult | null; + return Boolean(readNonEmptyString(consumed?.consumedByRunId) || readNonEmptyString(consumed?.consumedAt)); +} + +function markRequestConfirmationResultConsumed( + result: RequestConfirmationResult, + actorRunId: string, + consumedAt: Date, +): ConsumedRequestConfirmationResult { + return { + ...result, + consumedAt: consumedAt.toISOString(), + consumedByRunId: actorRunId, + }; +} + +function legacyTargetKeysFor(targetKey: string) { + if (targetKey.startsWith("agent:") && targetKey.endsWith(":instructions")) { + const agentId = targetKey.slice("agent:".length, -":instructions".length); + if (agentId) return [`reflection-coach:agent-instructions:${agentId}`]; + } + if (targetKey.startsWith("agent:") && targetKey.endsWith(":profile")) { + const agentId = targetKey.slice("agent:".length, -":profile".length); + if (agentId) return [`reflection-coach:agent-description:${agentId}`]; + } + if (targetKey.startsWith("skill:")) { + const skillId = targetKey.slice("skill:".length); + if (skillId) return [`reflection-coach:company-skill:${skillId}`]; + } + if (targetKey.startsWith("skill-slug:")) { + const slug = targetKey.slice("skill-slug:".length); + if (slug) return [`reflection-coach:company-skill-slug:${slug}`]; + } + if (targetKey.startsWith("skill-import:")) { + const source = targetKey.slice("skill-import:".length); + if (source) { + return [ + `reflection-coach:company-skill-import:${source}`, + `reflection-coach:company-skill-catalog:${source}`, + ]; + } + } + if (targetKey === "skills:scan-projects") { + return ["reflection-coach:company-skills:scan-projects"]; + } + return []; +} + +function expandTargetKeysForLegacyCompatibility(targetKeys: string[]) { + const expanded = new Set(); + for (const targetKey of targetKeys) { + expanded.add(targetKey); + for (const legacyTargetKey of legacyTargetKeysFor(targetKey)) { + expanded.add(legacyTargetKey); + } + } + return [...expanded]; +} + +export function changeConsentGateService(db: Db) { + return { + assertConsented: async (input: { + companyId: string; + actorAgentId: string | null | undefined; + actorRunId: string | null | undefined; + targetKeys: string[]; + }): Promise => { + const actorAgentId = readNonEmptyString(input.actorAgentId); + if (!actorAgentId) return false; + + const actorRunId = readNonEmptyString(input.actorRunId); + if (!actorRunId) { + throw forbidden("Reflection Coach mutations require a run id", { + code: "reflection_coach_mutation_run_id_required", + }); + } + + const targetKeys = [...new Set(input.targetKeys.map(readNonEmptyString).filter((key): key is string => Boolean(key)))]; + if (targetKeys.length === 0) { + throw forbidden("Reflection Coach mutation target is not gateable", { + code: "reflection_coach_mutation_target_required", + }); + } + const queryTargetKeys = expandTargetKeysForLegacyCompatibility(targetKeys); + + const targetKeyPredicate = or( + ...queryTargetKeys.map((targetKey) => + sql`${issueThreadInteractions.payload}->'target'->>'key' = ${targetKey}`, + ), + ); + + const rows = await db + .select({ + id: issueThreadInteractions.id, + sourceRunId: issueThreadInteractions.sourceRunId, + payload: issueThreadInteractions.payload, + result: issueThreadInteractions.result, + }) + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, input.companyId), + eq(issueThreadInteractions.createdByAgentId, actorAgentId), + eq(issueThreadInteractions.kind, "request_confirmation"), + eq(issueThreadInteractions.status, "accepted"), + targetKeyPredicate, + )) + .orderBy(desc(issueThreadInteractions.resolvedAt), desc(issueThreadInteractions.createdAt)) + .limit(10); + + const accepted = rows.find((row) => { + const payload = row.payload as RequestConfirmationPayload; + const result = row.result as RequestConfirmationResult | null; + return payload.target?.type === "custom" + && queryTargetKeys.includes(payload.target.key) + && result?.outcome === "accepted" + && !requestConfirmationResultConsumed(result) + && payloadHasDisplayedDiff(payload) + && Boolean(row.sourceRunId) + && row.sourceRunId !== actorRunId; + }); + + if (!accepted) { + throw forbidden( + "Reflection Coach mutations require an accepted request_confirmation with a displayed diff for this target, " + + "created in a previous run and not already consumed.", + { + code: "reflection_coach_mutation_gate_required", + targetKeys, + }, + ); + } + + const acceptedResult = accepted.result as RequestConfirmationResult | null; + if (!acceptedResult) { + throw forbidden( + "Reflection Coach mutations require an accepted request_confirmation with a displayed diff for this target, " + + "created in a previous run and not already consumed.", + { + code: "reflection_coach_mutation_gate_required", + targetKeys, + }, + ); + } + + const now = new Date(); + const [consumed] = await db + .update(issueThreadInteractions) + .set({ + result: markRequestConfirmationResultConsumed(acceptedResult, actorRunId, now), + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, accepted.id), + eq(issueThreadInteractions.companyId, input.companyId), + eq(issueThreadInteractions.createdByAgentId, actorAgentId), + eq(issueThreadInteractions.kind, "request_confirmation"), + eq(issueThreadInteractions.status, "accepted"), + sql`${issueThreadInteractions.result}->>'outcome' = 'accepted'`, + sql`coalesce(${issueThreadInteractions.result}->>'consumedByRunId', ${issueThreadInteractions.result}->>'consumedAt') is null`, + )) + .returning({ id: issueThreadInteractions.id }); + + if (!consumed) { + throw forbidden( + "Reflection Coach mutations require an accepted request_confirmation with a displayed diff for this target, " + + "created in a previous run and not already consumed.", + { + code: "reflection_coach_mutation_gate_required", + targetKeys, + }, + ); + } + + return true; + }, + }; +} diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index f33f7187a5..7f7ac07c28 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -33,6 +33,7 @@ import { notFound, unprocessable } from "../errors.js"; import { environmentService } from "./environments.js"; import { heartbeatService } from "./heartbeat.js"; import { logActivity } from "./activity-log.js"; +import { builtInAgentService } from "./built-in-agents.js"; export interface CompanyActivityActor { actorType: "user" | "agent" | "system" | "plugin"; @@ -52,6 +53,7 @@ export function companyService(db: Db) { const ISSUE_PREFIX_FALLBACK = "CMP"; const environmentsSvc = environmentService(db); const heartbeat = heartbeatService(db); + const builtInAgents = builtInAgentService(db); type CompanyTx = Parameters[0]>[0]; @@ -263,6 +265,7 @@ export function companyService(db: Db) { create: async (data: typeof companies.$inferInsert) => { const created = await createCompanyWithUniquePrefix(data); await environmentsSvc.ensureLocalEnvironment(created.id); + await builtInAgents.autoProvisionBundledAgents(created.id); const row = await getCompanyQuery(db) .where(eq(companies.id, created.id)) .then((rows) => rows[0] ?? null); diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts index d3443098d9..0c3057c07d 100644 --- a/server/src/services/company-member-roles.ts +++ b/server/src/services/company-member-roles.ts @@ -28,6 +28,7 @@ export function grantsForHumanRole( case "owner": return [ { permissionKey: "agents:create", scope: null }, + { permissionKey: "agents:configure", scope: null }, { permissionKey: "skills:create", scope: null }, { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, @@ -38,6 +39,7 @@ export function grantsForHumanRole( case "admin": return [ { permissionKey: "agents:create", scope: null }, + { permissionKey: "agents:configure", scope: null }, { permissionKey: "skills:create", scope: null }, { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index b1c1c7216c..01a32c287a 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -3,7 +3,8 @@ import { promises as fs } from "node:fs"; import { execFile } from "node:child_process"; import path from "node:path"; import { promisify } from "node:util"; -import type { Db } from "@paperclipai/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { builtInManagedResources, principalPermissionGrants, type Db } from "@paperclipai/db"; import type { CompanyPortabilityAgentManifestEntry, CompanyPortabilityCollisionStrategy, @@ -29,6 +30,7 @@ import type { CompanyPortabilitySkillManifestEntry, CompanySkill, AgentEnvConfig, + PermissionKey, RoutineVariable, } from "@paperclipai/shared"; import { @@ -48,6 +50,7 @@ import { issueCommentMetadataSchema, issueCommentPresentationSchema, normalizeAgentUrlKey, + PERMISSION_KEYS, } from "@paperclipai/shared"; import { readPaperclipSkillSyncPreference, @@ -77,6 +80,7 @@ import { readCatalogStringList, readPortableCatalogProvenance, } from "./catalog-provenance.js"; +import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js"; import { normalizePortablePath } from "./portable-path.js"; /** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */ @@ -718,6 +722,23 @@ function asBoolean(value: unknown): boolean | null { return typeof value === "boolean" ? value : null; } +type PortableAgentPermissionGrant = CompanyPortabilityAgentManifestEntry["permissionGrants"][number]; + +const VALID_PERMISSION_KEYS = new Set(PERMISSION_KEYS); + +function normalizePortablePermissionGrants(value: unknown): PortableAgentPermissionGrant[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry): PortableAgentPermissionGrant[] => { + if (!isPlainRecord(entry)) return []; + const permissionKey = asString(entry.permissionKey); + if (!permissionKey || !VALID_PERMISSION_KEYS.has(permissionKey as PermissionKey)) return []; + return [{ + permissionKey: permissionKey as PermissionKey, + scope: isPlainRecord(entry.scope) ? entry.scope : null, + }]; + }); +} + function asInteger(value: unknown): number | null { return typeof value === "number" && Number.isInteger(value) ? value : null; } @@ -1924,6 +1945,7 @@ const YAML_KEY_PRIORITY = [ "adapter", "runtime", "permissions", + "permissionGrants", "budgetMonthlyCents", "metadata", ] as const; @@ -2690,6 +2712,7 @@ function buildManifestFromPackageFiles( const extensionAdapter = isPlainRecord(extension.adapter) ? extension.adapter : null; const extensionRuntime = isPlainRecord(extension.runtime) ? extension.runtime : null; const extensionPermissions = isPlainRecord(extension.permissions) ? extension.permissions : null; + const extensionPermissionGrants = normalizePortablePermissionGrants(extension.permissionGrants); const extensionMetadata = isPlainRecord(extension.metadata) ? extension.metadata : null; const adapterConfig = isPlainRecord(extensionAdapter?.config) ? extensionAdapter.config @@ -2713,6 +2736,7 @@ function buildManifestFromPackageFiles( adapterConfig, runtimeConfig, permissions: extensionPermissions ?? {}, + permissionGrants: extensionPermissionGrants, budgetMonthlyCents: typeof extension.budgetMonthlyCents === "number" && Number.isFinite(extension.budgetMonthlyCents) ? Math.max(0, Math.floor(extension.budgetMonthlyCents)) @@ -3002,6 +3026,27 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; const defaultSecretProvider = getConfiguredSecretProvider(); + async function applyImportedAgentPermissionGrants( + companyId: string, + agentId: string, + permissionGrants: PortableAgentPermissionGrant[], + grantedByUserId: string | null, + ) { + if (permissionGrants.length === 0) return; + await access.ensureMembership(companyId, "agent", agentId, "member", "active"); + for (const grant of permissionGrants) { + await access.setPrincipalPermission( + companyId, + "agent", + agentId, + grant.permissionKey, + true, + grantedByUserId, + grant.scope ?? null, + ); + } + } + function assertKnownImportAdapterType(type: string | null | undefined): string { const adapterType = typeof type === "string" ? type.trim() : ""; if (!adapterType) { @@ -3253,24 +3298,61 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package"; let companyLogoPath: string | null = null; + const managedResourceRows = typeof (db as { select?: unknown }).select === "function" + ? await db + .select({ + resourceKind: builtInManagedResources.resourceKind, + resourceId: builtInManagedResources.resourceId, + }) + .from(builtInManagedResources) + .where(eq(builtInManagedResources.companyId, companyId)) + : []; + const managedSkillIds = new Set( + managedResourceRows + .filter((row) => row.resourceKind === "skill") + .map((row) => row.resourceId), + ); + const managedRoutineIds = new Set( + managedResourceRows + .filter((row) => row.resourceKind === "routine") + .map((row) => row.resourceId), + ); + const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : []; const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated"); - const companySkillRows = include.skills || include.agents ? await companySkills.listFull(companyId) : []; + const builtInAgentRows = liveAgentRows.filter((agent) => readBuiltInAgentMarker(agent.metadata)); + const portableAgentRows = liveAgentRows.filter((agent) => !readBuiltInAgentMarker(agent.metadata)); + const companySkillRowsRaw = include.skills || include.agents ? await companySkills.listFull(companyId) : []; + const managedSkillRows = companySkillRowsRaw.filter((skill) => managedSkillIds.has(skill.id)); + const companySkillRows = companySkillRowsRaw.filter((skill) => !managedSkillIds.has(skill.id)); if (include.agents) { const skipped = allAgentRows.length - liveAgentRows.length; if (skipped > 0) { warnings.push(`Skipped ${skipped} terminated agent${skipped === 1 ? "" : "s"} from export.`); } + if (builtInAgentRows.length > 0) { + warnings.push(`Skipped ${builtInAgentRows.length} built-in managed agent${builtInAgentRows.length === 1 ? "" : "s"} from export.`); + } + } + if (include.skills && managedSkillRows.length > 0) { + warnings.push(`Skipped ${managedSkillRows.length} built-in managed skill${managedSkillRows.length === 1 ? "" : "s"} from export.`); } const agentByReference = new Map(); - for (const agent of liveAgentRows) { - agentByReference.set(agent.id, agent); - agentByReference.set(agent.name, agent); + const builtInAgentByReference = new Map(); + const addAgentReferences = (map: Map, agent: typeof liveAgentRows[number]) => { + map.set(agent.id, agent); + map.set(agent.name, agent); const normalizedName = normalizeAgentUrlKey(agent.name); if (normalizedName) { - agentByReference.set(normalizedName, agent); + map.set(normalizedName, agent); } + }; + for (const agent of portableAgentRows) { + addAgentReferences(agentByReference, agent); + } + for (const agent of builtInAgentRows) { + addAgentReferences(builtInAgentByReference, agent); } const selectedAgents = new Map(); @@ -3280,6 +3362,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const normalized = normalizeAgentUrlKey(trimmed) ?? trimmed; const match = agentByReference.get(trimmed) ?? agentByReference.get(normalized); if (!match) { + const builtInMatch = builtInAgentByReference.get(trimmed) ?? builtInAgentByReference.get(normalized); + if (builtInMatch) { + warnings.push(`Agent selector "${selector}" is a built-in managed agent and was skipped.`); + continue; + } warnings.push(`Agent selector "${selector}" was not found and was skipped.`); continue; } @@ -3287,7 +3374,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } if (include.agents && selectedAgents.size === 0) { - for (const agent of liveAgentRows) { + for (const agent of portableAgentRows) { selectedAgents.set(agent.id, agent); } } @@ -3302,13 +3389,49 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const slug = uniqueSlug(baseSlug, usedSlugs); idToSlug.set(agent.id, slug); } + const agentPermissionGrantRows = agentRows.length > 0 && typeof (db as { select?: unknown }).select === "function" + ? await db + .select({ + principalId: principalPermissionGrants.principalId, + permissionKey: principalPermissionGrants.permissionKey, + scope: principalPermissionGrants.scope, + }) + .from(principalPermissionGrants) + .where(and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, "agent"), + inArray(principalPermissionGrants.principalId, agentRows.map((agent) => agent.id)), + )) + : []; + const permissionGrantsByAgentId = new Map(); + for (const row of agentPermissionGrantRows) { + if (!VALID_PERMISSION_KEYS.has(row.permissionKey as PermissionKey)) continue; + const grants = permissionGrantsByAgentId.get(row.principalId) ?? []; + grants.push({ + permissionKey: row.permissionKey as PermissionKey, + scope: isPlainRecord(row.scope) ? row.scope : null, + }); + permissionGrantsByAgentId.set(row.principalId, grants); + } + for (const grants of permissionGrantsByAgentId.values()) { + grants.sort((left, right) => left.permissionKey.localeCompare(right.permissionKey)); + } const projectsSvc = projectService(db); const issuesSvc = issueService(db); const routinesSvc = routineService(db); const allProjectsRaw = include.projects || include.issues ? await projectsSvc.list(companyId) : []; const allProjects = allProjectsRaw.filter((project) => !project.archivedAt); - const allRoutines = include.issues ? await routinesSvc.list(companyId) : []; + const allRoutinesRaw = include.issues ? await routinesSvc.list(companyId) : []; + const builtInRoutineRows = allRoutinesRaw.filter((routine) => + managedRoutineIds.has(routine.id) || routine.originKind === "built_in_agent_bundle" + ); + const allRoutines = allRoutinesRaw.filter((routine) => + !managedRoutineIds.has(routine.id) && routine.originKind !== "built_in_agent_bundle" + ); + if (include.issues && builtInRoutineRows.length > 0) { + warnings.push(`Skipped ${builtInRoutineRows.length} built-in managed routine${builtInRoutineRows.length === 1 ? "" : "s"} from export.`); + } const projectById = new Map(allProjects.map((project) => [project.id, project])); const projectByReference = new Map(); for (const project of allProjects) { @@ -3330,6 +3453,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const selectedIssues = new Map>>(); const selectedRoutines = new Map(); const routineById = new Map(allRoutines.map((routine) => [routine.id, routine])); + const builtInRoutineById = new Map(builtInRoutineRows.map((routine) => [routine.id, routine])); const resolveIssueBySelector = async (selector: string) => { const trimmed = selector.trim(); if (!trimmed) return null; @@ -3340,6 +3464,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const selector of input.issues ?? []) { const issue = await resolveIssueBySelector(selector); if (!issue || issue.companyId !== companyId) { + if (builtInRoutineById.has(selector.trim())) { + warnings.push(`Routine selector "${selector}" is a built-in managed routine and was skipped.`); + continue; + } const routine = routineById.get(selector.trim()); if (routine) { selectedRoutines.set(routine.id, routine); @@ -3551,6 +3679,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { }, ) as Record; const portablePermissions = pruneDefaultLikeValue(agent.permissions ?? {}, { dropFalseBooleans: true }) as Record; + const portablePermissionGrants = permissionGrantsByAgentId.get(agent.id) ?? []; const agentEnvInputs = dedupeEnvInputs( envInputs .slice(envInputsStart) @@ -3593,6 +3722,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { }, runtime: portableRuntimeConfig, permissions: portablePermissions, + permissionGrants: portablePermissionGrants.length > 0 ? portablePermissionGrants : undefined, budgetMonthlyCents: (agent.budgetMonthlyCents ?? 0) > 0 ? agent.budgetMonthlyCents : undefined, metadata: (agent.metadata as Record | null) ?? null, }); @@ -4593,6 +4723,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } catch (err) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } + await applyImportedAgentPermissionGrants( + targetCompany.id, + updated.id, + manifestAgent.permissionGrants ?? [], + actorUserId ?? null, + ); agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null); await secrets.syncEnvBindingsForTarget?.( targetCompany.id, @@ -4634,6 +4770,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } catch (err) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } + await applyImportedAgentPermissionGrants( + targetCompany.id, + created.id, + manifestAgent.permissionGrants ?? [], + actorUserId ?? null, + ); agentStatusById.set(created.id, created.status ?? createdStatus); await secrets.syncEnvBindingsForTarget?.( targetCompany.id, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 4a351f3e4a..43524a5642 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -4,6 +4,19 @@ export { companySearchService } from "./company-search.js"; export { feedbackService } from "./feedback.js"; export { companySkillService } from "./company-skills.js"; export { agentService, deduplicateAgentName } from "./agents.js"; +export { + builtInAgentService, + deriveBuiltInAgentStatus, + getBuiltInAgentDefinition, + listBuiltInAgentDefinitions, + reconcileBuiltInAgentsOnStartup, + validateBuiltInAgentDefinitions, + type BuiltInAgentDefinition, + type BuiltInManagedResourceState, + type BuiltInManagedResourceStockStatus, + type BuiltInAgentState, + type BuiltInAgentStatus, +} from "./built-in-agents.js"; export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js"; export { assetService } from "./assets.js"; export { documentService, extractLegacyPlanBody } from "./documents.js"; diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 55f08f1f29..9cc54d2c92 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -54,6 +54,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false, + enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false, enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false, enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, @@ -76,6 +77,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableExperimentalFileViewer: false, enableCloudSync: false, enableExternalObjects: false, + enableBuiltInAgents: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 54797e9945..b09498d7e9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -10,7 +10,7 @@ import { Dashboard } from "./pages/Dashboard"; import { DashboardLive } from "./pages/DashboardLive"; import { Timeline } from "./pages/Timeline"; import { Companies } from "./pages/Companies"; -import { Agents } from "./pages/Agents"; +import { AGENT_FILTER_TABS, Agents } from "./pages/Agents"; import { AgentDetail } from "./pages/AgentDetail"; import { Projects } from "./pages/Projects"; import { ProjectDetail } from "./pages/ProjectDetail"; @@ -116,10 +116,9 @@ function boardRoutes() { } /> } /> } /> - } /> - } /> - } /> - } /> + {AGENT_FILTER_TABS.map((tab) => ( + } /> + ))} } /> } /> } /> @@ -461,6 +460,9 @@ export function App() { } /> } /> } /> + {AGENT_FILTER_TABS.map((tab) => ( + } /> + ))} } /> } /> } /> diff --git a/ui/src/api/builtInAgents.ts b/ui/src/api/builtInAgents.ts new file mode 100644 index 0000000000..3210402f80 --- /dev/null +++ b/ui/src/api/builtInAgents.ts @@ -0,0 +1,165 @@ +import type { Agent, Approval } from "@paperclipai/shared"; +import { api } from "./client"; + +/** + * Lifecycle of a built-in agent, derived server-side from row existence, + * adapter-config completeness, board-approval state, and `pausedAt`. + * + * `not_provisioned → pending_approval → needs_setup → ready ⇄ paused` + * + * `pending_approval` only occurs when the company requires board approval for + * new agents; otherwise provisioning goes straight to `needs_setup`/`ready`. + */ +export type BuiltInAgentStatus = + | "not_provisioned" + | "pending_approval" + | "needs_setup" + | "ready" + | "paused"; + +/** + * Redacted bundle metadata returned alongside a built-in agent that ships a + * managed resource bundle (instructions + skill + routine). The server strips + * file bodies to key lists; the UI only needs the identity/labels to render the + * bundle status panel. Present only on bundle-backed built-ins (Reflection + * Coach); flat built-ins (briefs/learning) omit it. + */ +export interface BuiltInAgentBundleMeta { + stockVersion: string; + instructions: { entryFile: string; files: string[] }; + skill: { + skillKey: string; + displayName: string; + slug: string; + canonicalKey: string; + files: string[]; + }; + routine: { + routineKey: string; + title: string; + status: "active" | "paused"; + triggerCount: number; + scheduleLabel?: string; + }; +} + +export interface BuiltInAgentDefinition { + key: string; + displayName: string; + featureKeys: string[]; + shortPurpose: string; + defaultInstructions: string; + defaultRole: string; + allowedAdapterTypes?: string[]; + defaultBudgetMonthlyCents?: number; + bundle?: BuiltInAgentBundleMeta; +} + +/** Managed resources a bundle materializes; drift is tracked per kind. */ +export type BuiltInManagedResourceKind = "instructions" | "skill" | "routine"; + +/** + * Drift status of one managed resource versus the shipped stock default: + * - `missing` — expected resource absent; a reconcile will recreate it. + * - `stock_current` — present and byte-identical to the shipped default. + * - `stock_update_available` — unedited, but Paperclip shipped a newer default. + * - `operator_modified` — operator-edited; reconcile preserves these edits. + */ +export type BuiltInManagedResourceStockStatus = + | "missing" + | "stock_current" + | "stock_update_available" + | "operator_modified"; + +export interface BuiltInManagedResourceState { + resourceKind: BuiltInManagedResourceKind; + resourceKey: string; + resourceId: string | null; + stockVersion: string; + stockHash: string; + currentHash: string | null; + stockStatus: BuiltInManagedResourceStockStatus; + /** True when an unedited resource has a newer shipped default to apply. */ + updateAvailable: boolean; + /** True when the resource has drifted and can be reset to the default. */ + resetAvailable: boolean; + changedFiles?: string[]; + /** True when the managed weekly schedule is active and can create background work. */ + scheduleEnabled?: boolean; + /** Pending request_confirmation for a Reflection Coach update proposal, when one exists. */ + pendingUpdateInteractionId?: string | null; + /** Issue containing the pending proposal interaction. */ + pendingUpdateIssueId?: string | null; + pendingUpdateIssueIdentifier?: string | null; +} + +export interface BuiltInAgentState { + definition: BuiltInAgentDefinition; + status: BuiltInAgentStatus; + agentId: string | null; + agent: Agent | null; + pauseReason: string | null; + /** Per-resource drift/readiness for bundle-backed built-ins (may be empty). */ + resources?: BuiltInManagedResourceState[]; + /** Present when provisioning queued a board hire approval (HTTP 202). */ + approval?: Approval | null; +} + +export interface BuiltInAgentProvisionInput { + adapterType?: string; + adapterConfig?: Record; + budgetMonthlyCents?: number; +} + +/** + * Selectors accepted by the reset endpoint. `agent` resets the agent config + * (adapter/model/budget defaults) only; the resource kinds each reset a single + * managed resource back to its shipped default. Omitting the array resets + * everything (the agent-level "Reset to defaults" button). + */ +export type BuiltInResetResource = "agent" | BuiltInManagedResourceKind; + +/** + * Error `code` thrown as HTTP 412 by `requireBuiltInAgent` on the server when a + * feature needs a built-in agent that is missing or not fully configured. The + * configure-on-first-use modal is triggered from this signal. + */ +export const BUILT_IN_AGENT_NOT_CONFIGURED_CODE = "built_in_agent_not_configured"; + +/** + * Warning `code` returned alongside a paused built-in agent so callers can + * surface the use-while-paused toast without treating the agent as ready. + */ +export const BUILT_IN_AGENT_PAUSED_CODE = "built_in_agent_paused"; + +export const builtInAgentsApi = { + list: (companyId: string) => + api.get(`/companies/${companyId}/built-in-agents`), + provision: (companyId: string, key: string, input: BuiltInAgentProvisionInput = {}) => + api.post(`/companies/${companyId}/built-in-agents/${key}/provision`, input), + /** + * Reset built-in defaults. Pass `resources` to scope the reset to specific + * managed resources (e.g. `["skill"]`); omit it to reset the whole agent + + * bundle. A single-resource reset re-applies that resource's newest shipped + * default — the same path used for both "reset drifted edits" and "apply an + * available stock update". + */ + reset: (companyId: string, key: string, resources?: BuiltInResetResource[]) => + api.post( + `/companies/${companyId}/built-in-agents/${key}/reset`, + resources ? { resources } : {}, + ), + /** + * Re-materialize the bundle. Applies the newest shipped defaults to unedited + * (`stock_update_available`) and `missing` resources while preserving + * `operator_modified` edits — it is the safe "apply available updates" path. + */ + reconcile: (companyId: string, key: string) => + api.post(`/companies/${companyId}/built-in-agents/${key}/reconcile`, {}), + runRoutine: (companyId: string, key: string, routineKey: string) => + api.post(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/run`, {}), + enableRoutineSchedule: (companyId: string, key: string, routineKey: string) => + api.post(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/enable`, {}), + disableRoutineSchedule: (companyId: string, key: string, routineKey: string) => + api.post(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/disable`, {}), +}; diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index 56435f97ad..dc13464b06 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useState, type ReactNode } from "react"; import { useNavigate } from "@/lib/router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -18,6 +18,16 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { AgentStatusBadge } from "./StatusBadge"; import { agentsApi } from "../api/agents"; import { ApiError } from "../api/client"; @@ -155,6 +165,8 @@ export function AgentActionButtons({ workActionsDisabledReason, navigateToRunOnInvoke = true, onActionError, + pauseConfirm, + hideTerminate = false, children, className, }: { @@ -168,6 +180,13 @@ export function AgentActionButtons({ workActionsDisabled?: boolean; workActionsDisabledReason?: string; navigateToRunOnInvoke?: boolean; + /** + * When set, pausing prompts a confirmation dialog first (e.g. for built-in + * agents that power a feature). Omit for the immediate-pause default. + */ + pauseConfirm?: { title: string; description: ReactNode }; + /** Hide the Terminate action (e.g. built-in agents are undeletable). */ + hideTerminate?: boolean; /** * Optional inline error reporter. When provided it is used instead of a toast * for action failures (preserves the detail page's inline error banner). When @@ -183,6 +202,7 @@ export function AgentActionButtons({ const { openNewIssue } = useDialogActions(); const { pushToast } = useToastActions(); const [moreOpen, setMoreOpen] = useState(false); + const [pauseConfirmOpen, setPauseConfirmOpen] = useState(false); const resolvedCompanyId = companyId ?? agent.companyId; const canonicalAgentRef = agentRouteRef(agent); @@ -321,12 +341,30 @@ export function AgentActionButtons({ ) : ( agentAction.mutate("pause")} + onPause={() => (pauseConfirm ? setPauseConfirmOpen(true) : agentAction.mutate("pause"))} onResume={() => agentAction.mutate("resume")} disabled={pauseResumeDisabled} size={size} /> )} + {pauseConfirm && ( + + + + {pauseConfirm.title} + +
{pauseConfirm.description}
+
+
+ + Cancel + agentAction.mutate("pause")}> + Pause anyway + + +
+
+ )} {showStatus && ( @@ -372,16 +410,18 @@ export function AgentActionButtons({ Reset Sessions - + {!hideTerminate && ( + + )} diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 518b2df2a5..5b00190ff7 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1579,7 +1579,7 @@ export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmen /* ---- Internal sub-components ---- */ -function AdapterTypeDropdown({ +export function AdapterTypeDropdown({ value, onChange, disabledTypes, @@ -1652,7 +1652,7 @@ function ExperimentalBadge() { ); } -function ModelDropdown({ +export function ModelDropdown({ models, value, onChange, diff --git a/ui/src/components/BuiltInAgentBadges.tsx b/ui/src/components/BuiltInAgentBadges.tsx new file mode 100644 index 0000000000..293e719a74 --- /dev/null +++ b/ui/src/components/BuiltInAgentBadges.tsx @@ -0,0 +1,66 @@ +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { brandChipBadge } from "@/lib/status-colors"; +import type { BuiltInAgentStatus } from "@/api/builtInAgents"; + +/** + * Provenance label ("Built-in"). Constant for the life of a built-in agent — + * this is NOT a lifecycle/status chip, so it never routes through + * `StatusBadge`/`AgentStatusBadge` (ux-spec D2). + */ +export function BuiltInAgentBadge({ + className, + compact = false, +}: { + className?: string; + compact?: boolean; +}) { + return ( + + Built-in + + ); +} + +/** + * Derived lifecycle chip. Rendered for the amber attention states + * (`needs_setup`, `pending_approval`). Kept separate from the real agent status + * (`idle/active/…`) per ux-spec D1. + */ +export function BuiltInLifecycleChip({ + status, + compact = false, + className, +}: { + status: BuiltInAgentStatus; + compact?: boolean; + className?: string; +}) { + if (status !== "needs_setup" && status !== "pending_approval") return null; + const isPendingApproval = status === "pending_approval"; + return ( + + {isPendingApproval ? (compact ? "Approval" : "Pending approval") : compact ? "Setup" : "Needs setup"} + + ); +} diff --git a/ui/src/components/BuiltInAgentGate.test.tsx b/ui/src/components/BuiltInAgentGate.test.tsx new file mode 100644 index 0000000000..8617dd20cf --- /dev/null +++ b/ui/src/components/BuiltInAgentGate.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BuiltInAgentGate } from "./BuiltInAgentGate"; +import type { BuiltInAgentState, BuiltInAgentStatus } from "@/api/builtInAgents"; + +const listMock = vi.hoisted(() => vi.fn()); +const resumeMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/builtInAgents", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + builtInAgentsApi: { list: listMock, provision: vi.fn(), reset: vi.fn() }, + }; +}); + +vi.mock("@/api/agents", () => ({ + agentsApi: { resume: resumeMock }, +})); + +// The configure modal pulls in the full AgentConfigForm; stub it so the gate +// test stays focused on state selection. +vi.mock("@/components/ConfigureBuiltInAgentModal", () => ({ + ConfigureBuiltInAgentModal: ({ open }: { open: boolean }) => + open ?
: null, +})); + +vi.mock("react-router-dom", () => ({ + Link: ({ children, to }: { children: React.ReactNode; to: string }) => {children}, +})); + +function makeState(status: BuiltInAgentStatus, overrides: Partial = {}): BuiltInAgentState { + const provisioned = status !== "not_provisioned"; + return { + definition: { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["briefs"], + shortPurpose: "Prepares briefs.", + defaultInstructions: "…", + defaultRole: "general", + allowedAdapterTypes: ["codex_local"], + defaultBudgetMonthlyCents: 0, + }, + status, + agentId: provisioned ? "agent-1" : null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agent: provisioned + ? ({ id: "agent-1", pausedAt: status === "paused" ? new Date().toISOString() : null } as any) + : null, + pauseReason: null, + ...overrides, + }; +} + +async function flushReact() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +describe("BuiltInAgentGate (PAP-12978)", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + async function renderGate() { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => { + root!.render( + + +
brief content
+
+
, + ); + }); + await flushReact(); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + listMock.mockReset(); + resumeMock.mockReset(); + }); + + afterEach(() => { + flushSync(() => { + root?.unmount(); + }); + root = null; + container.remove(); + }); + + it("renders the setup empty-state for needs_setup and hides the feature", async () => { + listMock.mockResolvedValue([makeState("needs_setup")]); + await renderGate(); + expect(container.textContent).toContain("Set up the Briefs Agent"); + expect(container.textContent).toContain("Configure its model to enable the feature"); + expect(container.querySelector('[data-testid="feature"]')).toBeNull(); + }); + + it("renders the setup empty-state for not_provisioned", async () => { + listMock.mockResolvedValue([makeState("not_provisioned")]); + await renderGate(); + expect(container.textContent).toContain("Set up the Briefs Agent"); + expect(container.querySelector('[data-testid="feature"]')).toBeNull(); + }); + + it("renders a pending-approval state", async () => { + listMock.mockResolvedValue([makeState("pending_approval")]); + await renderGate(); + expect(container.textContent).toContain("pending approval"); + expect(container.querySelector('[data-testid="feature"]')).toBeNull(); + }); + + it("shows the paused banner and keeps children readable (stale)", async () => { + listMock.mockResolvedValue([makeState("paused")]); + await renderGate(); + expect(container.textContent).toContain("Briefs is paused."); + expect(container.textContent).toContain("Resume agent"); + // Paused ≠ hidden — children still render. + expect(container.querySelector('[data-testid="feature"]')).not.toBeNull(); + }); + + it("resumes the agent from the paused banner", async () => { + listMock.mockResolvedValue([makeState("paused")]); + resumeMock.mockResolvedValue({}); + await renderGate(); + const resumeButton = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("Resume agent"), + ); + expect(resumeButton).toBeTruthy(); + flushSync(() => { + resumeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + expect(resumeMock).toHaveBeenCalledWith("agent-1", "c1"); + }); + + it("renders the feature when ready", async () => { + listMock.mockResolvedValue([makeState("ready")]); + await renderGate(); + expect(container.querySelector('[data-testid="feature"]')).not.toBeNull(); + expect(container.textContent).not.toContain("Set up the Briefs Agent"); + }); + + it("fails open to the feature when the key is unknown", async () => { + listMock.mockResolvedValue([makeState("ready", { + definition: { ...makeState("ready").definition, key: "learning" }, + })]); + await renderGate(); + expect(container.querySelector('[data-testid="feature"]')).not.toBeNull(); + }); +}); diff --git a/ui/src/components/BuiltInAgentGate.tsx b/ui/src/components/BuiltInAgentGate.tsx new file mode 100644 index 0000000000..4d7cb8e4a0 --- /dev/null +++ b/ui/src/components/BuiltInAgentGate.tsx @@ -0,0 +1,125 @@ +import { useState, type ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Bot, Clock3 } from "lucide-react"; + +import { EmptyState } from "@/components/EmptyState"; +import { InlineBanner } from "@/components/InlineBanner"; +import { PageSkeleton } from "@/components/PageSkeleton"; +import { Button } from "@/components/ui/button"; +import { ConfigureBuiltInAgentModal } from "@/components/ConfigureBuiltInAgentModal"; +import { builtInAgentsApi, type BuiltInAgentState } from "@/api/builtInAgents"; +import { agentsApi } from "@/api/agents"; +import { queryKeys } from "@/lib/queryKeys"; +import { agentUrl } from "@/lib/utils"; +import { relativeTime } from "@/lib/utils"; + +export interface BuiltInAgentGateProps { + /** Registry key of the built-in agent that powers this feature (e.g. "briefs"). */ + agentKey: string; + companyId: string | null | undefined; + /** Human label for the gated feature. Defaults to the agent's display name. */ + featureLabel?: string; + children: ReactNode; +} + +/** + * Wraps a feature surface that depends on a built-in agent and renders the + * right lifecycle state (ux-spec §4): + * + * - loading → skeleton + * - not_provisioned / needs_setup → setup empty-state + configure modal CTA + * - paused → amber banner + Resume over the (stale) children + * - ready → children + */ +export function BuiltInAgentGate({ agentKey, companyId, featureLabel, children }: BuiltInAgentGateProps) { + const queryClient = useQueryClient(); + const [configureOpen, setConfigureOpen] = useState(false); + + const { data: states, isLoading } = useQuery({ + queryKey: queryKeys.builtInAgents.list(companyId ?? "__none__"), + queryFn: () => builtInAgentsApi.list(companyId!), + enabled: Boolean(companyId), + }); + + const state: BuiltInAgentState | undefined = states?.find((entry) => entry.definition.key === agentKey); + + const resume = useMutation({ + mutationFn: (agentId: string) => agentsApi.resume(agentId, companyId ?? undefined), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(companyId ?? "__none__") }); + if (companyId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); + }, + }); + + // Unknown key or still resolving the company — fail open to the feature. + if (!companyId) return <>{children}; + if (isLoading && !states) return ; + if (!state) return <>{children}; + + const label = featureLabel ?? state.definition.displayName; + + if (state.status === "not_provisioned" || state.status === "needs_setup") { + return ( + <> + setConfigureOpen(true)} + hideActionIcon + /> + + + ); + } + + if (state.status === "pending_approval") { + return ( + + ); + } + + if (state.status === "paused" && state.agent) { + const pausedAt = state.agent.pausedAt ? relativeTime(state.agent.pausedAt) : null; + return ( +
+ + + + + } + > + Its built-in agent was paused{pausedAt ? ` ${pausedAt}` : ""}, so new{" "} + {label.toLowerCase()} isn't being generated. + + {/* Paused ≠ hidden: keep existing content readable, marked stale. */} +
{children}
+
+ ); + } + + return <>{children}; +} diff --git a/ui/src/components/BuiltInBundlePanel.test.tsx b/ui/src/components/BuiltInBundlePanel.test.tsx new file mode 100644 index 0000000000..8a6db316fe --- /dev/null +++ b/ui/src/components/BuiltInBundlePanel.test.tsx @@ -0,0 +1,239 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BuiltInBundlePanel } from "./BuiltInBundlePanel"; +import type { + BuiltInAgentState, + BuiltInManagedResourceState, + BuiltInManagedResourceStockStatus, +} from "@/api/builtInAgents"; + +// The panel links to agent tabs via `@/lib/router` (company-prefixed Link). +// Stub it to a plain anchor so the panel test doesn't need CompanyContext. +vi.mock("@/lib/router", () => ({ + Link: ({ children, to }: { children: React.ReactNode; to: string }) => {children}, +})); + +function resource( + resourceKind: BuiltInManagedResourceState["resourceKind"], + stockStatus: BuiltInManagedResourceStockStatus, + overrides: Partial = {}, +): BuiltInManagedResourceState { + return { + resourceKind, + resourceKey: resourceKind === "skill" ? "reflection-coach" : resourceKind === "routine" ? "recent-agent-reflection" : "AGENTS.md", + resourceId: "res-1", + stockVersion: "2026-07-08", + stockHash: "aaaa", + currentHash: stockStatus === "missing" ? null : stockStatus === "stock_current" ? "aaaa" : "bbbb", + stockStatus, + updateAvailable: stockStatus === "stock_update_available" || stockStatus === "operator_modified", + resetAvailable: stockStatus !== "stock_current", + ...overrides, + }; +} + +function makeState( + status: BuiltInAgentState["status"], + resources: BuiltInManagedResourceState[], +): BuiltInAgentState { + return { + definition: { + key: "reflection-coach", + displayName: "Reflection Coach", + featureKeys: ["reflection"], + shortPurpose: "Coaches recent agents.", + defaultInstructions: "…", + defaultRole: "general", + allowedAdapterTypes: ["codex_local"], + defaultBudgetMonthlyCents: 0, + bundle: { + stockVersion: "2026-07-08", + instructions: { entryFile: "AGENTS.md", files: ["AGENTS.md"] }, + skill: { + skillKey: "reflection-coach", + displayName: "reflection-coach", + slug: "reflection-coach", + canonicalKey: "paperclipai/bundled/paperclip-operations/reflection-coach", + files: ["reflection-coach/SKILL.md"], + }, + routine: { + routineKey: "recent-agent-reflection", + title: "Recent agent reflection", + status: "paused", + triggerCount: 1, + scheduleLabel: "Weekly · Mon 09:00 UTC", + }, + }, + }, + status, + agentId: "agent-1", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agent: { id: "agent-1", pausedAt: status === "paused" ? new Date().toISOString() : null } as any, + pauseReason: null, + resources, + }; +} + +async function flushReact() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +const READY_RESOURCES = [ + resource("skill", "stock_current"), + resource("instructions", "stock_current"), + resource("routine", "stock_current"), +]; + +describe("BuiltInBundlePanel (PAP-13099)", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + function render(state: BuiltInAgentState, handlers: Partial<{ + onConfigure: () => void; + onResetResource: (kind: BuiltInManagedResourceState["resourceKind"]) => void; + onRunRoutine: (routineKey: string) => void; + onEnableSchedule: (routineKey: string) => void; + onDisableSchedule: (routineKey: string) => void; + }> = {}) { + root = createRoot(container); + flushSync(() => { + root!.render( + {})} + onResetResource={handlers.onResetResource ?? (() => {})} + onRunRoutine={handlers.onRunRoutine ?? (() => {})} + onEnableSchedule={handlers.onEnableSchedule ?? (() => {})} + onDisableSchedule={handlers.onDisableSchedule ?? (() => {})} + />, + ); + }); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + flushSync(() => root?.unmount()); + root = null; + container.remove(); + // Radix portals dialog content onto body; clear leftovers between tests. + document.body.querySelectorAll("[data-slot='alert-dialog-portal']").forEach((node) => node.remove()); + }); + + it("renders four resource rows with ready + schedule-off chips when healthy", () => { + render(makeState("ready", READY_RESOURCES)); + const text = container.textContent ?? ""; + expect(text).toContain("Bundle status"); + expect(text).toContain("Adapter"); + expect(text).toContain("Skill"); + expect(text).toContain("Instructions"); + expect(text).toContain("Routine"); + // Zero-token guarantee copy is always present on the routine row. + expect(text).toContain("costs zero tokens by default"); + expect(text).toContain("Schedule off"); + expect(text).toContain("Ready"); + expect(text).toContain("Run once"); + expect(text).toContain("Enable weekly"); + }); + + it("shows the active weekly schedule and disable action when enabled", () => { + render(makeState("ready", [ + resource("skill", "stock_current"), + resource("instructions", "stock_current"), + resource("routine", "stock_current", { scheduleEnabled: true }), + ])); + const text = container.textContent ?? ""; + expect(text).toContain("Weekly · Mon 09:00 UTC"); + expect(text).toContain("can create background work"); + expect(text).toContain("Disable schedule"); + expect(text).not.toContain("Enable weekly"); + }); + + it("links to a pending proposal interaction when the routine resource reports one", () => { + render(makeState("ready", [ + resource("skill", "stock_current"), + resource("instructions", "stock_current"), + resource("routine", "stock_current", { + pendingUpdateInteractionId: "interaction-1", + pendingUpdateIssueId: "issue-1", + pendingUpdateIssueIdentifier: "PAP-42", + }), + ])); + const link = Array.from(container.querySelectorAll("a")).find((anchor) => anchor.textContent === "Review proposal"); + expect(container.textContent).toContain("Proposal pending"); + expect(link?.getAttribute("href")).toBe("/issues/PAP-42#interaction-interaction-1"); + }); + + it("shows Needs setup for the adapter when the agent is not configured yet", () => { + render(makeState("needs_setup", READY_RESOURCES)); + const text = container.textContent ?? ""; + expect(text).toContain("Needs setup"); + expect(text).toContain("Pick an adapter this coach can run on"); + }); + + it("surfaces an update-available chip and Update action for unedited stock drift", () => { + render(makeState("ready", [ + resource("skill", "stock_current"), + resource("instructions", "stock_update_available"), + resource("routine", "stock_current"), + ])); + const text = container.textContent ?? ""; + expect(text).toContain("Update available"); + expect(text).toContain("Paperclip shipped a newer default"); + // The per-resource Update trigger button is present. + const buttons = Array.from(container.querySelectorAll("button")).map((b) => b.textContent); + expect(buttons).toContain("Update"); + }); + + it("surfaces a Drifted chip and Reset action for operator-modified resources", () => { + render(makeState("ready", [ + resource("skill", "operator_modified"), + resource("instructions", "stock_current"), + resource("routine", "stock_current"), + ])); + const text = container.textContent ?? ""; + expect(text).toContain("Drifted"); + expect(text).toContain("Your changes are kept"); + const buttons = Array.from(container.querySelectorAll("button")).map((b) => b.textContent); + expect(buttons).toContain("Reset"); + }); + + it("shows a Missing chip when a resource is not materialized", () => { + render(makeState("ready", [ + resource("skill", "missing"), + resource("instructions", "stock_current"), + resource("routine", "stock_current"), + ])); + expect(container.textContent).toContain("Missing"); + }); + + it("fires onConfigure when the adapter Configure button is clicked", () => { + const onConfigure = vi.fn(); + render(makeState("needs_setup", READY_RESOURCES), { onConfigure }); + const configureBtn = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent === "Configure", + ); + expect(configureBtn).toBeTruthy(); + flushSync(() => configureBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onConfigure).toHaveBeenCalledTimes(1); + }); + + it("renders nothing for a built-in without a bundle", () => { + const flat = makeState("ready", READY_RESOURCES); + delete flat.definition.bundle; + render(flat); + expect(container.textContent).toBe(""); + }); +}); diff --git a/ui/src/components/BuiltInBundlePanel.tsx b/ui/src/components/BuiltInBundlePanel.tsx new file mode 100644 index 0000000000..a65c4d4c2f --- /dev/null +++ b/ui/src/components/BuiltInBundlePanel.tsx @@ -0,0 +1,435 @@ +import type { ReactNode } from "react"; + +import { Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { ResourceStatusChip, type ResourceStatusVariant } from "@/components/ResourceStatusChip"; +import { cn } from "@/lib/utils"; +import type { + BuiltInAgentState, + BuiltInManagedResourceKind, + BuiltInManagedResourceState, +} from "@/api/builtInAgents"; + +/** + * Bundle status panel for a bundle-backed built-in agent (Reflection Coach — + * [PAP-13099], ux-spec §3–§8). Renders one row per managed resource + * (adapter · skill · instructions · routine, dependency order) with a readiness + * chip, drift chip, inline copy, and the wireable per-resource actions. + * + * Presentational: the parent owns queries/mutations and passes handlers. The + * confirm-before-mutate dialogs and copy live here (ux-spec §8). Adapter + * readiness is derived from the agent lifecycle `status` (there is no adapter + * resource in `resources[]`); skill/instructions/routine come from + * `state.resources`. + * + * Both "apply an available stock update" and "reset drifted edits" route + * through the same scoped reset (`onResetResource(kind)` → + * `built-in-agents/:key/reset { resources: [kind] }`), which re-materializes + * that one resource to Paperclip's newest shipped default without touching + * adapter credentials or the other resources. + */ + +function findResource( + resources: BuiltInManagedResourceState[] | undefined, + kind: BuiltInManagedResourceKind, +): BuiltInManagedResourceState | undefined { + return resources?.find((resource) => resource.resourceKind === kind); +} + +/** Readiness chip for a materialized resource. */ +function readinessVariant(resource: BuiltInManagedResourceState): ResourceStatusVariant { + if (resource.stockStatus === "missing") return "missing"; + return "ready"; +} + +/** Drift chip shown alongside a `ready` readiness chip, or `null`. */ +function driftVariant(resource: BuiltInManagedResourceState): ResourceStatusVariant | null { + if (resource.stockStatus === "missing") return null; // readiness wins; drift suppressed + if (resource.stockStatus === "stock_update_available") return "update_available"; + if (resource.stockStatus === "operator_modified") return "drifted"; + return null; +} + +interface ResourceActionCopy { + title: string; + body: string; + confirmLabel: string; + triggerLabel: string; +} + +/** Confirm-dialog copy per drift state (ux-spec §8 copy deck). */ +function resourceActionCopy( + resource: BuiltInManagedResourceState, + label: string, +): ResourceActionCopy | null { + if (resource.stockStatus === "stock_update_available") { + return { + title: `Update ${label} to the newest default?`, + body: `You haven't edited this, so Paperclip will replace it with the newer shipped version. Nothing you customized is affected, and your adapter credentials and settings are not touched.`, + confirmLabel: "Update", + triggerLabel: "Update", + }; + } + if (resource.stockStatus === "operator_modified") { + return { + title: `Reset ${label} to the shipped default?`, + body: `This replaces your edited version with Paperclip's current default. Your edits can't be recovered. Adapter credentials and settings are not touched.`, + confirmLabel: `Reset ${label}`, + triggerLabel: "Reset", + }; + } + if (resource.stockStatus === "missing") { + return { + title: `Recreate ${label}?`, + body: `This resource is missing. Paperclip will recreate it from the shipped default. Adapter credentials and settings are not touched.`, + confirmLabel: "Recreate", + triggerLabel: "Recreate", + }; + } + return null; +} + +function ResourceActionButton({ + resource, + label, + onConfirm, + pending, +}: { + resource: BuiltInManagedResourceState; + label: string; + onConfirm: () => void; + pending: boolean; +}) { + const copy = resourceActionCopy(resource, label); + if (!copy) return null; + return ( + + + + + + + {copy.title} + {copy.body} + + + Cancel + {copy.confirmLabel} + + + + ); +} + +function ConfirmActionButton({ + title, + body, + triggerLabel, + confirmLabel, + pending, + onConfirm, +}: { + title: string; + body: string; + triggerLabel: string; + confirmLabel: string; + pending: boolean; + onConfirm: () => void; +}) { + return ( + + + + + + + {title} + {body} + + + Cancel + {confirmLabel} + + + + ); +} + +interface BundleRowProps { + label: string; + secondary?: string; + chips: ReactNode; + detail?: ReactNode; + detailTone?: "muted" | "error"; + actions?: ReactNode; +} + +function BundleRow({ label, secondary, chips, detail, detailTone = "muted", actions }: BundleRowProps) { + return ( +
+
+
+ {label} + {secondary && ( + {secondary} + )} + {chips} +
+ {detail && ( +

+ {detail} +

+ )} +
+ {actions &&
{actions}
} +
+ ); +} + +function driftDetail(resource: BuiltInManagedResourceState): string | undefined { + switch (resource.stockStatus) { + case "operator_modified": + return "You've edited this. Your changes are kept until you reset."; + case "stock_update_available": + return "Paperclip shipped a newer default."; + case "missing": + return "Not materialized yet — recreate it from the shipped default."; + default: + return undefined; + } +} + +export interface BuiltInBundlePanelProps { + state: BuiltInAgentState; + /** Route ref used to link View › actions to the agent's tabs. */ + agentRef: string; + /** Opens the adapter configure modal. */ + onConfigure: () => void; + /** Scoped reset for one resource (apply update / reset drift / recreate). */ + onResetResource: (kind: BuiltInManagedResourceKind) => void; + /** Trigger the managed routine once without enabling its weekly schedule. */ + onRunRoutine?: (routineKey: string) => void; + /** Enable the managed routine's weekly schedule. */ + onEnableSchedule?: (routineKey: string) => void; + /** Disable the managed routine's weekly schedule. */ + onDisableSchedule?: (routineKey: string) => void; + /** The resource kind whose reset is currently in flight, if any. */ + resettingResource?: BuiltInManagedResourceKind | null; + routineActionPending?: "run" | "enable" | "disable" | null; + className?: string; +} + +export function BuiltInBundlePanel({ + state, + agentRef, + onConfigure, + onResetResource, + onRunRoutine, + onEnableSchedule, + onDisableSchedule, + resettingResource = null, + routineActionPending = null, + className, +}: BuiltInBundlePanelProps) { + const { status, definition, resources } = state; + const bundle = definition.bundle; + if (!bundle) return null; + + const adapterReady = status === "ready" || status === "paused"; + + // --- Adapter row (derived from the agent lifecycle status) ----------------- + let adapterChip: ResourceStatusVariant = "ready"; + let adapterDetail: string | undefined; + if (status === "pending_approval") { + adapterChip = "pending_approval"; + adapterDetail = "Waiting on board hire approval before this coach can run."; + } else if (!adapterReady) { + adapterChip = "needs_setup"; + adapterDetail = "Pick an adapter this coach can run on."; + } + + const skill = findResource(resources, "skill"); + const instructions = findResource(resources, "instructions"); + const routine = findResource(resources, "routine"); + const scheduleEnabled = routine?.scheduleEnabled === true; + const routineKey = bundle.routine.routineKey; + const scheduleLabel = bundle.routine.scheduleLabel ?? "Weekly schedule"; + const proposalIssueRef = routine?.pendingUpdateIssueIdentifier ?? routine?.pendingUpdateIssueId ?? null; + const proposalHref = proposalIssueRef && routine?.pendingUpdateInteractionId + ? `/issues/${proposalIssueRef}#interaction-${routine.pendingUpdateInteractionId}` + : null; + + const renderResourceRow = ( + kind: BuiltInManagedResourceKind, + label: string, + secondary: string, + viewHref: string, + resource: BuiltInManagedResourceState, + ) => { + const drift = driftVariant(resource); + return ( + + + {drift && } + + } + detail={driftDetail(resource)} + actions={ + <> + + onResetResource(kind)} + pending={resettingResource === kind} + /> + + } + /> + ); + }; + + return ( +
+

Bundle status

+ +
+ {/* Adapter — no resource entry; readiness is the agent lifecycle. */} + } + detail={adapterDetail} + actions={ + + } + /> + + {skill && + renderResourceRow( + "skill", + "Skill", + bundle.skill.displayName || skill.resourceKey, + `/agents/${agentRef}/skills`, + skill, + )} + + {instructions && + renderResourceRow( + "instructions", + "Instructions", + bundle.instructions.entryFile, + `/agents/${agentRef}/instructions`, + instructions, + )} + + {/* Routine — zero-token-by-default; the weekly schedule ships off. */} + + + {routine && driftVariant(routine) && ( + + )} + + } + detail={ + scheduleEnabled + ? "The weekly schedule is enabled and can create background work." + : "Nothing runs until you enable the weekly schedule — it costs zero tokens by default." + } + actions={ + routine ? ( + <> + {onRunRoutine && ( + onRunRoutine(routineKey)} + /> + )} + {scheduleEnabled + ? onDisableSchedule && ( + onDisableSchedule(routineKey)} + /> + ) + : onEnableSchedule && ( + onEnableSchedule(routineKey)} + /> + )} + {driftVariant(routine) && ( + onResetResource("routine")} + pending={resettingResource === "routine"} + /> + )} + + ) : undefined + } + /> + {proposalHref && ( + } + detail="A proposed Reflection Coach update is waiting for review." + actions={ + + } + /> + )} +
+
+ ); +} diff --git a/ui/src/components/ConfigureBuiltInAgentModal.test.tsx b/ui/src/components/ConfigureBuiltInAgentModal.test.tsx new file mode 100644 index 0000000000..ae609518a9 --- /dev/null +++ b/ui/src/components/ConfigureBuiltInAgentModal.test.tsx @@ -0,0 +1,250 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ConfigureBuiltInAgentModal } from "./ConfigureBuiltInAgentModal"; +import type { BuiltInAgentState } from "@/api/builtInAgents"; + +const provisionMock = vi.hoisted(() => vi.fn()); +const updateMock = vi.hoisted(() => vi.fn()); +const adapterModelsMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/builtInAgents", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, builtInAgentsApi: { list: vi.fn(), provision: provisionMock, reset: vi.fn() } }; +}); + +vi.mock("@/api/agents", () => ({ + agentsApi: { update: updateMock, adapterModels: adapterModelsMock }, +})); + +vi.mock("@/adapters/metadata", () => ({ + listAdapterOptions: () => [ + { value: "codex_local", label: "Codex" }, + { value: "claude_local", label: "Claude" }, + { value: "process", label: "Process" }, + ], +})); + +// Stub the shared pickers so the test can drive them without the full form. +vi.mock("@/components/AgentConfigForm", () => ({ + AdapterTypeDropdown: ({ value }: { value: string }) => ( +
+ ), + ModelDropdown: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => ( + onChange((e.target as HTMLInputElement).value)} + /> + ), +})); + +vi.mock("@/components/agent-config-primitives", () => ({ + Field: ({ label, children }: { label: string; children: React.ReactNode }) => ( + + ), +})); + +function makeState(overrides: Partial = {}): BuiltInAgentState { + return { + definition: { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["briefs"], + shortPurpose: "Prepares briefs.", + defaultInstructions: "…", + defaultRole: "general", + allowedAdapterTypes: ["codex_local", "claude_local"], + defaultBudgetMonthlyCents: 0, + }, + status: "not_provisioned", + agentId: null, + agent: null, + pauseReason: null, + ...overrides, + }; +} + +async function flushReact() { + for (let index = 0; index < 6; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +function findButton(text: string): HTMLButtonElement | undefined { + return Array.from(document.body.querySelectorAll("button")).find((b) => + b.textContent?.includes(text), + ) as HTMLButtonElement | undefined; +} + +describe("ConfigureBuiltInAgentModal (PAP-12978)", () => { + let container: HTMLDivElement; + let root: Root | null = null; + const onOpenChange = vi.fn(); + const onConfigured = vi.fn(); + + async function renderModal(state: BuiltInAgentState = makeState()) { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => { + root!.render( + + + , + ); + }); + await flushReact(); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + provisionMock.mockReset(); + updateMock.mockReset(); + adapterModelsMock.mockReset().mockResolvedValue([]); + onOpenChange.mockReset(); + onConfigured.mockReset(); + }); + + afterEach(() => { + flushSync(() => { + root?.unmount(); + }); + root = null; + container.remove(); + }); + + it("disables submit until a model is chosen, then provisions with adapter + model", async () => { + provisionMock.mockResolvedValue({ ...makeState(), status: "ready", agentId: "a1" }); + await renderModal(); + + const submit = findButton("Configure"); + expect(submit).toBeTruthy(); + expect(submit!.disabled).toBe(true); + + const modelInput = document.body.querySelector('[data-testid="model-input"]') as HTMLInputElement; + expect(modelInput).toBeTruthy(); + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + flushSync(() => { + setter.call(modelInput, "gpt-5"); + modelInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + + const submitReady = findButton("Configure")!; + expect(submitReady.disabled).toBe(false); + flushSync(() => { + submitReady.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(provisionMock).toHaveBeenCalledWith("c1", "briefs", { + adapterType: "codex_local", + adapterConfig: { model: "gpt-5" }, + }); + expect(onConfigured).toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("sends the budget with provisioning so approval-gated setup preserves it", async () => { + provisionMock.mockResolvedValue({ + ...makeState(), + status: "pending_approval", + agentId: "a1", + approval: { id: "approval-1", status: "pending" }, + }); + await renderModal(); + + const modelInput = document.body.querySelector('[data-testid="model-input"]') as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + flushSync(() => { + setter.call(modelInput, "gpt-5"); + modelInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + + const budgetInput = document.body.querySelector('input[type="number"]') as HTMLInputElement; + flushSync(() => { + setter.call(budgetInput, "50"); + budgetInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + + flushSync(() => { + findButton("Configure")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(provisionMock).toHaveBeenCalled(); + expect(provisionMock).toHaveBeenCalledWith("c1", "briefs", { + adapterType: "codex_local", + adapterConfig: { model: "gpt-5" }, + budgetMonthlyCents: 5000, + }); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("provisions non-model adapters so command fields can be completed later", async () => { + provisionMock.mockResolvedValue({ ...makeState(), status: "needs_setup", agentId: "a1" }); + await renderModal(makeState({ + definition: { + ...makeState().definition, + allowedAdapterTypes: ["process"], + }, + })); + + expect(document.body.textContent).toContain("needs command or endpoint fields"); + expect(document.body.querySelector('[data-testid="model-input"]')).toBeNull(); + const submit = findButton("Provision"); + expect(submit).toBeTruthy(); + expect(submit!.disabled).toBe(false); + flushSync(() => { + submit!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(provisionMock).toHaveBeenCalledWith("c1", "briefs", { + adapterType: "process", + adapterConfig: {}, + }); + expect(onConfigured).toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("surfaces provision errors inline instead of closing", async () => { + const { ApiError } = await import("@/api/client"); + provisionMock.mockRejectedValue(new ApiError("Adapter not allowed", 422, null)); + await renderModal(); + + const modelInput = document.body.querySelector('[data-testid="model-input"]') as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + flushSync(() => { + setter.call(modelInput, "gpt-5"); + modelInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + + flushSync(() => { + findButton("Configure")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(document.body.textContent).toContain("Adapter not allowed"); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); +}); diff --git a/ui/src/components/ConfigureBuiltInAgentModal.tsx b/ui/src/components/ConfigureBuiltInAgentModal.tsx new file mode 100644 index 0000000000..535ac90b5d --- /dev/null +++ b/ui/src/components/ConfigureBuiltInAgentModal.tsx @@ -0,0 +1,229 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field } from "@/components/agent-config-primitives"; +import { AdapterTypeDropdown, ModelDropdown } from "@/components/AgentConfigForm"; +import { InlineBanner } from "@/components/InlineBanner"; +import { listAdapterOptions } from "@/adapters/metadata"; +import { agentsApi } from "@/api/agents"; +import { queryKeys } from "@/lib/queryKeys"; +import { ApiError } from "@/api/client"; +import { + builtInAgentsApi, + type BuiltInAgentState, +} from "@/api/builtInAgents"; + +/** Adapters whose config completeness is keyed on a non-empty `model`. */ +function isModelBasedAdapter(adapterType: string): boolean { + return !["process", "command", "http", "openclaw_gateway", "hermes_gateway"].includes(adapterType); +} + +function defaultAdapterType(state: BuiltInAgentState): string { + return state.definition.allowedAdapterTypes?.[0] ?? "codex_local"; +} + +function parseBudgetMonthlyCents(value: string): number | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const cents = Math.round(Number(trimmed) * 100); + return Number.isFinite(cents) && cents >= 0 ? cents : undefined; +} + +export interface ConfigureBuiltInAgentModalProps { + companyId: string; + state: BuiltInAgentState; + open: boolean; + onOpenChange: (open: boolean) => void; + /** Called after a successful provision (e.g. to navigate to the agent). */ + onConfigured?: (result: BuiltInAgentState) => void; +} + +/** + * Configure-on-first-use modal for a built-in agent. Reuses the shared + * `AdapterTypeDropdown` + `ModelDropdown` (ux-spec D6 — no second model picker), + * plus an optional monthly budget, and submits to the provision endpoint. + */ +export function ConfigureBuiltInAgentModal({ + companyId, + state, + open, + onOpenChange, + onConfigured, +}: ConfigureBuiltInAgentModalProps) { + const queryClient = useQueryClient(); + const { definition } = state; + + const [adapterType, setAdapterType] = useState( + () => state.agent?.adapterType ?? defaultAdapterType(state), + ); + const [model, setModel] = useState(() => { + const config = state.agent?.adapterConfig; + return typeof config === "object" && config !== null && typeof (config as Record).model === "string" + ? ((config as Record).model as string) + : ""; + }); + const [modelOpen, setModelOpen] = useState(false); + const [budgetDollars, setBudgetDollars] = useState(() => { + const cents = definition.defaultBudgetMonthlyCents ?? 0; + return cents > 0 ? String(cents / 100) : ""; + }); + const [error, setError] = useState(null); + + // Restrict adapter choices to the registry's allow-list. Non-model adapters + // are still selectable: provisioning creates the row, then full agent config + // collects command/endpoint fields while the built-in remains `needs_setup`. + const disabledTypes = useMemo(() => { + const allowed = new Set(definition.allowedAdapterTypes ?? []); + return new Set( + listAdapterOptions() + .map((option) => option.value) + .filter((value) => allowed.size > 0 && !allowed.has(value)), + ); + }, [definition.allowedAdapterTypes]); + + const setupSupportedInModal = isModelBasedAdapter(adapterType); + + const { data: fetchedModels } = useQuery({ + queryKey: queryKeys.agents.adapterModels(companyId, adapterType, null), + queryFn: () => agentsApi.adapterModels(companyId, adapterType, {}), + enabled: open && Boolean(companyId) && setupSupportedInModal, + }); + const models = fetchedModels ?? []; + + const modelRequired = setupSupportedInModal; + const budgetMonthlyCents = parseBudgetMonthlyCents(budgetDollars); + const budgetValid = !budgetDollars.trim() || budgetMonthlyCents !== undefined; + const canSubmit = budgetValid && (setupSupportedInModal ? !modelRequired || model.trim().length > 0 : true); + const submitLabel = setupSupportedInModal + ? `Configure & enable ${definition.displayName}` + : `Provision ${definition.displayName}`; + + const provision = useMutation({ + mutationFn: async () => { + const adapterConfig: Record = {}; + if (model.trim()) adapterConfig.model = model.trim(); + const result = await builtInAgentsApi.provision(companyId, definition.key, { + adapterType, + adapterConfig, + ...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}), + }); + return result; + }, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); + if (result.agentId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(result.agentId) }); + } + onConfigured?.(result); + onOpenChange(false); + }, + onError: (err) => { + setError(err instanceof ApiError ? err.message : "Failed to configure the built-in agent."); + }, + }); + + return ( + (provision.isPending ? undefined : onOpenChange(next))}> + + + Set up the {definition.displayName} + {definition.shortPurpose} + + +
+ + Creates {definition.displayName} in your roster, badged{" "} + Built-in. Companies that require hire approval will queue this for the + board. + + + + { + setAdapterType(next); + setModel(""); + }} + disabledTypes={disabledTypes} + /> + + + {modelRequired && ( + // ModelDropdown supplies its own "Model" Field label + hint. + + )} + + {!setupSupportedInModal && ( + + This adapter needs command or endpoint fields before it can run. Provision the + built-in row now, then finish those fields from the full agent configuration. + + )} + + +
+ $ + setBudgetDollars(event.target.value)} + className="w-32" + /> + / month +
+
+ + {error && ( +

+ {error} +

+ )} +
+ + + + + +
+
+ ); +} diff --git a/ui/src/components/EmptyState.tsx b/ui/src/components/EmptyState.tsx index 2e2612ab8f..9fcfc35d90 100644 --- a/ui/src/components/EmptyState.tsx +++ b/ui/src/components/EmptyState.tsx @@ -4,21 +4,33 @@ import { Button } from "@/components/ui/button"; interface EmptyStateProps { icon: LucideIcon; + /** Optional bold heading rendered above the message. */ + title?: string; message: string; action?: string; onAction?: () => void; + /** Hide the leading "+" glyph on the action button (e.g. for a "Set up" CTA). */ + hideActionIcon?: boolean; } -export function EmptyState({ icon: Icon, message, action, onAction }: EmptyStateProps) { +export function EmptyState({ + icon: Icon, + title, + message, + action, + onAction, + hideActionIcon = false, +}: EmptyStateProps) { return (
-

{message}

+ {title &&

{title}

} +

{message}

{action && onAction && ( )} diff --git a/ui/src/components/EntityRow.test.tsx b/ui/src/components/EntityRow.test.tsx index d4f7eb9b27..5b94343405 100644 --- a/ui/src/components/EntityRow.test.tsx +++ b/ui/src/components/EntityRow.test.tsx @@ -59,4 +59,37 @@ describe("EntityRow", () => { const markup = renderToStaticMarkup(); expect(markup).toContain("min-w-0 flex-1"); }); + + it("gives the title a min-width floor and lets meta shrink under titlePriority (PAP-12988)", () => { + const markup = renderToStaticMarkup( + chips} + />, + ); + + // The name keeps a usable floor instead of collapsing to zero... + expect(markup).toContain("min-w-(--sz-6rem)"); + // ...and the meta cluster is the item that yields (shrinks), not the title. + expect(markup).toContain("min-w-0 shrink"); + expect(markup).not.toContain('class="flex items-center gap-2 shrink-0"'); + }); + + it("stacks a secondaryRow on its own line beneath the main row (PAP-12988)", () => { + const markup = renderToStaticMarkup( + chips-inline} + secondaryRow={chips-stacked} + />, + ); + + // The secondary content renders, and the shell switches from a single flex + // row to a stacked block layout so the cluster gets its own full-width line. + expect(markup).toContain("secondary-cell"); + expect(markup).toContain("chips-stacked"); + expect(markup).not.toMatch(/^
{leading &&
{leading}
} -
+
{identifier && ( @@ -78,23 +106,46 @@ export function EntityRow({

)}
- {meta &&
{meta}
} + {meta && ( +
+ {meta} +
+ )} {meta &&
} {trailing &&
{trailing}
} ); + // With a secondaryRow, wrap the main line in its own flex row and stack the + // secondary content beneath it (indented to align under the title, past the + // leading capsule). Without it, `content` is rendered directly (unchanged). + const body = secondaryRow ? ( + <> +
{content}
+
{secondaryRow}
+ + ) : ( + content + ); + if (to) { return ( - - {content} + + {body} ); } return ( -
- {content} +
+ {body}
); } diff --git a/ui/src/components/InlineBanner.tsx b/ui/src/components/InlineBanner.tsx new file mode 100644 index 0000000000..670c6dba4d --- /dev/null +++ b/ui/src/components/InlineBanner.tsx @@ -0,0 +1,69 @@ +import type { LucideIcon } from "lucide-react"; +import { Info, AlertTriangle } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; +import { brandBanner, type BannerTone } from "@/lib/status-colors"; + +const TONE_ICON: Record = { + info: Info, + warning: AlertTriangle, +}; + +export interface InlineBannerProps { + /** Visual tone. `info` (blue) for provenance/context, `warning` (amber) for paused/attention. */ + tone?: BannerTone; + /** Optional bold heading rendered above the body. */ + title?: ReactNode; + /** Body content. */ + children?: ReactNode; + /** Override the leading icon, or pass `false` to omit it. */ + icon?: LucideIcon | false; + /** Optional trailing actions (buttons/links) rendered on the right at ≥sm, wrapped below on mobile. */ + actions?: ReactNode; + /** Denser padding for embedding inside modals/dialogs. */ + compact?: boolean; + className?: string; +} + +/** + * Token-backed inline banner used for full-width informational and warning + * notices. Follows the existing bespoke-banner convention (`border … bg-… + * rounded-lg p-4`) but centralizes the color recipe in `brandBanner` so + * feature surfaces don't hand-roll `bg-yellow-*`/`bg-blue-*` variants. + * + * See `/design-guide` for tone examples. + */ +export function InlineBanner({ + tone = "info", + title, + children, + icon, + actions, + compact = false, + className, +}: InlineBannerProps) { + const Icon = icon === false ? null : (icon ?? TONE_ICON[tone]); + return ( +
+
+ {Icon &&
+ {actions && ( +
{actions}
+ )} +
+ ); +} diff --git a/ui/src/components/ResourceStatusChip.tsx b/ui/src/components/ResourceStatusChip.tsx new file mode 100644 index 0000000000..119946a13f --- /dev/null +++ b/ui/src/components/ResourceStatusChip.tsx @@ -0,0 +1,101 @@ +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { brandChipBadge, type BrandChipColor } from "@/lib/status-colors"; + +/** + * The load-bearing visual grammar for the built-in bundle status panel + * (Reflection Coach — [PAP-13099], ux-spec §4). Each variant double-encodes + * state as glyph + word + color so it never relies on color alone + * (WCAG 1.4.1). Colors route through the shared `brandChipBadge` families — no + * bespoke tints are minted here (ux-spec §10). + * + * A single resource shows at most one readiness chip and at most one drift + * chip; when both a readiness problem and a drift state coexist, the caller + * suppresses the drift chip until readiness is `ready` (ux-spec §4). + */ +export type ResourceStatusVariant = + | "ready" + | "needs_setup" + | "missing" + | "error" + | "update_available" + | "drifted" + | "schedule_off" + | "schedule_on" + | "pending_approval" + | "proposal_pending"; + +interface VariantSpec { + color: BrandChipColor; + glyph: string; + label: string; + title: string; +} + +const VARIANTS: Record = { + ready: { color: "green", glyph: "●", label: "Ready", title: "Materialized and matches the shipped default" }, + needs_setup: { color: "amber", glyph: "⚠", label: "Needs setup", title: "Present but not usable yet" }, + missing: { color: "amber", glyph: "⚠", label: "Missing", title: "Expected resource absent; reconcile will recreate it" }, + error: { color: "red", glyph: "✕", label: "Error", title: "Failed to load or reconcile" }, + update_available: { + color: "blue", + glyph: "↑", + label: "Update available", + title: "Unedited — a newer shipped default can be applied", + }, + drifted: { + color: "gray", + glyph: "✎", + label: "Drifted", + title: "You've edited this; your changes are kept, not overwritten", + }, + schedule_off: { + color: "gray", + glyph: "◌", + label: "Schedule off", + title: "No background work runs until you enable it — costs zero tokens", + }, + schedule_on: { color: "green", glyph: "●", label: "Weekly", title: "Runs on the weekly schedule" }, + pending_approval: { + color: "amber", + glyph: "⚠", + label: "Pending approval", + title: "Waiting on board hire approval before it can run", + }, + proposal_pending: { + color: "blue", + glyph: "↑", + label: "Proposal pending", + title: "A proposed update is waiting for your review", + }, +}; + +export function ResourceStatusChip({ + variant, + label, + compact = false, + className, +}: { + variant: ResourceStatusVariant; + /** Override the default label (e.g. "Weekly · Mon 09:00 UTC"). */ + label?: string; + compact?: boolean; + className?: string; +}) { + const spec = VARIANTS[variant]; + return ( + + + {label ?? spec.label} + + ); +} diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index fac08cccc4..f1f4b2dc8f 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -18,6 +18,8 @@ import { useDialogActions } from "../context/DialogContext"; import { useSidebar } from "../context/SidebarContext"; import { useToastActions } from "../context/ToastContext"; import { agentsApi } from "../api/agents"; +import { builtInAgentsApi, type BuiltInAgentStatus } from "../api/builtInAgents"; +import { BuiltInAgentBadge, BuiltInLifecycleChip } from "./BuiltInAgentBadges"; import { authApi } from "../api/auth"; import { heartbeatsApi } from "../api/heartbeats"; import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll"; @@ -112,6 +114,7 @@ function SidebarAgentItem({ rail, runCount, setSidebarOpen, + builtInStatus, starred = false, onToggleStar, starPending = false, @@ -127,6 +130,7 @@ function SidebarAgentItem({ rail: boolean; runCount: number; setSidebarOpen: (open: boolean) => void; + builtInStatus?: BuiltInAgentStatus; starred?: boolean; onToggleStar?: (agent: Agent, starred: boolean) => void; starPending?: boolean; @@ -147,6 +151,10 @@ function SidebarAgentItem({ : isPaused && hasInvalidOrgChain ? "Invalid org chain" : pauseResumeLabel; + const trailingLabel = [ + builtInStatus ? `Built-in agent ${builtInStatus.replace(/_/g, " ")}` : null, + hasInvalidOrgChain ? "Invalid reporting chain" : null, + ].filter(Boolean).join(", ") || undefined; // C11 (DECISION-SHEET.md): the row itself is a SidebarNavItem, so agent rows // share the nav-row chrome (type, active state, rail tooltip, live dot). @@ -157,6 +165,7 @@ function SidebarAgentItem({ iconNode={} active={isActive} liveCount={runCount} + labelClassName={builtInStatus ? "min-w-(--sz-4_5rem) flex-initial" : undefined} className={cn( "min-w-0 flex-1", // Reserve room for the hover ⋯ menu; starred rows widen it for the @@ -164,11 +173,21 @@ function SidebarAgentItem({ starred && !isMobile ? "pr-14" : "pr-8", )} trailing={ - hasInvalidOrgChain ? ( - + builtInStatus || hasInvalidOrgChain ? ( + + {builtInStatus ? ( + <> + + + + ) : null} + {hasInvalidOrgChain ? ( + + ) : null} + ) : undefined } - trailingLabel={hasInvalidOrgChain ? "Invalid reporting chain" : undefined} + trailingLabel={trailingLabel} liveAccessory={ agent.pauseReason === "budget" ? : undefined } @@ -291,6 +310,18 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const { data: builtInAgents } = useQuery({ + queryKey: queryKeys.builtInAgents.list(selectedCompanyId!), + queryFn: () => builtInAgentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const builtInStatusByAgentId = useMemo(() => { + const map = new Map(); + for (const entry of builtInAgents ?? []) { + if (entry.agentId) map.set(entry.agentId, entry.status); + } + return map; + }, [builtInAgents]); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), @@ -517,6 +548,7 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } rail={rail} runCount={liveCountByAgent.get(agent.id) ?? 0} setSidebarOpen={setSidebarOpen} + builtInStatus={builtInStatusByAgentId.get(agent.id)} starred={isStarredRow || isStarred(membershipsQuery.data, "agent", agent.id)} onToggleStar={toggleStarAgent} starPending={agentStarPending(agent)} diff --git a/ui/src/components/SidebarNavItem.test.tsx b/ui/src/components/SidebarNavItem.test.tsx index 959a80fd1b..cd99a274cc 100644 --- a/ui/src/components/SidebarNavItem.test.tsx +++ b/ui/src/components/SidebarNavItem.test.tsx @@ -68,6 +68,10 @@ describe("SidebarNavItem", () => { return container.querySelector("a") as HTMLAnchorElement; } + function classTokens(element: Element | null | undefined) { + return element?.className.toString().split(/\s+/).filter(Boolean) ?? []; + } + it("shows the full label and numeric badge when expanded", () => { render(); @@ -89,8 +93,8 @@ describe("SidebarNavItem", () => { const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox"); expect(label).toBeTruthy(); expect(label?.className).not.toContain("sr-only"); - expect(label?.className).toContain("w-0"); - expect(label?.className).toContain("overflow-hidden"); + expect(classTokens(label)).toContain("w-0"); + expect(classTokens(label)).toContain("overflow-hidden"); // The numeric count is no longer rendered as text; it is a dot with an // accessible text equivalent on the link. @@ -142,8 +146,8 @@ describe("SidebarNavItem", () => { ); const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox"); - expect(label?.className).not.toContain("w-0"); - expect(label?.className).toContain("flex-1"); + expect(classTokens(label)).not.toContain("w-0"); + expect(classTokens(label)).toContain("flex-1"); // Full numeric badge, no rail aria-label, no tooltip wrapper. expect(container.textContent).toContain("28"); expect(link().getAttribute("aria-label")).toBeNull(); diff --git a/ui/src/components/SidebarNavItem.tsx b/ui/src/components/SidebarNavItem.tsx index e7d7619afa..b9856e2ad7 100644 --- a/ui/src/components/SidebarNavItem.tsx +++ b/ui/src/components/SidebarNavItem.tsx @@ -44,6 +44,7 @@ interface SidebarNavItemProps { iconNode?: ReactNode; end?: boolean; className?: string; + labelClassName?: string; badge?: number; badgeTone?: "default" | "danger"; /** @@ -75,6 +76,7 @@ export function SidebarNavItem({ iconNode, end, className, + labelClassName, badge, badgeTone = "default", badgeLabel, @@ -156,7 +158,7 @@ export function SidebarNavItem({ /> )} - {label} + {label} {!rail && trailing} {!rail && textBadge && ( ["agents", companyId, "detect-model", adapterType] as const, }, + builtInAgents: { + list: (companyId: string) => ["built-in-agents", companyId] as const, + }, issues: { list: (companyId: string) => ["issues", companyId] as const, mentionPool: (companyId: string) => ["issues", companyId, "mention-pool"] as const, diff --git a/ui/src/lib/status-colors.ts b/ui/src/lib/status-colors.ts index f96808db89..fa6b2d3b73 100644 --- a/ui/src/lib/status-colors.ts +++ b/ui/src/lib/status-colors.ts @@ -167,6 +167,24 @@ export const runningLabelText = "text-[#1D4ED8] dark:text-[#2563EB]"; * (liveness), `todo` amber (queued), `in_review` violet (awaiting review), * `done` green, `blocked` red, `backlog`/`cancelled` gray (inert). */ + +// --------------------------------------------------------------------------- +// Inline banner tones (built-in agents provenance / paused notices) +// +// Softer, full-width banner surface derived from the same brand hue anchors as +// `brandChipBadge`. `info` (blue) carries provenance/informational context; +// `warning` (amber) carries paused/attention context. Consumed by +// `` so feature banners stay token-backed instead of hand-rolling +// per-instance `bg-yellow-*`/`bg-blue-*` recipes. +// --------------------------------------------------------------------------- + +export type BannerTone = "info" | "warning"; + +export const brandBanner: Record = { + info: "border-[#2563EB]/40 bg-[#DBEAFE]/50 text-[#1D4ED8] dark:border-[#2563eb59] dark:bg-[#2563eb14] dark:text-[#93C5FD]", + warning: "border-[#F59E0B]/50 bg-[#FEF3C7]/60 text-[#B45309] dark:border-[#f59e0b59] dark:bg-[#f59e0b12] dark:text-[#F59E0B]", +}; + export const issueStatusColor: Record = { backlog: "gray", todo: "amber", diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 5563a5f492..981f3ffe0b 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -7,6 +7,8 @@ import { type ClaudeLoginResult, type AgentPermissionUpdate, } from "../api/agents"; +import { builtInAgentsApi, type BuiltInManagedResourceKind } from "../api/builtInAgents"; +import { companySkillsApi } from "../api/companySkills"; import { budgetsApi } from "../api/budgets"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -41,6 +43,10 @@ import { StarToggle } from "../components/StarToggle"; import { Identity } from "../components/Identity"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentActionButtons } from "../components/AgentActionButtons"; +import { InlineBanner } from "../components/InlineBanner"; +import { BuiltInAgentBadge } from "../components/BuiltInAgentBadges"; +import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel"; +import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; import { TrustPresetSection } from "../components/TrustPresetSection"; import { FileTree, buildFileTree } from "../components/FileTree"; @@ -707,6 +713,80 @@ export function AgentDetail() { ? resourceMembershipState(membershipsQuery.data, "agent", resolvedAgentId) : "joined"; + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + enabled: !!resolvedCompanyId, + }); + const builtInAgentsEnabled = experimentalSettings?.enableBuiltInAgents === true; + const { data: builtInStates } = useQuery({ + queryKey: queryKeys.builtInAgents.list(resolvedCompanyId!), + queryFn: () => builtInAgentsApi.list(resolvedCompanyId!), + enabled: !!resolvedCompanyId && builtInAgentsEnabled, + }); + const builtInState = builtInAgentsEnabled + ? builtInStates?.find((entry) => entry.agentId === resolvedAgentId) ?? null + : null; + const builtInFeatureLabel = builtInState + ? builtInState.definition.featureKeys + .map((key) => key.charAt(0).toUpperCase() + key.slice(1)) + .join(", ") + : ""; + const invalidateBuiltIn = useCallback(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(resolvedCompanyId!) }); + if (resolvedAgentId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(resolvedAgentId) }); + } + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); + }, [queryClient, resolvedCompanyId, resolvedAgentId, routeAgentRef]); + + const resetBuiltIn = useMutation({ + mutationFn: () => builtInAgentsApi.reset(resolvedCompanyId!, builtInState!.definition.key), + onSuccess: invalidateBuiltIn, + }); + + const [showBuiltInConfigure, setShowBuiltInConfigure] = useState(false); + const resetBuiltInResource = useMutation({ + mutationFn: (kind: BuiltInManagedResourceKind) => + builtInAgentsApi.reset(resolvedCompanyId!, builtInState!.definition.key, [kind]), + onSuccess: invalidateBuiltIn, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to update bundle resource"); + }, + }); + const runBuiltInRoutine = useMutation({ + mutationFn: (routineKey: string) => + builtInAgentsApi.runRoutine(resolvedCompanyId!, builtInState!.definition.key, routineKey), + onSuccess: invalidateBuiltIn, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to run built-in routine"); + }, + }); + const enableBuiltInSchedule = useMutation({ + mutationFn: (routineKey: string) => + builtInAgentsApi.enableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey), + onSuccess: invalidateBuiltIn, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to enable routine schedule"); + }, + }); + const disableBuiltInSchedule = useMutation({ + mutationFn: (routineKey: string) => + builtInAgentsApi.disableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey), + onSuccess: invalidateBuiltIn, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to disable routine schedule"); + }, + }); + const builtInRoutineActionPending = + runBuiltInRoutine.isPending + ? "run" + : enableBuiltInSchedule.isPending + ? "enable" + : disableBuiltInSchedule.isPending + ? "disable" + : null; + const { data: runtimeState } = useQuery({ queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef), queryFn: () => agentsApi.runtimeState(resolvedAgentId!, resolvedCompanyId ?? undefined), @@ -1022,7 +1102,10 @@ export function AgentDetail() {
-

{agent.name}

+
+

{agent.name}

+ {builtInState && } +

{roleLabels[agent.role] ?? agent.role} {agent.title ? ` - ${agent.title}` : ""} @@ -1051,6 +1134,21 @@ export function AgentDetail() { workActionsDisabled={hasInvalidOrgChain} workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs" onActionError={setActionError} + hideTerminate={Boolean(builtInState)} + pauseConfirm={ + builtInState + ? { + title: `Pause the ${builtInState.definition.displayName}?`, + description: ( + <> + {builtInFeatureLabel} depends on this agent. While paused,{" "} + {builtInFeatureLabel.toLowerCase()} generation is skipped and the{" "} + {builtInFeatureLabel} page shows a warning. + + ), + } + : undefined + } > {mobileLiveRun && (

+ {builtInState && ( + resetBuiltIn.mutate()} + disabled={resetBuiltIn.isPending} + > + {resetBuiltIn.isPending ? "Resetting…" : "Reset to defaults"} + + } + > + Ships with Paperclip and powers {builtInFeatureLabel}. Configure it like + any agent — model, instructions, budget. It can be paused but not deleted; pausing it + pauses {builtInFeatureLabel}. + + )} + + {builtInState?.definition.bundle && ( + setShowBuiltInConfigure(true)} + onResetResource={(kind) => resetBuiltInResource.mutate(kind)} + onRunRoutine={(routineKey) => runBuiltInRoutine.mutate(routineKey)} + onEnableSchedule={(routineKey) => enableBuiltInSchedule.mutate(routineKey)} + onDisableSchedule={(routineKey) => disableBuiltInSchedule.mutate(routineKey)} + resettingResource={resetBuiltInResource.isPending ? resetBuiltInResource.variables ?? null : null} + routineActionPending={builtInRoutineActionPending} + /> + )} + + {builtInState && resolvedCompanyId && ( + { + setShowBuiltInConfigure(false); + invalidateBuiltIn(); + }} + /> + )} + {!urlRunId && ( ({ + pathname: "/agents/all", + navigate: vi.fn(), +})); + const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn(), org: vi.fn(), })); +const mockBuiltInAgentsApi = vi.hoisted(() => ({ + list: vi.fn(), + provision: vi.fn(), + reset: vi.fn(), +})); + const mockEnvironmentsApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn(), @@ -41,8 +53,8 @@ vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( {children} ), - useLocation: () => ({ pathname: "/agents/all", search: "", hash: "", state: null }), - useNavigate: () => vi.fn(), + useLocation: () => ({ pathname: mockRouterState.pathname, search: "", hash: "", state: null }), + useNavigate: () => mockRouterState.navigate, })); vi.mock("../context/CompanyContext", () => ({ @@ -65,6 +77,10 @@ vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi, })); +vi.mock("../api/builtInAgents", () => ({ + builtInAgentsApi: mockBuiltInAgentsApi, +})); + vi.mock("../api/environments", () => ({ environmentsApi: mockEnvironmentsApi, })); @@ -124,6 +140,24 @@ function makeAgent(overrides: Partial): Agent { }; } +function makeBuiltInAgentState(overrides: Partial = {}): BuiltInAgentState { + return { + definition: { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["Briefs"], + shortPurpose: "Generates briefs.", + defaultInstructions: "You are Paperclip's built-in Briefs agent.", + defaultRole: "engineer", + }, + status: "ready", + agentId: "built-in-agent", + agent: null, + pauseReason: null, + ...overrides, + }; +} + function makeEnvironment(overrides: Partial): Environment { return { id: "env-1", @@ -181,9 +215,11 @@ const environmentCapabilities: EnvironmentCapabilities = { function makeInstanceSettings({ defaultEnvironmentId = null, enableEnvironments = true, + enableBuiltInAgents = false, }: { defaultEnvironmentId?: string | null; enableEnvironments?: boolean; + enableBuiltInAgents?: boolean; } = {}) { return { id: "instance-settings-1", @@ -209,6 +245,7 @@ function makeInstanceSettings({ enableExperimentalFileViewer: false, enableCloudSync: false, enableExternalObjects: false, + enableBuiltInAgents, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -263,6 +300,8 @@ describe("Agents", () => { let queryClient: QueryClient; beforeEach(() => { + mockRouterState.pathname = "/agents/all"; + mockRouterState.navigate.mockClear(); container = document.createElement("div"); document.body.appendChild(container); root = null; @@ -286,6 +325,7 @@ describe("Agents", () => { reports: [], }, ]); + mockBuiltInAgentsApi.list.mockResolvedValue([]); mockEnvironmentsApi.list.mockResolvedValue([ makeEnvironment({ id: "env-daytona" }), ]); @@ -402,6 +442,58 @@ describe("Agents", () => { expect(subtitle?.classList.contains("truncate")).toBe(false); }); + it("uses the built-in agents route segment as the built-in filter", async () => { + mockRouterState.pathname = "/agents/builtin"; + mockInstanceSettingsApi.get.mockResolvedValue(makeInstanceSettings({ enableBuiltInAgents: true })); + const builtInAgent = makeAgent({ + id: "built-in-agent", + name: "Briefs Agent", + urlKey: "briefs-agent", + }); + const regularAgent = makeAgent({ + id: "regular-agent", + name: "Regular Agent", + urlKey: "regular-agent", + }); + mockAgentsApi.list.mockResolvedValue([builtInAgent, regularAgent]); + mockAgentsApi.org.mockResolvedValue([ + { + id: "built-in-agent", + name: "Briefs Agent", + role: "engineer", + status: "active", + reports: [], + }, + { + id: "regular-agent", + name: "Regular Agent", + role: "engineer", + status: "active", + reports: [], + }, + ]); + mockBuiltInAgentsApi.list.mockResolvedValue([ + makeBuiltInAgentState({ agentId: "built-in-agent", agent: builtInAgent }), + ]); + + root = createRoot(container); + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("1 agent"); + expect(container.textContent).toContain("Briefs Agent"); + expect(container.textContent).not.toContain("Regular Agent"); + }); + it("shows effective environment and sandbox provider beside agents", async () => { mockAgentsApi.list.mockResolvedValue([ makeAgent({ @@ -720,6 +812,82 @@ describe("Agents", () => { expect(container.querySelector('select[aria-label="Group agents"]')).toBeNull(); }); + it("hides built-in agent surfaces while the experimental flag is disabled", async () => { + mockRouterState.pathname = "/agents/builtin"; + + root = createRoot(container); + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(mockBuiltInAgentsApi.list).not.toHaveBeenCalled(); + expect(container.textContent).not.toContain("Built-in"); + expect(mockRouterState.navigate).toHaveBeenCalledWith("/agents/all", { replace: true }); + }); + + it("shows and filters built-in agents when the experimental flag is enabled", async () => { + mockRouterState.pathname = "/agents/builtin"; + mockInstanceSettingsApi.get.mockResolvedValue(makeInstanceSettings({ enableBuiltInAgents: true })); + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ + id: "built-in-agent", + name: "Briefs Agent", + urlKey: "briefs-agent", + }), + makeAgent({ + id: "regular-agent", + name: "Regular Agent", + urlKey: "regular-agent", + }), + ]); + mockAgentsApi.org.mockResolvedValue([ + { + id: "built-in-agent", + name: "Briefs Agent", + role: "engineer", + status: "active", + reports: [], + }, + { + id: "regular-agent", + name: "Regular Agent", + role: "engineer", + status: "active", + reports: [], + }, + ]); + mockBuiltInAgentsApi.list.mockResolvedValue([ + makeBuiltInAgentState({ agentId: "built-in-agent" }), + ]); + + root = createRoot(container); + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(mockBuiltInAgentsApi.list).toHaveBeenCalledWith("company-1"); + expect(container.textContent).toContain("Built-in"); + expect(container.textContent).toContain("Briefs Agent"); + expect(container.textContent).not.toContain("Regular Agent"); + expect(mockRouterState.navigate).not.toHaveBeenCalledWith("/agents/all", { replace: true }); + }); + it("gives list-view rows a fixed-width title so meta columns align (PAP-86)", async () => { root = createRoot(container); await act(async () => { diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 4c6ec29e47..558076ec50 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, lazy, Suspense } from "react"; import { Link, useNavigate, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { agentsApi, type OrgNode } from "../api/agents"; +import { builtInAgentsApi, type BuiltInAgentState } from "../api/builtInAgents"; import { environmentsApi } from "../api/environments"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -15,6 +16,7 @@ import { AgentActionButtons } from "../components/AgentActionButtons"; import { MembershipAction } from "../components/MembershipAction"; import { StarToggle } from "../components/StarToggle"; import { EntityRow } from "../components/EntityRow"; +import { BuiltInAgentBadge, BuiltInLifecycleChip } from "../components/BuiltInAgentBadges"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils"; @@ -34,7 +36,28 @@ import { getAdapterLabel } from "../adapters/adapter-display-registry"; const roleLabels = AGENT_ROLE_LABELS as Record; -type FilterTab = "all" | "active" | "paused" | "error"; +// Lazy-loaded so the roster page doesn't statically pull in the full +// AgentConfigForm module graph (the modal reuses its adapter/model pickers). +const ConfigureBuiltInAgentModal = lazy(() => + import("../components/ConfigureBuiltInAgentModal").then((m) => ({ + default: m.ConfigureBuiltInAgentModal, + })), +); + +export const AGENT_FILTER_TABS = ["all", "active", "paused", "error", "builtin"] as const; +type FilterTab = (typeof AGENT_FILTER_TABS)[number]; + +const AGENT_FILTER_TAB_ITEMS: { value: FilterTab; label: string }[] = [ + { value: "all", label: "All" }, + { value: "active", label: "Active" }, + { value: "paused", label: "Paused" }, + { value: "error", label: "Error" }, + { value: "builtin", label: "Built-in" }, +]; + +function isFilterTab(value: string): value is FilterTab { + return (AGENT_FILTER_TABS as readonly string[]).includes(value); +} interface EnvironmentDescriptor { label: string; @@ -67,9 +90,14 @@ function matchesFilter(status: string, tab: FilterTab): boolean { return true; } -function filterAgents(agents: Agent[], tab: FilterTab): Agent[] { +function filterAgents(agents: Agent[], tab: FilterTab, builtInAgentIds: Set): Agent[] { return agents - .filter((a) => !HIDDEN_AGENT_STATUSES.has(a.status) && matchesFilter(a.status, tab)) + .filter((a) => { + if (HIDDEN_AGENT_STATUSES.has(a.status)) return false; + // The `builtin` filter keys on the built-in marker, not agent status. + if (tab === "builtin") return builtInAgentIds.has(a.id); + return matchesFilter(a.status, tab); + }) .sort((a, b) => a.name.localeCompare(b.name)); } @@ -135,17 +163,20 @@ function resolveAgentEnvironment( : describeMissingEnvironment(environmentId); } -function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] { +function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set): OrgNode[] { return nodes .reduce((acc, node) => { - const filteredReports = filterOrgTree(node.reports, tab); + const filteredReports = filterOrgTree(node.reports, tab, builtInAgentIds); // Hidden agents (terminated / pending_approval) never render as a row, but // any visible reports are promoted so the tree doesn't lose live agents. if (HIDDEN_AGENT_STATUSES.has(node.status)) { acc.push(...filteredReports); return acc; } - if (matchesFilter(node.status, tab) || filteredReports.length > 0) { + const nodeMatches = tab === "builtin" + ? builtInAgentIds.has(node.id) + : matchesFilter(node.status, tab); + if (nodeMatches || filteredReports.length > 0) { acc.push({ ...node, reports: filteredReports }); } return acc; @@ -161,11 +192,39 @@ export function Agents() { const location = useLocation(); const { isMobile } = useSidebar(); const pathSegment = location.pathname.split("/").pop() ?? "all"; - const tab: FilterTab = (pathSegment === "all" || pathSegment === "active" || pathSegment === "paused" || pathSegment === "error") ? pathSegment : "all"; + const requestedTab: FilterTab = isFilterTab(pathSegment) ? pathSegment : "all"; const [view, setView] = useState<"list" | "org">("org"); const forceListView = isMobile; const effectiveView: "list" | "org" = forceListView ? "list" : view; + const { data: instanceSettings } = useQuery({ + queryKey: queryKeys.instance.settings, + queryFn: () => instanceSettingsApi.get(), + enabled: !!selectedCompanyId, + }); + const builtInAgentsEnabled = instanceSettings?.experimental.enableBuiltInAgents === true; + const tab: FilterTab = requestedTab === "builtin" && !builtInAgentsEnabled ? "all" : requestedTab; + const visibleTabItems = useMemo( + () => AGENT_FILTER_TAB_ITEMS.filter((item) => item.value !== "builtin" || builtInAgentsEnabled), + [builtInAgentsEnabled], + ); + + const { data: builtInAgents } = useQuery({ + queryKey: queryKeys.builtInAgents.list(selectedCompanyId!), + queryFn: () => builtInAgentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && builtInAgentsEnabled, + }); + const builtInByAgentId = useMemo(() => { + const map = new Map(); + if (!builtInAgentsEnabled) return map; + for (const entry of builtInAgents ?? []) { + if (entry.agentId) map.set(entry.agentId, entry); + } + return map; + }, [builtInAgents, builtInAgentsEnabled]); + const builtInAgentIds = useMemo(() => new Set(builtInByAgentId.keys()), [builtInByAgentId]); + const [configureState, setConfigureState] = useState(null); + const { data: agents, isLoading, error } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), @@ -178,11 +237,6 @@ export function Agents() { enabled: !!selectedCompanyId && effectiveView === "org", }); - const { data: instanceSettings } = useQuery({ - queryKey: queryKeys.instance.settings, - queryFn: () => instanceSettingsApi.get(), - enabled: !!selectedCompanyId, - }); const environmentsEnabled = instanceSettings?.experimental.enableEnvironments === true; const { data: environments } = useQuery({ @@ -253,6 +307,12 @@ export function Agents() { setBreadcrumbs([{ label: "Agents" }]); }, [setBreadcrumbs]); + useEffect(() => { + if (selectedCompanyId && requestedTab === "builtin" && instanceSettings && !builtInAgentsEnabled) { + navigate("/agents/all", { replace: true }); + } + }, [builtInAgentsEnabled, instanceSettings, navigate, requestedTab, selectedCompanyId]); + if (!selectedCompanyId) { return ; } @@ -261,8 +321,8 @@ export function Agents() { return ; } - const filtered = filterAgents(agents ?? [], tab); - const filteredOrg = filterOrgTree(orgTree ?? [], tab); + const filtered = filterAgents(agents ?? [], tab, builtInAgentIds); + const filteredOrg = filterOrgTree(orgTree ?? [], tab, builtInAgentIds); const environmentDataLoading = environmentsEnabled && environments === undefined; const showEnvironmentColumn = environmentsEnabled && (environments === undefined || environments.length > 1); const resolveRenderedEnvironment = (agentId: string) => ( @@ -280,6 +340,33 @@ export function Agents() { const agentStarPending = agentPending && membershipMutation.variables?.starred !== undefined; const agentJoinLeavePending = agentPending && membershipMutation.variables?.starred === undefined; const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id); + const builtInState = builtInByAgentId.get(agent.id); + // Provenance badge + lifecycle chip + inline `Set up`. Rendered inline in + // `meta` at xl (where there's room and the meta columns align) and on a + // dedicated full-width line beneath the name below xl, so the chips never + // starve the name — the row's primary identifier — at narrow widths. + const builtInCluster = builtInState ? ( + <> + + + {builtInState.status === "needs_setup" && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + )} + + ) : null; return ( )} + secondaryRow={ + builtInCluster ? ( +
+ {builtInCluster} +
+ ) : undefined + } meta={ -
- +
+ {builtInCluster && ( +
+ {builtInCluster} +
+ )} +
+ +
} metaSpacerClassName="hidden xl:block" @@ -385,12 +486,7 @@ export function Agents() {
navigate(`/agents/${v}`)}> navigate(`/agents/${v}`)} /> @@ -476,6 +572,8 @@ export function Agents() { tab={tab} memberships={membershipsQuery.data} membershipMutation={membershipMutation} + builtInByAgentId={builtInByAgentId} + onConfigureBuiltIn={setConfigureState} /> ))}
@@ -492,6 +590,18 @@ export function Agents() { No organizational hierarchy defined.

)} + {configureState && selectedCompanyId && ( + + { + if (!open) setConfigureState(null); + }} + /> + + )}
); } @@ -507,6 +617,8 @@ function OrgTreeNode({ tab, memberships, membershipMutation, + builtInByAgentId, + onConfigureBuiltIn, }: { node: OrgNode; depth: number; @@ -518,8 +630,11 @@ function OrgTreeNode({ tab: FilterTab; memberships: ReturnType["data"]; membershipMutation: ReturnType; + builtInByAgentId: Map; + onConfigureBuiltIn: (state: BuiltInAgentState) => void; }) { const agent = agentMap.get(node.id); + const builtInState = builtInByAgentId.get(node.id); const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain"); const membershipState = resourceMembershipState(memberships, "agent", node.id); const pending = membershipMutation.isPending && @@ -544,14 +659,35 @@ function OrgTreeNode({ ) : ( )} - {/* min-w-0 + truncate so deep indentation on narrow screens shortens - the name with an ellipsis instead of overflowing the row. */} -
- {node.name} - - {roleLabels[node.role] ?? node.role} - {agent?.title ? ` - ${agent.title}` : ""} - +
+ {/* Name floor + `truncate` keeps the primary identifier readable; the + cluster wraps to a second line under pressure instead of starving + the name at narrow widths. */} +
+ {node.name} + + {roleLabels[node.role] ?? node.role} + {agent?.title ? ` - ${agent.title}` : ""} + +
+ {builtInState && ( +
+ + + {builtInState.status === "needs_setup" && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + )} +
+ )}
@@ -639,6 +775,8 @@ function OrgTreeNode({ tab={tab} memberships={memberships} membershipMutation={membershipMutation} + builtInByAgentId={builtInByAgentId} + onConfigureBuiltIn={onConfigureBuiltIn} /> ))}
diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 361f86369b..6ec47167e7 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -24,6 +24,8 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { InlineBanner } from "@/components/InlineBanner"; +import { BuiltInAgentBadge, BuiltInLifecycleChip } from "@/components/BuiltInAgentBadges"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Checkbox } from "@/components/ui/checkbox"; @@ -423,6 +425,7 @@ export function DesignGuide() { "StatusBadge", "StatusIcon", "PriorityIcon", "EntityRow", "EmptyState", "MetricCard", "FilterBar", "InlineEditor", "PageSkeleton", "Identity", "CommentThread", "MarkdownEditor", "PropertiesPanel", "Sidebar", "CommandPalette", "EnvironmentVariablesEditor", + "InlineBanner", "BuiltInAgentGate", "BuiltInAgentBadge", ].map((name) => ( {name} @@ -1815,6 +1818,72 @@ export function DesignGuide() {
+ + {/* ============================================================ */} + {/* INLINE BANNER + BUILT-IN AGENTS */} + {/* ============================================================ */} +
+

+ Token-backed full-width notice (brandBanner tones). Use{" "} + info for provenance/context and{" "} + warning for paused/attention. Supports an optional bold + title and a trailing actions slot. Replaces hand-rolled{" "} + bg-yellow-*/bg-blue-*{" "} + banners. +

+
+ Reset to defaults} + > + Ships with Paperclip and powers Briefs. It can be paused but not deleted. + + + + + + } + > + Its built-in agent was paused 2 days ago, so new briefs aren't being generated. + + + Compact variant for embedding inside dialogs and modals. + +
+
+ +
+

+ Provenance badge (constant, blue) plus a derived lifecycle chip (amber) for attention + states. The lifecycle chip is separate from the agent status vocabulary and only shows for{" "} + needs_setup / pending_approval. +

+
+
+ + +
+
+ + +
+
+ + +
+
+

+ <BuiltInAgentGate agentKey> composes{" "} + PageSkeleton + EmptyState{" "} + + InlineBanner to render the loading / setup / + pending-approval / paused / ready states of a feature that depends on a built-in agent. +

+
); } diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 2882941978..ce9227ea56 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -48,6 +48,8 @@ const GOALS_SIDEBAR_LINK_TOGGLE_SELECTOR = 'button[aria-label="Toggle goals sidebar link experimental setting"]'; const SERVER_INFO_TOGGLE_SELECTOR = 'button[aria-label="Toggle server info debug view experimental setting"]'; +const BUILT_IN_AGENTS_TOGGLE_SELECTOR = + 'button[aria-label="Toggle built-in agents experimental setting"]'; function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { return { @@ -59,6 +61,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableExternalObjects: false, + enableBuiltInAgents: false, enableGoalsSidebarLink: false, enableTaskWatchdogs: false, enableCloudSync: false, @@ -274,6 +277,26 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) expect(toggle?.getAttribute("aria-checked")).toBe("true"); }); + it("renders and patches the Built-in Agents experimental toggle", async () => { + await renderPage(); + + expect(container.textContent).toContain("Built-in Agents"); + expect(container.textContent).toContain("Show Paperclip-managed built-in agent surfaces"); + + const toggle = container.querySelector(BUILT_IN_AGENTS_TOGGLE_SELECTOR); + expect(toggle?.getAttribute("aria-checked")).toBe("false"); + + await act(async () => { + toggle?.click(); + }); + await flushReact(); + + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ + enableBuiltInAgents: true, + }); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); + }); + it("renders and patches the Server Info Debug View experimental toggle", async () => { await renderPage(); diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 5aa72810f2..ecf983160f 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -171,6 +171,7 @@ export function InstanceExperimentalSettings() { queryClient.setQueryData(queryKeys.instance.experimentalSettings, updatedSettings); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }), + queryClient.invalidateQueries({ queryKey: ["built-in-agents"] }), queryClient.invalidateQueries({ queryKey: queryKeys.health }), ]); }, @@ -246,6 +247,7 @@ export function InstanceExperimentalSettings() { const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true; const enableCloudSync = experimentalQuery.data?.enableCloudSync === true; const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true; + const enableBuiltInAgents = experimentalQuery.data?.enableBuiltInAgents === true; const enableGoalsSidebarLink = experimentalQuery.data?.enableGoalsSidebarLink === true; const enableServerInfoDebugView = experimentalQuery.data?.enableServerInfoDebugView === true; const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true; @@ -362,6 +364,24 @@ export function InstanceExperimentalSettings() {
+ +
+
+

Built-in Agents

+

+ Show Paperclip-managed built-in agent surfaces, including built-in roster badges, the Built-in agents + tab, and built-in agent setup controls. +

+
+ toggleMutation.mutate({ enableBuiltInAgents: !enableBuiltInAgents })} + disabled={toggleMutation.isPending} + aria-label="Toggle built-in agents experimental setting" + /> +
+
+
diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx index 2022b98f95..fb213cb2f4 100644 --- a/ui/src/pages/Routines.test.tsx +++ b/ui/src/pages/Routines.test.tsx @@ -6,7 +6,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue, RoutineListItem } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Routines, buildRoutineGroups, sortRoutines } from "./Routines"; +import { Routines, buildRoutineGroups, buildRoutineSections, sortRoutines } from "./Routines"; let currentSearch = ""; @@ -376,6 +376,28 @@ describe("Routines page", () => { expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]); }); + it("keeps built-in routines in their own section after configured groups", () => { + const groups = buildRoutineSections( + [ + createRoutine({ + id: "routine-1", + title: "Reflection review", + projectId: "project-1", + originKind: "built_in_agent_bundle", + originId: "reflection-coach:recent-agent-reflection", + }), + createRoutine({ id: "routine-2", title: "Morning sync", projectId: "project-1" }), + ], + "project", + new Map([["project-1", { name: "Project Alpha" }]]), + new Map([["agent-1", { name: "Agent One" }]]), + ); + + expect(groups.map((group) => group.label)).toEqual(["Project Alpha", "Built-in routines"]); + expect(groups[0]?.items.map((item) => item.title)).toEqual(["Morning sync"]); + expect(groups[1]?.items.map((item) => item.title)).toEqual(["Reflection review"]); + }); + it("sorts routines by selected field and direction without mutating the source list", () => { const routines = [ createRoutine({ @@ -508,6 +530,55 @@ describe("Routines page", () => { }); }); + it("renders built-in routines in a dedicated section on the routines tab", async () => { + routinesListMock.mockResolvedValue([ + createRoutine({ + id: "routine-1", + title: "Morning sync", + projectId: "project-1", + }), + createRoutine({ + id: "routine-2", + title: "Reflection review", + projectId: null, + originKind: "built_in_agent_bundle", + originId: "reflection-coach:recent-agent-reflection", + }), + ]); + issuesListMock.mockResolvedValue([]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + + for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Built-in routines"); attempts += 1) { + await act(async () => { + await flush(); + }); + } + + const text = container.textContent ?? ""; + expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Morning sync")); + expect(text.indexOf("Morning sync")).toBeLessThan(text.indexOf("Built-in routines")); + expect(text.indexOf("Built-in routines")).toBeLessThan(text.indexOf("Reflection review")); + + await act(async () => { + root.unmount(); + }); + }); + it("hides archived routines from the routines list", async () => { routinesListMock.mockResolvedValue([ createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }), diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index 5805384502..c57bb13fe3 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -82,6 +82,8 @@ type RoutineGroup = { items: RoutineListItem[]; }; +const builtInRoutineGroupKey = "__built_in_routines"; + const defaultRoutineViewState: RoutineViewState = { sortField: "title", sortDir: "asc", @@ -170,6 +172,38 @@ export function buildRoutineGroups( })); } +export function isBuiltInRoutine(routine: Pick) { + return routine.originKind === "built_in_agent_bundle"; +} + +export function buildRoutineSections( + routines: RoutineListItem[], + groupByValue: RoutineGroupBy, + projectById: Map, + agentById: Map, +): RoutineGroup[] { + const builtInRoutines = routines.filter(isBuiltInRoutine); + const customRoutines = routines.filter((routine) => !isBuiltInRoutine(routine)); + const customGroups = buildRoutineGroups(customRoutines, groupByValue, projectById, agentById) + .filter((group) => group.items.length > 0) + .map((group) => ( + builtInRoutines.length > 0 && groupByValue === "none" && group.key === "__all" + ? { ...group, label: "Custom routines" } + : group + )); + + if (builtInRoutines.length === 0) return customGroups; + + return [ + ...customGroups, + { + key: builtInRoutineGroupKey, + label: "Built-in routines", + items: builtInRoutines, + }, + ]; +} + export function sortRoutines( routines: RoutineListItem[], sortField: RoutineSortField, @@ -199,6 +233,34 @@ function buildRoutinesTabHref(tab: RoutinesTab) { return tab === "runs" ? "/routines?tab=runs" : "/routines"; } +function RoutineSectionHeader({ + label, + count, + isOpen, +}: { + label: string; + count: number; + isOpen: boolean; +}) { + return ( +
+ + + + {label} + + + + {count} + +
+ ); +} + export function Routines() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -425,8 +487,8 @@ export function Routines() { () => sortRoutines(visibleRoutines, routineViewState.sortField, routineViewState.sortDir), [routineViewState.sortDir, routineViewState.sortField, visibleRoutines], ); - const routineGroups = useMemo( - () => buildRoutineGroups(sortedRoutines, routineViewState.groupBy, projectById, agentById), + const routineSections = useMemo( + () => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById), [agentById, projectById, routineViewState.groupBy, sortedRoutines], ); const recentRunsIssueLinkState = useMemo( @@ -886,7 +948,7 @@ export function Routines() {
) : (
- {routineGroups.map((group) => { + {routineSections.map((group) => { const isOpen = !routineViewState.collapsedGroups.includes(group.key); return ( {group.label ? ( -
- - - - {group.label} - - - - {group.items.length} - -
+ ) : null} {group.items.map((routine) => ( diff --git a/ui/storybook/stories/built-in-agents.stories.tsx b/ui/storybook/stories/built-in-agents.stories.tsx new file mode 100644 index 0000000000..1925e34025 --- /dev/null +++ b/ui/storybook/stories/built-in-agents.stories.tsx @@ -0,0 +1,403 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Agent } from "@paperclipai/shared"; + +import { Button } from "@/components/ui/button"; +import { EntityRow } from "@/components/EntityRow"; +import { EmptyState } from "@/components/EmptyState"; +import { InlineBanner } from "@/components/InlineBanner"; +import { AgentStatusBadge } from "@/components/StatusBadge"; +import { BuiltInAgentBadge, BuiltInLifecycleChip } from "@/components/BuiltInAgentBadges"; +import { ConfigureBuiltInAgentModal } from "@/components/ConfigureBuiltInAgentModal"; +import { BuiltInBundlePanel } from "@/components/BuiltInBundlePanel"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import type { BuiltInAgentState, BuiltInManagedResourceState } from "@/api/builtInAgents"; +import { Bot, Clock3 } from "lucide-react"; + +const briefsAgent: Agent = { + id: "agent-briefs", + companyId: "company-storybook", + name: "Briefs Agent", + urlKey: "briefs-agent", + role: "general", + title: null, + icon: "sparkles", + status: "idle", + reportsTo: null, + capabilities: "Prepares concise operational briefs for the board and agent company.", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5" }, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: { paperclipBuiltInAgent: { key: "briefs", featureKeys: ["briefs"] } }, + createdAt: new Date("2026-06-01T09:00:00.000Z"), + updatedAt: new Date("2026-07-01T09:00:00.000Z"), +}; + +const definition = { + key: "briefs", + displayName: "Briefs Agent", + featureKeys: ["briefs"], + shortPurpose: "Prepares concise operational briefs for the board and agent company.", + defaultInstructions: "You are Paperclip's built-in Briefs agent.", + defaultRole: "general", + allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"], + defaultBudgetMonthlyCents: 0, +}; + +const notProvisionedState: BuiltInAgentState = { + definition, + status: "not_provisioned", + agentId: null, + agent: null, + pauseReason: null, +}; + +/** + * Mirrors Agents.tsx renderAgentRow: the built-in cluster sits inline in `meta` + * at xl and drops to a full-width `secondaryRow` beneath the name below xl, with + * `titlePriority` giving the name a floor so it never collapses (PAP-12988). + */ +function RosterRow({ + name, + lifecycle, + status, +}: { + name: string; + lifecycle?: "needs_setup" | "pending_approval"; + status: string; +}) { + const cluster = ( + <> + + {lifecycle && } + {lifecycle === "needs_setup" && ( + + )} + + ); + return ( + {cluster}
} + meta={
{cluster}
} + trailing={} + /> + ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +const meta: Meta = { + title: "Product/Built-in Agents", + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +/** Boards 1, 2, 4, 5 — all presentational states in one gallery. */ +export const SurfaceGallery: Story = { + render: () => ( +
+
+ Board 1 — Roster rows +
+ + + +
+

+ Resize below the xl breakpoint to see the badge/action + cluster drop to a second line so the agent name never collapses + (PAP-12988). +

+
+ +
+ Board 2 — Agent detail provenance banner + Reset to defaults} + > + Ships with Paperclip and powers Briefs. Configure it like any agent — + model, instructions, budget. It can be paused but not deleted; pausing it pauses Briefs. + +
+ +
+ Board 4A — Feature gate: setup empty-state +
+ {}} + hideActionIcon + /> +
+
+ +
+ Board 4 — Feature gate: pending approval +
+ +
+
+ +
+ Board 4B — Feature gate: paused banner over stale content +
+ + + + + } + > + Its built-in agent was paused 2 days ago, so new briefs aren't being generated. + +
+ Previously generated briefs stay readable while the agent is paused. +
+
+
+ +
+ Board 5 — Sidebar treatment +
+
+ Briefs Agent + + + + +
+
+ Learning Agent + + + +
+
+
+ +
+ Board 5 — Use-while-paused toast +
+

Briefs Agent is paused

+

Resume the agent to generate this brief.

+ View agent +
+
+
+ ), +}; + +/** Board 3 — configure-on-first-use modal (open). */ +export const ConfigureModal: Story = { + render: () => { + const [open, setOpen] = useState(true); + return ( +
+ + +
+ ); + }, +}; + +/** Board 2 — pause confirmation dialog with dependency warning. */ +export const PauseConfirmDialog: Story = { + render: () => ( +
+ + + + Pause the Briefs Agent? + +
+ Briefs depends on this agent. While paused, briefs generation is skipped and the + Briefs page shows a warning. +
+
+
+ + Cancel + Pause anyway + +
+
+
+ ), +}; + +// --------------------------------------------------------------------------- +// Reflection Coach bundle status panel (PAP-13099). +// --------------------------------------------------------------------------- + +const reflectionBundle = { + stockVersion: "2026-07-08", + instructions: { entryFile: "AGENTS.md", files: ["AGENTS.md"] }, + skill: { + skillKey: "reflection-coach", + displayName: "reflection-coach", + slug: "reflection-coach", + canonicalKey: "paperclipai/bundled/paperclip-operations/reflection-coach", + files: ["reflection-coach/SKILL.md"], + }, + routine: { + routineKey: "recent-agent-reflection", + title: "Recent agent reflection", + status: "paused" as const, + triggerCount: 1, + }, +}; + +const reflectionDefinition = { + key: "reflection-coach", + displayName: "Reflection Coach", + featureKeys: ["reflection"], + shortPurpose: "Reviews recent agents and coaches them.", + defaultInstructions: "You are Paperclip's built-in Reflection Coach.", + defaultRole: "general", + allowedAdapterTypes: ["codex_local", "claude_local"], + defaultBudgetMonthlyCents: 0, + bundle: reflectionBundle, +}; + +function bundleResource( + resourceKind: BuiltInManagedResourceState["resourceKind"], + stockStatus: BuiltInManagedResourceState["stockStatus"], +): BuiltInManagedResourceState { + return { + resourceKind, + resourceKey: + resourceKind === "skill" + ? "reflection-coach" + : resourceKind === "routine" + ? "recent-agent-reflection" + : "AGENTS.md", + resourceId: "res-1", + stockVersion: "2026-07-08", + stockHash: "aaaa", + currentHash: stockStatus === "missing" ? null : stockStatus === "stock_current" ? "aaaa" : "bbbb", + stockStatus, + updateAvailable: stockStatus === "stock_update_available" || stockStatus === "operator_modified", + resetAvailable: stockStatus !== "stock_current", + }; +} + +function bundleState( + status: BuiltInAgentState["status"], + resources: BuiltInManagedResourceState[], +): BuiltInAgentState { + return { + definition: reflectionDefinition, + status, + agentId: "agent-reflection", + agent: null, + pauseReason: null, + resources, + }; +} + +const READY = [ + bundleResource("skill", "stock_current"), + bundleResource("instructions", "stock_current"), + bundleResource("routine", "stock_current"), +]; + +function BundleCase({ title, state }: { title: string; state: BuiltInAgentState }) { + return ( +
+

{title}

+ {}} + onResetResource={() => {}} + onRunRoutine={() => {}} + onEnableSchedule={() => {}} + onDisableSchedule={() => {}} + /> +
+ ); +} + +/** + * Board — Reflection Coach bundle status panel across the ux-spec states + * (§5a needs-adapter, §5b all-ready, §5c update available, §5d drifted, + * §5f missing). Light + dark are captured by the screenshot recipe. + */ +export const BundleStatusPanel: Story = { + render: () => ( +
+ + + + + +
+ ), +}; + +void briefsAgent;