fix: explain project access denials and add an authorized repair path (#17509)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Low-trust agents may read only the projects in their allowed scope.
> - Workspace setup correctly stops a task outside that scope.
> - The task previously showed a generic failure, and agent settings did
not offer an additive repair.
> - This pull request explains the denial and lets authorized operators
add projects while preserving other restrictions.
> - The saved task then offers an explicit retry. Saving access does not
start work.

## Linked Issues or Issue Description

**What happened?**
A project access denial appeared as an ordinary failed attempt.
Operators could not add a second allowed project from agent settings.

**Expected behavior**
The task should explain the denied access and link to a working
permission editor. The editor should preserve existing scope and
restrictions, enforce operator authority, and retain drafts after a
failed save.

**Steps to reproduce**
1. Assign a low-trust agent to a task whose project is outside its
allowed projects.
2. Let workspace setup deny project access.
3. Open the task, follow Manage project access, add the project, save,
reload, and return to the task.
4. Confirm that the saved message remains and Retry task is a separate
action.

## What Changed

- Record a typed, non-retryable project denial before provider dispatch.
Project denials do not enter automatic continuation or provider comment
policy.
- Project current and historical setup denials into a safe task notice.
Keep unrelated failures, prior assignees, and approval/pause/budget
gates separate. Recheck current project authority, including standard
trust and root-issue grants. Accepted retries keep their working state.
- Add board-only project-access reads and additive saves under the agent
row lock. Enforce company scope, agent-management authority,
pending-approval restrictions, and an audit record for new additions.
- Add the production notice and searchable multi-project editor,
including read-only, saving, cancellation, and failed-save states.
Preserve the return-to-task query through canonical agent redirects.
- Register board-only project-access routes in OpenAPI with a strict
request schema.
- Add production Storybook examples and API/persistence/concurrency
regressions. Retain the approved design snapshot and update the behavior
maps and implementation contract.

## Verification

- Passed: `pnpm -r typecheck`, `pnpm build`, `pnpm build-storybook`,
`pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node
scripts/storybook-catalog.mjs`.
- Passed: 11 permission/projection tests and 30 run-completion tests
against an isolated PostgreSQL database. These cover historical
failures, concurrent additions, idempotent audit, preserved
restrictions, foreign projects, read-only users, agent self-grants, and
no automatic wakeup.
- Passed: all 27 OpenAPI route tests, including route coverage and the
new board-only request/response contract.
- Browser: the real app displayed a historical denial; Manage project
access opened the editor; saving retained the original project and
survived reload; Return to task showed the ready notice. Saving created
one audit event and zero runs. Explicit Retry returned 202 and reached
the separate sandbox requirement, which remained enforced. No external
provider was used.
- All five production Storybook interaction checks pass: blocked task,
save, cancellation, failed-save recovery, and read-only settings. These
use fixture callbacks; durable authorization is covered separately
above.
- `pnpm test:run` was attempted locally but stopped after repeated
embedded PostgreSQL startup failures caused by macOS semaphore
exhaustion. Full-suite CI is required before merge. The separate UI
suite passed all 9,095 tests. The 41 focused server regressions also
passed after rebasing onto the latest master. A further 26
recovery-classification tests pass, including a red-to-green regression
for automatic continuation after a project denial.

## Risks

- No schema migration. The new endpoint only adds same-company project
IDs and preserves the rest of the stored policy.
- The historical notice recognizes one exact setup denial message. It
does not display arbitrary run errors or disclose a different source
project's identity.
- The retry still passes all current admission and isolation checks. The
notice does not promise that other execution requirements are satisfied.
- Related open work: #13309 covers inherited secret restrictions; #11234
covers the broader repository catalog. This change does not replace
either policy surface.

## Model Used

OpenAI Codex (GPT-6), with code execution and browser tools. The precise
runtime model ID and context-window size were not exposed in this
session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused server and full UI
suites; full-suite infrastructure limit above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-24 16:16:29 -07:00
1 parent a3675ddcdf
commit d0c8f0b090
40 files changed
+1030 -30

No files matched your search

+9
View File
@@ -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
+6
View File
@@ -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.
+6
View File
@@ -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.
+6
View File
@@ -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,
+2
View File
@@ -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,
+5
View File
@@ -25,6 +25,11 @@ export interface AgentPermissions extends Record<string, unknown> {
authorizationPolicy?: TrustAuthorizationPolicy;
}
export interface AgentProjectAccess {
projectIds: string[];
canManage: boolean;
}
export type AgentRuntimeConfig = Record<string, unknown> & {
aiConnection?: import("../ai-connection-router.js").AiRuntimeConnectionBinding;
};
@@ -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;
}
+1
View File
@@ -280,6 +280,7 @@ export type {
AgentDetail,
ClearAgentErrorResponse,
AgentPermissions,
AgentProjectAccess,
AgentPublicIdentity,
AgentRuntimeConfig,
AgentInstructionsBundleMode,
+4
View File
@@ -322,6 +322,10 @@ export const updateAgentPermissionsSchema = z.object({
export type UpdateAgentPermissions = z.infer<typeof updateAgentPermissionsSchema>;
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),
+1
View File
@@ -370,6 +370,7 @@ export {
qualifyAgentRunnerSchema,
agentPermissionsSchema,
updateAgentPermissionsSchema,
addAgentProjectAccessSchema,
type CreateAgent,
type BuiltInAgentProvision,
type BuiltInAgentReset,
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: ReturnType<typeof createDb>;
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);
});
});
@@ -21,6 +21,7 @@ const apiPrefixes: Record<string, string> = {
"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"];
+25
View File
@@ -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));
});
}
+3
View File
@@ -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");
+23
View File
@@ -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",
@@ -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<AgentProjectAccess> {
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;
}
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)("heartbeat run completion boundary", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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" } })));
+17 -10
View File
@@ -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:
@@ -244,6 +244,7 @@ const LIVENESS_BOOKKEEPING_EVENT_TYPES = [
];
export const NON_RETRYABLE_PREFLIGHT_FAILURE_CODES = new Set<string>([
"project_access_denied",
"low_trust_isolation_unavailable",
"low_trust_requires_isolated_workspace",
"low_trust_boundary_mismatch",
@@ -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.");
}
}
@@ -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" });
+1
View File
@@ -516,6 +516,7 @@ const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set<string>([
]);
const NON_RETRYABLE_CONTINUATION_ERROR_CODES = new Set<string>([
"project_access_denied",
"native_provider_model_rejected",
"provider_tool_definition_invalid",
"adapter_engine_unavailable",
@@ -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" });
});
});
@@ -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<typeof issues.$inferSelect, "id" | "companyId" | "status" | "assigneeAgentId" | "projectId">;
type ProjectionRun = Pick<typeof heartbeatRuns.$inferSelect,
"id" | "status" | "scheduledRetryAt" | "errorCode" | "livenessState" | "resultJson">;
"id" | "status" | "scheduledRetryAt" | "errorCode" | "livenessState" | "resultJson"> &
Partial<Pick<typeof heartbeatRuns.$inferSelect, "agentId" | "error">>;
type ProjectionState = {
run?: ProjectionRun;
action?: typeof issueRecoveryActions.$inferSelect;
wait?: { id: string; requestedAt: Date };
agent?: Pick<typeof agents.$inferSelect, "status" | "pauseReason">;
projectAccessAllowed?: boolean;
project?: Pick<typeof projects.$inferSelect, "pausedAt" | "pauseReason">;
company?: Pick<typeof companies.$inferSelect, "status" | "pauseReason">;
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<string | null>`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<Record<string, unknown>>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete')`,
resultJson: sql<Record<string, unknown>>`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 = <T extends { issueId: string }>(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<string, Set<string>>();
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<string>();
ids.add(projectId);
deniedByAgent.set(issue.assigneeAgentId, ids);
}
const currentAccess = new Map<string, boolean>();
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),
}));
+7 -1
View File
@@ -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<Parameters<Db["transaction"]>[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(
+3
View File
@@ -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<AgentProjectAccess>(`/agents/${id}/project-access`),
addProjectAccess: (id: string, projectIds: string[]) => api.post<AgentProjectAccess>(`/agents/${id}/project-access`, { projectIds }),
qualifyRunner: (id: string, expectedEnvironmentId: string, companyId?: string) =>
api.post<EnvironmentProbeResult>(agentPath(id, companyId, "/qualify-runner"), { expectedEnvironmentId }),
setAvatar: (companyId: string, agentId: string, imageBase64: string | null) =>
@@ -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 <div role="alert" className="space-y-2 text-sm">
<p>Couldn’t load project access.</p><Button variant="outline" size="sm" onClick={() => { void access.refetch(); void projects.refetch(); }}>Try again</Button>
</div>;
if (!access.data || !projects.data) return <p role="status" className="text-sm text-muted-foreground">Loading project access…</p>;
return <div className="space-y-4">
<AllowedProjectsEditor key={agent.id} agentName={agent.name} projects={projects.data} projectIds={access.data.projectIds} canManage={access.data.canManage}
onSave={async projectIds => {
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) && <Button variant="outline" size="sm" asChild><Link to={`/issues/${fromTask}`}>Return to task</Link></Button>}
</div>;
}
@@ -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<unknown>;
}
export function AllowedProjectsEditor({ projects, projectIds, canManage, agentName, onSave }: AllowedProjectsEditorProps) {
const [added, setAdded] = useState<string[]>([]);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(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 <section aria-label="Allowed projects" className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1"><h3 className="text-sm font-semibold">Allowed projects</h3>
<p className="text-sm text-muted-foreground">Projects whose tasks and repositories this agent may access.</p></div>
{canManage && <Popover open={open} onOpenChange={setOpen}><PopoverTrigger asChild>
<Button variant="outline" size="sm" disabled={saving}><Plus className="size-4" aria-hidden />Add project</Button>
</PopoverTrigger><PopoverContent align="end" className="w-72 space-y-3">
<Input aria-label="Search projects" placeholder="Search projects…" value={query} onChange={event => setQuery(event.target.value)} />
<div className="max-h-64 space-y-1 overflow-y-auto">{matches.map(project => <label key={project.id} className="flex cursor-pointer items-start gap-3 rounded-md px-2 py-2 hover:bg-accent">
<Checkbox aria-label={project.name} checked={projectIds.includes(project.id) || pending.includes(project.id)} disabled={projectIds.includes(project.id)} className="mt-0.5"
onCheckedChange={checked => { setAdded(current => checked ? [...new Set([...current, project.id])] : current.filter(id => id !== project.id)); setSaved(false); }} />
<span className="min-w-0 text-sm">{project.name}{projectIds.includes(project.id) && <span className="block text-xs text-muted-foreground">Already allowed</span>}</span>
</label>)}</div>
{!matches.length && <p className="text-sm text-muted-foreground">No projects found.</p>}
<Button variant="secondary" size="sm" className="w-full" onClick={() => setOpen(false)}>Done</Button>
</PopoverContent></Popover>}
</div>
{visibleIds.length ? <ul aria-label="Allowed projects" className="divide-y divide-border rounded-lg border">
{visibleIds.map(id => <li key={id} className="flex items-center gap-3 p-4">
<FolderOpen className="size-4 shrink-0 text-muted-foreground" aria-hidden /><span className="min-w-0 flex-1 break-words text-sm font-medium">{name(id)}</span>
{projectIds.includes(id) ? <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"><Check className="size-3.5" aria-hidden />Allowed</span>
: <><span className="text-xs text-muted-foreground">To add</span><Button variant="ghost" size="icon-sm" disabled={saving} aria-label={`Undo adding ${name(id)}`} onClick={() => setAdded(current => current.filter(value => value !== id))}><X className="size-4" aria-hidden /></Button></>}
</li>)}
</ul> : <p className="text-sm text-muted-foreground">No projects allowed yet.</p>}
{!canManage && <p className="text-sm text-muted-foreground">A person with agent-management permission can add projects here.</p>}
{pending.length > 0 && <div className="space-y-2 rounded-md bg-muted p-4 text-sm">
<p className="font-medium">Add access to {pending.map(name).join(", ")}</p>
<p className="text-muted-foreground">{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.</p>
</div>}
{error && <p role="alert" className="text-sm text-destructive">Couldn’t save project access. Your selection is kept. {error}</p>}
{saved && <p role="status" className="inline-flex items-center gap-2 text-sm"><Check className="size-4" aria-hidden />Project access saved. Return to your task to retry.</p>}
{canManage && <div className="flex items-center justify-between gap-3 border-t pt-4">
<Button variant="ghost" disabled={saving || !pending.length} onClick={() => { setAdded([]); setError(null); setSaved(false); }}>Cancel</Button>
<Button disabled={saving || !pending.length} onClick={() => void save()}>{saving && <Loader2 className="size-4 animate-spin" aria-hidden />}{saving ? "Saving access…" : error ? "Try saving again" : "Save project access"}</Button>
</div>}
</section>;
}
+8 -1
View File
@@ -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 <TaskProjectAccessNotice
access={presentation.projectAccess} issueId={issueId} agentName={agentName} projectName={projectName}
canRetry={canRetry && presentation.canRetry} onRetry={onRetry} error={error} className={className} />;
if (error && !optimistic && !takingLonger) return <div role="alert" className={cn("flex items-center justify-between gap-2 text-sm text-destructive", className)}><span>{error.message}</span>{canRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}</div>;
if (takingLonger) return <div role="status" className={cn("flex items-center justify-between gap-2 text-sm text-muted-foreground", className)}><span>Taking longer than usual…</span>{canRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}</div>;
if (optimistic || !presentation || presentation.responsibility !== "system" ||
+38
View File
@@ -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 <section role={ready ? "status" : "alert"} aria-label="Project access" className={cn("space-y-4 rounded-lg border border-border p-4", className)}>
<div className="flex items-start gap-3">
{ready ? <ShieldCheck className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden /> : <ShieldX className="mt-0.5 size-5 shrink-0 text-destructive" aria-hidden />}
<div className="min-w-0 space-y-2">
<h3 className="text-sm font-semibold">{ready ? "Project access is ready" : `${agentName} can’t access this project`}</h3>
<p className="text-sm text-muted-foreground">{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.`}</p>
{!ready && !canManage && <p className="text-sm text-muted-foreground">A person with agent-management permission can add this project in the agent’s Permissions / Trust settings.</p>}
</div>
</div>
<div className="flex flex-wrap gap-2 pl-8">
{ready ? canRetry && <Button size="sm" onClick={onRetry}>Retry task<ArrowRight className="size-4" aria-hidden /></Button>
: <Button size="sm" variant="outline" onClick={onManage}>{canManage ? "Manage project access" : "View agent access"}<ArrowRight className="size-4" aria-hidden /></Button>}
</div>
{error && <p role="alert" className="text-sm text-destructive">{error.message}</p>}
{!ready && <details className="pl-8 text-xs text-muted-foreground"><summary className="cursor-pointer">Run details</summary>
<dl className="mt-3 grid grid-cols-2 gap-2"><dt>Stopped during</dt><dd>Workspace setup</dd><dt>Reason</dt><dd>Project outside allowed scope</dd><dt>Provider started</dt><dd>No</dd></dl>
</details>}
</section>;
}
@@ -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<TaskInterruptionPresentation["projectAccess"]>;
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 <ProjectAccessNotice {...props} agentName={agentName} projectName={access.projectId ? projectName : undefined}
ready={access.ready} canManage={permission.data?.canManage === true}
onManage={() => navigate(`/agents/${access.agentId}/permissions${issueId ? `?fromTask=${encodeURIComponent(issueId)}` : ""}`)} />;
}
+8 -4
View File
@@ -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<AgentPermissions> | null | undefined;
onChange: (permissions: Partial<AgentPermissions>) => 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."}
</p>
</div>
{boundaryEditable ? (
{projectAccessEditor && (boundary?.projectIds?.length ?? 0) > 0 ? null : boundaryEditable ? (
<div className="rounded-md border border-border/70 bg-background/70 p-3 text-foreground space-y-3">
<div className="grid gap-3 sm:grid-cols-(--gtc-12)">
<Field label="Boundary type">
@@ -211,7 +213,7 @@ export function TrustPresetSection({
</p>
</div>
)}
<p className="text-xs text-muted-foreground">
{!projectAccessEditor && <p className="text-xs text-muted-foreground">
Want to set more than one containment boundary?{" "}
<a
className="underline underline-offset-2 hover:text-foreground"
@@ -221,7 +223,7 @@ export function TrustPresetSection({
>
Get Paperclip EE.
</a>
</p>
</p>}
<CollapsibleSection
title="View policy"
open={policyOpen}
@@ -249,6 +251,8 @@ export function TrustPresetSection({
</div>
) : null}
{lowTrust && projectAccessEditor}
{managedPermissions.authorizationPolicy?.reviewPreset ? null : (
<p className="text-xs text-muted-foreground">
Advanced permissions remain editable through the EE permissions extension when installed.
+1
View File
@@ -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,
+6 -3
View File
@@ -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<string | null>(null);
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => 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" ? <TrustPresetSection
projectAccessEditor={<AgentProjectAccessSettings agent={agent} />}
permissions={agent.permissions}
disabled={updatePermissions.isPending}
companyId={companyId}
+6 -3
View File
@@ -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<string | null>(null);
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => 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" ? <TrustPresetSection
projectAccessEditor={<AgentProjectAccessSettings agent={agent} />}
permissions={agent.permissions}
disabled={updatePermissions.isPending}
companyId={companyId}
+1
View File
@@ -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;
+1
View File
@@ -7814,6 +7814,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
)}
{resolvedDetailTab === "chat" && (
<ExecutionBlockerNotice className={shellSectionClass} presentation={issue.interruption}
issueId={issue.id} agentName={agentMap.get(issue.assigneeAgentId ?? "")?.name} projectName={issue.project?.name}
canRetry={canResolveBoardRecoveryAction} onRetry={taskRetry.retry}
optimistic={taskRetry.optimistic} takingLonger={taskRetry.takingLonger} error={taskRetry.error} />
)}
@@ -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<typeof ProjectAccessProposal>;
export default meta;
type Story = StoryObj<typeof meta>;
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");
} };
@@ -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) => <div className="mx-auto max-w-2xl space-y-4 p-4"><Story /></div>],
args: { agentName: "Reviewer", projectName: "Agent Extensions", ready: false, canManage: true, canRetry: false, onManage: fn(), onRetry: fn() },
} satisfies Meta<typeof ProjectAccessNotice>;
export default meta;
type Story = StoryObj<typeof meta>;
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 <AllowedProjectsEditor agentName="Reviewer" projects={projects} projectIds={ids} canManage={canManage}
onSave={async added => {
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: () => <Editor /> };
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: () => <Editor fail />, 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: () => <Editor canManage={false} />, 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 } } };
@@ -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<void>(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 = <div className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<section className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>DEMO-42</span><span aria-hidden>·</span><FolderOpen className="size-3.5" aria-hidden /><span>Agent Extensions</span>
</div>
<div className="flex flex-wrap items-center gap-3"><h2 className="text-2xl font-semibold">Quick check-in</h2>
<IssueStatusBadge status={run === "replied" ? "done" : run === "starting" ? "in_progress" : "blocked"} />
</div>
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground"><AgentAvatar agent={agent} size={24} />Reviewer</span>
</section>
<TaskChatBubble item={{ id: "check-in", kind: "message", author: "human", authorName: "You", isCurrentUser: true, text: "Are you there?" }} />
{run === "starting" ? <div role="status" aria-label="Retry in progress"><TaskChatLiveRunPill status="running" startedAtMs={null} toolSummary={null} /></div>
: run === "replied" ? <TaskChatBubble item={{ id: "reply", kind: "message", author: "agent", authorName: agent.name, text: "Yes, I’m here. What would you like me to look at?" }} />
: <section role={accessReady ? "status" : "alert"} aria-label="Project access" className="space-y-4 rounded-lg border border-border p-4">
<div className="flex items-start gap-3">
{accessReady ? <ShieldCheck className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden /> : <ShieldX className="mt-0.5 size-5 shrink-0 text-destructive" aria-hidden />}
<div className="min-w-0 space-y-2">
<h3 className="text-sm font-semibold">{accessReady ? "Project access is ready" : "Reviewer can’t access this project"}</h3>
<p className="text-sm text-muted-foreground">{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."}</p>
{!accessReady && !canManage && <p className="text-sm text-muted-foreground">An organization admin can add this project in Reviewer’s Permissions / Trust settings.</p>}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pl-8">
{accessReady ? <Button size="sm" onClick={() => void retry()}>Retry task<ArrowRight className="size-4" aria-hidden /></Button>
: canManage ? <Button size="sm" variant="outline" onClick={openPermissions}>Manage project access<ArrowRight className="size-4" aria-hidden /></Button>
: <Button size="sm" variant="outline" onClick={openPermissions}>View agent access</Button>}
</div>
{retryError && <p role="alert" className="text-sm text-destructive">Couldn’t start the retry. Project access is saved. Try again.</p>}
{!accessReady && <details className="pl-8 text-xs text-muted-foreground"><summary className="cursor-pointer">Run details</summary>
<dl className="mt-3 grid grid-cols-2 gap-2"><dt>Stopped during</dt><dd>Workspace setup</dd><dt>Reason</dt><dd>Project outside allowed scope</dd><dt>Provider started</dt><dd>No</dd></dl>
</details>}
</section>}
{run === "replied" && <Button variant="ghost" size="sm" className="self-start" onClick={openPermissions}>View saved project access</Button>}
</div>;
const permissions = <div className="mx-auto w-full max-w-3xl space-y-6">
<Button variant="ghost" size="sm" onClick={() => setView("task")} disabled={saving}><ArrowLeft className="size-4" aria-hidden />Back to task</Button>
<div className="flex items-center gap-3"><AgentAvatar agent={agent} size={40} /><div><h2 className="text-xl font-semibold">Reviewer</h2><p className="text-sm text-muted-foreground">Permissions / Trust</p></div></div>
<section className="flex items-start gap-3 rounded-lg border p-4" aria-label="Trust preset">
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="space-y-1"><h3 className="text-sm font-semibold">Low-trust review</h3><p className="text-sm text-muted-foreground">Runs in an isolated sandbox. Untrusted output stays quarantined.</p></div>
</section>
<section className="space-y-4" aria-labelledby="allowed-projects-heading">
<div className="flex flex-wrap items-start justify-between gap-3"><div className="space-y-1">
<h3 id="allowed-projects-heading" className="text-sm font-semibold">Allowed projects</h3>
<p className="text-sm text-muted-foreground">Projects whose tasks and repositories this agent may access.</p>
</div>
{canManage && <Popover open={pickerOpen} onOpenChange={setPickerOpen}><PopoverTrigger asChild>
<Button variant="outline" size="sm" disabled={saving}><Plus className="size-4" aria-hidden />Add project</Button>
</PopoverTrigger><PopoverContent align="end" className="w-72 space-y-3">
<Input aria-label="Search projects" placeholder="Search projects…" value={query} onChange={event => setQuery(event.target.value)} />
<div className="space-y-1">{projects.filter(project => `${project.name} ${project.repository}`.toLowerCase().includes(query.toLowerCase())).map(project => <label key={project.id} className="flex cursor-pointer items-start gap-3 rounded-md px-2 py-2 hover:bg-accent">
<Checkbox aria-label={project.name} checked={draft.includes(project.id)} disabled={allowed.includes(project.id)} className="mt-0.5"
onCheckedChange={checked => { setDraft(current => checked ? [...new Set([...current, project.id])] : current.filter(id => id !== project.id)); setSavedNotice(false); }} />
<span className="min-w-0"><span className="block text-sm">{project.name}</span><span className="block text-xs text-muted-foreground">{allowed.includes(project.id) ? "Already allowed" : project.repository}</span></span>
</label>)}</div>
{!projects.some(project => `${project.name} ${project.repository}`.toLowerCase().includes(query.toLowerCase())) && <p className="px-2 text-sm text-muted-foreground">No projects found.</p>}
<Button variant="secondary" size="sm" className="w-full" onClick={() => setPickerOpen(false)}>Done</Button>
</PopoverContent></Popover>}
</div>
<ul aria-label="Allowed projects" className="divide-y divide-border rounded-lg border">
{projects.filter(project => draft.includes(project.id)).map(project => <li key={project.id} className="flex items-center gap-3 p-4" data-testid={`project-${project.id}`}>
<FolderOpen className="size-4 shrink-0 text-muted-foreground" aria-hidden /><div className="min-w-0 flex-1"><p className="text-sm font-medium">{project.name}</p><p className="break-all text-xs text-muted-foreground">{project.repository}</p></div>
{allowed.includes(project.id) ? <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"><Check className="size-3.5" aria-hidden />Allowed</span>
: <><span className="text-xs text-muted-foreground">To add</span><Button size="icon-sm" variant="ghost" aria-label={`Undo adding ${project.name}`} disabled={saving} onClick={() => setDraft(current => current.filter(id => id !== project.id))}><X className="size-4" aria-hidden /></Button></>}
</li>)}
</ul>
{!accessReady && <p className="text-sm text-muted-foreground">DEMO-42 is waiting for access to <span className="font-medium text-foreground">Agent Extensions</span>.</p>}
{!canManage && <p role="note" className="text-sm text-muted-foreground">Only people with agent configuration permission can change this access.</p>}
{added.length > 0 && <div aria-label="Changes to save" className="space-y-2 rounded-md bg-muted p-4 text-sm">
<p className="font-medium">Add access to {added.map(project => project.name).join(" and ")}</p>
<p className="text-muted-foreground">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.</p>
</div>}
{saveError && <p role="alert" className="text-sm text-destructive">Couldn’t save project access. No permissions changed. Your selection is kept.</p>}
{savedNotice && <div role="status" className="flex flex-wrap items-center justify-between gap-3 rounded-md bg-muted p-4 text-sm">
<span className="inline-flex items-center gap-2"><Check className="size-4" aria-hidden />Project access saved.</span>
<Button variant="outline" size="sm" onClick={() => setView("task")}>Return to task<ArrowRight className="size-4" aria-hidden /></Button>
</div>}
{canManage && <div className="flex items-center justify-between gap-3 border-t pt-4">
<Button variant="ghost" disabled={saving} onClick={() => { setDraft(allowed); setSaveError(false); setView("task"); }}>Cancel</Button>
<Button disabled={!added.length || saving} onClick={() => void save()}>{saving && <Loader2 className="size-4 animate-spin" aria-hidden />}{saving ? "Saving access…" : saveError ? "Try saving again" : "Save project access"}</Button>
</div>}
</section>
</div>;
return <div className="[&_.design-page-navigation]:hidden md:[&_.design-page-navigation]:flex">
<DesignPageShell title={view === "task" ? "Tasks / DEMO-42" : "Agents / Reviewer"} sidebarStorageKey="paperclip.proposal.project-access.sidebar"
sidebar={<DesignSidebar onNewTask={() => setShellNotice("This preview focuses on project access. Use Manage project access to try the repair.")} />}
headerActions={<span className="text-xs text-muted-foreground">Design preview</span>}>
{shellNotice && <p role="status" className="text-xs text-muted-foreground">{shellNotice}</p>}
{view === "task" ? task : permissions}
</DesignPageShell>
</div>;
}
export function ProjectAccessProposal(props: ProjectAccessProposalProps) {
return <DesignProposalAccess><Proposal {...props} /></DesignProposalAccess>;
}