diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 13067391ef2..1048f886509 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -891,6 +891,15 @@ grant privileged tools. Backlog assignments retain the existing human requester without starting a run. Reassignment transactionally cancels prior human requests, including service/plugin writes; cancelled runs cannot authorize later retries. See `doc/LOW-TRUST-PRESETS.md` for containment details. +Project or repository access is a separate boundary from exact-task direction. +A workspace project denial appears on the task with a link to the agent's +Permissions / Trust settings. Board operators with `agents:create` can add +same-company projects through `POST /api/agents/:id/project-access`; the operation +locks the agent, preserves existing permissions and audits added projects. +Saving does not retry work. The task offers explicit Retry after the required +project is allowed, and dispatch rechecks authorization. Existing setup failures +with the exact project-boundary denial also receive the explanation on reload. + Cross-issue writes are contained per heartbeat run. An agent-authored comment may wake the target assignee, including an explicit `resume: true` comment on a `done` or `cancelled` issue, but the wake remains agent-class and is subject to diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index efc593ec743..1fa58307434 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -104,6 +104,12 @@ Review: `baseline`. API: `present` — representative operation IDs: - `GET /api/agents/{id}/instructions-bundle/file`. +- `GET /api/agents/{id}/project-access` and `POST /api/agents/{id}/project-access` + are board-only. The POST requires `agents:create`, checks selected projects in + the same company, and adds IDs under an agent row lock. It preserves existing + containment, audits new grants and rejects pending-approval agents. Agent keys + cannot use this route to grant themselves access; a read-only board viewer + receives `canManage: false`. Saving never creates a run or wakeup. - `PUT /api/agents/{id}/instructions-bundle/file`. - `PATCH /api/agents/{id}` with an explicit `runner: "paperclip"` upgrade. - `POST /api/agents/{id}/qualify-runner` to qualify the saved effective sandbox before upgrading. diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index 95fed092ec1..bb3b705cd1d 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -41,6 +41,12 @@ existing preparation and verification lifecycle. Open `/agents/:agentId` and the configuration/instruction surfaces it exposes. +For a low-trust agent, open **Permissions / Trust → Allowed projects**. An operator +with agent-management permission can select additional projects and explicitly +save them. Existing projects and other containment restrictions remain intact. +Cancel discards the draft; a failed save retains it. Reload to verify persistence. +This does not start or retry a task. + In `/agents/all`, select roster checkboxes and choose **Edit connection & model**. A mixed selection offers one action per harness. Each field is opt-in. The editor previews each changed value and excludes agents already at the target value. diff --git a/maps/feature-map/recovery.md b/maps/feature-map/recovery.md index 4294a1876a3..6624fbc67f7 100644 --- a/maps/feature-map/recovery.md +++ b/maps/feature-map/recovery.md @@ -48,6 +48,12 @@ Open the affected task from Tasks, Inbox/Blocked, search, or a direct task link. Observe one working, backoff, failed or waiting-for-user state. Use Retry after an infrastructure failure. No process or file certification is required. +A workspace project denial names the blocked access and links to the agent's +**Permissions / Trust** settings. After an authorized person adds the required +project, return to the saved task for an explicit **Retry task** action. Saving +permissions alone does not wake the agent. Historical setup denials receive the +same notice on reload; unrelated errors and prior assignees do not. + Thinking appears immediately after Retry, before the request completes. Technical source/run details remain in deliberate diagnostics. The [task recovery contract](../../doc/task-recovery.md) describes receipt identity, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 99f0a885507..a45052d7fe0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -889,6 +889,7 @@ export type { AgentDetail, ClearAgentErrorResponse, AgentPermissions, + AgentProjectAccess, AgentPublicIdentity, AgentInstructionsBundleMode, AgentInstructionsFileSummary, @@ -1882,6 +1883,7 @@ export { qualifyAgentRunnerSchema, agentPermissionsSchema, updateAgentPermissionsSchema, + addAgentProjectAccessSchema, type CreateAgent, type BuiltInAgentProvision, type BuiltInAgentReset, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index ac1970305b7..ba4b594389b 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -25,6 +25,11 @@ export interface AgentPermissions extends Record { authorizationPolicy?: TrustAuthorizationPolicy; } +export interface AgentProjectAccess { + projectIds: string[]; + canManage: boolean; +} + export type AgentRuntimeConfig = Record & { aiConnection?: import("../ai-connection-router.js").AiRuntimeConnectionBinding; }; diff --git a/packages/shared/src/types/execution-projection.ts b/packages/shared/src/types/execution-projection.ts index 23c7d3d688d..ff28fd01dc1 100644 --- a/packages/shared/src/types/execution-projection.ts +++ b/packages/shared/src/types/execution-projection.ts @@ -104,7 +104,9 @@ export interface TaskInterruptionPresentation { state: "working" | "retry_scheduled" | "failed" | "waiting_for_user" | "resolved"; nextRetryAt: string | null; canRetry: boolean; - userAction: "approval" | "reconnect" | "assign_agent" | "choose_model" | "resume" | "budget" | null; + userAction: "approval" | "reconnect" | "assign_agent" | "choose_model" | "resume" | "budget" | "project_access" | null; + /** Only the task's own project id is exposed. Other source projects stay unnamed. */ + projectAccess?: { agentId: string; projectId: string | null; ready: boolean }; sourceRunIds: string[]; observedRunId: string | null; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c89aa62b1e3..b10aa2c33ea 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -280,6 +280,7 @@ export type { AgentDetail, ClearAgentErrorResponse, AgentPermissions, + AgentProjectAccess, AgentPublicIdentity, AgentRuntimeConfig, AgentInstructionsBundleMode, diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 32d65555e6d..1e9e42e2876 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -322,6 +322,10 @@ export const updateAgentPermissionsSchema = z.object({ export type UpdateAgentPermissions = z.infer; +export const addAgentProjectAccessSchema = z.object({ + projectIds: z.array(z.string().uuid()).min(1).max(100), +}).strict(); + export const resolveAgentInstructionCandidateSchema = z.object({ baseRevisionId: z.string().uuid().nullable(), content: z.string().max(1024 * 1024), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 05519dd262f..8f1ba1bf907 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -370,6 +370,7 @@ export { qualifyAgentRunnerSchema, agentPermissionsSchema, updateAgentPermissionsSchema, + addAgentProjectAccessSchema, type CreateAgent, type BuiltInAgentProvision, type BuiltInAgentReset, diff --git a/server/src/__tests__/agent-project-access.test.ts b/server/src/__tests__/agent-project-access.test.ts new file mode 100644 index 00000000000..38618a3669c --- /dev/null +++ b/server/src/__tests__/agent-project-access.test.ts @@ -0,0 +1,107 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, agentWakeupRequests, activityLog, authUsers, companies, companyMemberships, createDb, + heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { registerAgentProjectAccessRoutes } from "../routes/agent-project-access.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { assertTaskWorkspaceSourceProjectAccess } from "../services/task-workspace-access.js"; +import { taskInterruptionPresentation } from "../services/task-interruption-presentation.js"; +import { ProjectAccessDeniedError } from "../services/project-access-denial.js"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +describe("additive project access", () => { + let database: Awaited> | undefined; + let db: ReturnType; + beforeAll(async () => { + const external = process.env.PAPERCLIP_TEST_DATABASE_URL; + if (external) db = createDb(external); + else { database = await startEmbeddedPostgresTestDatabase("agent-project-access-"); db = createDb(database.connectionString); } + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + + async function seed() { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Project access", issuePrefix: `A${companyId.slice(0, 6)}` }); + const selected = await db.insert(projects).values(["Existing", "Needed", "Third"].map(name => ({ companyId, name }))).returning(); + const permissions = { canCreateAgents: false, canCreateSkills: false, trustPreset: "low_trust_review", + authorizationPolicy: { reviewPreset: { id: "low_trust_review", version: 1, rawOutputDisposition: "quarantine" }, + customPolicy: { preserve: true }, trustBoundary: { mode: "low_trust_review", companyId, + projectIds: [selected[0]!.id], issueIds: [issueId], allowedToolClasses: ["git.read"], allowedSecretBindingIds: [] } } }; + await db.insert(agents).values({ id: agentId, companyId, name: "Reviewer", adapterType: "process", status: "idle", permissions }); + await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" }); + const [issue] = await db.insert(issues).values({ id: issueId, companyId, title: "Saved message", status: "blocked", assigneeAgentId: agentId, projectId: selected[1]!.id }).returning(); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, status: "failed", errorCode: "setup_failed", + error: "Project is outside this low-trust boundary.", contextSnapshot: { issueId } }); + return { companyId, agentId, issue: issue!, runId, selected, permissions }; + } + function app(actor: express.Request["actor"]) { + const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); }); + registerAgentProjectAccessRoutes(app, db); app.use(errorHandler); return app; + } + const board = { type: "board" as const, source: "local_implicit" as const }; + + it("preserves containment, audits concurrent additions, and changes the persisted denial into an explicit retry", async () => { + const f = await seed(); + const actor = { type: "agent" as const, source: "agent_key" as const, agentId: f.agentId, companyId: f.companyId }; + await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, f.companyId, f.selected[1]!.id)).rejects.toBeInstanceOf(ProjectAccessDeniedError); + expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ userAction: "project_access", canRetry: false }); + const api = app(board); + const results = await Promise.all(f.selected.slice(1).map(project => request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [project.id] }))); + expect(results.map(result => result.status)).toEqual([200, 200]); + const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId)); + expect(saved!.permissions).toEqual({ ...f.permissions, authorizationPolicy: { ...f.permissions.authorizationPolicy, + trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, projectIds: expect.arrayContaining(f.selected.map(project => project.id)) } } }); + expect((saved!.permissions as typeof f.permissions).authorizationPolicy.trustBoundary.projectIds).toHaveLength(3); + await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, f.companyId, f.selected[1]!.id)).resolves.toBeUndefined(); + expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ canRetry: true, projectAccess: { ready: true } }); + const again = await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] }); + expect(again.status).toBe(200); + expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.action, "agent.project_access_added")))).toHaveLength(2); + expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(0); + expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1); + }); + + it("rejects foreign projects, agent self-grants, unknown policy fields, and pending-approval agents", async () => { + const f = await seed(), other = await seed(), api = app(board); + expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [other.selected[1]!.id] })).status).toBe(404); + expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id], trustPreset: "standard" })).status).toBe(400); + expect((await request(app({ type: "agent", agentId: f.agentId, companyId: f.companyId, source: "agent_key" })) + .post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403); + await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agentId)); + expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(409); + const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId)); expect(saved!.permissions).toEqual(f.permissions); + }); + + it.each(["standard", "root_issue"])("rechecks actual project authority after a %s repair and acknowledges an accepted retry", async repair => { + const f = await seed(); + const permissions = repair === "standard" ? { canCreateAgents: false, trustPreset: "standard", authorizationPolicy: {} } + : { ...f.permissions, authorizationPolicy: { ...f.permissions.authorizationPolicy, + trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, rootIssueId: f.issue.id } } }; + await db.update(agents).set({ permissions }).where(eq(agents.id, f.agentId)); + expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ canRetry: true, projectAccess: { ready: true } }); + await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.agentId, + source: "on_demand", status: "deferred_issue_execution", requestedByActorType: "user", payload: { issueId: f.issue.id } }); + const accepted = await taskInterruptionPresentation(db, f.issue); + expect(accepted).toMatchObject({ state: "working", canRetry: false }); + expect(accepted).not.toHaveProperty("projectAccess"); + // Current project policy remains authoritative even after agent access changes. + await db.update(projects).set({ executionWorkspacePolicy: { trustPreset: "low_trust_review", authorizationPolicy: { + trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.selected[0]!.id] }, + } } }).where(eq(projects.id, f.selected[1]!.id)); + if (repair === "standard") expect(await taskInterruptionPresentation(db, f.issue)) + .toMatchObject({ canRetry: false, userAction: "project_access" }); + }); + + it("shows read-only access to viewers and denies their saves and cross-company reads", async () => { + const f = await seed(), other = await seed(), userId = randomUUID(); + await db.insert(authUsers).values({ id: userId, name: "Viewer", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, membershipRole: "viewer", status: "active" }); + const api = app({ type: "board", source: "session", userId, companyIds: [f.companyId], memberships: [{ companyId: f.companyId, membershipRole: "viewer", status: "active" }] }); + expect((await request(api).get(`/agents/${f.agentId}/project-access`)).body).toMatchObject({ canManage: false }); + expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403); + expect((await request(api).get(`/agents/${other.agentId}/project-access`)).status).toBe(404); + }); +}); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index d0abe4e4b4b..ff43e560394 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -21,6 +21,7 @@ const apiPrefixes: Record = { "activity.ts": "/api", "adapters.ts": "/api", "agents.ts": "/api", + "agent-project-access.ts": "/api", "agent-commentary.ts": "/api", "agent-avatars.ts": "/api", "agent-profile-avatar.ts": "/api", @@ -987,6 +988,21 @@ describe("openapi routes", () => { }); }); + it("documents additive project access as board-only with a strict request", () => { + const { spec } = loadSpecRoutes(); + const path = spec.paths["/api/agents/{id}/project-access"]; + for (const operation of [path.get, path.post]) { + expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]); + expect(operation.responses["200"].content["application/json"].schema.required).toEqual(["projectIds", "canManage"]); + } + expect(path.post.requestBody.content["application/json"].schema).toMatchObject({ + additionalProperties: false, required: ["projectIds"], + properties: { projectIds: { type: "array", items: { type: "string", format: "uuid" } } }, + }); + expect(path.post.responses["409"]).toBeDefined(); + }); + it("documents the authenticated personal primary-agent contract", () => { const { spec } = loadSpecRoutes(); const path = spec.paths["/api/companies/{companyId}/primary-agent/me"]; diff --git a/server/src/routes/agent-project-access.ts b/server/src/routes/agent-project-access.ts new file mode 100644 index 00000000000..08346180aab --- /dev/null +++ b/server/src/routes/agent-project-access.ts @@ -0,0 +1,25 @@ +import type { Router } from "express"; +import { agents, type Db } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; +import { addAgentProjectAccessSchema } from "@paperclipai/shared"; +import { notFound } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { addAgentProjectAccess, readAgentProjectAccess } from "../services/agent-project-access.js"; +import { assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js"; + +export function registerAgentProjectAccessRoutes(router: Router, db: Db) { + router.get("/agents/:id/project-access", async (req, res) => { + assertBoard(req); + const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, req.params.id as string)); + if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found"); + assertCompanyAccess(req, agent.companyId); + res.json(await readAgentProjectAccess(db, req.actor, agent.companyId, agent.id)); + }); + router.post("/agents/:id/project-access", validate(addAgentProjectAccessSchema), async (req, res) => { + assertBoard(req); + const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, req.params.id as string)); + if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found"); + assertCompanyAccess(req, agent.companyId); + res.json(await addAgentProjectAccess(db, req.actor, agent.companyId, agent.id, req.body.projectIds)); + }); +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 540e1f7d8cc..0aba86abb8f 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -98,6 +98,7 @@ import { } from "@paperclipai/adapter-utils/server-utils"; import { trackAgentCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; +import { registerAgentProjectAccessRoutes } from "./agent-project-access.js"; import { inheritNativeRunnerAdapterConfig } from "../services/native-runtime/native-agent-runtime-inheritance.js"; import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js"; import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js"; @@ -4918,6 +4919,8 @@ export function agentRoutes( res.status(201).json({ ...redactAgentRowForResponse(agent), ...(configurationWarnings.length ? { configurationWarnings } : {}) }); }); + registerAgentProjectAccessRoutes(router, db); + router.patch("/agents/:id/permissions", validate(updateAgentPermissionsSchema), async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 650eb0670eb..bbb50ac7f55 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -48,6 +48,7 @@ import { updateAgentSchema, updatePrimaryAgentSchema, updateAgentPermissionsSchema, + addAgentProjectAccessSchema, updateAgentInstructionsPathSchema, updateAgentInstructionsBundleSchema, upsertAgentInstructionsFileSchema, @@ -1390,6 +1391,8 @@ const browserUseOperations = [ ] as const; const BOARD_ONLY_OPERATIONS = new Set([ + "GET /api/agents/{id}/project-access", + "POST /api/agents/{id}/project-access", "POST /api/issues/{id}/interactions/{interactionId}/dismiss", "POST /api/issues/{id}/retry", "GET /api/companies/{companyId}/prompt-injection-prevention", @@ -3790,6 +3793,26 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +const agentProjectAccessResponse = z.object({ projectIds: z.array(z.string().uuid()), canManage: z.boolean() }); +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/project-access", + tags: ["agents"], + summary: "Read an agent's allowed projects and the current operator's management capability", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(agentProjectAccessResponse), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/project-access", + tags: ["agents"], + summary: "Add allowed projects without replacing an agent's other permissions", + description: "Requires board access and agents:create in the agent's company. Accepts 1 to 100 project IDs. Selected projects must be readable and in that company. Locks the low-trust agent, preserves existing containment, audits new additions, and rejects pending-approval agents or changed trust settings. Repeated additions are idempotent. Saving does not start or retry a task.", + request: { params: z.object({ id: z.string() }), body: jsonBody(addAgentProjectAccessSchema) }, + responses: { 200: r.ok(agentProjectAccessResponse), 400: r.badRequest, 401: r.unauthorized, + 403: r.forbidden, 404: r.notFound, 409: r.conflict }, +}); + registry.registerPath({ method: "patch", path: "/api/agents/{id}/instructions-path", diff --git a/server/src/services/agent-project-access.ts b/server/src/services/agent-project-access.ts new file mode 100644 index 00000000000..4c6ee81659a --- /dev/null +++ b/server/src/services/agent-project-access.ts @@ -0,0 +1,69 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { agents, projects, type Db } from "@paperclipai/db"; +import { trustAuthorizationPolicySchema, type AgentProjectAccess } from "@paperclipai/shared"; +import { conflict, forbidden, notFound } from "../errors.js"; +import { normalizeAgentPermissions } from "../lib/agent-permissions.js"; +import { accessService } from "./access.js"; +import type { AuthorizationActor } from "./authorization.js"; +import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js"; + +export async function readAgentProjectAccess(db: Db, actor: AuthorizationActor, companyId: string, agentId: string): Promise { + const [agent] = await db.select({ permissions: agents.permissions, status: agents.status }).from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))); + if (!agent) throw notFound("Agent not found"); + const decision = await accessService(db).decide({ actor, action: "agents:create", resource: { type: "company", companyId } }); + const permissions = normalizeAgentPermissions(agent.permissions); + const policy = trustAuthorizationPolicySchema.safeParse(permissions.authorizationPolicy ?? {}); + const boundary = policy.success ? policy.data.trustBoundary : undefined; + return { projectIds: policy.success ? policy.data.trustBoundary?.projectIds ?? [] : [], + canManage: actor.type === "board" && decision.allowed && agent.status !== "pending_approval" + && policy.success && permissions.trustPreset === "low_trust_review" + && (!boundary || (boundary.mode === "low_trust_review" && (!boundary.companyId || boundary.companyId === companyId))) }; +} + +/** An additive operation under the agent row lock, never a stale policy replacement. */ +export async function addAgentProjectAccess(db: Db, actor: AuthorizationActor, companyId: string, agentId: string, projectIds: string[]) { + if (actor.type !== "board") throw forbidden("Board access required"); + const publications: ActivityPublication[] = []; + const result = await db.transaction(async tx => { + const [agent] = await tx.select().from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update"); + if (!agent) throw notFound("Agent not found"); + const transactionDb = tx as unknown as Db; + const access = accessService(transactionDb); + const decision = await access.decide({ actor, action: "agents:create", resource: { type: "company", companyId } }); + if (!decision.allowed) throw forbidden(decision.explanation); + if (agent.status === "pending_approval") throw conflict("Pending approval agent permissions cannot be changed before board approval"); + const permissions = normalizeAgentPermissions(agent.permissions); + const parsedPolicy = trustAuthorizationPolicySchema.safeParse(permissions.authorizationPolicy ?? {}); + if (!parsedPolicy.success) throw conflict("The agent’s trust settings need review before adding project access."); + const policy = parsedPolicy.data; + const boundary = policy?.trustBoundary; + if (permissions.trustPreset !== "low_trust_review" || (boundary && (boundary.mode !== "low_trust_review" + || (boundary.companyId && boundary.companyId !== companyId)))) { + throw conflict("The agent’s trust settings changed. Refresh before adding project access."); + } + const requested = [...new Set(projectIds)]; + const rows = await tx.select({ id: projects.id }).from(projects) + .where(and(eq(projects.companyId, companyId), inArray(projects.id, requested))); + if (rows.length !== requested.length) throw notFound("A selected project is unavailable"); + for (const project of rows) { + const projectDecision = await access.decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId: project.id } }); + if (!projectDecision.allowed) throw forbidden("A selected project is unavailable"); + } + const previous = boundary?.projectIds ?? []; + const added = requested.filter(id => !previous.includes(id)); + const next = [...previous, ...added]; + if (added.length) { + await tx.update(agents).set({ permissions: { ...agent.permissions, authorizationPolicy: { + ...policy, trustBoundary: { ...boundary, mode: "low_trust_review", companyId, projectIds: next }, + } }, updatedAt: new Date() }).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))); + await logActivity(transactionDb, { companyId, actorType: "user", actorId: actor.userId ?? "local-board", + action: "agent.project_access_added", entityType: "agent", entityId: agentId, + details: { addedProjectIds: added, trustPreset: "low_trust_review" } }, publications); + } + return { projectIds: next, canManage: true } satisfies AgentProjectAccess; + }); + for (const publication of publications) publishActivity(publication); + return result; +} diff --git a/server/src/services/heartbeat/run-completion.test.ts b/server/src/services/heartbeat/run-completion.test.ts index 39df53e1d52..1dc15b06e9c 100644 --- a/server/src/services/heartbeat/run-completion.test.ts +++ b/server/src/services/heartbeat/run-completion.test.ts @@ -9,6 +9,7 @@ import { createAgentIdentityRedactor } from "../agent-identity-redaction.js"; import { UnresolvedWorkspaceBaseRefError } from "../workspace-runtime.js"; import { LegacyControllerLeaseLostError } from "../legacy-controller-lease.js"; import { requestedRunCancellation } from "../run-cancellation.js"; +import { ProjectAccessDeniedError } from "../project-access-denial.js"; import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from "./run-lifecycle.js"; import { createHeartbeatRunPreparation, ConfigurationIncompleteFailure } from "./run-preparation.js"; import { createHeartbeatRunState, getAdapterSessionCodec } from "./run-state.js"; @@ -77,15 +78,19 @@ it("constructs completion handlers without querying or starting work", () => { }); const support = await getEmbeddedPostgresTestSupport(); -describe.skipIf(!support.supported)("heartbeat run completion boundary", () => { - let database: Awaited>; +describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)("heartbeat run completion boundary", () => { + let database: Awaited> | undefined; let db: Db; beforeAll(async () => { // Keep the accounting transaction real without reading provider receipt files. vi.spyOn(usageReceipts, "indexPendingUsageReceipts").mockResolvedValue(new Map()); vi.spyOn(usageReceipts, "recoverPendingRunUsageReceipts").mockResolvedValue([]); - database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-completion"); - db = createDb(database.connectionString); + const external = process.env.PAPERCLIP_TEST_DATABASE_URL; + if (external) db = createDb(external); + else { + database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-completion"); + db = createDb(database.connectionString); + } await db.execute(sql`set client_min_messages = warning`); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterAll(async () => { await db?.$client.end(); await database?.cleanup(); vi.restoreAllMocks(); }); @@ -334,6 +339,18 @@ describe.skipIf(!support.supported)("heartbeat run completion boundary", () => { expect(f.deps.releaseIssueExecutionAndPromote).toHaveBeenCalledWith(expect.objectContaining({ id: f.run.id }), { suppressImmediateRecovery: true }); }); + it("persists a project denial without provider work or an automatic retry", async () => { + const f = await fixture(); + const projectId = randomUUID(); + await f.completion.failRunSetup(f.setup(new ProjectAccessDeniedError(projectId))); + expect(await read(f.run.id)).toMatchObject({ status: "failed", errorCode: "project_access_denied", + resultJson: { projectAccessDenied: { projectId }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } }); + expect(f.deps.scheduleBoundedRetryForRun).not.toHaveBeenCalled(); + expect(f.deps.enqueueWakeup).not.toHaveBeenCalled(); + expect(f.deps.scheduleInteractionContinuationInfrastructureRetryIfEligible).not.toHaveBeenCalled(); + expect(f.deps.finalizeIssueCommentPolicy).not.toHaveBeenCalled(); + }); + it("records a setup configuration gap without inventing provider work or applying comment policy", async () => { const f = await fixture(); await f.completion.failRunSetup(f.setup(new ConfigurationIncompleteFailure("missing credential", { configurationIncomplete: { reason: "missing_binding" } }))); diff --git a/server/src/services/heartbeat/run-completion.ts b/server/src/services/heartbeat/run-completion.ts index 7f2b486dfe7..95026919171 100644 --- a/server/src/services/heartbeat/run-completion.ts +++ b/server/src/services/heartbeat/run-completion.ts @@ -1,4 +1,5 @@ import { measureRunPhase } from "../execution-preparation-timing.js"; +import { PROJECT_ACCESS_DENIED, ProjectAccessDeniedError } from "../project-access-denial.js"; import { pendingCaseHandoffForRun } from "./case-handoff-lifecycle.js"; import { NON_RETRYABLE_PREFLIGHT_FAILURE_CODES, isHeartbeatRunTerminalStatus } from "./run-lifecycle.js"; import { ChatControlRecoveryUnresolvedError } from "./queue.js"; @@ -145,6 +146,7 @@ export interface FailHeartbeatRunSetupInput { } function nonRetryablePreflightFailureCode(error: unknown): string | null { + if (error instanceof ProjectAccessDeniedError) return PROJECT_ACCESS_DENIED; if (error instanceof ChatControlRecoveryUnresolvedError) return CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE; if ( @@ -1344,6 +1346,8 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC // action, so it is persisted even when the agent lookup failed and the // agent-scoped stop metadata cannot be merged in. const setupFailureDetails = + (outerErr instanceof ProjectAccessDeniedError + ? { projectAccessDenied: { projectId: outerErr.projectId } } : null) ?? (workspaceGitScanFailure ? { workspaceGitScan: { code: workspaceGitScanFailure.code, @@ -1453,7 +1457,8 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC ).catch(() => undefined); if ( !isWorkspaceValidationFailedRun(livenessRun) && - !isConfigurationIncompleteFailedRun(livenessRun) + !isConfigurationIncompleteFailedRun(livenessRun) && + livenessRun.errorCode !== PROJECT_ACCESS_DENIED ) { await finalizeIssueCommentPolicy(livenessRun, failedAgent).catch( () => undefined, @@ -1462,15 +1467,17 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC // No provider work began. Retry temporary host scan failures with // the existing durable failure budget, before releasing execution. // Generic recovery must not grant a second budget on exhaustion. - await (isTransientWorkspaceGitScanCode(livenessRun.errorCode) - ? scheduleBoundedRetryForRun(livenessRun, failedAgent) - : scheduleInteractionContinuationInfrastructureRetryIfEligible(livenessRun, failedAgent) - ).catch((retryError) => { - logger.warn( - { err: retryError, runId: livenessRun.id }, - "failed to schedule interaction continuation retry after setup failure", - ); - }); + if (livenessRun.errorCode !== PROJECT_ACCESS_DENIED) { + await (isTransientWorkspaceGitScanCode(livenessRun.errorCode) + ? scheduleBoundedRetryForRun(livenessRun, failedAgent) + : scheduleInteractionContinuationInfrastructureRetryIfEligible(livenessRun, failedAgent) + ).catch((retryError) => { + logger.warn( + { err: retryError, runId: livenessRun.id }, + "failed to schedule interaction continuation retry after setup failure", + ); + }); + } } await releaseIssueExecutionAndPromote(livenessRun, { suppressImmediateRecovery: diff --git a/server/src/services/heartbeat/run-lifecycle.ts b/server/src/services/heartbeat/run-lifecycle.ts index 5f299e855fa..3bbb6f30b40 100644 --- a/server/src/services/heartbeat/run-lifecycle.ts +++ b/server/src/services/heartbeat/run-lifecycle.ts @@ -244,6 +244,7 @@ const LIVENESS_BOOKKEEPING_EVENT_TYPES = [ ]; export const NON_RETRYABLE_PREFLIGHT_FAILURE_CODES = new Set([ + "project_access_denied", "low_trust_isolation_unavailable", "low_trust_requires_isolated_workspace", "low_trust_boundary_mismatch", diff --git a/server/src/services/project-access-denial.ts b/server/src/services/project-access-denial.ts new file mode 100644 index 00000000000..f9e699d1bdc --- /dev/null +++ b/server/src/services/project-access-denial.ts @@ -0,0 +1,10 @@ +import { HttpError } from "../errors.js"; + +export const PROJECT_ACCESS_DENIED = "project_access_denied"; + +/** Carries a safe, typed setup failure without changing the authorization decision. */ +export class ProjectAccessDeniedError extends HttpError { + constructor(readonly projectId: string) { + super(403, "Project is outside this low-trust boundary."); + } +} diff --git a/server/src/services/recovery/provider-failure-classification.test.ts b/server/src/services/recovery/provider-failure-classification.test.ts index 31407ac82c3..29dded8d4d6 100644 --- a/server/src/services/recovery/provider-failure-classification.test.ts +++ b/server/src/services/recovery/provider-failure-classification.test.ts @@ -10,6 +10,10 @@ import { legacyExecutionNeedsReconciliation } from "../legacy-execution-recovery import { isConfigurationIncompleteFailedRun } from "../../modules/wake-queue/domain/values.js"; describe("classifyAdapterFailureForRecovery", () => { + it("does not automatically replay a project access denial", () => { + expect(classifyContinuationFailure({ errorCode: "project_access_denied", resultJson: null } as never)) + .toMatchObject({ kind: "non_retryable", maxAttempts: 0 }); + }); it("waits for model configuration changes instead of retrying a typed native model rejection", () => { const run = { errorCode: "native_provider_model_rejected", error: "Fixed model configuration message", resultJson: null }; expect(classifyAdapterFailureForRecovery(run)).toEqual({ kind: "configuration_incomplete" }); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index e72811ec8d0..331122e10d6 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -516,6 +516,7 @@ const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set([ ]); const NON_RETRYABLE_CONTINUATION_ERROR_CODES = new Set([ + "project_access_denied", "native_provider_model_rejected", "provider_tool_definition_invalid", "adapter_engine_unavailable", diff --git a/server/src/services/task-interruption-presentation.test.ts b/server/src/services/task-interruption-presentation.test.ts new file mode 100644 index 00000000000..9c730d1c5fc --- /dev/null +++ b/server/src/services/task-interruption-presentation.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { projectTaskInterruption } from "./task-interruption-presentation.js"; + +const issue = { id: "task", companyId: "11111111-1111-4111-8111-111111111111", status: "blocked", assigneeAgentId: "agent", projectId: "22222222-2222-4222-8222-222222222222" }; +const run = { id: "run", agentId: "agent", status: "failed", scheduledRetryAt: null, + errorCode: "project_access_denied", error: null, livenessState: null, + resultJson: { projectAccessDenied: { projectId: "22222222-2222-4222-8222-222222222222" } } }; +const agent = { status: "idle", pauseReason: null }; + +describe("task project-access interruption", () => { + it("explains a project denial without offering a futile retry", () => { + expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: false })).toMatchObject({ + state: "waiting_for_user", responsibility: "user", userAction: "project_access", canRetry: false, + projectAccess: { agentId: "agent", projectId: "22222222-2222-4222-8222-222222222222", ready: false }, + }); + }); + + it("projects the historical setup denial without requiring another failed run", () => { + expect(projectTaskInterruption(issue, { run: { ...run, errorCode: "setup_failed", + error: "Project is outside this low-trust boundary.", resultJson: null }, agent, paused: false, approval: false })) + .toMatchObject({ userAction: "project_access", canRetry: false }); + }); + + it("offers an explicit retry after the project is added, without changing the failed run", () => { + expect(projectTaskInterruption(issue, { run, agent, projectAccessAllowed: true, paused: false, approval: false })) + .toMatchObject({ state: "failed", canRetry: true, projectAccess: { ready: true } }); + expect(run.status).toBe("failed"); + }); + + it("shows an accepted retry as working after access is repaired", () => { + const input = { run, agent, paused: false, approval: false, wait: { id: "request", requestedAt: new Date() } }; + expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true })) + .toMatchObject({ state: "working", canRetry: false }); + expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true })).not.toHaveProperty("projectAccess"); + expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: false })) + .toMatchObject({ state: "waiting_for_user", userAction: "project_access", canRetry: false }); + }); + + it("does not reinterpret unrelated failures or an old assignee's denial", () => { + for (const otherRun of [{ ...run, errorCode: "setup_failed", error: "Disk full", resultJson: null }, + { ...run, agentId: "previous-agent" }]) { + expect(projectTaskInterruption(issue, { run: otherRun, agent, paused: false, approval: false })) + .not.toHaveProperty("projectAccess"); + } + }); + + it("keeps approval, pause, running and completed states authoritative", () => { + expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: true })).toMatchObject({ userAction: "approval" }); + expect(projectTaskInterruption(issue, { run, agent, paused: true, approval: false })).toMatchObject({ userAction: "resume" }); + expect(projectTaskInterruption(issue, { run: { ...run, status: "running" }, agent, paused: false, approval: false })) + .toMatchObject({ state: "working" }); + expect(projectTaskInterruption({ ...issue, status: "done" }, { run, agent, paused: false, approval: false })) + .toMatchObject({ state: "resolved" }); + }); +}); diff --git a/server/src/services/task-interruption-presentation.ts b/server/src/services/task-interruption-presentation.ts index 249ff55d8e5..f8dd6664e33 100644 --- a/server/src/services/task-interruption-presentation.ts +++ b/server/src/services/task-interruption-presentation.ts @@ -4,21 +4,35 @@ import { agents, agentWakeupRequests, approvals, companies, heartbeatRuns, issue import type { TaskInterruptionPresentation } from "@paperclipai/shared"; import { isAiAuthenticationBlocked } from "./ai-auth-failure.js"; import { readSystemRecovery } from "./system-recovery-state.js"; +import { PROJECT_ACCESS_DENIED } from "./project-access-denial.js"; +import { authorizationService } from "./authorization.js"; type ProjectionIssue = Pick; type ProjectionRun = Pick; + "id" | "status" | "scheduledRetryAt" | "errorCode" | "livenessState" | "resultJson"> & + Partial>; type ProjectionState = { run?: ProjectionRun; action?: typeof issueRecoveryActions.$inferSelect; wait?: { id: string; requestedAt: Date }; agent?: Pick; + projectAccessAllowed?: boolean; project?: Pick; company?: Pick; paused: boolean; approval: boolean; }; +/** Recognize old persisted failures without displaying arbitrary run error text. */ +function deniedProjectId(issue: ProjectionIssue, run?: ProjectionRun): string | null | undefined { + const historical = run?.errorCode === "setup_failed" && run.error === "Project is outside this low-trust boundary."; + if (!run || run.status !== "failed" || run.agentId !== issue.assigneeAgentId + || (run.errorCode !== PROJECT_ACCESS_DENIED && !historical)) return undefined; + const detail = run.resultJson?.projectAccessDenied; + return detail && typeof detail === "object" && "projectId" in detail && typeof detail.projectId === "string" + ? detail.projectId : historical ? issue.projectId : null; +} + /** Presentation never grants authority. Dispatch repeats all gates under its * locks. The same projection is used in detail, lists and attention surfaces. */ export function projectTaskInterruption(issue: ProjectionIssue, input: ProjectionState, now = Date.now()): TaskInterruptionPresentation | null { @@ -36,6 +50,14 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio ? "budget" : input.paused || project?.pausedAt || agent?.status === "paused" || company?.status === "paused" ? "resume" : null; if (userAction) return { ...base, responsibility: "user", state: "waiting_for_user", userAction }; if (["running", "queued"].includes(run.status)) return { ...base, state: "working" }; + const deniedProject = deniedProjectId(issue, run); + let projectAccess: TaskInterruptionPresentation["projectAccess"]; + if (deniedProject !== undefined) { + const ready = input.projectAccessAllowed === true; + projectAccess = { agentId: run.agentId!, projectId: deniedProject === issue.projectId ? deniedProject : null, ready }; + if (!ready) return { ...base, responsibility: "user", state: "waiting_for_user", + userAction: "project_access", projectAccess }; + } const retryAt = system?.nextAttemptAt ?? run.scheduledRetryAt?.getTime(); if (retryAt && retryAt > now) return { ...base, state: "retry_scheduled", nextRetryAt: new Date(retryAt).toISOString(), canRetry: true }; @@ -44,7 +66,7 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio activeObservation > now - 45_000) return { ...base, state: "working" }; if (wait && wait.requestedAt.getTime() > now - 10_000 && system?.state !== "exhausted") return { ...base, state: "working" }; - return { ...base, state: "failed", canRetry: true }; + return { ...base, state: "failed", canRetry: true, ...(projectAccess ? { projectAccess } : {}) }; } /** Batch list reads rather than doing seven queries per row. Keep run logs and @@ -61,8 +83,12 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i const projectIds = selected.flatMap(issue => issue.projectId ? [issue.projectId] : []); const [runs, waits, agentRows, projectRows, pauseRows, approvalRows, companyRows] = await Promise.all([ db.selectDistinctOn([runIssue], { issueId: runIssue, id: heartbeatRuns.id, status: heartbeatRuns.status, + agentId: heartbeatRuns.agentId, + error: sql`case when ${heartbeatRuns.errorCode} = 'setup_failed' + and ${heartbeatRuns.error} = 'Project is outside this low-trust boundary.' then ${heartbeatRuns.error} else null end`, errorCode: heartbeatRuns.errorCode, livenessState: heartbeatRuns.livenessState, scheduledRetryAt: heartbeatRuns.scheduledRetryAt, - resultJson: sql>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete')`, + resultJson: sql>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete', + 'projectAccessDenied', ${heartbeatRuns.resultJson}->'projectAccessDenied')`, }).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), inArray(runIssue, ids))) .orderBy(runIssue, desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)), db.selectDistinctOn([wakeIssue], { issueId: wakeIssue, id: agentWakeupRequests.id, requestedAt: agentWakeupRequests.requestedAt }) @@ -91,12 +117,32 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i )).orderBy(issueRecoveryActions.sourceIssueId, desc(issueRecoveryActions.createdAt)) : []; const byIssue = (rows: T[]) => new Map(rows.map(row => [row.issueId, row])); const runMap = byIssue(runs), waitMap = byIssue(waits); + // Recheck current authority, including standard trust, root-issue grants and + // project policy. Do not reuse the failed run's frozen policy for a new retry. + // Only denied tasks need these reads; deduplicate projects and batch per agent. + const deniedByAgent = new Map>(); + for (const issue of selected) { + const projectId = deniedProjectId(issue, runMap.get(issue.id)); + if (!projectId || !issue.assigneeAgentId || ["done", "cancelled"].includes(issue.status)) continue; + const ids = deniedByAgent.get(issue.assigneeAgentId) ?? new Set(); + ids.add(projectId); + deniedByAgent.set(issue.assigneeAgentId, ids); + } + const currentAccess = new Map(); + const authorization = authorizationService(db); + for (const [agentId, ids] of deniedByAgent) { + const projectIds = [...ids]; + const decisions = await authorization.decideReads({ type: "agent", agentId, companyId, source: "agent_key" }, + projectIds.map(projectId => ({ type: "project" as const, companyId, projectId }))); + projectIds.forEach((projectId, index) => currentAccess.set(`${agentId}:${projectId}`, decisions[index]?.allowed === true)); + } const actionMap = new Map(actions.map(action => [action.sourceIssueId, action])); const agentMap = new Map(agentRows.map(agent => [agent.id, agent])); const projectMap = new Map(projectRows.map(project => [project.id, project])); const pauses = new Set(pauseRows.map(row => row.issueId)), approvalsSet = new Set(approvalRows.map(row => row.issueId)); for (const issue of selected) result.set(issue.id, projectTaskInterruption(issue, { run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id), + projectAccessAllowed: currentAccess.get(`${issue.assigneeAgentId}:${deniedProjectId(issue, runMap.get(issue.id))}`), agent: agentMap.get(issue.assigneeAgentId ?? ""), project: projectMap.get(issue.projectId ?? ""), company: companyRows[0], paused: pauses.has(issue.id), approval: approvalsSet.has(issue.id), })); diff --git a/server/src/services/task-workspace-access.ts b/server/src/services/task-workspace-access.ts index b49ba9d9432..31d40f79655 100644 --- a/server/src/services/task-workspace-access.ts +++ b/server/src/services/task-workspace-access.ts @@ -8,6 +8,7 @@ import { canonicalExecutionUuid } from "./execution-identity.js"; import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns, taskRepositoryResponsibleUserMatches } from "./task-repository-acquisition.js"; import { resolveCoreTrustPreset } from "./trust-preset-resolver.js"; import { isDeepStrictEqual } from "node:util"; +import { ProjectAccessDeniedError } from "./project-access-denial.js"; import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection } from "@paperclipai/shared"; type DbTransaction = Parameters[0]>[0]; @@ -19,7 +20,12 @@ export async function assertTaskWorkspaceSourceProjectAccess( const decision = await measureRunPhase("workspace.access.authorization", () => accessService(db as Db).decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId }, })); - if (!decision.allowed) throw forbidden(decision.explanation); + if (!decision.allowed) { + if (actor.type === "agent" && decision.reason === "deny_low_trust_boundary") { + throw new ProjectAccessDeniedError(projectId); + } + throw forbidden(decision.explanation); + } } export async function assertExecutionWorkspaceRecordAccess( diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 5d31460ea38..9f97ce68bfe 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,4 +1,5 @@ import type { AgentPublicIdentity } from "@paperclipai/shared"; +import type { AgentProjectAccess } from "@paperclipai/shared"; import type { Agent, EnvironmentProbeResult, @@ -111,6 +112,8 @@ function agentPath(id: string, companyId?: string, suffix = "") { } export const agentsApi = { + projectAccess: (id: string) => api.get(`/agents/${id}/project-access`), + addProjectAccess: (id: string, projectIds: string[]) => api.post(`/agents/${id}/project-access`, { projectIds }), qualifyRunner: (id: string, expectedEnvironmentId: string, companyId?: string) => api.post(agentPath(id, companyId, "/qualify-runner"), { expectedEnvironmentId }), setAvatar: (companyId: string, agentId: string, imageBase64: string | null) => diff --git a/ui/src/components/AgentProjectAccessSettings.tsx b/ui/src/components/AgentProjectAccessSettings.tsx new file mode 100644 index 00000000000..2320755c85f --- /dev/null +++ b/ui/src/components/AgentProjectAccessSettings.tsx @@ -0,0 +1,34 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { isUuidLike } from "@paperclipai/shared"; +import { agentsApi } from "../api/agents"; +import { projectsApi } from "../api/projects"; +import { queryKeys } from "../lib/queryKeys"; +import { Link, useSearchParams } from "../lib/router"; +import { AllowedProjectsEditor } from "./AllowedProjectsEditor"; +import { Button } from "./ui/button"; + +export function AgentProjectAccessSettings({ agent }: { agent: { id: string; companyId: string; name: string; urlKey?: string } }) { + const client = useQueryClient(); + const [params] = useSearchParams(); + const fromTask = params.get("fromTask"); + const access = useQuery({ queryKey: queryKeys.agents.projectAccess(agent.id), queryFn: () => agentsApi.projectAccess(agent.id) }); + const projects = useQuery({ queryKey: queryKeys.projects.list(agent.companyId), queryFn: () => projectsApi.list(agent.companyId) }); + if (access.error || projects.error) return
+

Couldn’t load project access.

+
; + if (!access.data || !projects.data) return

Loading project access…

; + return
+ { + const saved = await agentsApi.addProjectAccess(agent.id, projectIds); + client.setQueryData(queryKeys.agents.projectAccess(agent.id), saved); + await Promise.all([ + client.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }), + ...(agent.urlKey ? [client.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) })] : []), + client.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) }), + client.invalidateQueries({ queryKey: ["issues"] }), + ]); + }} /> + {fromTask && isUuidLike(fromTask) && } +
; +} diff --git a/ui/src/components/AllowedProjectsEditor.tsx b/ui/src/components/AllowedProjectsEditor.tsx new file mode 100644 index 00000000000..a34ced1479d --- /dev/null +++ b/ui/src/components/AllowedProjectsEditor.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { Check, FolderOpen, Loader2, Plus, X } from "lucide-react"; +import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; +import { Input } from "./ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; + +export interface AllowedProjectsEditorProps { + projects: Array<{ id: string; name: string }>; + projectIds: string[]; + canManage: boolean; + agentName: string; + onSave: (projectIds: string[]) => Promise; +} + +export function AllowedProjectsEditor({ projects, projectIds, canManage, agentName, onSave }: AllowedProjectsEditorProps) { + const [added, setAdded] = useState([]); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + const pending = added.filter(id => !projectIds.includes(id)); + const visibleIds = [...new Set([...projectIds, ...pending])]; + const name = (id: string) => projects.find(project => project.id === id)?.name ?? "Unavailable project"; + const matches = projects.filter(project => project.name.toLowerCase().includes(query.toLowerCase())); + + async function save() { + if (!canManage || saving || !pending.length) return; + setSaving(true); setError(null); setSaved(false); + try { await onSave(pending); setAdded([]); setSaved(true); } + catch (failure) { setError(failure instanceof Error ? failure.message : "Couldn’t save project access."); } + finally { setSaving(false); } + } + + return
+
+

Allowed projects

+

Projects whose tasks and repositories this agent may access.

+ {canManage && + + + setQuery(event.target.value)} /> +
{matches.map(project => )}
+ {!matches.length &&

No projects found.

} + +
} +
+ {visibleIds.length ?
    + {visibleIds.map(id =>
  • + {name(id)} + {projectIds.includes(id) ? Allowed + : <>To add} +
  • )} +
:

No projects allowed yet.

} + {!canManage &&

A person with agent-management permission can add projects here.

} + {pending.length > 0 &&
+

Add access to {pending.map(name).join(", ")}

+

{agentName} will be able to access work and repositories in {pending.length === 1 ? "this project" : "these projects"}. Existing project access and low-trust restrictions stay in place.

+
} + {error &&

Couldn’t save project access. Your selection is kept. {error}

} + {saved &&

Project access saved. Return to your task to retry.

} + {canManage &&
+ + +
} +
; +} diff --git a/ui/src/components/ExecutionBlockerNotice.tsx b/ui/src/components/ExecutionBlockerNotice.tsx index 9c34d72748f..17bd169750c 100644 --- a/ui/src/components/ExecutionBlockerNotice.tsx +++ b/ui/src/components/ExecutionBlockerNotice.tsx @@ -2,9 +2,10 @@ import type { TaskInterruptionPresentation } from "@paperclipai/shared"; import { Button } from "./ui/button"; import { cn } from "../lib/utils"; import { useSecondTick } from "../hooks/useSecondTick"; +import { TaskProjectAccessNotice } from "./TaskProjectAccessNotice"; /** One ordinary interruption surface. Technical records remain in diagnostics. */ -export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimistic, takingLonger, error, className }: { +export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimistic, takingLonger, error, className, issueId, agentName, projectName }: { presentation: TaskInterruptionPresentation | null | undefined; canRetry: boolean; onRetry: () => void; @@ -12,8 +13,14 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi takingLonger?: boolean; error?: Error | null; className?: string; + issueId?: string; + agentName?: string; + projectName?: string; }) { useSecondTick(presentation?.state === "retry_scheduled"); + if (presentation?.projectAccess && !optimistic && !takingLonger) return ; if (error && !optimistic && !takingLonger) return
{error.message}{canRetry && }
; if (takingLonger) return
Taking longer than usual…{canRetry && }
; if (optimistic || !presentation || presentation.responsibility !== "system" || diff --git a/ui/src/components/ProjectAccessNotice.tsx b/ui/src/components/ProjectAccessNotice.tsx new file mode 100644 index 00000000000..07386a68de5 --- /dev/null +++ b/ui/src/components/ProjectAccessNotice.tsx @@ -0,0 +1,38 @@ +import { ShieldCheck, ShieldX, ArrowRight } from "lucide-react"; +import { Button } from "./ui/button"; +import { cn } from "../lib/utils"; + +export interface ProjectAccessNoticeProps { + agentName?: string; + projectName?: string; + ready: boolean; + canManage: boolean; + canRetry: boolean; + onManage: () => void; + onRetry: () => void; + error?: Error | null; + className?: string; +} + +export function ProjectAccessNotice({ agentName = "This agent", projectName, ready, canManage, canRetry, onManage, onRetry, error, className }: ProjectAccessNoticeProps) { + return
+
+ {ready ? : } +
+

{ready ? "Project access is ready" : `${agentName} can’t access this project`}

+

{ready + ? "The agent can now access the required project. Retry to continue with your saved message." + : `This task needs ${projectName ?? "a project"} outside the agent’s allowed projects. The agent hasn’t started. Your message is saved.`}

+ {!ready && !canManage &&

A person with agent-management permission can add this project in the agent’s Permissions / Trust settings.

} +
+
+
+ {ready ? canRetry && + : } +
+ {error &&

{error.message}

} + {!ready &&
Run details +
Stopped during
Workspace setup
Reason
Project outside allowed scope
Provider started
No
+
} +
; +} diff --git a/ui/src/components/TaskProjectAccessNotice.tsx b/ui/src/components/TaskProjectAccessNotice.tsx new file mode 100644 index 00000000000..021d955f8ec --- /dev/null +++ b/ui/src/components/TaskProjectAccessNotice.tsx @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; +import type { TaskInterruptionPresentation } from "@paperclipai/shared"; +import { agentsApi } from "../api/agents"; +import { useNavigate } from "../lib/router"; +import { queryKeys } from "../lib/queryKeys"; +import { ProjectAccessNotice } from "./ProjectAccessNotice"; + +export function TaskProjectAccessNotice({ access, issueId, agentName, projectName, ...props }: { + access: NonNullable; + issueId?: string; + agentName?: string; + projectName?: string; + canRetry: boolean; + onRetry: () => void; + error?: Error | null; + className?: string; +}) { + const navigate = useNavigate(); + const permission = useQuery({ queryKey: queryKeys.agents.projectAccess(access.agentId), queryFn: () => agentsApi.projectAccess(access.agentId) }); + return navigate(`/agents/${access.agentId}/permissions${issueId ? `?fromTask=${encodeURIComponent(issueId)}` : ""}`)} />; +} diff --git a/ui/src/components/TrustPresetSection.tsx b/ui/src/components/TrustPresetSection.tsx index 132ab11bff3..cae599bbdf6 100644 --- a/ui/src/components/TrustPresetSection.tsx +++ b/ui/src/components/TrustPresetSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import type { AgentPermissions, TrustPreset } from "@paperclipai/shared"; import { Lock, ShieldAlert } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -59,6 +59,7 @@ export function TrustPresetSection({ issueCandidates = [], candidatesLoading, allowSingleIssue = true, + projectAccessEditor, }: { permissions: Partial | null | undefined; onChange: (permissions: Partial) => void; @@ -68,6 +69,7 @@ export function TrustPresetSection({ issueCandidates?: LowTrustBoundaryCandidate[]; candidatesLoading?: boolean; allowSingleIssue?: boolean; + projectAccessEditor?: ReactNode; }) { const [policyOpen, setPolicyOpen] = useState(false); const preset = getTrustPreset(permissions); @@ -148,7 +150,7 @@ export function TrustPresetSection({ : "This agent is set to low-trust review, but no project, root issue, or issue scope is set in the core policy. Add a scope before this agent can run without denial."}

- {boundaryEditable ? ( + {projectAccessEditor && (boundary?.projectIds?.length ?? 0) > 0 ? null : boundaryEditable ? (
@@ -211,7 +213,7 @@ export function TrustPresetSection({

)} -

+ {!projectAccessEditor &&

Want to set more than one containment boundary?{" "} Get Paperclip EE. -

+

} ) : null} + {lowTrust && projectAccessEditor} + {managedPermissions.authorizationPolicy?.reviewPreset ? null : (

Advanced permissions remain editable through the EE permissions extension when installed. diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 85bdb7694fd..26dafb622df 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -222,6 +222,7 @@ export const queryKeys = { ["team-catalog", "installed", companyId] as const, }, agents: { + projectAccess: (id: string) => ["agents", "project-access", id] as const, identity: (id: string) => ["agents", "identity", id] as const, list: (companyId: string) => ["agents", companyId] as const, detail: (id: string) => ["agents", "detail", id] as const, diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx index 1258faa6955..b89c8f42501 100644 --- a/ui/src/pages/AgentDetail.production.tsx +++ b/ui/src/pages/AgentDetail.production.tsx @@ -4,7 +4,7 @@ import { characterStateForAgent } from "@paperclipai/shared"; import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks"; import { getPageVisibility, usePageVisibility } from "../lib/page-visibility"; import { useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router"; +import { useParams, useNavigate, useLocation, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router"; import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { agentsApi, @@ -58,6 +58,7 @@ import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel"; import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; import { TrustPresetSection } from "../components/TrustPresetSection"; +import { AgentProjectAccessSettings } from "../components/AgentProjectAccessSettings"; import { FileTree, buildFileTree } from "../components/FileTree"; import { ScrollToBottom } from "../components/ScrollToBottom"; import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout"; @@ -768,6 +769,7 @@ export function AgentDetail() { const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const location = useLocation(); const [actionError, setActionError] = useState(null); const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState>(() => new Set()); const { enabled: chatEnabled, githubEnabled, loaded: chatConnectorsLoaded } = useChatConnectorsEnabled(); @@ -987,10 +989,10 @@ export function AgentDetail() { ? "budget" : "dashboard"; if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) { - navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true }); + navigate(`/agents/${canonicalAgentRef}/${canonicalTab}${location.search}`, { replace: true }); return; } - }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate, chatConnectorsLoaded]); + }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate, chatConnectorsLoaded, location.search]); useEffect(() => { if (!agent?.companyId || agent.companyId === selectedCompanyId) return; @@ -2209,6 +2211,7 @@ function ConfigurationTab({ ) : null} {content === "configuration" ? } permissions={agent.permissions} disabled={updatePermissions.isPending} companyId={companyId} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index ccc3d42335f..5a2693c0e2d 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -11,7 +11,7 @@ import { characterStateForAgent } from "@paperclipai/shared"; import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks"; import { getPageVisibility, usePageVisibility } from "../lib/page-visibility"; import { useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router"; +import { useParams, useNavigate, useLocation, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router"; import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { agentsApi, @@ -63,6 +63,7 @@ import { InlineBanner } from "../components/InlineBanner"; import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel"; import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal"; import { TrustPresetSection } from "../components/TrustPresetSection"; +import { AgentProjectAccessSettings } from "../components/AgentProjectAccessSettings"; import { FileTree, buildFileTree } from "../components/FileTree"; import { ScrollToBottom } from "../components/ScrollToBottom"; import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout"; @@ -780,6 +781,7 @@ export function AgentDetail() { const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const location = useLocation(); const [actionError, setActionError] = useState(null); const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState>(() => new Set()); const activeView: AgentDetailView = urlRunId ? "run-detail" @@ -983,10 +985,10 @@ export function AgentDetail() { if (legacyAuditSection) return; const canonicalTab = activeView === "run-detail" ? "overview" : activeView; if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) { - navigate(agentDetailHref(canonicalAgentRef, canonicalTab), { replace: true }); + navigate(`${agentDetailHref(canonicalAgentRef, canonicalTab)}${location.search}`, { replace: true }); return; } - }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate]); + }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, location.search]); useEffect(() => { if (!agent?.companyId || agent.companyId === selectedCompanyId) return; @@ -2135,6 +2137,7 @@ export function ConfigurationTab({ {content === "permissions" ? } permissions={agent.permissions} disabled={updatePermissions.isPending} companyId={companyId} diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 5913637d3f0..c64b6fc4e3f 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -394,6 +394,7 @@ export function FailedRunInboxRow({ : interruption?.state === "waiting_for_user" ? ({ approval: "Approval required", reconnect: "Reconnect account", assign_agent: "Assign an agent", choose_model: "Choose a supported model", resume: "Work is paused", budget: "Budget limit reached", + project_access: "Agent needs project access", }[interruption.userAction ?? "resume"]) : runFailureMessage(run); const retryAvailable = !interruption || interruption.canRetry; const showUnreadSlot = unreadState !== null; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index fcb321a66f1..abac74816d5 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -7814,6 +7814,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS )} {resolvedDetailTab === "chat" && ( )} diff --git a/ui/storybook/stories/design-proposals/project-access.stories.tsx b/ui/storybook/stories/design-proposals/project-access.stories.tsx new file mode 100644 index 00000000000..8efb0bc3a7e --- /dev/null +++ b/ui/storybook/stories/design-proposals/project-access.stories.tsx @@ -0,0 +1,121 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { ProjectAccessProposal } from "../../support/design-proposals/ProjectAccessProposal"; + +const meta = { + id: "design-project-access", + title: "Design proposals/Patterns/Project access recovery", + component: ProjectAccessProposal, + parameters: { + layout: "fullscreen", + initialEntries: ["/PAP/issues/DEMO-42"], + docs: { description: { component: "Start at Blocked task. Manage project access opens the proposed agent settings. Add Agent Extensions, review the additive access change, save, return to the task and retry. Low-trust review stays active. Settings supports multiple allowed projects without replacing the existing one. Read-only, pending save, failed save and failed retry states remain actionable. The reply and every state transition are local fixtures; no project permissions change and no provider runs. Reset the story to restore the starting state." } }, + paperclip: { + capabilities: ["recovery/diagnosis", "recovery/source-action", "agents/instructions"], + home: "design-proposals", status: "proposed", + exception: "Requested design for a visible project denial and additive project access repair before production implementation.", + implementation: "prototype", boundary: "fixtures", + notes: "Reuses DesignPageShell, DesignSidebar, AgentAvatar, IssueStatusBadge, TaskChatBubble, TaskChatLiveRunPill and shared form primitives. Notice, access editor and navigation are a local prototype. Synthetic names and repositories only. Save retains existing projects; failed saves preserve draft but grant nothing. Retry is unavailable before a saved grant and does not run on save. Read-only viewers cannot edit. No production API, persistence, permission, provider or recovery proof. The implemented notice and editor are covered by the task-project-access stories; this retains the original design snapshot. Server regression tests and application verification cover authorization, persistence and explicit retry separately.", + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const BlockedTask: Story = {}; +export const AgentSettings: Story = { args: { initialView: "permissions" } }; +export const AccessSaved: Story = { args: { initialState: "access_saved" } }; +export const ReadOnly: Story = { args: { canManage: false }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("Reviewer can’t access this project")).toBeVisible(); + await expect(canvas.queryByRole("button", { name: "Manage project access" })).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "View agent access" })); + await expect(canvas.getByText("Only people with agent configuration permission can change this access.")).toBeVisible(); + await expect(canvas.queryByRole("button", { name: "Add project" })).not.toBeInTheDocument(); +} }; +export const Light: Story = { globals: { theme: "light" } }; +export const Mobile: Story = { globals: { viewport: { value: "mobile1", isRotated: false } } }; +export const MobileSettings: Story = { args: { initialView: "permissions" }, globals: { viewport: { value: "mobile1", isRotated: false } } }; + +async function addProject(canvasElement: HTMLElement, name = "Agent Extensions") { + const canvas = within(canvasElement), screen = within(canvasElement.ownerDocument.body); + await userEvent.click(await canvas.findByRole("button", { name: "Add project" })); + await userEvent.click(await screen.findByRole("checkbox", { name })); + await userEvent.click(screen.getByRole("button", { name: "Done" })); +} + +export const RepairJourney: Story = { play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + await step("Explain the denied project before any provider work", async () => { + await expect(await canvas.findByText("Reviewer can’t access this project")).toBeVisible(); + await expect(canvas.getByRole("alert")).toHaveTextContent("The agent hasn’t started. Your message is saved."); + await expect(canvas.queryByRole("button", { name: "Retry task" })).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Manage project access" })); + }); + await step("Add a project while preserving existing containment", async () => { + await expect(canvas.getByRole("button", { name: "Save project access" })).toBeDisabled(); + await addProject(canvasElement); + await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed"); + await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("To add"); + await expect(canvas.getByText("Low-trust review", { exact: true })).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Save project access" })); + await expect(await canvas.findByText("Project access saved.")).toBeVisible(); + await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed"); + await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("Allowed"); + }); + await step("Explicitly retry the saved message after saving access", async () => { + await userEvent.click(canvas.getByRole("button", { name: "Return to task" })); + await expect(canvas.getByText("Project access is ready")).toBeVisible(); + await waitFor(() => expect(canvas.getByText("Are you there?")).toBeVisible()); + await userEvent.click(canvas.getByRole("button", { name: "Retry task" })); + await waitFor(() => expect(canvas.getByText("Yes, I’m here. What would you like me to look at?")).toBeVisible()); + await expect(canvas.queryByRole("alert")).not.toBeInTheDocument(); + }); +} }; + +export const CancelJourney: Story = { args: { initialView: "permissions" }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await addProject(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Cancel" })); + await expect(canvas.getByText("Reviewer can’t access this project")).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Manage project access" })); + await expect(canvas.queryByTestId("project-extensions")).not.toBeInTheDocument(); + await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed"); +} }; + +export const SaveFailure: Story = { args: { initialView: "permissions", failSave: true }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await addProject(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Save project access" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("No permissions changed. Your selection is kept."); + await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("To add"); + await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed"); +} }; +export const SaveFailureJourney: Story = { args: { initialView: "permissions", failSave: true }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await addProject(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Save project access" })); + await userEvent.click(await canvas.findByRole("button", { name: "Try saving again" })); + await expect(await canvas.findByText("Project access saved.")).toBeVisible(); + await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("Allowed"); +} }; +export const Saving: Story = { args: { initialView: "permissions", holdSaving: true }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await addProject(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Save project access" })); + await expect(canvas.getByRole("button", { name: "Saving access…" })).toBeDisabled(); + await expect(canvas.getByRole("button", { name: "Cancel" })).toBeDisabled(); +} }; +export const RetryFailure: Story = { args: { initialState: "access_saved", failRetry: true }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Retry task" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Couldn’t start the retry. Project access is saved."); +} }; +export const MultipleProjects: Story = { args: { initialView: "permissions" }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await addProject(canvasElement); + await addProject(canvasElement, "Website"); + await userEvent.click(canvas.getByRole("button", { name: "Save project access" })); + await expect(await canvas.findByText("Project access saved.")).toBeVisible(); + for (const id of ["product", "extensions", "website"]) await expect(canvas.getByTestId(`project-${id}`)).toHaveTextContent("Allowed"); +} }; diff --git a/ui/storybook/stories/recovery/project-access.stories.tsx b/ui/storybook/stories/recovery/project-access.stories.tsx new file mode 100644 index 00000000000..3349e6605b3 --- /dev/null +++ b/ui/storybook/stories/recovery/project-access.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { AllowedProjectsEditor } from "@/components/AllowedProjectsEditor"; +import { ProjectAccessNotice } from "@/components/ProjectAccessNotice"; + +const meta = { + id: "task-project-access", + title: "Projects and execution/Recovery/Task chat/Project access", + component: ProjectAccessNotice, + parameters: { paperclip: { + capabilities: ["recovery/diagnosis", "agents/instructions"], implementation: "production", boundary: "fixtures", + notes: "Production notice and additive project editor. Local save callbacks cover selection, read-only, cancellation and failed-save states. API authorization, persistence and explicit retry admission are separate server regressions and local application verification.", + } }, + decorators: [(Story) =>

], + args: { agentName: "Reviewer", projectName: "Agent Extensions", ready: false, canManage: true, canRetry: false, onManage: fn(), onRetry: fn() }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const BlockedTask: Story = { play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole("alert")).toHaveTextContent("Your message is saved."); + await expect(canvas.queryByRole("button", { name: "Retry task" })).not.toBeInTheDocument(); + await expect(canvas.getByRole("button", { name: "Manage project access" })).toBeVisible(); +} }; +export const AccessSaved: Story = { args: { ready: true, canRetry: true } }; +export const ReadOnly: Story = { args: { canManage: false } }; +export const Light: Story = { globals: { theme: "light" } }; +export const Mobile: Story = { globals: { viewport: { value: "mobile1", isRotated: false } } }; + +const projects = [{ id: "product", name: "Product" }, { id: "extensions", name: "Agent Extensions" }, { id: "website", name: "Website" }]; +function Editor({ fail = false, canManage = true }: { fail?: boolean; canManage?: boolean }) { + const [ids, setIds] = useState(["product"]); + const [failed, setFailed] = useState(false); + return { + if (fail && !failed) { setFailed(true); throw new Error("Connection interrupted."); } + setIds(current => [...new Set([...current, ...added])]); + }} />; +} +async function selectProject(canvasElement: HTMLElement) { + const canvas = within(canvasElement), screen = within(canvasElement.ownerDocument.body); + await userEvent.click(canvas.getByRole("button", { name: "Add project" })); + await expect(await screen.findByRole("checkbox", { name: "Product" })).toBeDisabled(); + await userEvent.click(screen.getByRole("checkbox", { name: "Agent Extensions" })); + await userEvent.click(screen.getByRole("button", { name: "Done" })); +} +export const AgentSettings: Story = { render: () => }; +export const SaveAccess: Story = { ...AgentSettings, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await selectProject(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Save project access" })); + await expect(await canvas.findByRole("status")).toHaveTextContent("Project access saved"); + await expect(canvas.getAllByText("Allowed", { exact: true })).toHaveLength(2); + await expect(canvas.queryByRole("button", { name: "Retry task" })).not.toBeInTheDocument(); +} }; +export const CancelAddition: Story = { ...AgentSettings, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await selectProject(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Cancel" })); + await expect(canvas.queryByText("Agent Extensions", { exact: true })).not.toBeInTheDocument(); + await expect(canvas.getByText("Product")).toBeVisible(); +} }; +export const SaveFailure: Story = { render: () => , play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await selectProject(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Save project access" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Your selection is kept"); + await expect(canvas.getByText("To add")).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Try saving again" })); + await expect(await canvas.findByRole("status")).toHaveTextContent("Project access saved"); +} }; +export const ReadOnlySettings: Story = { render: () => , play: async ({ canvasElement }) => { + await expect(within(canvasElement).queryByRole("button", { name: "Add project" })).not.toBeInTheDocument(); +} }; +export const MobileSettings: Story = { ...AgentSettings, globals: { viewport: { value: "mobile1", isRotated: false } } }; diff --git a/ui/storybook/support/design-proposals/ProjectAccessProposal.tsx b/ui/storybook/support/design-proposals/ProjectAccessProposal.tsx new file mode 100644 index 00000000000..a8894e19af5 --- /dev/null +++ b/ui/storybook/support/design-proposals/ProjectAccessProposal.tsx @@ -0,0 +1,180 @@ +import { useState } from "react"; +import { ArrowLeft, ArrowRight, Check, FolderOpen, Loader2, Plus, ShieldCheck, ShieldX, X } from "lucide-react"; +import { AgentAvatar } from "@/components/AgentAvatar"; +import { DesignPageShell } from "@/components/DesignPageShell"; +import { DesignSidebar } from "@/components/DesignSidebar"; +import { IssueStatusBadge } from "@/components/StatusBadge"; +import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble"; +import { TaskChatLiveRunPill } from "@/components/task-chat/TaskChatLiveRunPill"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { DesignProposalAccess } from "./DesignProposalAccess"; + +const projects = [ + { id: "product", name: "Product", repository: "acme/product" }, + { id: "extensions", name: "Agent Extensions", repository: "acme/agent-extensions" }, + { id: "website", name: "Website", repository: "acme/website" }, +]; +const agent = { id: "reviewer-preview", name: "Reviewer" }; +const wait = () => new Promise(resolve => setTimeout(resolve, 650)); + +export interface ProjectAccessProposalProps { + initialView?: "task" | "permissions"; + initialState?: "blocked" | "access_saved"; + canManage?: boolean; + failSave?: boolean; + failRetry?: boolean; + holdSaving?: boolean; +} + +/** Design-only state machine. It never calls a permissions or execution API. */ +function Proposal({ initialView = "task", initialState = "blocked", canManage = true, + failSave = false, failRetry = false, holdSaving = false }: ProjectAccessProposalProps) { + const [view, setView] = useState(initialView); + const [allowed, setAllowed] = useState(initialState === "access_saved" ? ["product", "extensions"] : ["product"]); + const [draft, setDraft] = useState(allowed); + const [pickerOpen, setPickerOpen] = useState(false); + const [query, setQuery] = useState(""); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(false); + const [saveAttempted, setSaveAttempted] = useState(false); + const [savedNotice, setSavedNotice] = useState(false); + const [run, setRun] = useState<"stopped" | "starting" | "replied">("stopped"); + const [retryError, setRetryError] = useState(false); + const [retryAttempted, setRetryAttempted] = useState(false); + const [shellNotice, setShellNotice] = useState(""); + const added = projects.filter(project => draft.includes(project.id) && !allowed.includes(project.id)); + const accessReady = allowed.includes("extensions"); + + function openPermissions() { + setDraft(allowed); + setSavedNotice(false); + setSaveError(false); + setView("permissions"); + } + + async function save() { + if (!canManage || saving || !added.length) return; + setSaving(true); + setSaveError(false); + if (holdSaving) return; + await wait(); + setSaving(false); + setSaveAttempted(true); + if (failSave && !saveAttempted) { setSaveError(true); return; } + setAllowed([...draft]); + setSavedNotice(true); + } + + async function retry() { + if (!accessReady || run === "starting") return; + setRun("starting"); + setRetryError(false); + await wait(); + setRetryAttempted(true); + if (failRetry && !retryAttempted) { setRun("stopped"); setRetryError(true); return; } + setRun("replied"); + } + + const task =
+
+
+ DEMO-42·Agent Extensions +
+

Quick check-in

+ +
+ Reviewer +
+ + {run === "starting" ?
+ : run === "replied" ? + :
+
+ {accessReady ? : } +
+

{accessReady ? "Project access is ready" : "Reviewer can’t access this project"}

+

{accessReady + ? "Agent Extensions is now in Reviewer’s allowed projects. Retry to continue with your saved message." + : "This task uses Agent Extensions, which isn’t in Reviewer’s allowed projects. The agent hasn’t started. Your message is saved."}

+ {!accessReady && !canManage &&

An organization admin can add this project in Reviewer’s Permissions / Trust settings.

} +
+
+
+ {accessReady ? + : canManage ? + : } +
+ {retryError &&

Couldn’t start the retry. Project access is saved. Try again.

} + {!accessReady &&
Run details +
Stopped during
Workspace setup
Reason
Project outside allowed scope
Provider started
No
+
} +
} + {run === "replied" && } +
; + + const permissions =
+ +

Reviewer

Permissions / Trust

+
+ +

Low-trust review

Runs in an isolated sandbox. Untrusted output stays quarantined.

+
+
+
+

Allowed projects

+

Projects whose tasks and repositories this agent may access.

+
+ {canManage && + + + setQuery(event.target.value)} /> +
{projects.filter(project => `${project.name} ${project.repository}`.toLowerCase().includes(query.toLowerCase())).map(project => )}
+ {!projects.some(project => `${project.name} ${project.repository}`.toLowerCase().includes(query.toLowerCase())) &&

No projects found.

} + +
} +
+
    + {projects.filter(project => draft.includes(project.id)).map(project =>
  • +

    {project.name}

    {project.repository}

    + {allowed.includes(project.id) ? Allowed + : <>To add} +
  • )} +
+ {!accessReady &&

DEMO-42 is waiting for access to Agent Extensions.

} + {!canManage &&

Only people with agent configuration permission can change this access.

} + {added.length > 0 &&
+

Add access to {added.map(project => project.name).join(" and ")}

+

Reviewer will be able to access work and repositories in {added.length === 1 ? "this project" : "these projects"}. Existing project access and low-trust restrictions stay in place.

+
} + {saveError &&

Couldn’t save project access. No permissions changed. Your selection is kept.

} + {savedNotice &&
+ Project access saved. + +
} + {canManage &&
+ + +
} +
+
; + + return
+ setShellNotice("This preview focuses on project access. Use Manage project access to try the repair.")} />} + headerActions={Design preview}> + {shellNotice &&

{shellNotice}

} + {view === "task" ? task : permissions} +
+
; +} + +export function ProjectAccessProposal(props: ProjectAccessProposalProps) { + return ; +}