diff --git a/doc/DATABASE.md b/doc/DATABASE.md
index 13424c8fad6..bc1292bbe94 100644
--- a/doc/DATABASE.md
+++ b/doc/DATABASE.md
@@ -320,6 +320,31 @@ DATABASE_MAX_LIFETIME_SECONDS=1800 # recycle a pooled connection after this lo
DATABASE_APPLICATION_NAME=paperclip # application_name in pg_stat_activity; default: paperclip
```
+### Bounded response diagnostics
+
+`PAPERCLIP_DB_RESPONSE_CAPTURE_WINDOW=/` enables one explicit UTC
+window. Both timestamps use ISO format ending in `Z`. The window lasts at most
+60 seconds and starts within 30 minutes. Unset, invalid, and expired windows
+leave database construction unchanged. An expired window stays inactive after a
+restart; remove the setting after the investigation.
+
+The diagnostic measures awaited Postgres.js `unsafe` responses before Drizzle
+column decoding. It retains the Drizzle builder caller for normal, prepared,
+transaction, and dedicated-pool execution. It issues no queries.
+It logs the public server caller locations, static schema table, operation,
+query/row counts, and response value sizes. It emits at most 128 aggregate
+groups and a summary at the deadline. It never logs SQL, parameters, result
+values, connection strings, or query errors. In-flight queries continue normally.
+
+Look for `paperclip.db_response_capture.entry.v1` and
+`paperclip.db_response_capture.summary.v1` in the process logs. Tagged raw-driver calls, streams, unawaited raw-driver
+dispatch, and protocol overhead are not measured. Relational queries use a driver-level caller fallback.
+Queries still in flight at the deadline are reported separately. Sizes describe
+driver values before column decoding, not billed network bytes. Complex parsed
+values use JSON size estimates, counted in `estimatedValues`. Reconcile them with
+provider metering and socket measurements. Serialization adds temporary CPU and memory work;
+measure the final transfer rate after the window expires.
+
### Push the schema
```sh
diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index 13067391ef2..1048f886509 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -891,6 +891,15 @@ grant privileged tools. Backlog assignments retain the existing human requester
without starting a run. Reassignment transactionally cancels prior human requests,
including service/plugin writes; cancelled runs cannot authorize later retries. See `doc/LOW-TRUST-PRESETS.md` for containment details.
+Project or repository access is a separate boundary from exact-task direction.
+A workspace project denial appears on the task with a link to the agent's
+Permissions / Trust settings. Board operators with `agents:create` can add
+same-company projects through `POST /api/agents/:id/project-access`; the operation
+locks the agent, preserves existing permissions and audits added projects.
+Saving does not retry work. The task offers explicit Retry after the required
+project is allowed, and dispatch rechecks authorization. Existing setup failures
+with the exact project-boundary denial also receive the explanation on reload.
+
Cross-issue writes are contained per heartbeat run. An agent-authored comment
may wake the target assignee, including an explicit `resume: true` comment on a
`done` or `cancelled` issue, but the wake remains agent-class and is subject to
diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md
index 4b2df094f3a..8bc8886b075 100644
--- a/doc/execution-semantics.md
+++ b/doc/execution-semantics.md
@@ -1807,7 +1807,11 @@ experimental isolated-workspaces UI. Ordinary workspace updates remain gated;
the runtime can bind only a validated shared workspace in the issue's company
and project. Follow-ups can therefore reuse the same sandbox and provider
session. A staged provider package is reused only after the complete expected
-manifest and artifact hashes verify. A missing, changed, or incompatible package
+manifest and artifact hashes verify. Exact live-process reattachment uses the
+controller-owned pack and runner identities recorded after the original session
+passed authentication. It preserves protocol and capability checks. Fresh or
+replacement launches use current release authority. A missing historical receipt
+retains the current release gate; guest files cannot establish old authority. A missing, changed, or incompatible package
must be replaced and verified before launch.
Warm attachment requires two consecutive authenticated readiness snapshots.
diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md
index f6ba0ee73ea..8b9c004282c 100644
--- a/doc/native-workspace-finalization-recovery.md
+++ b/doc/native-workspace-finalization-recovery.md
@@ -371,6 +371,26 @@ and operator guidance remain under Recovery details. Source-only repair remains
available through the board-authorized recovery resolution API and its original
stop-receipt and evidence checks.
+After a remote provider pack passes full integrity verification and the session
+passes authenticated admission, the controller saves its manifest and runner
+binary identity in `provider-pack-authority.json`. The receipt binds the company,
+agent, workspace, session, runner instance, environment lease, remote working
+directory, and provider descriptor. It stays in private controller state, outside
+guest workspace and harness backup directories. Projectless runs use their
+original environment lease as the workspace identity because each run otherwise
+has a new workspace placeholder. Real workspace identities still require an exact
+match. A receipt write failure warns and returns the admitted session to its
+normal owner; it does not discard the live session handle. Future recovery still
+fails closed without matching authority.
+
+Exact process reattachment uses that saved release authority. It still verifies
+the full remote pack, runner protocol and capabilities, exact process identity,
+and authenticated PRP handshake. A new controller release alone does not require
+a live provider to change its executable files. Fresh launches and explicit
+session replacement select current release artifacts. Invalid or mismatched
+receipts fail closed. Older sessions without a receipt retain the strict current
+release gate; a sandbox's own manifest cannot supply missing launch authority.
+
Remote provider-pack verification retries an interrupted read at most once.
Each command retains its 30-second transport timeout. A timeout, signal, absent
exit status, or transport exception does not prove artifact incompatibility.
@@ -380,7 +400,9 @@ a new provider attempt, so it cannot renew this exhausted read budget. A
completed nonzero verification, digest mismatch, or
wrong OpenCode version remains incompatible and cannot authorize a new provider
turn. Recovery retains the exact source workspace and existing ownership gates;
-these read-only retries do not replay tools or alter a retained pack.
+these read-only retries do not replay tools or alter a retained pack. Verification
+errors include bounded, redacted output from both transport streams, including
+providers that combine stderr into stdout.
### Continue after confirmed source destruction
diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md
index efc593ec743..1fa58307434 100644
--- a/maps/agent-access-map/agents.md
+++ b/maps/agent-access-map/agents.md
@@ -104,6 +104,12 @@ Review: `baseline`.
API: `present` — representative operation IDs:
- `GET /api/agents/{id}/instructions-bundle/file`.
+- `GET /api/agents/{id}/project-access` and `POST /api/agents/{id}/project-access`
+ are board-only. The POST requires `agents:create`, checks selected projects in
+ the same company, and adds IDs under an agent row lock. It preserves existing
+ containment, audits new grants and rejects pending-approval agents. Agent keys
+ cannot use this route to grant themselves access; a read-only board viewer
+ receives `canManage: false`. Saving never creates a run or wakeup.
- `PUT /api/agents/{id}/instructions-bundle/file`.
- `PATCH /api/agents/{id}` with an explicit `runner: "paperclip"` upgrade.
- `POST /api/agents/{id}/qualify-runner` to qualify the saved effective sandbox before upgrading.
diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md
index 95fed092ec1..bb3b705cd1d 100644
--- a/maps/feature-map/agents.md
+++ b/maps/feature-map/agents.md
@@ -41,6 +41,12 @@ existing preparation and verification lifecycle.
Open `/agents/:agentId` and the configuration/instruction surfaces it exposes.
+For a low-trust agent, open **Permissions / Trust → Allowed projects**. An operator
+with agent-management permission can select additional projects and explicitly
+save them. Existing projects and other containment restrictions remain intact.
+Cancel discards the draft; a failed save retains it. Reload to verify persistence.
+This does not start or retry a task.
+
In `/agents/all`, select roster checkboxes and choose **Edit connection & model**.
A mixed selection offers one action per harness. Each field is opt-in. The editor
previews each changed value and excludes agents already at the target value.
diff --git a/maps/feature-map/recovery.md b/maps/feature-map/recovery.md
index 4294a1876a3..b7de12dddc4 100644
--- a/maps/feature-map/recovery.md
+++ b/maps/feature-map/recovery.md
@@ -48,6 +48,12 @@ Open the affected task from Tasks, Inbox/Blocked, search, or a direct task link.
Observe one working, backoff, failed or waiting-for-user state. Use Retry after
an infrastructure failure. No process or file certification is required.
+A workspace project denial names the blocked access and links to the agent's
+**Permissions / Trust** settings. After an authorized person adds the required
+project, return to the saved task for an explicit **Retry task** action. Saving
+permissions alone does not wake the agent. Historical setup denials receive the
+same notice on reload; unrelated errors and prior assignees do not.
+
Thinking appears immediately after Retry, before the request completes.
Technical source/run details remain in deliberate diagnostics. The
[task recovery contract](../../doc/task-recovery.md) describes receipt identity,
@@ -170,6 +176,14 @@ and rejected identity evidence. The Runner restart tests require positive exit
evidence before replacement. This coverage does not prove recovery of a
historical retained sandbox or behavior during a live provider outage.
+For a release change during a remote provider session, verify that reattachment
+uses the controller's admitted pack manifest and runner digest. Check useful
+output from the same process without replaying a turn. Repeat with a modified
+sidecar, mismatched receipt scope, replacement lease, and incompatible runner
+protocol. The [authority tests](../../server/src/services/native-runtime/remote-provider-pack-authority.test.ts)
+and executor wiring regressions cover selection and fail-closed scope checks.
+They do not prove a historical retained sandbox was recovered.
+
For completion recovery, restart after a native completion proposal is appended
but before its session checkpoint. Verify that the original result is finalized
after provider termination, with no new provider turn or repeated external action.
diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts
index 5149c1c9b27..f1b8079db45 100644
--- a/packages/db/src/client.ts
+++ b/packages/db/src/client.ts
@@ -4,6 +4,7 @@ import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import postgres from "postgres";
import { installDatabaseWorkSignals } from "./work-signals.js";
+import { installDatabaseResponseCapture } from "./response-capture.js";
import * as schema from "./schema/index.js";
const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url));
@@ -307,6 +308,7 @@ function createDbClient(url: string, resolved: DatabaseClientOptions, owner?: ob
// message cannot establish that replay is safe. Leave retries to callers
// that know the complete operation is idempotent.
const db = installDatabaseWorkSignals(drizzlePg(sql, { schema }), owner);
+ installDatabaseResponseCapture(db, schema);
dedicatedDbFactories.set(db, () => createDbClient(url, {
...resolved, maxConnections: 1, applicationName: "paperclip-workspace-finalization-lock",
}, db));
diff --git a/packages/db/src/response-capture-postgres.test.ts b/packages/db/src/response-capture-postgres.test.ts
new file mode 100644
index 00000000000..370d738d056
--- /dev/null
+++ b/packages/db/src/response-capture-postgres.test.ts
@@ -0,0 +1,190 @@
+import { randomUUID } from "node:crypto";
+import { compileFunction } from "node:vm";
+import { eq, sql } from "drizzle-orm";
+import { pgTable, text } from "drizzle-orm/pg-core";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { createDb, withDedicatedDbConnection } from "./client.js";
+import { createDatabaseResponseCapture, databaseResponseCaptureFromEnv, installDatabaseResponseCapture } from "./response-capture.js";
+import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
+import { encodeRunEventPayload, runEventPayload } from "./run-event-payload.js";
+import { databaseWorkPending, reconcileDatabaseWork, signalDatabaseWork, subscribeDatabaseWork } from "./work-signals.js";
+
+const table = pgTable("response_capture_fixture", { id: text("id").primaryKey(), payload: text("payload").notNull() });
+const encodedTable = pgTable("response_capture_encoded_fixture", { id: text("id").primaryKey(), payload: runEventPayload("payload") });
+const support = await getEmbeddedPostgresTestSupport();
+describe.skipIf(!support.supported)("database response capture PostgreSQL behavior", () => {
+ let temporary: Awaited>;
+ const clients: Array> = [];
+ const captures: Array> = [];
+ beforeAll(async () => {
+ temporary = await startEmbeddedPostgresTestDatabase("response-capture-");
+ const db = createDb(temporary.connectionString);
+ try {
+ await db.execute(sql`create table response_capture_fixture (id text primary key, payload text not null)`);
+ await db.execute(sql`create table response_capture_encoded_fixture (id text primary key, payload jsonb not null)`);
+ }
+ finally { await db.$client.end(); }
+ }, 90_000);
+ afterEach(async () => {
+ for (const capture of captures.splice(0)) capture.flush();
+ for (const client of clients.splice(0)) await client.$client.end();
+ vi.restoreAllMocks(); vi.unstubAllEnvs();
+ });
+ afterAll(async () => { await temporary?.cleanup(); });
+ function fixture() {
+ const events: Array> = [];
+ const now = Date.now();
+ const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
+ const db = createDb(temporary.connectionString);
+ clients.push(db); captures.push(capture);
+ expect(installDatabaseResponseCapture(db, { table, encodedTable }, capture)).toBe(db);
+ expect(installDatabaseResponseCapture(db, { table, encodedTable }, capture)).toBe(db);
+ return { db, capture, events };
+ }
+ function summary(events: Array>) { return events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!; }
+
+ it("keeps rows and builder identity through lazy select and write returning", async () => {
+ const f = fixture(), id = randomUUID(), payload = "sentinel-result-and-parameter-value🙂\n\"";
+ const insert = f.db.insert(table).values({ id, payload });
+ expect(insert.returning()).toBe(insert);
+ expect(await insert).toEqual([{ id, payload }]);
+ const query = f.db.select().from(table);
+ expect(query.where(eq(table.id, id))).toBe(query);
+ expect(await query).toEqual([{ id, payload }]);
+ const updated = await f.db.update(table).set({ payload: "changed" }).where(eq(table.id, id)).returning();
+ expect(updated).toEqual([{ id, payload: "changed" }]);
+ expect(await f.db.delete(table).where(eq(table.id, id)).returning()).toEqual([{ id, payload: "changed" }]);
+ f.capture.flush();
+ expect(summary(f.events)).toMatchObject({ startedQueries: 4, completedQueries: 4 });
+ expect(f.events.filter(event => event.event === "paperclip.db_response_capture.entry.v1").map(event => event.operation).sort())
+ .toEqual(["delete", "insert", "select", "update"]);
+ expect(JSON.stringify(f.events)).not.toContain(payload);
+ expect(JSON.stringify(f.events)).not.toContain(id);
+ });
+ it("counts the stored encoding before a custom column decoder changes the value", async () => {
+ const setup = createDb(temporary.connectionString); clients.push(setup);
+ const id = randomUUID(), payload = { text: "codec-payload-sentinel\u0000value".repeat(512) };
+ await setup.insert(encodedTable).values({ id, payload });
+ const f = fixture();
+ expect(await f.db.select().from(encodedTable).where(eq(encodedTable.id, id))).toEqual([{ id, payload }]);
+ f.capture.flush();
+ const entry = f.events.find(event => event.table === "response_capture_encoded_fixture")!;
+ expect(entry.responseValueBytes).toBe(Buffer.byteLength(id) + Buffer.byteLength(encodeRunEventPayload(payload)));
+ expect(entry.responseValueBytes).toBeGreaterThan(Buffer.byteLength(JSON.stringify({ id, payload })));
+ expect(JSON.stringify(f.events)).not.toContain("codec-payload-sentinel");
+ });
+ it("keeps a raw driver query lazy and counts repeated awaits without replay", async () => {
+ const f = fixture(), id = randomUUID();
+ const observer = createDb(temporary.connectionString); clients.push(observer);
+ const pending = f.db.$client.unsafe("insert into response_capture_fixture (id, payload) values ($1, $2) returning id", [id, "raw-lazy-sentinel"]);
+ expect(await observer.select().from(table).where(eq(table.id, id))).toEqual([]);
+ const first = await pending;
+ expect(await pending).toBe(first);
+ expect(first).toEqual([{ id }]);
+ expect(await observer.select().from(table).where(eq(table.id, id))).toEqual([{ id, payload: "raw-lazy-sentinel" }]);
+ f.capture.flush();
+ expect(summary(f.events)).toMatchObject({ startedQueries: 1, completedQueries: 1 });
+ expect(JSON.stringify(f.events)).not.toContain("raw-lazy-sentinel");
+ expect(JSON.stringify(f.events)).not.toContain(id);
+ });
+ it("counts prepared executions once and retains their parameter bindings", async () => {
+ const f = fixture(), id = randomUUID();
+ await f.db.insert(table).values({ id, payload: "prepared-value" });
+ const prepared = f.db.select().from(table).where(eq(table.id, sql.placeholder("id"))).prepare("response_capture_prepared");
+ expect(await prepared.execute({ id })).toEqual([{ id, payload: "prepared-value" }]);
+ expect(await prepared.execute({ id: randomUUID() })).toEqual([]);
+ f.capture.flush();
+ expect(summary(f.events)).toMatchObject({ startedQueries: 3, completedQueries: 3 });
+ expect(JSON.stringify(f.events)).not.toContain("prepared-value");
+ });
+ it("retains distinct builder callers through normal, prepared, and transaction execution", async () => {
+ const f = fixture(), id = randomUUID();
+ const setup = createDb(temporary.connectionString); clients.push(setup);
+ await setup.insert(table).values({ id, payload: "caller-sentinel" });
+ const shape = f.db.select().from(table);
+ const caller = (name: string) => compileFunction(
+ "return db.select().from(table).where(eq(table.id, id))", ["db", "id"],
+ { filename: `/fixture/server/src/services/${name}.ts`, contextExtensions: [{ table, eq }] },
+ ) as (db: Pick, id: string) => typeof shape;
+ const fromA = caller("capture-caller-a"), fromB = caller("capture-caller-b");
+ const normalA = fromA(f.db, id), normalB = fromB(f.db, id);
+ expect(await normalA).toEqual([{ id, payload: "caller-sentinel" }]);
+ expect(await normalB).toEqual([{ id, payload: "caller-sentinel" }]);
+ const prepared = fromA(f.db, id).prepare("capture_caller_prepared");
+ expect(await prepared.execute()).toEqual([{ id, payload: "caller-sentinel" }]);
+ await f.db.transaction(async tx => {
+ expect(await fromB(tx, id)).toEqual([{ id, payload: "caller-sentinel" }]);
+ });
+ f.capture.flush();
+ const entries = f.events.filter(event => event.event === "paperclip.db_response_capture.entry.v1");
+ expect(entries).toHaveLength(2);
+ for (const name of ["a", "b"]) {
+ expect(entries.find(event => (event.callers as string[]).some(location => location.startsWith(`services/capture-caller-${name}.ts:`))))
+ .toMatchObject({ operation: "select", table: "response_capture_fixture", calls: 2, rows: 2 });
+ }
+ expect(summary(f.events)).toMatchObject({ startedQueries: 4, completedQueries: 4 });
+ expect(JSON.stringify(f.events)).not.toContain("/fixture/");
+ expect(JSON.stringify(f.events)).not.toContain("caller-sentinel");
+ expect(JSON.stringify(f.events)).not.toContain(id);
+ });
+ it("records the executed CTE rather than its unexecuted inner select", async () => {
+ const f = fixture(), id = randomUUID();
+ await f.db.insert(table).values({ id, payload: "cte-value" });
+ const cte = f.db.$with("capture_cte").as(f.db.select().from(table).where(eq(table.id, id)));
+ expect(await f.db.with(cte).select().from(cte)).toEqual([{ id, payload: "cte-value" }]);
+ f.capture.flush();
+ expect(summary(f.events)).toMatchObject({ startedQueries: 2, completedQueries: 2 });
+ });
+ it("keeps raw execute results and rejection behavior without logging SQL or errors", async () => {
+ const f = fixture();
+ expect(await f.db.execute(sql`select ${"sentinel-sql-parameter"}::text as value`)).toEqual([{ value: "sentinel-sql-parameter" }]);
+ await expect(f.db.execute(sql`select 1/0`)).rejects.toThrow();
+ f.capture.flush();
+ expect(summary(f.events)).toMatchObject({ startedQueries: 2, completedQueries: 2 });
+ expect(f.events.find(event => event.operation === "execute")).toMatchObject({ queryErrors: 1 });
+ expect(JSON.stringify(f.events)).not.toContain("sentinel-sql-parameter");
+ expect(JSON.stringify(f.events)).not.toContain("select 1/0");
+ });
+ it("preserves nested commit, rollback, and durable work-signal boundaries", async () => {
+ const f = fixture(), id = randomUUID(), events: string[] = [];
+ const unsubscribe = subscribeDatabaseWork(f.db, "capture-topic", event => events.push(event));
+ try {
+ expect(await f.db.transaction(async tx => {
+ await tx.transaction(async nested => {
+ await signalDatabaseWork(nested, "capture-topic");
+ await nested.insert(table).values({ id, payload: "committed" });
+ });
+ expect(events).toEqual(["intent"]); expect(databaseWorkPending(f.db, "capture-topic")).toBe(true);
+ return 42;
+ })).toBe(42);
+ expect(events).toEqual(["intent", "settled"]);
+ expect(databaseWorkPending(f.db, "capture-topic")).toBe(false);
+ const rolledBack = randomUUID();
+ await expect(f.db.transaction(async tx => {
+ await signalDatabaseWork(tx, "capture-topic");
+ await tx.insert(table).values({ id: rolledBack, payload: "rolled-back" });
+ throw new Error("rollback fixture");
+ })).rejects.toThrow("rollback fixture");
+ await reconcileDatabaseWork(f.db, "capture-topic");
+ expect(databaseWorkPending(f.db, "capture-topic")).toBe(false);
+ expect(await f.db.select().from(table).where(eq(table.id, rolledBack))).toEqual([]);
+ expect(await f.db.select().from(table).where(eq(table.id, id))).toEqual([{ id, payload: "committed" }]);
+ } finally { unsubscribe(); }
+ f.capture.flush();
+ expect(summary(f.events).completedQueries).toBeGreaterThan(4);
+ });
+ it("covers automatically created dedicated pools and retains owner identity", async () => {
+ const logs: string[] = [], now = Date.now();
+ vi.spyOn(console, "info").mockImplementation(value => logs.push(String(value)));
+ vi.stubEnv("PAPERCLIP_DB_RESPONSE_CAPTURE_WINDOW", `${new Date(now - 100).toISOString()}/${new Date(now + 59_000).toISOString()}`);
+ const db = createDb(temporary.connectionString); clients.push(db);
+ const capture = databaseResponseCaptureFromEnv()!; captures.push(capture);
+ expect(await withDedicatedDbConnection(db, async dedicated => dedicated.execute(sql`select ${"dedicated-value"}::text as value`)))
+ .toEqual([{ value: "dedicated-value" }]);
+ capture.flush();
+ const events = logs.map(line => JSON.parse(line) as Record);
+ expect(summary(events)).toMatchObject({ startedQueries: 1, completedQueries: 1 });
+ expect(logs.join("\n")).not.toContain("dedicated-value");
+ expect(logs.join("\n")).not.toContain(temporary.connectionString);
+ });
+});
diff --git a/packages/db/src/response-capture.test.ts b/packages/db/src/response-capture.test.ts
new file mode 100644
index 00000000000..65f58d10883
--- /dev/null
+++ b/packages/db/src/response-capture.test.ts
@@ -0,0 +1,133 @@
+import { integer, pgTable } from "drizzle-orm/pg-core";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createDatabaseResponseCapture, installDatabaseResponseCapture, publicDatabaseCallers, responseCaptureWindow } from "./response-capture.js";
+
+const epoch = Date.parse("2020-01-01T00:00:00Z");
+const metadata = { operation: "select", table: "capture_fixture", callers: ["services/fixture.ts:4:2"] };
+const table = pgTable("capture_fixture", { id: integer("id") });
+const captures: Array> = [];
+afterEach(() => { for (const capture of captures.splice(0)) capture.flush(); vi.unstubAllEnvs(); });
+function fixture(emitOverride?: (event: Record) => void) {
+ let clock = epoch;
+ const events: Array> = [];
+ const capture = createDatabaseResponseCapture({ start: epoch, end: epoch + 60_000 }, emitOverride ?? (event => events.push(event)), () => clock);
+ captures.push(capture);
+ return { capture, events, now: (value: number) => { clock = value; } };
+}
+
+describe("bounded database response capture", () => {
+ it.each([undefined, "", "sentinel-invalid-input", "2020-01-01T00:00:00Z", "2020-01-01T00:00:00Z/2020-01-01T00:00:00Z",
+ "2020-01-01T00:00:00Z/2020-01-01T00:01:01Z", "2020-01-01T01:00:00Z/2020-01-01T01:01:00Z",
+ "2020-01-01T00:00:00Z/2020-01-01T00:01:00Z/extra",
+ ])("disables absent, malformed, expired, or unbounded windows: %s", value => {
+ expect(responseCaptureWindow(value, epoch + 60_000)).toBeUndefined();
+ });
+ it("accepts a fixed future window of at most one minute", () => {
+ expect(responseCaptureWindow("2020-01-01T00:01:00Z/2020-01-01T00:02:00Z", epoch))
+ .toEqual({ start: epoch + 60_000, end: epoch + 120_000 });
+ expect(() => createDatabaseResponseCapture({ start: epoch, end: epoch + 60_001 }, () => {})).toThrow("Invalid");
+ });
+ it("keeps only public server file locations from stacks", () => {
+ const stack = ["Error: sentinel-message", "at f (/private/fixture/server/src/services/example.ts:4:2)",
+ "at f (/private/fixture/server/src/services/example.ts:4:2)", "at f (/private/fixture/server/dist/routes/example.js:5:3)",
+ "at f (/private/fixture/customer-file.js:7:9)", "at f (/app/server/src/services/../../customer-file.ts:8:1)"].join("\n");
+ expect(publicDatabaseCallers(stack)).toEqual(["services/example.ts:4:2", "routes/example.js:5:3"]);
+ });
+ it("leaves methods and identity unchanged when disabled", () => {
+ vi.stubEnv("PAPERCLIP_DB_RESPONSE_CAPTURE_WINDOW", undefined);
+ const db = { select: vi.fn(), transaction: vi.fn() }, select = db.select, transaction = db.transaction;
+ expect(installDatabaseResponseCapture(db, { table })).toBe(db);
+ expect(db.select).toBe(select); expect(db.transaction).toBe(transaction);
+ });
+ it("preserves exact values and only emits counts, sizes, and caller metadata", async () => {
+ const f = fixture(), rows = [{ text: "sentinel-result-value🙂\n\"", n: 4n }];
+ expect(await f.capture.execute(() => Promise.resolve(rows), metadata)).toBe(rows);
+ f.capture.flush();
+ const entry = f.events.find(event => event.event === "paperclip.db_response_capture.entry.v1")!;
+ expect(entry).toMatchObject({ calls: 1, rows: 1, queryErrors: 0, serializationErrors: 0,
+ responseValueBytes: Buffer.byteLength(JSON.stringify(rows, (_, value) => typeof value === "bigint" ? value.toString() : value)) });
+ expect(JSON.stringify(f.events)).not.toContain("sentinel-result-value");
+ expect(f.events.at(-1)).toMatchObject({ startedQueries: 1, completedQueries: 1, inFlightAtEnd: 0,
+ rowValuesLogged: false, sqlOrParametersLogged: false, wireBytesMeasured: false });
+ });
+ it("counts driver text values without adding JSON escaping or column names", async () => {
+ const f = fixture(), text = "sentinel-driver-text\n\"";
+ const rows = Object.assign([[text, true, 42n, null]], { columns: [
+ { name: "sentinel-column-name" }, { name: "b" }, { name: "n" }, { name: "empty" } ] });
+ expect(await f.capture.execute(() => Promise.resolve(rows), metadata)).toBe(rows);
+ f.capture.flush();
+ expect(f.events[0]).toMatchObject({ responseValueBytes: Buffer.byteLength(text) + 1 + 2, estimatedValues: 0 });
+ expect(JSON.stringify(f.events)).not.toContain("sentinel-driver-text");
+ expect(JSON.stringify(f.events)).not.toContain("sentinel-column-name");
+ });
+ it("does not alter synchronous or asynchronous errors or retry them", async () => {
+ const f = fixture(), error = new Error("sentinel-private-error");
+ const sync = vi.fn(() => { throw error; }), asyncFailure = vi.fn(() => Promise.reject(error));
+ expect(() => f.capture.execute(sync, metadata)).toThrow(error);
+ await expect(f.capture.execute(asyncFailure, metadata)).rejects.toBe(error);
+ expect(sync).toHaveBeenCalledTimes(1); expect(asyncFailure).toHaveBeenCalledTimes(1);
+ f.capture.flush();
+ expect(f.events[0]).toMatchObject({ calls: 2, queryErrors: 2, responseValueBytes: 0 });
+ expect(JSON.stringify(f.events)).not.toContain("sentinel-private-error");
+ });
+ it("keeps unserializable results and logging failures out of the query outcome", () => {
+ const f = fixture(), circular: Record = {}; circular.self = circular;
+ expect(f.capture.execute(() => circular, metadata)).toBe(circular);
+ f.capture.flush();
+ expect(f.events[0]).toMatchObject({ calls: 1, serializationErrors: 1 });
+ const brokenLogger = fixture(() => { throw new Error("logger fixture"); });
+ expect(brokenLogger.capture.execute(() => circular, metadata)).toBe(circular);
+ expect(() => brokenLogger.capture.flush()).not.toThrow();
+ });
+ it("stops accounting at the deadline and leaves in-flight work untouched", async () => {
+ const f = fixture();
+ let resolve!: (rows: unknown[]) => void;
+ const pending = new Promise(done => { resolve = done; });
+ const result = f.capture.execute(() => pending, metadata);
+ f.now(epoch + 60_000); f.capture.flush();
+ expect(f.events.at(-1)).toMatchObject({ startedQueries: 1, completedQueries: 0, inFlightAtEnd: 1 });
+ const rows = [{ marker: "late-result-value" }]; resolve(rows);
+ expect(await result).toBe(rows);
+ const direct = Promise.resolve(rows);
+ expect(f.capture.execute(() => direct, metadata)).toBe(direct);
+ expect(f.events).toHaveLength(1);
+ });
+ it("retains the deadline count when a query resolves before a delayed flush", async () => {
+ const f = fixture();
+ let resolve!: (rows: unknown[]) => void;
+ const pending = new Promise(done => { resolve = done; });
+ const result = f.capture.execute(() => pending, metadata);
+ f.now(epoch + 60_001);
+ const rows = [{ marker: "late-before-flush-sentinel" }]; resolve(rows);
+ expect(await result).toBe(rows);
+ f.capture.flush();
+ expect(f.events).toHaveLength(1);
+ expect(f.events[0]).toMatchObject({ startedQueries: 1, completedQueries: 0, inFlightAtEnd: 1, responseValueBytes: 0 });
+ expect(JSON.stringify(f.events)).not.toContain("late-before-flush-sentinel");
+ });
+ it("bounds cardinality without dropping response-byte totals", () => {
+ const f = fixture();
+ for (let i = 0; i < 200; i += 1) f.capture.execute(() => [i], { ...metadata, callers: [`services/f${i}.ts:1:1`] });
+ f.capture.flush();
+ const summary = f.events.at(-1)!;
+ expect(summary).toMatchObject({ startedQueries: 200, completedQueries: 200, groups: 128, overflowCalls: 73 });
+ expect(summary.responseValueBytes).toBe(Array.from({ length: 200 }, (_, i) => Buffer.byteLength(JSON.stringify([i]))).reduce((a, b) => a + b, 0));
+ });
+ it("does not execute builders or prepared statements until requested", async () => {
+ const f = fixture(), rows = [{ id: 1 }], execute = vi.fn(async () => rows);
+ const query = { execute, prepare: () => ({ execute }) };
+ const builder = { from: () => query };
+ const db = { select: () => builder };
+ expect(installDatabaseResponseCapture(db, { table }, f.capture)).toBe(db);
+ expect(db.select()).toBe(builder);
+ expect(db.select().from()).toBe(query);
+ expect(execute).not.toHaveBeenCalled();
+ const prepared = query.prepare();
+ expect(execute).not.toHaveBeenCalled();
+ expect(await prepared.execute()).toBe(rows);
+ expect(await query.execute()).toBe(rows);
+ expect(execute).toHaveBeenCalledTimes(2);
+ f.capture.flush();
+ expect(f.events.at(-1)).toMatchObject({ completedQueries: 0 });
+ });
+});
diff --git a/packages/db/src/response-capture.ts b/packages/db/src/response-capture.ts
new file mode 100644
index 00000000000..8f738043f4d
--- /dev/null
+++ b/packages/db/src/response-capture.ts
@@ -0,0 +1,273 @@
+import { AsyncLocalStorage } from "node:async_hooks";
+import { randomUUID } from "node:crypto";
+import { getTableName, is, Table } from "drizzle-orm";
+
+type Metadata = { operation: string; table: string; callers: string[] };
+type Entry = Metadata & { calls: number; rows: number; bytes: number; maxBytes: number; queryErrors: number; serializationErrors: number; estimatedValues: number };
+type Event = Record;
+type Method = (this: unknown, ...args: unknown[]) => unknown;
+export type ResponseCaptureWindow = { start: number; end: number };
+
+export function responseCaptureWindow(value: string | undefined, now = Date.now()): ResponseCaptureWindow | undefined {
+ if (!value) return;
+ const parts = value.split("/");
+ if (parts.length !== 2 || parts.some(part => !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(part))) return;
+ const start = Date.parse(parts[0]!), end = Date.parse(parts[1]!);
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || end - start > 60_000 || end <= now || start > now + 30 * 60_000) return;
+ return { start, end };
+}
+
+export function publicDatabaseCallers(stack: string | undefined): string[] {
+ const callers: string[] = [];
+ for (const line of (stack ?? "").split("\n")) {
+ const match = line.match(/[/\\]server[/\\](?:src|dist)[/\\]((?:services|routes|modules|middleware)[/\\][A-Za-z0-9_./\\-]+\.[cm]?[jt]s):(\d+):(\d+)/);
+ if (!match) continue;
+ const relative = match[1]!.replaceAll("\\", "/");
+ if (relative.split("/").includes("..")) continue;
+ const caller = `${relative}:${match[2]}:${match[3]}`;
+ if (!callers.includes(caller)) callers.push(caller);
+ if (callers.length === 4) break;
+ }
+ return callers;
+}
+
+function responseValueSize(value: unknown): { bytes: number; estimatedValues: number } {
+ const columns = value && typeof value === "object" ? (value as { columns?: unknown }).columns : undefined;
+ if (!Array.isArray(value) || !Array.isArray(columns)) {
+ const serialized = JSON.stringify(value, (_, item: unknown) => typeof item === "bigint" ? item.toString() : item);
+ return { bytes: serialized === undefined ? 0 : Buffer.byteLength(serialized), estimatedValues: 1 };
+ }
+ let bytes = 0, estimatedValues = 0;
+ for (const row of value) {
+ const cells = Array.isArray(row) ? row : columns.map(column => row[column.name]);
+ for (const cell of cells) {
+ if (cell === null || cell === undefined) continue;
+ if (typeof cell === "string") bytes += Buffer.byteLength(cell);
+ else if (typeof cell === "boolean") bytes += 1;
+ else if (typeof cell === "number" || typeof cell === "bigint") bytes += Buffer.byteLength(String(cell));
+ else {
+ estimatedValues += 1;
+ const serialized = JSON.stringify(cell, (_, item: unknown) => typeof item === "bigint" ? item.toString() : item);
+ if (serialized !== undefined) bytes += Buffer.byteLength(serialized);
+ }
+ }
+ }
+ return { bytes, estimatedValues };
+}
+
+export function createDatabaseResponseCapture(
+ window: ResponseCaptureWindow,
+ emit: (event: Event) => void,
+ now: () => number = Date.now,
+) {
+ if (!Number.isFinite(new Date(window.start).getTime()) || !Number.isFinite(new Date(window.end).getTime()) ||
+ window.end <= window.start || window.end - window.start > 60_000) throw new Error("Invalid database response capture window");
+ const id = randomUUID(), entries = new Map();
+ const roots = new WeakSet(), queries = new WeakSet(), factories = new WeakSet();
+ const clients = new WeakSet(), driverQueries = new WeakSet();
+ const scope = new AsyncLocalStorage();
+ let closed = false, inFlight = 0, started = 0, completed = 0, overflowCalls = 0;
+ const send = (event: Event) => { try { emit(event); } catch {} };
+ const expired = () => closed || now() >= window.end;
+ const active = () => !expired() && now() >= window.start;
+ const flush = () => {
+ if (closed) return;
+ closed = true;
+ clearTimeout(timer);
+ scope.disable();
+ const ordered = [...entries.values()].sort((a, b) => b.bytes - a.bytes);
+ for (const entry of ordered) send({ event: "paperclip.db_response_capture.entry.v1", captureId: id,
+ operation: entry.operation, table: entry.table, callers: entry.callers, calls: entry.calls, rows: entry.rows,
+ responseValueBytes: entry.bytes, maxResponseValueBytes: entry.maxBytes, estimatedValues: entry.estimatedValues,
+ queryErrors: entry.queryErrors, serializationErrors: entry.serializationErrors });
+ send({ event: "paperclip.db_response_capture.summary.v1", captureId: id,
+ windowStart: new Date(window.start).toISOString(), windowEnd: new Date(window.end).toISOString(),
+ closedAt: new Date(now()).toISOString(),
+ startedQueries: started, completedQueries: completed, inFlightAtEnd: inFlight, groups: entries.size,
+ overflowCalls, responseValueBytes: ordered.reduce((sum, entry) => sum + entry.bytes, 0),
+ rowValuesLogged: false, sqlOrParametersLogged: false, wireBytesMeasured: false,
+ measurementBoundary: "postgres-js results before Drizzle column decoding",
+ relationalQueriesMeasured: true, unsafeDriverQueriesMeasured: true,
+ taggedDriverQueriesMeasured: false, streamingQueriesMeasured: false });
+ entries.clear();
+ };
+ const timer = setTimeout(flush, Math.max(0, window.end - now()));
+ timer.unref();
+ function finish(metadata: Metadata, value: unknown, failed: boolean) {
+ if (!active()) return;
+ inFlight -= 1;
+ completed += 1;
+ try {
+ let key = JSON.stringify(metadata);
+ if (!entries.has(key) && entries.size >= 127) {
+ overflowCalls += 1;
+ metadata = { operation: "other", table: "other", callers: [] };
+ key = "overflow";
+ }
+ let entry = entries.get(key);
+ if (!entry) {
+ entry = { ...metadata, calls: 0, rows: 0, bytes: 0, maxBytes: 0, queryErrors: 0, serializationErrors: 0, estimatedValues: 0 };
+ entries.set(key, entry);
+ }
+ entry.calls += 1;
+ if (failed) { entry.queryErrors += 1; return; }
+ entry.rows += Array.isArray(value) ? value.length : 0;
+ try {
+ const size = responseValueSize(value);
+ entry.bytes += size.bytes;
+ entry.maxBytes = Math.max(entry.maxBytes, size.bytes);
+ entry.estimatedValues += size.estimatedValues;
+ } catch { entry.serializationErrors += 1; }
+ } catch {}
+ }
+ function execute(action: () => unknown, metadata: Metadata) {
+ if (!active()) return action();
+ inFlight += 1; started += 1;
+ let result: unknown;
+ try { result = action(); }
+ catch (error) { finish(metadata, undefined, true); throw error; }
+ let asynchronous = false;
+ try { asynchronous = Boolean(result && typeof (result as { then?: unknown }).then === "function"); } catch {}
+ if (asynchronous) {
+ return Promise.resolve(result).then(value => { finish(metadata, value, false); return value; },
+ error => { finish(metadata, undefined, true); throw error; });
+ }
+ finish(metadata, result, false);
+ return result;
+ }
+ return { window, roots, queries, factories, clients, driverQueries, scope, active, expired, execute, flush };
+}
+
+type Capture = ReturnType;
+let environmentCapture: { key: string; capture: Capture } | undefined;
+export function databaseResponseCaptureFromEnv(env: NodeJS.ProcessEnv = process.env): Capture | undefined {
+ const key = env.PAPERCLIP_DB_RESPONSE_CAPTURE_WINDOW;
+ const window = responseCaptureWindow(key);
+ if (!key || !window) return;
+ if (environmentCapture?.key === key) return environmentCapture.capture;
+ const capture = createDatabaseResponseCapture(window, event => console.info(JSON.stringify(event)));
+ environmentCapture = { key, capture };
+ return capture;
+}
+
+export function installDatabaseResponseCapture(db: T, schema: Record, capture = databaseResponseCaptureFromEnv()): T {
+ if (!capture || capture.expired()) return db;
+ const tables = new Set(Object.values(schema).filter(value => is(value, Table)).map(table => getTableName(table)));
+ const object = (value: unknown): value is Record => value !== null && typeof value === "object";
+ function replace(target: Record, name: string, replacement: (original: Method) => Method) {
+ const original = target[name];
+ if (typeof original !== "function") return;
+ try { target[name] = replacement(original as Method); } catch {}
+ }
+ function tableName(value: unknown) {
+ try { if (is(value, Table)) { const name = getTableName(value); if (tables.has(name)) return name; } } catch {}
+ return "other";
+ }
+ function query(value: unknown, metadata: Metadata) {
+ if (!object(value) || capture!.queries.has(value)) return;
+ capture!.queries.add(value);
+ replace(value, "execute", original => function (this: unknown, ...args: unknown[]) {
+ if (capture!.expired()) return Reflect.apply(original, this, args);
+ return capture!.scope.run(metadata, () => Reflect.apply(original, this, args));
+ });
+ replace(value, "prepare", original => function (this: unknown, ...args: unknown[]) {
+ const prepared = Reflect.apply(original, this, args);
+ try { query(prepared, metadata); } catch {}
+ return prepared;
+ });
+ }
+ function factory(value: unknown, step: string, metadata: Metadata, from = false) {
+ if (!object(value) || capture!.factories.has(value)) return;
+ capture!.factories.add(value);
+ replace(value, step, original => function (this: unknown, ...args: unknown[]) {
+ const result = Reflect.apply(original, this, args);
+ try { query(result, { ...metadata, table: from ? tableName(args[0]) : metadata.table }); } catch {}
+ return result;
+ });
+ }
+ function driverQuery(value: unknown, metadata: Metadata) {
+ if (!object(value) || capture!.driverQueries.has(value)) return;
+ capture!.driverQueries.add(value);
+ let consumed = false;
+ replace(value, "then", original => function (this: unknown, ...handlers: unknown[]) {
+ if (consumed || !capture!.active()) {
+ consumed = true;
+ return Reflect.apply(original, this, handlers);
+ }
+ consumed = true;
+ const result = capture!.execute(() => Reflect.apply(original, this, []), metadata) as Promise;
+ return Reflect.apply(result.then, result, handlers);
+ });
+ for (const method of ["execute", "cursor", "forEach", "readable", "writable"]) {
+ replace(value, method, original => function (this: unknown, ...args: unknown[]) {
+ consumed = true;
+ return Reflect.apply(original, this, args);
+ });
+ }
+ }
+ function sqlMetadata(value: unknown): Metadata {
+ const scoped = capture!.scope.getStore();
+ const text = typeof value === "string" ? value : "";
+ let table = "other";
+ for (const match of text.matchAll(/\b(?:from|update|into)\s+"([A-Za-z0-9_]+)"/gi)) {
+ if (tables.has(match[1]!)) { table = match[1]!; break; }
+ }
+ return scoped ? { ...scoped, table: scoped.table === "other" ? table : scoped.table }
+ : { operation: text.match(/^\s*(select|insert|update|delete)\b/i)?.[1]?.toLowerCase() ?? "execute",
+ table, callers: publicDatabaseCallers(new Error().stack) };
+ }
+ function client(value: unknown) {
+ if ((!object(value) && typeof value !== "function") || capture!.clients.has(value)) return;
+ capture!.clients.add(value);
+ const target = value as Record;
+ replace(target, "unsafe", original => function (this: unknown, ...args: unknown[]) {
+ const result = Reflect.apply(original, this, args);
+ if (!capture!.expired()) { try { driverQuery(result, sqlMetadata(args[0])); } catch {} }
+ return result;
+ });
+ for (const method of ["begin", "savepoint"]) {
+ replace(target, method, original => function (this: unknown, ...args: unknown[]) {
+ const wrapped = args.map(arg => typeof arg !== "function" ? arg : function (this: unknown, ...inner: unknown[]) {
+ try { client(inner[0]); } catch {}
+ return Reflect.apply(arg, this, inner);
+ });
+ return Reflect.apply(original, this, wrapped);
+ });
+ }
+ replace(target, "reserve", original => function (this: unknown, ...args: unknown[]) {
+ return Promise.resolve(Reflect.apply(original, this, args)).then(reserved => {
+ try { client(reserved); } catch {}
+ return reserved;
+ });
+ });
+ }
+ function root(value: unknown) {
+ if (!object(value) || capture!.roots.has(value)) return;
+ capture!.roots.add(value);
+ for (const method of ["select", "selectDistinct", "selectDistinctOn", "insert", "update", "delete", "execute", "with"] as const) {
+ replace(value, method, original => function (this: unknown, ...args: unknown[]) {
+ const result = Reflect.apply(original, this, args);
+ if (capture!.expired()) return result;
+ try {
+ if (method === "with") { root(result); return result; }
+ const operation = method.startsWith("select") ? "select" : method;
+ const metadata = { operation, table: operation === "select" || method === "execute" ? "other" : tableName(args[0]), callers: publicDatabaseCallers(new Error().stack) };
+ if (operation === "select") factory(result, "from", metadata, true);
+ else if (method === "insert") factory(result, "values", metadata);
+ else if (method === "update") factory(result, "set", metadata);
+ else query(result, metadata);
+ } catch {}
+ return result;
+ });
+ }
+ replace(value, "transaction", original => function (this: unknown, callback: unknown, ...options: unknown[]) {
+ if (typeof callback !== "function" || capture!.expired()) return Reflect.apply(original, this, [callback, ...options]);
+ return Reflect.apply(original, this, [function (this: unknown, ...args: unknown[]) {
+ try { root(args[0]); } catch {}
+ return Reflect.apply(callback, this, args);
+ }, ...options]);
+ });
+ }
+ try { client((db as Record).$client); root(db); } catch {}
+ return db;
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 99f0a885507..a45052d7fe0 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -889,6 +889,7 @@ export type {
AgentDetail,
ClearAgentErrorResponse,
AgentPermissions,
+ AgentProjectAccess,
AgentPublicIdentity,
AgentInstructionsBundleMode,
AgentInstructionsFileSummary,
@@ -1882,6 +1883,7 @@ export {
qualifyAgentRunnerSchema,
agentPermissionsSchema,
updateAgentPermissionsSchema,
+ addAgentProjectAccessSchema,
type CreateAgent,
type BuiltInAgentProvision,
type BuiltInAgentReset,
diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts
index ac1970305b7..ba4b594389b 100644
--- a/packages/shared/src/types/agent.ts
+++ b/packages/shared/src/types/agent.ts
@@ -25,6 +25,11 @@ export interface AgentPermissions extends Record {
authorizationPolicy?: TrustAuthorizationPolicy;
}
+export interface AgentProjectAccess {
+ projectIds: string[];
+ canManage: boolean;
+}
+
export type AgentRuntimeConfig = Record & {
aiConnection?: import("../ai-connection-router.js").AiRuntimeConnectionBinding;
};
diff --git a/packages/shared/src/types/execution-projection.ts b/packages/shared/src/types/execution-projection.ts
index 23c7d3d688d..ff28fd01dc1 100644
--- a/packages/shared/src/types/execution-projection.ts
+++ b/packages/shared/src/types/execution-projection.ts
@@ -104,7 +104,9 @@ export interface TaskInterruptionPresentation {
state: "working" | "retry_scheduled" | "failed" | "waiting_for_user" | "resolved";
nextRetryAt: string | null;
canRetry: boolean;
- userAction: "approval" | "reconnect" | "assign_agent" | "choose_model" | "resume" | "budget" | null;
+ userAction: "approval" | "reconnect" | "assign_agent" | "choose_model" | "resume" | "budget" | "project_access" | null;
+ /** Only the task's own project id is exposed. Other source projects stay unnamed. */
+ projectAccess?: { agentId: string; projectId: string | null; ready: boolean };
sourceRunIds: string[];
observedRunId: string | null;
}
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index c89aa62b1e3..b10aa2c33ea 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -280,6 +280,7 @@ export type {
AgentDetail,
ClearAgentErrorResponse,
AgentPermissions,
+ AgentProjectAccess,
AgentPublicIdentity,
AgentRuntimeConfig,
AgentInstructionsBundleMode,
diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts
index 32d65555e6d..1e9e42e2876 100644
--- a/packages/shared/src/validators/agent.ts
+++ b/packages/shared/src/validators/agent.ts
@@ -322,6 +322,10 @@ export const updateAgentPermissionsSchema = z.object({
export type UpdateAgentPermissions = z.infer;
+export const addAgentProjectAccessSchema = z.object({
+ projectIds: z.array(z.string().uuid()).min(1).max(100),
+}).strict();
+
export const resolveAgentInstructionCandidateSchema = z.object({
baseRevisionId: z.string().uuid().nullable(),
content: z.string().max(1024 * 1024),
diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts
index 05519dd262f..8f1ba1bf907 100644
--- a/packages/shared/src/validators/index.ts
+++ b/packages/shared/src/validators/index.ts
@@ -370,6 +370,7 @@ export {
qualifyAgentRunnerSchema,
agentPermissionsSchema,
updateAgentPermissionsSchema,
+ addAgentProjectAccessSchema,
type CreateAgent,
type BuiltInAgentProvision,
type BuiltInAgentReset,
diff --git a/server/src/__tests__/agent-project-access.test.ts b/server/src/__tests__/agent-project-access.test.ts
new file mode 100644
index 00000000000..38618a3669c
--- /dev/null
+++ b/server/src/__tests__/agent-project-access.test.ts
@@ -0,0 +1,107 @@
+import { randomUUID } from "node:crypto";
+import express from "express";
+import request from "supertest";
+import { and, eq } from "drizzle-orm";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { agents, agentWakeupRequests, activityLog, authUsers, companies, companyMemberships, createDb,
+ heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
+import { registerAgentProjectAccessRoutes } from "../routes/agent-project-access.js";
+import { errorHandler } from "../middleware/error-handler.js";
+import { assertTaskWorkspaceSourceProjectAccess } from "../services/task-workspace-access.js";
+import { taskInterruptionPresentation } from "../services/task-interruption-presentation.js";
+import { ProjectAccessDeniedError } from "../services/project-access-denial.js";
+import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
+
+describe("additive project access", () => {
+ let database: Awaited> | undefined;
+ let db: ReturnType;
+ beforeAll(async () => {
+ const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
+ if (external) db = createDb(external);
+ else { database = await startEmbeddedPostgresTestDatabase("agent-project-access-"); db = createDb(database.connectionString); }
+ }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
+ afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
+
+ async function seed() {
+ const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
+ await db.insert(companies).values({ id: companyId, name: "Project access", issuePrefix: `A${companyId.slice(0, 6)}` });
+ const selected = await db.insert(projects).values(["Existing", "Needed", "Third"].map(name => ({ companyId, name }))).returning();
+ const permissions = { canCreateAgents: false, canCreateSkills: false, trustPreset: "low_trust_review",
+ authorizationPolicy: { reviewPreset: { id: "low_trust_review", version: 1, rawOutputDisposition: "quarantine" },
+ customPolicy: { preserve: true }, trustBoundary: { mode: "low_trust_review", companyId,
+ projectIds: [selected[0]!.id], issueIds: [issueId], allowedToolClasses: ["git.read"], allowedSecretBindingIds: [] } } };
+ await db.insert(agents).values({ id: agentId, companyId, name: "Reviewer", adapterType: "process", status: "idle", permissions });
+ await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" });
+ const [issue] = await db.insert(issues).values({ id: issueId, companyId, title: "Saved message", status: "blocked", assigneeAgentId: agentId, projectId: selected[1]!.id }).returning();
+ await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, status: "failed", errorCode: "setup_failed",
+ error: "Project is outside this low-trust boundary.", contextSnapshot: { issueId } });
+ return { companyId, agentId, issue: issue!, runId, selected, permissions };
+ }
+ function app(actor: express.Request["actor"]) {
+ const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); });
+ registerAgentProjectAccessRoutes(app, db); app.use(errorHandler); return app;
+ }
+ const board = { type: "board" as const, source: "local_implicit" as const };
+
+ it("preserves containment, audits concurrent additions, and changes the persisted denial into an explicit retry", async () => {
+ const f = await seed();
+ const actor = { type: "agent" as const, source: "agent_key" as const, agentId: f.agentId, companyId: f.companyId };
+ await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, f.companyId, f.selected[1]!.id)).rejects.toBeInstanceOf(ProjectAccessDeniedError);
+ expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ userAction: "project_access", canRetry: false });
+ const api = app(board);
+ const results = await Promise.all(f.selected.slice(1).map(project => request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [project.id] })));
+ expect(results.map(result => result.status)).toEqual([200, 200]);
+ const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId));
+ expect(saved!.permissions).toEqual({ ...f.permissions, authorizationPolicy: { ...f.permissions.authorizationPolicy,
+ trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, projectIds: expect.arrayContaining(f.selected.map(project => project.id)) } } });
+ expect((saved!.permissions as typeof f.permissions).authorizationPolicy.trustBoundary.projectIds).toHaveLength(3);
+ await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, f.companyId, f.selected[1]!.id)).resolves.toBeUndefined();
+ expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
+ const again = await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] });
+ expect(again.status).toBe(200);
+ expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.action, "agent.project_access_added")))).toHaveLength(2);
+ expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(0);
+ expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
+ });
+
+ it("rejects foreign projects, agent self-grants, unknown policy fields, and pending-approval agents", async () => {
+ const f = await seed(), other = await seed(), api = app(board);
+ expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [other.selected[1]!.id] })).status).toBe(404);
+ expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id], trustPreset: "standard" })).status).toBe(400);
+ expect((await request(app({ type: "agent", agentId: f.agentId, companyId: f.companyId, source: "agent_key" }))
+ .post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403);
+ await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agentId));
+ expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(409);
+ const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId)); expect(saved!.permissions).toEqual(f.permissions);
+ });
+
+ it.each(["standard", "root_issue"])("rechecks actual project authority after a %s repair and acknowledges an accepted retry", async repair => {
+ const f = await seed();
+ const permissions = repair === "standard" ? { canCreateAgents: false, trustPreset: "standard", authorizationPolicy: {} }
+ : { ...f.permissions, authorizationPolicy: { ...f.permissions.authorizationPolicy,
+ trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, rootIssueId: f.issue.id } } };
+ await db.update(agents).set({ permissions }).where(eq(agents.id, f.agentId));
+ expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
+ await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.agentId,
+ source: "on_demand", status: "deferred_issue_execution", requestedByActorType: "user", payload: { issueId: f.issue.id } });
+ const accepted = await taskInterruptionPresentation(db, f.issue);
+ expect(accepted).toMatchObject({ state: "working", canRetry: false });
+ expect(accepted).not.toHaveProperty("projectAccess");
+ // Current project policy remains authoritative even after agent access changes.
+ await db.update(projects).set({ executionWorkspacePolicy: { trustPreset: "low_trust_review", authorizationPolicy: {
+ trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.selected[0]!.id] },
+ } } }).where(eq(projects.id, f.selected[1]!.id));
+ if (repair === "standard") expect(await taskInterruptionPresentation(db, f.issue))
+ .toMatchObject({ canRetry: false, userAction: "project_access" });
+ });
+
+ it("shows read-only access to viewers and denies their saves and cross-company reads", async () => {
+ const f = await seed(), other = await seed(), userId = randomUUID();
+ await db.insert(authUsers).values({ id: userId, name: "Viewer", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
+ await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, membershipRole: "viewer", status: "active" });
+ const api = app({ type: "board", source: "session", userId, companyIds: [f.companyId], memberships: [{ companyId: f.companyId, membershipRole: "viewer", status: "active" }] });
+ expect((await request(api).get(`/agents/${f.agentId}/project-access`)).body).toMatchObject({ canManage: false });
+ expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403);
+ expect((await request(api).get(`/agents/${other.agentId}/project-access`)).status).toBe(404);
+ });
+});
diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts
index d0abe4e4b4b..ff43e560394 100644
--- a/server/src/__tests__/openapi-routes.test.ts
+++ b/server/src/__tests__/openapi-routes.test.ts
@@ -21,6 +21,7 @@ const apiPrefixes: Record = {
"activity.ts": "/api",
"adapters.ts": "/api",
"agents.ts": "/api",
+ "agent-project-access.ts": "/api",
"agent-commentary.ts": "/api",
"agent-avatars.ts": "/api",
"agent-profile-avatar.ts": "/api",
@@ -987,6 +988,21 @@ describe("openapi routes", () => {
});
});
+ it("documents additive project access as board-only with a strict request", () => {
+ const { spec } = loadSpecRoutes();
+ const path = spec.paths["/api/agents/{id}/project-access"];
+ for (const operation of [path.get, path.post]) {
+ expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
+ expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
+ expect(operation.responses["200"].content["application/json"].schema.required).toEqual(["projectIds", "canManage"]);
+ }
+ expect(path.post.requestBody.content["application/json"].schema).toMatchObject({
+ additionalProperties: false, required: ["projectIds"],
+ properties: { projectIds: { type: "array", items: { type: "string", format: "uuid" } } },
+ });
+ expect(path.post.responses["409"]).toBeDefined();
+ });
+
it("documents the authenticated personal primary-agent contract", () => {
const { spec } = loadSpecRoutes();
const path = spec.paths["/api/companies/{companyId}/primary-agent/me"];
diff --git a/server/src/routes/agent-project-access.ts b/server/src/routes/agent-project-access.ts
new file mode 100644
index 00000000000..08346180aab
--- /dev/null
+++ b/server/src/routes/agent-project-access.ts
@@ -0,0 +1,25 @@
+import type { Router } from "express";
+import { agents, type Db } from "@paperclipai/db";
+import { eq } from "drizzle-orm";
+import { addAgentProjectAccessSchema } from "@paperclipai/shared";
+import { notFound } from "../errors.js";
+import { validate } from "../middleware/validate.js";
+import { addAgentProjectAccess, readAgentProjectAccess } from "../services/agent-project-access.js";
+import { assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js";
+
+export function registerAgentProjectAccessRoutes(router: Router, db: Db) {
+ router.get("/agents/:id/project-access", async (req, res) => {
+ assertBoard(req);
+ const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, req.params.id as string));
+ if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found");
+ assertCompanyAccess(req, agent.companyId);
+ res.json(await readAgentProjectAccess(db, req.actor, agent.companyId, agent.id));
+ });
+ router.post("/agents/:id/project-access", validate(addAgentProjectAccessSchema), async (req, res) => {
+ assertBoard(req);
+ const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, req.params.id as string));
+ if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found");
+ assertCompanyAccess(req, agent.companyId);
+ res.json(await addAgentProjectAccess(db, req.actor, agent.companyId, agent.id, req.body.projectIds));
+ });
+}
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index 540e1f7d8cc..0aba86abb8f 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -98,6 +98,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { trackAgentCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
+import { registerAgentProjectAccessRoutes } from "./agent-project-access.js";
import { inheritNativeRunnerAdapterConfig } from "../services/native-runtime/native-agent-runtime-inheritance.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
@@ -4918,6 +4919,8 @@ export function agentRoutes(
res.status(201).json({ ...redactAgentRowForResponse(agent), ...(configurationWarnings.length ? { configurationWarnings } : {}) });
});
+ registerAgentProjectAccessRoutes(router, db);
+
router.patch("/agents/:id/permissions", validate(updateAgentPermissionsSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts
index 650eb0670eb..bbb50ac7f55 100644
--- a/server/src/routes/openapi.ts
+++ b/server/src/routes/openapi.ts
@@ -48,6 +48,7 @@ import {
updateAgentSchema,
updatePrimaryAgentSchema,
updateAgentPermissionsSchema,
+ addAgentProjectAccessSchema,
updateAgentInstructionsPathSchema,
updateAgentInstructionsBundleSchema,
upsertAgentInstructionsFileSchema,
@@ -1390,6 +1391,8 @@ const browserUseOperations = [
] as const;
const BOARD_ONLY_OPERATIONS = new Set([
+ "GET /api/agents/{id}/project-access",
+ "POST /api/agents/{id}/project-access",
"POST /api/issues/{id}/interactions/{interactionId}/dismiss",
"POST /api/issues/{id}/retry",
"GET /api/companies/{companyId}/prompt-injection-prevention",
@@ -3790,6 +3793,26 @@ registry.registerPath({
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
+const agentProjectAccessResponse = z.object({ projectIds: z.array(z.string().uuid()), canManage: z.boolean() });
+registry.registerPath({
+ method: "get",
+ path: "/api/agents/{id}/project-access",
+ tags: ["agents"],
+ summary: "Read an agent's allowed projects and the current operator's management capability",
+ request: { params: z.object({ id: z.string() }) },
+ responses: { 200: r.ok(agentProjectAccessResponse), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
+});
+registry.registerPath({
+ method: "post",
+ path: "/api/agents/{id}/project-access",
+ tags: ["agents"],
+ summary: "Add allowed projects without replacing an agent's other permissions",
+ description: "Requires board access and agents:create in the agent's company. Accepts 1 to 100 project IDs. Selected projects must be readable and in that company. Locks the low-trust agent, preserves existing containment, audits new additions, and rejects pending-approval agents or changed trust settings. Repeated additions are idempotent. Saving does not start or retry a task.",
+ request: { params: z.object({ id: z.string() }), body: jsonBody(addAgentProjectAccessSchema) },
+ responses: { 200: r.ok(agentProjectAccessResponse), 400: r.badRequest, 401: r.unauthorized,
+ 403: r.forbidden, 404: r.notFound, 409: r.conflict },
+});
+
registry.registerPath({
method: "patch",
path: "/api/agents/{id}/instructions-path",
diff --git a/server/src/services/agent-project-access.ts b/server/src/services/agent-project-access.ts
new file mode 100644
index 00000000000..4c6ee81659a
--- /dev/null
+++ b/server/src/services/agent-project-access.ts
@@ -0,0 +1,69 @@
+import { and, eq, inArray } from "drizzle-orm";
+import { agents, projects, type Db } from "@paperclipai/db";
+import { trustAuthorizationPolicySchema, type AgentProjectAccess } from "@paperclipai/shared";
+import { conflict, forbidden, notFound } from "../errors.js";
+import { normalizeAgentPermissions } from "../lib/agent-permissions.js";
+import { accessService } from "./access.js";
+import type { AuthorizationActor } from "./authorization.js";
+import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
+
+export async function readAgentProjectAccess(db: Db, actor: AuthorizationActor, companyId: string, agentId: string): Promise {
+ const [agent] = await db.select({ permissions: agents.permissions, status: agents.status }).from(agents)
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
+ if (!agent) throw notFound("Agent not found");
+ const decision = await accessService(db).decide({ actor, action: "agents:create", resource: { type: "company", companyId } });
+ const permissions = normalizeAgentPermissions(agent.permissions);
+ const policy = trustAuthorizationPolicySchema.safeParse(permissions.authorizationPolicy ?? {});
+ const boundary = policy.success ? policy.data.trustBoundary : undefined;
+ return { projectIds: policy.success ? policy.data.trustBoundary?.projectIds ?? [] : [],
+ canManage: actor.type === "board" && decision.allowed && agent.status !== "pending_approval"
+ && policy.success && permissions.trustPreset === "low_trust_review"
+ && (!boundary || (boundary.mode === "low_trust_review" && (!boundary.companyId || boundary.companyId === companyId))) };
+}
+
+/** An additive operation under the agent row lock, never a stale policy replacement. */
+export async function addAgentProjectAccess(db: Db, actor: AuthorizationActor, companyId: string, agentId: string, projectIds: string[]) {
+ if (actor.type !== "board") throw forbidden("Board access required");
+ const publications: ActivityPublication[] = [];
+ const result = await db.transaction(async tx => {
+ const [agent] = await tx.select().from(agents)
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update");
+ if (!agent) throw notFound("Agent not found");
+ const transactionDb = tx as unknown as Db;
+ const access = accessService(transactionDb);
+ const decision = await access.decide({ actor, action: "agents:create", resource: { type: "company", companyId } });
+ if (!decision.allowed) throw forbidden(decision.explanation);
+ if (agent.status === "pending_approval") throw conflict("Pending approval agent permissions cannot be changed before board approval");
+ const permissions = normalizeAgentPermissions(agent.permissions);
+ const parsedPolicy = trustAuthorizationPolicySchema.safeParse(permissions.authorizationPolicy ?? {});
+ if (!parsedPolicy.success) throw conflict("The agent’s trust settings need review before adding project access.");
+ const policy = parsedPolicy.data;
+ const boundary = policy?.trustBoundary;
+ if (permissions.trustPreset !== "low_trust_review" || (boundary && (boundary.mode !== "low_trust_review"
+ || (boundary.companyId && boundary.companyId !== companyId)))) {
+ throw conflict("The agent’s trust settings changed. Refresh before adding project access.");
+ }
+ const requested = [...new Set(projectIds)];
+ const rows = await tx.select({ id: projects.id }).from(projects)
+ .where(and(eq(projects.companyId, companyId), inArray(projects.id, requested)));
+ if (rows.length !== requested.length) throw notFound("A selected project is unavailable");
+ for (const project of rows) {
+ const projectDecision = await access.decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId: project.id } });
+ if (!projectDecision.allowed) throw forbidden("A selected project is unavailable");
+ }
+ const previous = boundary?.projectIds ?? [];
+ const added = requested.filter(id => !previous.includes(id));
+ const next = [...previous, ...added];
+ if (added.length) {
+ await tx.update(agents).set({ permissions: { ...agent.permissions, authorizationPolicy: {
+ ...policy, trustBoundary: { ...boundary, mode: "low_trust_review", companyId, projectIds: next },
+ } }, updatedAt: new Date() }).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)));
+ await logActivity(transactionDb, { companyId, actorType: "user", actorId: actor.userId ?? "local-board",
+ action: "agent.project_access_added", entityType: "agent", entityId: agentId,
+ details: { addedProjectIds: added, trustPreset: "low_trust_review" } }, publications);
+ }
+ return { projectIds: next, canManage: true } satisfies AgentProjectAccess;
+ });
+ for (const publication of publications) publishActivity(publication);
+ return result;
+}
diff --git a/server/src/services/heartbeat/run-completion.test.ts b/server/src/services/heartbeat/run-completion.test.ts
index 39df53e1d52..1dc15b06e9c 100644
--- a/server/src/services/heartbeat/run-completion.test.ts
+++ b/server/src/services/heartbeat/run-completion.test.ts
@@ -9,6 +9,7 @@ import { createAgentIdentityRedactor } from "../agent-identity-redaction.js";
import { UnresolvedWorkspaceBaseRefError } from "../workspace-runtime.js";
import { LegacyControllerLeaseLostError } from "../legacy-controller-lease.js";
import { requestedRunCancellation } from "../run-cancellation.js";
+import { ProjectAccessDeniedError } from "../project-access-denial.js";
import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from "./run-lifecycle.js";
import { createHeartbeatRunPreparation, ConfigurationIncompleteFailure } from "./run-preparation.js";
import { createHeartbeatRunState, getAdapterSessionCodec } from "./run-state.js";
@@ -77,15 +78,19 @@ it("constructs completion handlers without querying or starting work", () => {
});
const support = await getEmbeddedPostgresTestSupport();
-describe.skipIf(!support.supported)("heartbeat run completion boundary", () => {
- let database: Awaited>;
+describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)("heartbeat run completion boundary", () => {
+ let database: Awaited> | undefined;
let db: Db;
beforeAll(async () => {
// Keep the accounting transaction real without reading provider receipt files.
vi.spyOn(usageReceipts, "indexPendingUsageReceipts").mockResolvedValue(new Map());
vi.spyOn(usageReceipts, "recoverPendingRunUsageReceipts").mockResolvedValue([]);
- database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-completion");
- db = createDb(database.connectionString);
+ const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
+ if (external) db = createDb(external);
+ else {
+ database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-completion");
+ db = createDb(database.connectionString);
+ }
await db.execute(sql`set client_min_messages = warning`);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); vi.restoreAllMocks(); });
@@ -334,6 +339,18 @@ describe.skipIf(!support.supported)("heartbeat run completion boundary", () => {
expect(f.deps.releaseIssueExecutionAndPromote).toHaveBeenCalledWith(expect.objectContaining({ id: f.run.id }), { suppressImmediateRecovery: true });
});
+ it("persists a project denial without provider work or an automatic retry", async () => {
+ const f = await fixture();
+ const projectId = randomUUID();
+ await f.completion.failRunSetup(f.setup(new ProjectAccessDeniedError(projectId)));
+ expect(await read(f.run.id)).toMatchObject({ status: "failed", errorCode: "project_access_denied",
+ resultJson: { projectAccessDenied: { projectId }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } });
+ expect(f.deps.scheduleBoundedRetryForRun).not.toHaveBeenCalled();
+ expect(f.deps.enqueueWakeup).not.toHaveBeenCalled();
+ expect(f.deps.scheduleInteractionContinuationInfrastructureRetryIfEligible).not.toHaveBeenCalled();
+ expect(f.deps.finalizeIssueCommentPolicy).not.toHaveBeenCalled();
+ });
+
it("records a setup configuration gap without inventing provider work or applying comment policy", async () => {
const f = await fixture();
await f.completion.failRunSetup(f.setup(new ConfigurationIncompleteFailure("missing credential", { configurationIncomplete: { reason: "missing_binding" } })));
diff --git a/server/src/services/heartbeat/run-completion.ts b/server/src/services/heartbeat/run-completion.ts
index 7f2b486dfe7..95026919171 100644
--- a/server/src/services/heartbeat/run-completion.ts
+++ b/server/src/services/heartbeat/run-completion.ts
@@ -1,4 +1,5 @@
import { measureRunPhase } from "../execution-preparation-timing.js";
+import { PROJECT_ACCESS_DENIED, ProjectAccessDeniedError } from "../project-access-denial.js";
import { pendingCaseHandoffForRun } from "./case-handoff-lifecycle.js";
import { NON_RETRYABLE_PREFLIGHT_FAILURE_CODES, isHeartbeatRunTerminalStatus } from "./run-lifecycle.js";
import { ChatControlRecoveryUnresolvedError } from "./queue.js";
@@ -145,6 +146,7 @@ export interface FailHeartbeatRunSetupInput {
}
function nonRetryablePreflightFailureCode(error: unknown): string | null {
+ if (error instanceof ProjectAccessDeniedError) return PROJECT_ACCESS_DENIED;
if (error instanceof ChatControlRecoveryUnresolvedError)
return CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE;
if (
@@ -1344,6 +1346,8 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
// action, so it is persisted even when the agent lookup failed and the
// agent-scoped stop metadata cannot be merged in.
const setupFailureDetails =
+ (outerErr instanceof ProjectAccessDeniedError
+ ? { projectAccessDenied: { projectId: outerErr.projectId } } : null) ??
(workspaceGitScanFailure ? {
workspaceGitScan: {
code: workspaceGitScanFailure.code,
@@ -1453,7 +1457,8 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
).catch(() => undefined);
if (
!isWorkspaceValidationFailedRun(livenessRun) &&
- !isConfigurationIncompleteFailedRun(livenessRun)
+ !isConfigurationIncompleteFailedRun(livenessRun) &&
+ livenessRun.errorCode !== PROJECT_ACCESS_DENIED
) {
await finalizeIssueCommentPolicy(livenessRun, failedAgent).catch(
() => undefined,
@@ -1462,15 +1467,17 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
// No provider work began. Retry temporary host scan failures with
// the existing durable failure budget, before releasing execution.
// Generic recovery must not grant a second budget on exhaustion.
- await (isTransientWorkspaceGitScanCode(livenessRun.errorCode)
- ? scheduleBoundedRetryForRun(livenessRun, failedAgent)
- : scheduleInteractionContinuationInfrastructureRetryIfEligible(livenessRun, failedAgent)
- ).catch((retryError) => {
- logger.warn(
- { err: retryError, runId: livenessRun.id },
- "failed to schedule interaction continuation retry after setup failure",
- );
- });
+ if (livenessRun.errorCode !== PROJECT_ACCESS_DENIED) {
+ await (isTransientWorkspaceGitScanCode(livenessRun.errorCode)
+ ? scheduleBoundedRetryForRun(livenessRun, failedAgent)
+ : scheduleInteractionContinuationInfrastructureRetryIfEligible(livenessRun, failedAgent)
+ ).catch((retryError) => {
+ logger.warn(
+ { err: retryError, runId: livenessRun.id },
+ "failed to schedule interaction continuation retry after setup failure",
+ );
+ });
+ }
}
await releaseIssueExecutionAndPromote(livenessRun, {
suppressImmediateRecovery:
diff --git a/server/src/services/heartbeat/run-lifecycle.ts b/server/src/services/heartbeat/run-lifecycle.ts
index 5f299e855fa..3bbb6f30b40 100644
--- a/server/src/services/heartbeat/run-lifecycle.ts
+++ b/server/src/services/heartbeat/run-lifecycle.ts
@@ -244,6 +244,7 @@ const LIVENESS_BOOKKEEPING_EVENT_TYPES = [
];
export const NON_RETRYABLE_PREFLIGHT_FAILURE_CODES = new Set([
+ "project_access_denied",
"low_trust_isolation_unavailable",
"low_trust_requires_isolated_workspace",
"low_trust_boundary_mismatch",
diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts
index 51e0c3af818..ad754e8e84e 100644
--- a/server/src/services/native-runtime/native-session-executor.test.ts
+++ b/server/src/services/native-runtime/native-session-executor.test.ts
@@ -54,9 +54,11 @@ import {
type NativeExecutionInput,
type PrpEvent,
} from "@paperclipai/paperclip-runner";
+import { existsSync } from "node:fs";
import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { nativeSha256 } from "./canonical.js";
+import { resolveRemoteProviderPackAuthority } from "./remote-provider-pack-authority.js";
import { RemoteRunnerRecoveryError, readRemoteRunnerRecoveryError } from "./remote-runner-recovery-diagnostics.js";
import { agentDirectoryWorkingCopyService } from "../agent-directory-working-copies.js";
import { probeAgentDirectory } from "../agent-directory-probe.js";
@@ -1432,7 +1434,7 @@ describe("remote provider pack manifest", () => {
productionLock: "pnpm-lock.yaml", opencodeCommand: "node_modules/.bin/opencode",
opencodeExecutable: "node_modules/opencode-ai/bin/opencode.exe",
opencodeProxy: "dist/cli/opencode-app-server-proxy.cjs", acpxSidecar: "dist/cli/acpx-runtime-sidecar.cjs",
- }).map(([key, path]) => [key, { path, sha256: digest }]));
+ }).map(([key, path]) => [key, { path, sha256: digest }])) as Record<"nodeCommand" | "opencodeCommand", { path: string; sha256: string }>;
const payload = {
target: { platform: "linux", architecture }, runnerSourceRevision: "a".repeat(40),
pins: { nodeMinimum: "24.11.0", codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion,
@@ -1484,6 +1486,43 @@ describe("remote provider pack manifest", () => {
sourcePath: state.bundledRunnerBinary, targetPath: "/workspace/.paperclip-runtime/paperclip-runner/bin/paperclip-runnerd",
})] })]);
expect(execute.mock.calls.some(([call]) => call.args?.[1] === "uname -s; uname -m")).toBe(false);
+ expect(existsSync(join(transport.stateDirectory!, "provider-pack-authority.json"))).toBe(false);
+ if (provider === "claude-x64") {
+ const identity = transport.prpIdentity!;
+ const artifact = { version: "0.0.0", digest: "sha256:" + createHash("sha256").update(daemon).digest("hex") };
+ resolveRemoteProviderPackAuthority({ stateDirectory: transport.stateDirectory!,
+ binding: { companyId: remoteExecution.binding.companyId, agentId: remoteExecution.binding.agentId,
+ workspaceId: remoteExecution.binding.executionWorkspaceId, sessionId: remoteExecution.session.normalizedSessionId!,
+ runnerInstanceId: identity.runnerInstanceId, environmentLeaseId: identity.environmentLeaseId,
+ remoteCwd: "/workspace", providerDigest: nativeSha256(remoteExecution.provider) },
+ reattach: false, current: () => manifest, runnerArtifact: () => artifact }).verified();
+ await mkdir(join(transport.stateDirectory!, "control-plane"), { recursive: true });
+ await writeFile(join(transport.stateDirectory!, "control-plane", "control-plane-state.json"), JSON.stringify(durableControlPlaneState(identity)));
+ const next = structuredClone(manifest);
+ next.payload.runnerSourceRevision = "b".repeat(40);
+ next.digest = "sha256:" + createHash("sha256").update(canonical(next.payload)).digest("hex");
+ await writeFile(state.bundledPackManifestPath!, JSON.stringify(next));
+ await writeFile(state.bundledRunnerBinary!, Buffer.concat([daemon, Buffer.from("new release")]));
+ const recoveryExecute = vi.fn(async (request: { args: string[] }) => {
+ const script = request.args[1];
+ const stdout = script.includes("base64") ? Buffer.from(JSON.stringify({ ...identity, lifecycle: "running" })).toString("base64")
+ : script.includes("cat --") ? `nonce\n123\n2026-09-19T10:00:00.000Z\n${identity.runnerInstanceId}\nlinux:abcd-1234:100\n` : "";
+ return { stdout, stderr: "", exitCode: 0, timedOut: false };
+ });
+ await createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution,
+ runnerInstanceId: identity.runnerInstanceId,
+ runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace",
+ environmentId: "environment", leaseId: "lease", providerKey: "daytona", runner: { execute: recoveryExecute },
+ sandboxLeaseAcquisition: { outcome: "resumed", providerLeaseId: "sandbox" } } as never,
+ restartRecovery: { kind: "reattach_remote_runner", runId: remoteExecution.binding.runId,
+ leaseOwner: "controller", controllerGeneration: 2, providerAttempt: 1, restartKind: "graceful", recoveryRequestId: null,
+ remote: { providerLeaseId: "sandbox", remoteCwd: "/workspace" } },
+ });
+ state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
+ expect(state.createTransport.mock.calls.at(-1)![0]).toMatchObject({
+ providerPackAuthorityDigest: manifest.digest, runnerArtifact: artifact, adoptExistingRunner: { pid: 123 },
+ });
+ }
} finally {
state.bundledPackManifestPath = null; state.bundledRunnerBinary = null;
await rm(root, { recursive: true, force: true });
diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts
index fe6d4732e76..cc03fcbfa56 100644
--- a/server/src/services/native-runtime/native-session-executor.ts
+++ b/server/src/services/native-runtime/native-session-executor.ts
@@ -1,3 +1,5 @@
+import { providerPackWorkspaceId, recordAdmittedRemoteProviderPack, resolveRemoteProviderPackAuthority } from "./remote-provider-pack-authority.js";
+import { verifyRemoteProviderPack as verifyRemoteProviderPackIdentity } from "./remote-provider-pack-verification.js";
import { writeEnvironmentCleanup } from "../environment-cleanup-notifications.js";
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
import { writeExecutionControl } from "../execution-control-notifications.js";
@@ -47,7 +49,7 @@ import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server";
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js";
import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js";
-import { prepareVerifiedRemoteProviderPack, useVerifiedPreinstalledRemoteProviderPack, runRemoteProviderVerification, shouldUseBundledAcpxImageAssets } from "./remote-provider-pack.js";
+import { prepareVerifiedRemoteProviderPack, useVerifiedPreinstalledRemoteProviderPack, shouldUseBundledAcpxImageAssets } from "./remote-provider-pack.js";
import { readProviderPackArchive, stageProviderPackArchive } from "./provider-pack-archive.js";
import { selectRemotePiCompanion } from "./remote-pi-companion.js";
import { readNativeLocalProcessStop, PROCESS_START_REQUESTED, withReleasedNativeRunProcess } from "../native-local-process-stop.js";
@@ -12023,7 +12025,6 @@ async function createRunnerdBackendWithinSessionClaim(
}));
const configuredProviderPackRoot =
input.runnerRemoteProviderPackPath?.trim() || remotePiCompanion?.providerPack || bundledClaudeRoot || null;
- let expectedProviderPackManifest: RemoteProviderPackManifest | null = null;
const useBundledRemoteImageAssets =
(requiresRemoteProviderPack && !configuredProviderPackRoot &&
(input.execution.provider.kind === "opencode" || remoteClaude)) ||
@@ -12031,24 +12032,6 @@ async function createRunnerdBackendWithinSessionClaim(
requiresRemoteProviderPack, configuredProviderPackRoot,
provider: input.execution.provider,
});
- if (useBundledRemoteImageAssets) {
- expectedProviderPackManifest = bundledClaudeManifest && (remoteClaudeTarget === "linux-x64" || remoteClaudeTarget === "linux-arm64")
- ? readBundledRemoteProviderPackManifest(bundledClaudeManifest, remoteClaudeTarget)
- : readBundledRemoteProviderPackManifest();
- } else if (requiresRemoteProviderPack) {
- if (
- !configuredProviderPackRoot ||
- !existsSync(configuredProviderPackRoot) ||
- !lstatSync(configuredProviderPackRoot).isDirectory()
- ) {
- throw new Error(
- "runner_remote_provider_artifact_incompatible: configure PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH with the build-owned provider pack",
- );
- }
- expectedProviderPackManifest = readRemoteProviderPackManifest(
- configuredProviderPackRoot,
- );
- }
const stagedRemoteProviderPackRoot = remoteRuntimeRoot
? posix.join(remoteRuntimeRoot, "provider-pack")
: null;
@@ -12056,6 +12039,25 @@ async function createRunnerdBackendWithinSessionClaim(
const remoteBinary = remoteRuntimeRoot
? posix.join(remoteRuntimeRoot, "bin", "paperclip-runnerd")
: null;
+ let currentProviderPackManifest: RemoteProviderPackManifest | undefined;
+ const resolveCurrentProviderPackManifest = () => {
+ if (currentProviderPackManifest) return currentProviderPackManifest;
+ currentProviderPackManifest = (() => {
+ if (useBundledRemoteImageAssets) {
+ return bundledClaudeManifest && (remoteClaudeTarget === "linux-x64" || remoteClaudeTarget === "linux-arm64")
+ ? readBundledRemoteProviderPackManifest(bundledClaudeManifest, remoteClaudeTarget)
+ : readBundledRemoteProviderPackManifest();
+ }
+ if (!configuredProviderPackRoot || !existsSync(configuredProviderPackRoot)
+ || !lstatSync(configuredProviderPackRoot).isDirectory()) {
+ throw new Error("runner_remote_provider_artifact_incompatible: configure PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH with the build-owned provider pack");
+ }
+ return readRemoteProviderPackManifest(configuredProviderPackRoot);
+ })();
+ return currentProviderPackManifest;
+ };
+ const reattachProviderPack = input.restartRecovery?.kind === "reattach_remote_runner" || input.remoteWarmRecovery !== undefined;
+ if (requiresRemoteProviderPack && !reattachProviderPack) resolveCurrentProviderPackManifest();
// The transport hashes runnerBinary on the controller before an external
// launcher starts runnerd. Keep that artifact identity in the controller's
// filesystem; the remote launcher separately owns the sandbox command path.
@@ -12067,6 +12069,19 @@ async function createRunnerdBackendWithinSessionClaim(
: remoteTarget
? remoteClaudeRunnerBinary || input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledRemoteImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
: resolvePaperclipRunnerBinary();
+ const selectProviderPackAuthority = (reattach: boolean) => requiresRemoteProviderPack ? resolveRemoteProviderPackAuthority({
+ stateDirectory: root,
+ binding: { companyId: input.execution.binding.companyId, agentId: input.execution.binding.agentId,
+ workspaceId: providerPackWorkspaceId(input.execution.binding.executionWorkspaceId, input.execution.binding.runId, effectiveEnvironmentLeaseId), sessionId: nativeSessionKey(input.execution),
+ runnerInstanceId: effectiveRunnerInstanceId, environmentLeaseId: effectiveEnvironmentLeaseId,
+ remoteCwd: remoteTarget!.remoteCwd, providerDigest: nativeSha256(input.execution.provider) },
+ reattach,
+ runnerArtifact: () => readRunnerdArtifactBinding(controllerRunnerBinary),
+ current: resolveCurrentProviderPackManifest,
+ }) : null;
+ let providerPackAuthority = selectProviderPackAuthority(reattachProviderPack);
+ let expectedProviderPackManifest = providerPackAuthority?.manifest ?? null;
+ let providerPackVerified = false;
const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null;
const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null;
if (explicitRemoteCodex && remoteCodexNpmSpec) {
@@ -12159,62 +12174,9 @@ async function createRunnerdBackendWithinSessionClaim(
const verifyRemoteProviderPack = async (packRoot: string) => {
if (!remoteTarget || !remoteCommandRunner || !expectedProviderPackManifest)
return;
- const expected = Buffer.from(
- canonicalJson(expectedProviderPackManifest),
- "utf8",
- ).toString("base64");
- const providerNodeCommand = posix.join(
- packRoot,
- expectedProviderPackManifest.payload.artifacts.nodeCommand.path,
- );
- const verifyScript = [
- "const fs=require('node:fs')",
- "const crypto=require('node:crypto')",
- "const path=require('node:path')",
- "const root=process.argv[1]",
- "const expected=Buffer.from(process.argv[2],'base64').toString('utf8')",
- "const actual=fs.readFileSync(path.join(root,'provider-pack.json'),'utf8').trim()",
- "const canonical=(v)=>Array.isArray(v)?'['+v.map(canonical).join(',')+']':v&&typeof v==='object'?'{'+Object.keys(v).sort().map(k=>JSON.stringify(k)+':'+canonical(v[k])).join(',')+'}':JSON.stringify(v)",
- "const manifest=JSON.parse(actual)",
- "if(canonical(manifest)!==expected)throw new Error('manifest mismatch')",
- "const hash=(p)=>'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(path.join(root,p))).digest('hex')",
- "const tree=(treeRoot)=>{const digest=crypto.createHash('sha256');const visit=(directory,prefix='')=>{for(const entry of fs.readdirSync(directory,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))){const relative=prefix?prefix+'/'+entry.name:entry.name;const absolute=path.join(directory,entry.name);if(entry.isDirectory()){digest.update('directory\\0'+relative+'\\n');visit(absolute,relative)}else if(entry.isFile()){digest.update('file\\0'+relative+'\\0'+'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex')+'\\n')}else if(entry.isSymbolicLink()){digest.update('symlink\\0'+relative+'\\0'+fs.readlinkSync(absolute)+'\\n')}else throw new Error('unsupported dist entry '+relative)}};visit(treeRoot);return 'sha256:'+digest.digest('hex')}",
- "for(const name of ['nodeCommand','productionLock','opencodeCommand','opencodeExecutable','opencodeProxy','acpxSidecar','grokLauncher']){const artifact=manifest.payload.artifacts[name];if(hash(artifact.path)!==artifact.sha256)throw new Error(name+' digest mismatch')}",
- "if(tree(path.join(root,'dist'))!==manifest.payload.distDigest)throw new Error('dist tree digest mismatch')",
- "for(const candidate of Object.values({...manifest.payload.providers,...manifest.payload.candidateProviders})){if(tree(path.join(root,candidate.path))!==candidate.sha256)throw new Error('candidate asset tree digest mismatch')}",
- "const version=process.versions.node.split('.').map(Number)",
- "const minimum=manifest.payload.pins.nodeMinimum.split('.').map(Number)",
- "if(version[0]JSON.parse(fs.readFileSync(path.join(root,'node_modules',...pkg.split('/'),'package.json'),'utf8')).version",
- "const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode}",
- "for(const [pkg,version] of Object.entries(expectedPackages))if(packageVersion(pkg)!==version)throw new Error(pkg+' version mismatch')",
- ].join(";");
- await runRemoteProviderVerification(() => remoteCommandRunner.execute({
- command: providerNodeCommand,
- args: ["-e", verifyScript, packRoot, expected],
- cwd: remoteTarget.remoteCwd,
- bypassSession: true,
- timeoutMs: 30_000,
- }), "provider pack verification");
- const opencodeCommand = posix.join(
- packRoot,
- expectedProviderPackManifest.payload.artifacts.opencodeCommand.path,
- );
- const opencodeVersion = await runRemoteProviderVerification(() => remoteCommandRunner.execute({
- command: opencodeCommand,
- args: ["--version"],
- cwd: remoteTarget.remoteCwd,
- bypassSession: true,
- timeoutMs: 30_000,
- }), "OpenCode version verification");
- if (
- opencodeVersion.stdout.trim() !== REMOTE_PROVIDER_PACK_PINS.opencode
- ) {
- throw new Error(
- "runner_remote_provider_artifact_incompatible: OpenCode version mismatch",
- );
- }
+ await verifyRemoteProviderPackIdentity({ runner: remoteCommandRunner,
+ cwd: remoteTarget.remoteCwd, packRoot, manifest: expectedProviderPackManifest });
+ providerPackVerified = true;
};
const discoverPreinstalledProviderPack = async () => {
@@ -12333,7 +12295,7 @@ async function createRunnerdBackendWithinSessionClaim(
);
}
const archive = await measureNativeRunnerSpan(input.trace, "provider_pack.archive.verify", () =>
- readProviderPackArchive(configuredProviderPackRoot, expectedProviderPackManifest.digest),
+ readProviderPackArchive(configuredProviderPackRoot, expectedProviderPackManifest!.digest),
);
const escapedPackRoot = stagedRemoteProviderPackRoot.replaceAll(
"'",
@@ -13243,6 +13205,7 @@ async function createRunnerdBackendWithinSessionClaim(
// makes the fresh replacement look like ambiguous lost harness state.
// Keep their evidence inside the same continuity-break archive.
"failover-backups",
+ "provider-pack-authority.json",
]) {
const source = resolve(root, name);
if (existsSync(source)) renameSync(source, resolve(archiveRoot, name));
@@ -13265,6 +13228,9 @@ async function createRunnerdBackendWithinSessionClaim(
}
}
remotePrepared = false;
+ providerPackAuthority = selectProviderPackAuthority(false);
+ expectedProviderPackManifest = providerPackAuthority?.manifest ?? null;
+ providerPackVerified = false;
};
const localRecoveryProcess =
input.restartRecovery?.kind === "reattach_existing_runner"
@@ -13455,6 +13421,7 @@ async function createRunnerdBackendWithinSessionClaim(
: {}),
stateDirectory: root,
runnerBinary: controllerRunnerBinary,
+ ...(providerPackAuthority ? { runnerArtifact: providerPackAuthority.runnerArtifact } : {}),
codexCommand: remoteCodexBinary ?? undefined,
sourceCodexHome: remoteTarget
? resolveSourceCodexHome(resolveNativeProviderEnvironment(input.execution.provider, runnerEnvironment))
@@ -13660,12 +13627,22 @@ async function createRunnerdBackendWithinSessionClaim(
priorAuthorityEpoch.revoke();
}
sessionToolAuthorityEpochs.set(sessionScopeId, authorityEpoch);
+ const recordAdmittedProviderPack = async () => {
+ if (providerPackVerified && providerPackAuthority) {
+ await recordAdmittedRemoteProviderPack(providerPackAuthority, message => input.onLog?.("stderr", message));
+ }
+ };
return {
bindManagedSession: wrapManagedSession,
descriptor: () => backend.descriptor(),
- openSession: async (sessionInput) => wrapManagedSession(await backend.openSession(sessionInput)),
+ openSession: async (sessionInput) => {
+ const session = await backend.openSession(sessionInput);
+ await recordAdmittedProviderPack();
+ return wrapManagedSession(session);
+ },
recoverSession: async (snapshot, options) => {
const result = backend.recoverSession ? await backend.recoverSession(snapshot, options) : { recovered: false, reason: "driver does not support recovery" };
+ if (result.session) await recordAdmittedProviderPack();
return result.session ? { ...result, session: wrapManagedSession(result.session) } : result;
},
openReplacementSession: async (sessionInput) => {
@@ -13675,7 +13652,9 @@ async function createRunnerdBackendWithinSessionClaim(
archiveContinuityState,
{ parentName: "native.session.execute" },
);
- return wrapManagedSession(await backend.openSession(sessionInput));
+ const session = await backend.openSession(sessionInput);
+ await recordAdmittedProviderPack();
+ return wrapManagedSession(session);
},
} satisfies NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession };
}
diff --git a/server/src/services/native-runtime/remote-provider-pack-authority.test.ts b/server/src/services/native-runtime/remote-provider-pack-authority.test.ts
new file mode 100644
index 00000000000..f5f2039c4cf
--- /dev/null
+++ b/server/src/services/native-runtime/remote-provider-pack-authority.test.ts
@@ -0,0 +1,121 @@
+import { createHash } from "node:crypto";
+import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync, symlinkSync, statSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, expect, it, vi } from "vitest";
+import { providerPackWorkspaceId, recordAdmittedRemoteProviderPack, resolveRemoteProviderPackAuthority } from "./remote-provider-pack-authority.js";
+import { canonicalProviderPackJson } from "./remote-provider-pack-verification.js";
+
+const directories: string[] = [];
+afterEach(() => { for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true }); });
+const binding = { companyId: "company", agentId: "agent", workspaceId: "workspace", sessionId: "session",
+ runnerInstanceId: "runner", environmentLeaseId: "lease", remoteCwd: "/workspace", providerDigest: "provider" };
+const runnerArtifact = () => ({ version: "0.0.0", digest: "sha256:" + "c".repeat(64) });
+const manifest = (revision: string) => {
+ const payload = { runnerSourceRevision: revision.repeat(40), target: { platform: "linux", architecture: "x64" },
+ pins: { opencode: "1.18.34" }, artifacts: { nodeCommand: { path: "node", sha256: "sha256:" + "1".repeat(64) },
+ opencodeCommand: { path: "opencode", sha256: "sha256:" + "2".repeat(64) } } };
+ return { schema: "paperclip-runner/remote-provider-pack/v1", payload,
+ digest: "sha256:" + createHash("sha256").update(canonicalProviderPackJson(payload)).digest("hex") };
+};
+it("reattaches with the verified launch authority after the controller release changes", () => {
+ const stateDirectory = mkdtempSync(join(tmpdir(), "provider-pack-authority-")); directories.push(stateDirectory);
+ const original = manifest("a"), next = manifest("b");
+ const launched = resolveRemoteProviderPackAuthority({ stateDirectory, binding, reattach: false, runnerArtifact, current: () => original });
+ launched.verified();
+ const current = vi.fn(() => next);
+ const recovered = resolveRemoteProviderPackAuthority({ stateDirectory, binding, reattach: true, current, runnerArtifact });
+ expect(recovered.manifest).toEqual(original);
+ expect(recovered.runnerArtifact).toEqual(runnerArtifact());
+ expect(current).not.toHaveBeenCalled();
+});
+
+function fixture() {
+ const stateDirectory = mkdtempSync(join(tmpdir(), "provider-pack-authority-")); directories.push(stateDirectory);
+ const current = vi.fn(() => manifest("a"));
+ const input = { stateDirectory, binding, reattach: false, current, runnerArtifact };
+ return { input, path: join(stateDirectory, "provider-pack-authority.json") };
+}
+it("uses the new release for a fresh launch without changing the old receipt until verification", () => {
+ const { input, path } = fixture();
+ resolveRemoteProviderPackAuthority(input).verified();
+ input.current.mockReturnValue(manifest("b"));
+ const fresh = resolveRemoteProviderPackAuthority(input);
+ expect(fresh.manifest).toEqual(manifest("b"));
+ expect(JSON.parse(readFileSync(path, "utf8")).manifest).toEqual(manifest("a"));
+ fresh.verified();
+ expect(JSON.parse(readFileSync(path, "utf8")).manifest).toEqual(manifest("b"));
+ expect(statSync(path).mode & 0o777).toBe(0o600);
+});
+it("keeps historical sessions without a receipt at the current release integrity gate", () => {
+ const { input } = fixture();
+ const recovered = resolveRemoteProviderPackAuthority({ ...input, reattach: true });
+ expect(recovered.manifest).toEqual(manifest("a"));
+ expect(input.current).toHaveBeenCalledOnce();
+});
+it.each(Object.keys(binding) as (keyof typeof binding)[])("rejects a saved authority for a different %s", field => {
+ const { input } = fixture();
+ resolveRemoteProviderPackAuthority(input).verified();
+ expect(() => resolveRemoteProviderPackAuthority({ ...input, reattach: true,
+ binding: { ...binding, [field]: "other" } })).toThrow("does not match this execution");
+ expect(input.current).toHaveBeenCalledOnce();
+});
+it.each(["digest", "schema", "symlink", "oversized", "invalid_json"])("fails closed on %s instead of trusting remote or current bytes", kind => {
+ const { input, path } = fixture();
+ resolveRemoteProviderPackAuthority(input).verified();
+ const saved = JSON.parse(readFileSync(path, "utf8"));
+ if (kind === "digest") saved.manifest.payload.runnerSourceRevision = "c".repeat(40);
+ if (kind === "schema") saved.schema = "unknown";
+ writeFileSync(path, JSON.stringify(saved));
+ if (kind === "invalid_json") writeFileSync(path, "{");
+ if (kind === "oversized") writeFileSync(path, " ".repeat(512 * 1024 + 1));
+ if (kind === "symlink") { const copy = path + ".copy"; writeFileSync(copy, JSON.stringify(saved)); rmSync(path); symlinkSync(copy, path); }
+ expect(() => resolveRemoteProviderPackAuthority({ ...input, reattach: true })).toThrow("saved provider pack authority");
+ expect(input.current).toHaveBeenCalledOnce();
+});
+
+it("uses current authority for a replacement lease instead of restoring the prior pack", () => {
+ const { input, path } = fixture();
+ resolveRemoteProviderPackAuthority(input).verified();
+ const currentRunner = { version: "0.0.1", digest: "sha256:" + "d".repeat(64) };
+ const replacement = resolveRemoteProviderPackAuthority({ ...input, binding: { ...binding, environmentLeaseId: "replacement" },
+ current: () => manifest("b"), runnerArtifact: () => currentRunner });
+ expect(replacement.manifest).toEqual(manifest("b"));
+ expect(replacement.runnerArtifact).toEqual(currentRunner);
+ replacement.verified();
+ expect(JSON.parse(readFileSync(path, "utf8")).binding.environmentLeaseId).toBe("replacement");
+});
+it("retains the authenticated daemon identity across controller daemon replacement", () => {
+ const { input } = fixture();
+ resolveRemoteProviderPackAuthority(input).verified();
+ const changedRunner = vi.fn(() => ({ version: "new", digest: "sha256:" + "d".repeat(64) }));
+ const recovered = resolveRemoteProviderPackAuthority({ ...input, reattach: true, runnerArtifact: changedRunner });
+ expect(recovered.runnerArtifact).toEqual(runnerArtifact());
+ expect(changedRunner).not.toHaveBeenCalled();
+});
+it.each([{}, { version: "0.0.0", digest: "not-a-sha256" }])("rejects an invalid saved daemon identity: %j", runnerArtifact => {
+ const { input, path } = fixture();
+ resolveRemoteProviderPackAuthority(input).verified();
+ const saved = JSON.parse(readFileSync(path, "utf8"));
+ writeFileSync(path, JSON.stringify({ ...saved, runnerArtifact }));
+ expect(() => resolveRemoteProviderPackAuthority({ ...input, reattach: true })).toThrow("saved provider pack authority");
+});
+
+it("keeps admitted session ownership with the caller if receipt storage or its warning fails", async () => {
+ const { input, path } = fixture();
+ mkdirSync(path);
+ const authority = resolveRemoteProviderPackAuthority(input);
+ const warning = vi.fn(() => { throw new Error("log unavailable"); });
+ await expect(recordAdmittedRemoteProviderPack(authority, warning)).resolves.toBe(false);
+ expect(warning).toHaveBeenCalledWith(expect.stringContaining("could not save remote release authority"));
+ expect(() => resolveRemoteProviderPackAuthority({ ...input, reattach: true })).toThrow("saved provider pack authority");
+});
+it("retains the original projectless lease across warm run placeholders but rejects a different real workspace", () => {
+ const { input } = fixture();
+ const first = { ...binding, workspaceId: providerPackWorkspaceId("run-1", "run-1", "original-lease") };
+ resolveRemoteProviderPackAuthority({ ...input, binding: first }).verified();
+ const next = { ...binding, workspaceId: providerPackWorkspaceId("run-2", "run-2", "original-lease") };
+ expect(resolveRemoteProviderPackAuthority({ ...input, binding: next, reattach: true }).manifest).toEqual(manifest("a"));
+ const changed = { ...binding, workspaceId: providerPackWorkspaceId("project-workspace", "run-2", "original-lease") };
+ expect(() => resolveRemoteProviderPackAuthority({ ...input, binding: changed, reattach: true })).toThrow("does not match this execution");
+});
diff --git a/server/src/services/native-runtime/remote-provider-pack-authority.ts b/server/src/services/native-runtime/remote-provider-pack-authority.ts
new file mode 100644
index 00000000000..5516ad6d959
--- /dev/null
+++ b/server/src/services/native-runtime/remote-provider-pack-authority.ts
@@ -0,0 +1,107 @@
+import { createHash, randomUUID } from "node:crypto";
+import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { redactSensitiveText } from "../../redaction.js";
+import { canonicalProviderPackJson, type ProviderPackIdentity } from "./remote-provider-pack-verification.js";
+
+export type ProviderPackBinding = {
+ companyId: string;
+ agentId: string;
+ workspaceId: string;
+ sessionId: string;
+ runnerInstanceId: string;
+ environmentLeaseId: string;
+ remoteCwd: string;
+ providerDigest: string;
+};
+export type RunnerArtifactIdentity = { version: string; digest: string };
+
+const schema = "paperclip.remote-provider-pack-authority.v1";
+const filename = "provider-pack-authority.json";
+const maximumBytes = 512 * 1024;
+
+function assertManifest(manifest: ProviderPackIdentity): void {
+ if (manifest?.schema !== "paperclip-runner/remote-provider-pack/v1" || !manifest.payload
+ || manifest.digest !== `sha256:${createHash("sha256").update(canonicalProviderPackJson(manifest.payload)).digest("hex")}`) {
+ throw new Error("runner_remote_provider_artifact_incompatible: saved provider pack authority is invalid");
+ }
+}
+
+export function resolveRemoteProviderPackAuthority(input: {
+ stateDirectory: string;
+ binding: ProviderPackBinding;
+ reattach: boolean;
+ current: () => T;
+ runnerArtifact: () => RunnerArtifactIdentity;
+}): { manifest: T; runnerArtifact: RunnerArtifactIdentity; verified: () => void } {
+ const root = lstatSync(input.stateDirectory);
+ if (!root.isDirectory() || root.isSymbolicLink()) {
+ throw new Error("runner_remote_provider_artifact_incompatible: provider pack authority directory is unsafe");
+ }
+ const path = join(input.stateDirectory, filename);
+ let manifest: T | undefined;
+ let runnerArtifact: RunnerArtifactIdentity | undefined;
+ if (input.reattach) {
+ let fd: number | undefined;
+ try {
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
+ const stat = fstatSync(fd);
+ if (!stat.isFile() || stat.size > maximumBytes) throw new Error("invalid authority file");
+ const saved = JSON.parse(readFileSync(fd, "utf8"));
+ if (saved.schema !== schema || canonicalProviderPackJson(saved.binding) !== canonicalProviderPackJson(input.binding)) {
+ throw new Error("authority binding mismatch");
+ }
+ assertManifest(saved.manifest);
+ if (typeof saved.runnerArtifact?.version !== "string" || !saved.runnerArtifact.version
+ || !/^sha256:[a-f0-9]{64}$/.test(saved.runnerArtifact.digest)) throw new Error("invalid runner artifact identity");
+ manifest = saved.manifest as T;
+ runnerArtifact = saved.runnerArtifact;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
+ throw new Error("runner_remote_provider_artifact_incompatible: saved provider pack authority is invalid or does not match this execution", { cause: error });
+ }
+ } finally {
+ if (fd !== undefined) closeSync(fd);
+ }
+ }
+ manifest ??= input.current();
+ assertManifest(manifest);
+ runnerArtifact ??= input.runnerArtifact();
+ const selectedRunner = structuredClone(runnerArtifact);
+ const selected = structuredClone(manifest);
+ const receipt = JSON.stringify({ schema, binding: input.binding, manifest: selected, runnerArtifact: selectedRunner });
+ if (Buffer.byteLength(receipt) > maximumBytes) throw new Error("runner_remote_provider_artifact_incompatible: provider pack authority exceeds its size limit");
+ return {
+ manifest: selected,
+ runnerArtifact: selectedRunner,
+ verified: () => {
+ const temporary = `${path}.${randomUUID()}.tmp`;
+ try {
+ writeFileSync(temporary, receipt, { flag: "wx", mode: 0o600 });
+ renameSync(temporary, path);
+ } finally {
+ rmSync(temporary, { force: true });
+ }
+ },
+ };
+}
+
+export function providerPackWorkspaceId(workspaceId: string, runId: string, environmentLeaseId: string): string {
+ return workspaceId === runId ? environmentLeaseId : workspaceId;
+}
+
+export async function recordAdmittedRemoteProviderPack(
+ authority: { verified: () => void },
+ onWarning: (message: string) => void | Promise,
+): Promise {
+ try {
+ authority.verified();
+ return true;
+ } catch (error) {
+ const reason = redactSensitiveText(error instanceof Error ? error.message : String(error)).slice(-1_024);
+ try {
+ await onWarning(`[paperclip-runner] could not save remote release authority; recovery still requires a verified receipt or current release artifacts: ${reason}\n`);
+ } catch {}
+ return false;
+ }
+}
diff --git a/server/src/services/native-runtime/remote-provider-pack-verification.ts b/server/src/services/native-runtime/remote-provider-pack-verification.ts
new file mode 100644
index 00000000000..3f504a636e9
--- /dev/null
+++ b/server/src/services/native-runtime/remote-provider-pack-verification.ts
@@ -0,0 +1,85 @@
+import { posix } from "node:path";
+import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
+import { runRemoteProviderVerification } from "./remote-provider-pack.js";
+
+export function canonicalProviderPackJson(value: unknown): string {
+ if (Array.isArray(value)) return `[${value.map(canonicalProviderPackJson).join(",")}]`;
+ if (value && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b))
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalProviderPackJson(entry)}`).join(",")}}`;
+ return JSON.stringify(value) ?? "null";
+}
+
+export type ProviderPackIdentity = {
+ schema: string;
+ digest: string;
+ payload: {
+ pins: { opencode: string; [key: string]: string | null };
+ target: { platform: string; architecture: string };
+ runnerSourceRevision: string;
+ artifacts: { nodeCommand: { path: string; sha256: string }; opencodeCommand: { path: string; sha256: string }; [key: string]: { path: string; sha256: string } };
+ };
+};
+
+export async function verifyRemoteProviderPack({ runner, cwd, packRoot, manifest }: {
+ runner: CommandManagedRuntimeRunner;
+ cwd: string;
+ packRoot: string;
+ manifest: ProviderPackIdentity;
+}): Promise {
+ const expected = Buffer.from(
+ canonicalProviderPackJson(manifest),
+ "utf8",
+ ).toString("base64");
+ const providerNodeCommand = posix.join(
+ packRoot,
+ manifest.payload.artifacts.nodeCommand.path,
+ );
+ const verifyScript = [
+ "const fs=require('node:fs')",
+ "const crypto=require('node:crypto')",
+ "const path=require('node:path')",
+ "const root=process.argv[1]",
+ "const expected=Buffer.from(process.argv[2],'base64').toString('utf8')",
+ "const actual=fs.readFileSync(path.join(root,'provider-pack.json'),'utf8').trim()",
+ "const canonical=(v)=>Array.isArray(v)?'['+v.map(canonical).join(',')+']':v&&typeof v==='object'?'{'+Object.keys(v).sort().map(k=>JSON.stringify(k)+':'+canonical(v[k])).join(',')+'}':JSON.stringify(v)",
+ "const manifest=JSON.parse(actual)",
+ "if(canonical(manifest)!==expected)throw new Error('manifest mismatch')",
+ "const hash=(p)=>'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(path.join(root,p))).digest('hex')",
+ "const tree=(treeRoot)=>{const digest=crypto.createHash('sha256');const visit=(directory,prefix='')=>{for(const entry of fs.readdirSync(directory,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))){const relative=prefix?prefix+'/'+entry.name:entry.name;const absolute=path.join(directory,entry.name);if(entry.isDirectory()){digest.update('directory\\0'+relative+'\\n');visit(absolute,relative)}else if(entry.isFile()){digest.update('file\\0'+relative+'\\0'+'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex')+'\\n')}else if(entry.isSymbolicLink()){digest.update('symlink\\0'+relative+'\\0'+fs.readlinkSync(absolute)+'\\n')}else throw new Error('unsupported dist entry '+relative)}};visit(treeRoot);return 'sha256:'+digest.digest('hex')}",
+ "for(const name of ['nodeCommand','productionLock','opencodeCommand','opencodeExecutable','opencodeProxy','acpxSidecar','grokLauncher']){const artifact=manifest.payload.artifacts[name];if(hash(artifact.path)!==artifact.sha256)throw new Error(name+' digest mismatch')}",
+ "if(tree(path.join(root,'dist'))!==manifest.payload.distDigest)throw new Error('dist tree digest mismatch')",
+ "for(const candidate of Object.values({...manifest.payload.providers,...manifest.payload.candidateProviders})){if(tree(path.join(root,candidate.path))!==candidate.sha256)throw new Error('candidate asset tree digest mismatch')}",
+ "const version=process.versions.node.split('.').map(Number)",
+ "const minimum=manifest.payload.pins.nodeMinimum.split('.').map(Number)",
+ "if(version[0]JSON.parse(fs.readFileSync(path.join(root,'node_modules',...pkg.split('/'),'package.json'),'utf8')).version",
+ "const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode}",
+ "for(const [pkg,version] of Object.entries(expectedPackages))if(packageVersion(pkg)!==version)throw new Error(pkg+' version mismatch')",
+ ].join(";");
+ await runRemoteProviderVerification(() => runner.execute({
+ command: providerNodeCommand,
+ args: ["-e", verifyScript, packRoot, expected],
+ cwd: cwd,
+ bypassSession: true,
+ timeoutMs: 30_000,
+ }), "provider pack verification");
+ const opencodeCommand = posix.join(
+ packRoot,
+ manifest.payload.artifacts.opencodeCommand.path,
+ );
+ const opencodeVersion = await runRemoteProviderVerification(() => runner.execute({
+ command: opencodeCommand,
+ args: ["--version"],
+ cwd: cwd,
+ bypassSession: true,
+ timeoutMs: 30_000,
+ }), "OpenCode version verification");
+ if (
+ opencodeVersion.stdout.trim() !== manifest.payload.pins.opencode
+ ) {
+ throw new Error(
+ "runner_remote_provider_artifact_incompatible: OpenCode version mismatch",
+ );
+ }
+}
diff --git a/server/src/services/native-runtime/remote-provider-pack.test.ts b/server/src/services/native-runtime/remote-provider-pack.test.ts
index a4ec1f60d97..5d5991abca4 100644
--- a/server/src/services/native-runtime/remote-provider-pack.test.ts
+++ b/server/src/services/native-runtime/remote-provider-pack.test.ts
@@ -25,6 +25,14 @@ describe("remote provider verification", () => {
await expect(runRemoteProviderVerification(execute, "pack")).rejects.toThrow("runner_remote_provider_artifact_incompatible: pack failed (exitCode=1");
expect(execute).toHaveBeenCalledOnce();
});
+ it("retains a bounded redacted rejection from a combined-output transport", async () => {
+ const execute = vi.fn().mockResolvedValue({ ...ok, exitCode: 1,
+ stdout: "Error: manifest mismatch\nANTHROPIC_API_KEY=secret-fixture-value", stderr: "" });
+ const failure = await runRemoteProviderVerification(execute, "pack").catch(error => error);
+ expect(failure.message).toContain("manifest mismatch");
+ expect(failure.message).not.toContain("secret-fixture-value");
+ expect(execute).toHaveBeenCalledOnce();
+ });
it("recovers a transport exception and propagates cancellation", async () => {
const execute = vi.fn().mockRejectedValueOnce(new Error("connection unavailable")).mockResolvedValueOnce(ok);
await expect(runRemoteProviderVerification(execute, "pack")).resolves.toEqual(ok);
diff --git a/server/src/services/native-runtime/remote-provider-pack.ts b/server/src/services/native-runtime/remote-provider-pack.ts
index b7045ad42d7..f6621e1e1ea 100644
--- a/server/src/services/native-runtime/remote-provider-pack.ts
+++ b/server/src/services/native-runtime/remote-provider-pack.ts
@@ -1,3 +1,4 @@
+import { redactSensitiveText } from "../../redaction.js";
import type { NativeRunTrace } from "./native-run-trace.js";
import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
@@ -37,10 +38,10 @@ export async function runRemoteProviderVerification(
result = await execute();
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw error;
- diagnostic = error instanceof Error ? error.message : String(error);
+ diagnostic = redactSensitiveText(error instanceof Error ? error.message : String(error)).slice(-1_024);
continue;
}
- diagnostic = `exitCode=${result.exitCode}; timedOut=${result.timedOut}; signal=${result.signal ?? "none"}; ${result.stderr.trim().slice(-1_024)}`;
+ diagnostic = `exitCode=${result.exitCode}; timedOut=${result.timedOut}; signal=${result.signal ?? "none"}; ${redactSensitiveText([result.stderr, result.stdout].filter(Boolean).join("\n")).trim().slice(-1_024)}`;
if (result.timedOut || result.exitCode === null || result.signal) continue;
if (result.exitCode !== 0) {
throw new Error(`runner_remote_provider_artifact_incompatible: ${label} failed (${diagnostic})`);
diff --git a/server/src/services/project-access-denial.ts b/server/src/services/project-access-denial.ts
new file mode 100644
index 00000000000..f9e699d1bdc
--- /dev/null
+++ b/server/src/services/project-access-denial.ts
@@ -0,0 +1,10 @@
+import { HttpError } from "../errors.js";
+
+export const PROJECT_ACCESS_DENIED = "project_access_denied";
+
+/** Carries a safe, typed setup failure without changing the authorization decision. */
+export class ProjectAccessDeniedError extends HttpError {
+ constructor(readonly projectId: string) {
+ super(403, "Project is outside this low-trust boundary.");
+ }
+}
diff --git a/server/src/services/recovery/provider-failure-classification.test.ts b/server/src/services/recovery/provider-failure-classification.test.ts
index 31407ac82c3..29dded8d4d6 100644
--- a/server/src/services/recovery/provider-failure-classification.test.ts
+++ b/server/src/services/recovery/provider-failure-classification.test.ts
@@ -10,6 +10,10 @@ import { legacyExecutionNeedsReconciliation } from "../legacy-execution-recovery
import { isConfigurationIncompleteFailedRun } from "../../modules/wake-queue/domain/values.js";
describe("classifyAdapterFailureForRecovery", () => {
+ it("does not automatically replay a project access denial", () => {
+ expect(classifyContinuationFailure({ errorCode: "project_access_denied", resultJson: null } as never))
+ .toMatchObject({ kind: "non_retryable", maxAttempts: 0 });
+ });
it("waits for model configuration changes instead of retrying a typed native model rejection", () => {
const run = { errorCode: "native_provider_model_rejected", error: "Fixed model configuration message", resultJson: null };
expect(classifyAdapterFailureForRecovery(run)).toEqual({ kind: "configuration_incomplete" });
diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts
index e72811ec8d0..331122e10d6 100644
--- a/server/src/services/recovery/service.ts
+++ b/server/src/services/recovery/service.ts
@@ -516,6 +516,7 @@ const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set([
]);
const NON_RETRYABLE_CONTINUATION_ERROR_CODES = new Set([
+ "project_access_denied",
"native_provider_model_rejected",
"provider_tool_definition_invalid",
"adapter_engine_unavailable",
diff --git a/server/src/services/task-interruption-presentation.test.ts b/server/src/services/task-interruption-presentation.test.ts
new file mode 100644
index 00000000000..9c730d1c5fc
--- /dev/null
+++ b/server/src/services/task-interruption-presentation.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { projectTaskInterruption } from "./task-interruption-presentation.js";
+
+const issue = { id: "task", companyId: "11111111-1111-4111-8111-111111111111", status: "blocked", assigneeAgentId: "agent", projectId: "22222222-2222-4222-8222-222222222222" };
+const run = { id: "run", agentId: "agent", status: "failed", scheduledRetryAt: null,
+ errorCode: "project_access_denied", error: null, livenessState: null,
+ resultJson: { projectAccessDenied: { projectId: "22222222-2222-4222-8222-222222222222" } } };
+const agent = { status: "idle", pauseReason: null };
+
+describe("task project-access interruption", () => {
+ it("explains a project denial without offering a futile retry", () => {
+ expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: false })).toMatchObject({
+ state: "waiting_for_user", responsibility: "user", userAction: "project_access", canRetry: false,
+ projectAccess: { agentId: "agent", projectId: "22222222-2222-4222-8222-222222222222", ready: false },
+ });
+ });
+
+ it("projects the historical setup denial without requiring another failed run", () => {
+ expect(projectTaskInterruption(issue, { run: { ...run, errorCode: "setup_failed",
+ error: "Project is outside this low-trust boundary.", resultJson: null }, agent, paused: false, approval: false }))
+ .toMatchObject({ userAction: "project_access", canRetry: false });
+ });
+
+ it("offers an explicit retry after the project is added, without changing the failed run", () => {
+ expect(projectTaskInterruption(issue, { run, agent, projectAccessAllowed: true, paused: false, approval: false }))
+ .toMatchObject({ state: "failed", canRetry: true, projectAccess: { ready: true } });
+ expect(run.status).toBe("failed");
+ });
+
+ it("shows an accepted retry as working after access is repaired", () => {
+ const input = { run, agent, paused: false, approval: false, wait: { id: "request", requestedAt: new Date() } };
+ expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true }))
+ .toMatchObject({ state: "working", canRetry: false });
+ expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true })).not.toHaveProperty("projectAccess");
+ expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: false }))
+ .toMatchObject({ state: "waiting_for_user", userAction: "project_access", canRetry: false });
+ });
+
+ it("does not reinterpret unrelated failures or an old assignee's denial", () => {
+ for (const otherRun of [{ ...run, errorCode: "setup_failed", error: "Disk full", resultJson: null },
+ { ...run, agentId: "previous-agent" }]) {
+ expect(projectTaskInterruption(issue, { run: otherRun, agent, paused: false, approval: false }))
+ .not.toHaveProperty("projectAccess");
+ }
+ });
+
+ it("keeps approval, pause, running and completed states authoritative", () => {
+ expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: true })).toMatchObject({ userAction: "approval" });
+ expect(projectTaskInterruption(issue, { run, agent, paused: true, approval: false })).toMatchObject({ userAction: "resume" });
+ expect(projectTaskInterruption(issue, { run: { ...run, status: "running" }, agent, paused: false, approval: false }))
+ .toMatchObject({ state: "working" });
+ expect(projectTaskInterruption({ ...issue, status: "done" }, { run, agent, paused: false, approval: false }))
+ .toMatchObject({ state: "resolved" });
+ });
+});
diff --git a/server/src/services/task-interruption-presentation.ts b/server/src/services/task-interruption-presentation.ts
index 249ff55d8e5..f8dd6664e33 100644
--- a/server/src/services/task-interruption-presentation.ts
+++ b/server/src/services/task-interruption-presentation.ts
@@ -4,21 +4,35 @@ import { agents, agentWakeupRequests, approvals, companies, heartbeatRuns, issue
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
import { isAiAuthenticationBlocked } from "./ai-auth-failure.js";
import { readSystemRecovery } from "./system-recovery-state.js";
+import { PROJECT_ACCESS_DENIED } from "./project-access-denial.js";
+import { authorizationService } from "./authorization.js";
type ProjectionIssue = Pick;
type ProjectionRun = Pick;
+ "id" | "status" | "scheduledRetryAt" | "errorCode" | "livenessState" | "resultJson"> &
+ Partial>;
type ProjectionState = {
run?: ProjectionRun;
action?: typeof issueRecoveryActions.$inferSelect;
wait?: { id: string; requestedAt: Date };
agent?: Pick;
+ projectAccessAllowed?: boolean;
project?: Pick;
company?: Pick;
paused: boolean;
approval: boolean;
};
+/** Recognize old persisted failures without displaying arbitrary run error text. */
+function deniedProjectId(issue: ProjectionIssue, run?: ProjectionRun): string | null | undefined {
+ const historical = run?.errorCode === "setup_failed" && run.error === "Project is outside this low-trust boundary.";
+ if (!run || run.status !== "failed" || run.agentId !== issue.assigneeAgentId
+ || (run.errorCode !== PROJECT_ACCESS_DENIED && !historical)) return undefined;
+ const detail = run.resultJson?.projectAccessDenied;
+ return detail && typeof detail === "object" && "projectId" in detail && typeof detail.projectId === "string"
+ ? detail.projectId : historical ? issue.projectId : null;
+}
+
/** Presentation never grants authority. Dispatch repeats all gates under its
* locks. The same projection is used in detail, lists and attention surfaces. */
export function projectTaskInterruption(issue: ProjectionIssue, input: ProjectionState, now = Date.now()): TaskInterruptionPresentation | null {
@@ -36,6 +50,14 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio
? "budget" : input.paused || project?.pausedAt || agent?.status === "paused" || company?.status === "paused" ? "resume" : null;
if (userAction) return { ...base, responsibility: "user", state: "waiting_for_user", userAction };
if (["running", "queued"].includes(run.status)) return { ...base, state: "working" };
+ const deniedProject = deniedProjectId(issue, run);
+ let projectAccess: TaskInterruptionPresentation["projectAccess"];
+ if (deniedProject !== undefined) {
+ const ready = input.projectAccessAllowed === true;
+ projectAccess = { agentId: run.agentId!, projectId: deniedProject === issue.projectId ? deniedProject : null, ready };
+ if (!ready) return { ...base, responsibility: "user", state: "waiting_for_user",
+ userAction: "project_access", projectAccess };
+ }
const retryAt = system?.nextAttemptAt ?? run.scheduledRetryAt?.getTime();
if (retryAt && retryAt > now) return { ...base, state: "retry_scheduled",
nextRetryAt: new Date(retryAt).toISOString(), canRetry: true };
@@ -44,7 +66,7 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio
activeObservation > now - 45_000) return { ...base, state: "working" };
if (wait && wait.requestedAt.getTime() > now - 10_000 && system?.state !== "exhausted")
return { ...base, state: "working" };
- return { ...base, state: "failed", canRetry: true };
+ return { ...base, state: "failed", canRetry: true, ...(projectAccess ? { projectAccess } : {}) };
}
/** Batch list reads rather than doing seven queries per row. Keep run logs and
@@ -61,8 +83,12 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
const projectIds = selected.flatMap(issue => issue.projectId ? [issue.projectId] : []);
const [runs, waits, agentRows, projectRows, pauseRows, approvalRows, companyRows] = await Promise.all([
db.selectDistinctOn([runIssue], { issueId: runIssue, id: heartbeatRuns.id, status: heartbeatRuns.status,
+ agentId: heartbeatRuns.agentId,
+ error: sql`case when ${heartbeatRuns.errorCode} = 'setup_failed'
+ and ${heartbeatRuns.error} = 'Project is outside this low-trust boundary.' then ${heartbeatRuns.error} else null end`,
errorCode: heartbeatRuns.errorCode, livenessState: heartbeatRuns.livenessState, scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
- resultJson: sql>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete')`,
+ resultJson: sql>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete',
+ 'projectAccessDenied', ${heartbeatRuns.resultJson}->'projectAccessDenied')`,
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), inArray(runIssue, ids)))
.orderBy(runIssue, desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)),
db.selectDistinctOn([wakeIssue], { issueId: wakeIssue, id: agentWakeupRequests.id, requestedAt: agentWakeupRequests.requestedAt })
@@ -91,12 +117,32 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
)).orderBy(issueRecoveryActions.sourceIssueId, desc(issueRecoveryActions.createdAt)) : [];
const byIssue = (rows: T[]) => new Map(rows.map(row => [row.issueId, row]));
const runMap = byIssue(runs), waitMap = byIssue(waits);
+ // Recheck current authority, including standard trust, root-issue grants and
+ // project policy. Do not reuse the failed run's frozen policy for a new retry.
+ // Only denied tasks need these reads; deduplicate projects and batch per agent.
+ const deniedByAgent = new Map>();
+ for (const issue of selected) {
+ const projectId = deniedProjectId(issue, runMap.get(issue.id));
+ if (!projectId || !issue.assigneeAgentId || ["done", "cancelled"].includes(issue.status)) continue;
+ const ids = deniedByAgent.get(issue.assigneeAgentId) ?? new Set();
+ ids.add(projectId);
+ deniedByAgent.set(issue.assigneeAgentId, ids);
+ }
+ const currentAccess = new Map();
+ const authorization = authorizationService(db);
+ for (const [agentId, ids] of deniedByAgent) {
+ const projectIds = [...ids];
+ const decisions = await authorization.decideReads({ type: "agent", agentId, companyId, source: "agent_key" },
+ projectIds.map(projectId => ({ type: "project" as const, companyId, projectId })));
+ projectIds.forEach((projectId, index) => currentAccess.set(`${agentId}:${projectId}`, decisions[index]?.allowed === true));
+ }
const actionMap = new Map(actions.map(action => [action.sourceIssueId, action]));
const agentMap = new Map(agentRows.map(agent => [agent.id, agent]));
const projectMap = new Map(projectRows.map(project => [project.id, project]));
const pauses = new Set(pauseRows.map(row => row.issueId)), approvalsSet = new Set(approvalRows.map(row => row.issueId));
for (const issue of selected) result.set(issue.id, projectTaskInterruption(issue, {
run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id),
+ projectAccessAllowed: currentAccess.get(`${issue.assigneeAgentId}:${deniedProjectId(issue, runMap.get(issue.id))}`),
agent: agentMap.get(issue.assigneeAgentId ?? ""), project: projectMap.get(issue.projectId ?? ""),
company: companyRows[0], paused: pauses.has(issue.id), approval: approvalsSet.has(issue.id),
}));
diff --git a/server/src/services/task-workspace-access.ts b/server/src/services/task-workspace-access.ts
index b49ba9d9432..31d40f79655 100644
--- a/server/src/services/task-workspace-access.ts
+++ b/server/src/services/task-workspace-access.ts
@@ -8,6 +8,7 @@ import { canonicalExecutionUuid } from "./execution-identity.js";
import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns, taskRepositoryResponsibleUserMatches } from "./task-repository-acquisition.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { isDeepStrictEqual } from "node:util";
+import { ProjectAccessDeniedError } from "./project-access-denial.js";
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection } from "@paperclipai/shared";
type DbTransaction = Parameters[0]>[0];
@@ -19,7 +20,12 @@ export async function assertTaskWorkspaceSourceProjectAccess(
const decision = await measureRunPhase("workspace.access.authorization", () => accessService(db as Db).decide({
actor, action: "project:read", resource: { type: "project", companyId, projectId },
}));
- if (!decision.allowed) throw forbidden(decision.explanation);
+ if (!decision.allowed) {
+ if (actor.type === "agent" && decision.reason === "deny_low_trust_boundary") {
+ throw new ProjectAccessDeniedError(projectId);
+ }
+ throw forbidden(decision.explanation);
+ }
}
export async function assertExecutionWorkspaceRecordAccess(
diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts
index 5d31460ea38..9f97ce68bfe 100644
--- a/ui/src/api/agents.ts
+++ b/ui/src/api/agents.ts
@@ -1,4 +1,5 @@
import type { AgentPublicIdentity } from "@paperclipai/shared";
+import type { AgentProjectAccess } from "@paperclipai/shared";
import type {
Agent,
EnvironmentProbeResult,
@@ -111,6 +112,8 @@ function agentPath(id: string, companyId?: string, suffix = "") {
}
export const agentsApi = {
+ projectAccess: (id: string) => api.get(`/agents/${id}/project-access`),
+ addProjectAccess: (id: string, projectIds: string[]) => api.post(`/agents/${id}/project-access`, { projectIds }),
qualifyRunner: (id: string, expectedEnvironmentId: string, companyId?: string) =>
api.post(agentPath(id, companyId, "/qualify-runner"), { expectedEnvironmentId }),
setAvatar: (companyId: string, agentId: string, imageBase64: string | null) =>
diff --git a/ui/src/components/AgentProjectAccessSettings.tsx b/ui/src/components/AgentProjectAccessSettings.tsx
new file mode 100644
index 00000000000..2320755c85f
--- /dev/null
+++ b/ui/src/components/AgentProjectAccessSettings.tsx
@@ -0,0 +1,34 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { isUuidLike } from "@paperclipai/shared";
+import { agentsApi } from "../api/agents";
+import { projectsApi } from "../api/projects";
+import { queryKeys } from "../lib/queryKeys";
+import { Link, useSearchParams } from "../lib/router";
+import { AllowedProjectsEditor } from "./AllowedProjectsEditor";
+import { Button } from "./ui/button";
+
+export function AgentProjectAccessSettings({ agent }: { agent: { id: string; companyId: string; name: string; urlKey?: string } }) {
+ const client = useQueryClient();
+ const [params] = useSearchParams();
+ const fromTask = params.get("fromTask");
+ const access = useQuery({ queryKey: queryKeys.agents.projectAccess(agent.id), queryFn: () => agentsApi.projectAccess(agent.id) });
+ const projects = useQuery({ queryKey: queryKeys.projects.list(agent.companyId), queryFn: () => projectsApi.list(agent.companyId) });
+ if (access.error || projects.error) return
+
Couldn’t load project access.
{ void access.refetch(); void projects.refetch(); }}>Try again
+
;
+ if (!access.data || !projects.data) return Loading project access…
;
+ return
+
{
+ const saved = await agentsApi.addProjectAccess(agent.id, projectIds);
+ client.setQueryData(queryKeys.agents.projectAccess(agent.id), saved);
+ await Promise.all([
+ client.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }),
+ ...(agent.urlKey ? [client.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) })] : []),
+ client.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) }),
+ client.invalidateQueries({ queryKey: ["issues"] }),
+ ]);
+ }} />
+ {fromTask && isUuidLike(fromTask) && Return to task }
+ ;
+}
diff --git a/ui/src/components/AllowedProjectsEditor.tsx b/ui/src/components/AllowedProjectsEditor.tsx
new file mode 100644
index 00000000000..a34ced1479d
--- /dev/null
+++ b/ui/src/components/AllowedProjectsEditor.tsx
@@ -0,0 +1,72 @@
+import { useState } from "react";
+import { Check, FolderOpen, Loader2, Plus, X } from "lucide-react";
+import { Button } from "./ui/button";
+import { Checkbox } from "./ui/checkbox";
+import { Input } from "./ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
+
+export interface AllowedProjectsEditorProps {
+ projects: Array<{ id: string; name: string }>;
+ projectIds: string[];
+ canManage: boolean;
+ agentName: string;
+ onSave: (projectIds: string[]) => Promise;
+}
+
+export function AllowedProjectsEditor({ projects, projectIds, canManage, agentName, onSave }: AllowedProjectsEditorProps) {
+ const [added, setAdded] = useState([]);
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState(null);
+ const pending = added.filter(id => !projectIds.includes(id));
+ const visibleIds = [...new Set([...projectIds, ...pending])];
+ const name = (id: string) => projects.find(project => project.id === id)?.name ?? "Unavailable project";
+ const matches = projects.filter(project => project.name.toLowerCase().includes(query.toLowerCase()));
+
+ async function save() {
+ if (!canManage || saving || !pending.length) return;
+ setSaving(true); setError(null); setSaved(false);
+ try { await onSave(pending); setAdded([]); setSaved(true); }
+ catch (failure) { setError(failure instanceof Error ? failure.message : "Couldn’t save project access."); }
+ finally { setSaving(false); }
+ }
+
+ return
+
+
Allowed projects
+
Projects whose tasks and repositories this agent may access.
+ {canManage &&
+ Add project
+
+ setQuery(event.target.value)} />
+ {matches.map(project =>
+ { setAdded(current => checked ? [...new Set([...current, project.id])] : current.filter(id => id !== project.id)); setSaved(false); }} />
+ {project.name}{projectIds.includes(project.id) && Already allowed }
+ )}
+ {!matches.length && No projects found.
}
+ setOpen(false)}>Done
+ }
+
+ {visibleIds.length ?
+ {visibleIds.map(id =>
+ {name(id)}
+ {projectIds.includes(id) ? Allowed
+ : <>To add setAdded(current => current.filter(value => value !== id))}> >}
+ )}
+ : No projects allowed yet.
}
+ {!canManage && A person with agent-management permission can add projects here.
}
+ {pending.length > 0 &&
+
Add access to {pending.map(name).join(", ")}
+
{agentName} will be able to access work and repositories in {pending.length === 1 ? "this project" : "these projects"}. Existing project access and low-trust restrictions stay in place.
+
}
+ {error && Couldn’t save project access. Your selection is kept. {error}
}
+ {saved && Project access saved. Return to your task to retry.
}
+ {canManage &&
+ { setAdded([]); setError(null); setSaved(false); }}>Cancel
+ void save()}>{saving && }{saving ? "Saving access…" : error ? "Try saving again" : "Save project access"}
+
}
+ ;
+}
diff --git a/ui/src/components/ExecutionBlockerNotice.tsx b/ui/src/components/ExecutionBlockerNotice.tsx
index 9c34d72748f..17bd169750c 100644
--- a/ui/src/components/ExecutionBlockerNotice.tsx
+++ b/ui/src/components/ExecutionBlockerNotice.tsx
@@ -2,9 +2,10 @@ import type { TaskInterruptionPresentation } from "@paperclipai/shared";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
import { useSecondTick } from "../hooks/useSecondTick";
+import { TaskProjectAccessNotice } from "./TaskProjectAccessNotice";
/** One ordinary interruption surface. Technical records remain in diagnostics. */
-export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimistic, takingLonger, error, className }: {
+export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimistic, takingLonger, error, className, issueId, agentName, projectName }: {
presentation: TaskInterruptionPresentation | null | undefined;
canRetry: boolean;
onRetry: () => void;
@@ -12,8 +13,14 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi
takingLonger?: boolean;
error?: Error | null;
className?: string;
+ issueId?: string;
+ agentName?: string;
+ projectName?: string;
}) {
useSecondTick(presentation?.state === "retry_scheduled");
+ if (presentation?.projectAccess && !optimistic && !takingLonger) return ;
if (error && !optimistic && !takingLonger) return {error.message} {canRetry && Retry }
;
if (takingLonger) return Taking longer than usual… {canRetry && Retry }
;
if (optimistic || !presentation || presentation.responsibility !== "system" ||
diff --git a/ui/src/components/ProjectAccessNotice.tsx b/ui/src/components/ProjectAccessNotice.tsx
new file mode 100644
index 00000000000..07386a68de5
--- /dev/null
+++ b/ui/src/components/ProjectAccessNotice.tsx
@@ -0,0 +1,38 @@
+import { ShieldCheck, ShieldX, ArrowRight } from "lucide-react";
+import { Button } from "./ui/button";
+import { cn } from "../lib/utils";
+
+export interface ProjectAccessNoticeProps {
+ agentName?: string;
+ projectName?: string;
+ ready: boolean;
+ canManage: boolean;
+ canRetry: boolean;
+ onManage: () => void;
+ onRetry: () => void;
+ error?: Error | null;
+ className?: string;
+}
+
+export function ProjectAccessNotice({ agentName = "This agent", projectName, ready, canManage, canRetry, onManage, onRetry, error, className }: ProjectAccessNoticeProps) {
+ return
+
+ {ready ?
:
}
+
+
{ready ? "Project access is ready" : `${agentName} can’t access this project`}
+
{ready
+ ? "The agent can now access the required project. Retry to continue with your saved message."
+ : `This task needs ${projectName ?? "a project"} outside the agent’s allowed projects. The agent hasn’t started. Your message is saved.`}
+ {!ready && !canManage &&
A person with agent-management permission can add this project in the agent’s Permissions / Trust settings.
}
+
+
+
+ {ready ? canRetry &&
Retry task
+ :
{canManage ? "Manage project access" : "View agent access"} }
+
+ {error && {error.message}
}
+ {!ready && Run details
+ Stopped during Workspace setup Reason Project outside allowed scope Provider started No
+ }
+ ;
+}
diff --git a/ui/src/components/TaskProjectAccessNotice.tsx b/ui/src/components/TaskProjectAccessNotice.tsx
new file mode 100644
index 00000000000..021d955f8ec
--- /dev/null
+++ b/ui/src/components/TaskProjectAccessNotice.tsx
@@ -0,0 +1,23 @@
+import { useQuery } from "@tanstack/react-query";
+import type { TaskInterruptionPresentation } from "@paperclipai/shared";
+import { agentsApi } from "../api/agents";
+import { useNavigate } from "../lib/router";
+import { queryKeys } from "../lib/queryKeys";
+import { ProjectAccessNotice } from "./ProjectAccessNotice";
+
+export function TaskProjectAccessNotice({ access, issueId, agentName, projectName, ...props }: {
+ access: NonNullable;
+ issueId?: string;
+ agentName?: string;
+ projectName?: string;
+ canRetry: boolean;
+ onRetry: () => void;
+ error?: Error | null;
+ className?: string;
+}) {
+ const navigate = useNavigate();
+ const permission = useQuery({ queryKey: queryKeys.agents.projectAccess(access.agentId), queryFn: () => agentsApi.projectAccess(access.agentId) });
+ return navigate(`/agents/${access.agentId}/permissions${issueId ? `?fromTask=${encodeURIComponent(issueId)}` : ""}`)} />;
+}
diff --git a/ui/src/components/TrustPresetSection.tsx b/ui/src/components/TrustPresetSection.tsx
index 132ab11bff3..cae599bbdf6 100644
--- a/ui/src/components/TrustPresetSection.tsx
+++ b/ui/src/components/TrustPresetSection.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentPermissions, TrustPreset } from "@paperclipai/shared";
import { Lock, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -59,6 +59,7 @@ export function TrustPresetSection({
issueCandidates = [],
candidatesLoading,
allowSingleIssue = true,
+ projectAccessEditor,
}: {
permissions: Partial | null | undefined;
onChange: (permissions: Partial) => void;
@@ -68,6 +69,7 @@ export function TrustPresetSection({
issueCandidates?: LowTrustBoundaryCandidate[];
candidatesLoading?: boolean;
allowSingleIssue?: boolean;
+ projectAccessEditor?: ReactNode;
}) {
const [policyOpen, setPolicyOpen] = useState(false);
const preset = getTrustPreset(permissions);
@@ -148,7 +150,7 @@ export function TrustPresetSection({
: "This agent is set to low-trust review, but no project, root issue, or issue scope is set in the core policy. Add a scope before this agent can run without denial."}
- {boundaryEditable ? (
+ {projectAccessEditor && (boundary?.projectIds?.length ?? 0) > 0 ? null : boundaryEditable ? (
@@ -211,7 +213,7 @@ export function TrustPresetSection({
)}
-
+ {!projectAccessEditor &&
Want to set more than one containment boundary?{" "}
Get Paperclip EE.
-
+ }
) : null}
+ {lowTrust && projectAccessEditor}
+
{managedPermissions.authorizationPolicy?.reviewPreset ? null : (
Advanced permissions remain editable through the EE permissions extension when installed.
diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts
index 85bdb7694fd..26dafb622df 100644
--- a/ui/src/lib/queryKeys.ts
+++ b/ui/src/lib/queryKeys.ts
@@ -222,6 +222,7 @@ export const queryKeys = {
["team-catalog", "installed", companyId] as const,
},
agents: {
+ projectAccess: (id: string) => ["agents", "project-access", id] as const,
identity: (id: string) => ["agents", "identity", id] as const,
list: (companyId: string) => ["agents", companyId] as const,
detail: (id: string) => ["agents", "detail", id] as const,
diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx
index 1258faa6955..b89c8f42501 100644
--- a/ui/src/pages/AgentDetail.production.tsx
+++ b/ui/src/pages/AgentDetail.production.tsx
@@ -4,7 +4,7 @@ import { characterStateForAgent } from "@paperclipai/shared";
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
import { getPageVisibility, usePageVisibility } from "../lib/page-visibility";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
-import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
+import { useParams, useNavigate, useLocation, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
agentsApi,
@@ -58,6 +58,7 @@ import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel";
import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { TrustPresetSection } from "../components/TrustPresetSection";
+import { AgentProjectAccessSettings } from "../components/AgentProjectAccessSettings";
import { FileTree, buildFileTree } from "../components/FileTree";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
@@ -768,6 +769,7 @@ export function AgentDetail() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
+ const location = useLocation();
const [actionError, setActionError] = useState(null);
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState>(() => new Set());
const { enabled: chatEnabled, githubEnabled, loaded: chatConnectorsLoaded } = useChatConnectorsEnabled();
@@ -987,10 +989,10 @@ export function AgentDetail() {
? "budget"
: "dashboard";
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
- navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
+ navigate(`/agents/${canonicalAgentRef}/${canonicalTab}${location.search}`, { replace: true });
return;
}
- }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate, chatConnectorsLoaded]);
+ }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate, chatConnectorsLoaded, location.search]);
useEffect(() => {
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
@@ -2209,6 +2211,7 @@ function ConfigurationTab({
) : null}
{content === "configuration" ? }
permissions={agent.permissions}
disabled={updatePermissions.isPending}
companyId={companyId}
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index ccc3d42335f..5a2693c0e2d 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -11,7 +11,7 @@ import { characterStateForAgent } from "@paperclipai/shared";
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
import { getPageVisibility, usePageVisibility } from "../lib/page-visibility";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
-import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
+import { useParams, useNavigate, useLocation, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
agentsApi,
@@ -63,6 +63,7 @@ import { InlineBanner } from "../components/InlineBanner";
import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel";
import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal";
import { TrustPresetSection } from "../components/TrustPresetSection";
+import { AgentProjectAccessSettings } from "../components/AgentProjectAccessSettings";
import { FileTree, buildFileTree } from "../components/FileTree";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
@@ -780,6 +781,7 @@ export function AgentDetail() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
+ const location = useLocation();
const [actionError, setActionError] = useState(null);
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState>(() => new Set());
const activeView: AgentDetailView = urlRunId ? "run-detail"
@@ -983,10 +985,10 @@ export function AgentDetail() {
if (legacyAuditSection) return;
const canonicalTab = activeView === "run-detail" ? "overview" : activeView;
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
- navigate(agentDetailHref(canonicalAgentRef, canonicalTab), { replace: true });
+ navigate(`${agentDetailHref(canonicalAgentRef, canonicalTab)}${location.search}`, { replace: true });
return;
}
- }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate]);
+ }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, location.search]);
useEffect(() => {
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
@@ -2135,6 +2137,7 @@ export function ConfigurationTab({
{content === "permissions" ? }
permissions={agent.permissions}
disabled={updatePermissions.isPending}
companyId={companyId}
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx
index 5913637d3f0..c64b6fc4e3f 100644
--- a/ui/src/pages/Inbox.tsx
+++ b/ui/src/pages/Inbox.tsx
@@ -394,6 +394,7 @@ export function FailedRunInboxRow({
: interruption?.state === "waiting_for_user" ? ({
approval: "Approval required", reconnect: "Reconnect account", assign_agent: "Assign an agent", choose_model: "Choose a supported model",
resume: "Work is paused", budget: "Budget limit reached",
+ project_access: "Agent needs project access",
}[interruption.userAction ?? "resume"]) : runFailureMessage(run);
const retryAvailable = !interruption || interruption.canRetry;
const showUnreadSlot = unreadState !== null;
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index fcb321a66f1..abac74816d5 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -7814,6 +7814,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
)}
{resolvedDetailTab === "chat" && (
)}
diff --git a/ui/storybook/stories/design-proposals/project-access.stories.tsx b/ui/storybook/stories/design-proposals/project-access.stories.tsx
new file mode 100644
index 00000000000..8efb0bc3a7e
--- /dev/null
+++ b/ui/storybook/stories/design-proposals/project-access.stories.tsx
@@ -0,0 +1,121 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, waitFor, within } from "storybook/test";
+import { ProjectAccessProposal } from "../../support/design-proposals/ProjectAccessProposal";
+
+const meta = {
+ id: "design-project-access",
+ title: "Design proposals/Patterns/Project access recovery",
+ component: ProjectAccessProposal,
+ parameters: {
+ layout: "fullscreen",
+ initialEntries: ["/PAP/issues/DEMO-42"],
+ docs: { description: { component: "Start at Blocked task. Manage project access opens the proposed agent settings. Add Agent Extensions, review the additive access change, save, return to the task and retry. Low-trust review stays active. Settings supports multiple allowed projects without replacing the existing one. Read-only, pending save, failed save and failed retry states remain actionable. The reply and every state transition are local fixtures; no project permissions change and no provider runs. Reset the story to restore the starting state." } },
+ paperclip: {
+ capabilities: ["recovery/diagnosis", "recovery/source-action", "agents/instructions"],
+ home: "design-proposals", status: "proposed",
+ exception: "Requested design for a visible project denial and additive project access repair before production implementation.",
+ implementation: "prototype", boundary: "fixtures",
+ notes: "Reuses DesignPageShell, DesignSidebar, AgentAvatar, IssueStatusBadge, TaskChatBubble, TaskChatLiveRunPill and shared form primitives. Notice, access editor and navigation are a local prototype. Synthetic names and repositories only. Save retains existing projects; failed saves preserve draft but grant nothing. Retry is unavailable before a saved grant and does not run on save. Read-only viewers cannot edit. No production API, persistence, permission, provider or recovery proof. The implemented notice and editor are covered by the task-project-access stories; this retains the original design snapshot. Server regression tests and application verification cover authorization, persistence and explicit retry separately.",
+ },
+ },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const BlockedTask: Story = {};
+export const AgentSettings: Story = { args: { initialView: "permissions" } };
+export const AccessSaved: Story = { args: { initialState: "access_saved" } };
+export const ReadOnly: Story = { args: { canManage: false }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(await canvas.findByText("Reviewer can’t access this project")).toBeVisible();
+ await expect(canvas.queryByRole("button", { name: "Manage project access" })).not.toBeInTheDocument();
+ await userEvent.click(canvas.getByRole("button", { name: "View agent access" }));
+ await expect(canvas.getByText("Only people with agent configuration permission can change this access.")).toBeVisible();
+ await expect(canvas.queryByRole("button", { name: "Add project" })).not.toBeInTheDocument();
+} };
+export const Light: Story = { globals: { theme: "light" } };
+export const Mobile: Story = { globals: { viewport: { value: "mobile1", isRotated: false } } };
+export const MobileSettings: Story = { args: { initialView: "permissions" }, globals: { viewport: { value: "mobile1", isRotated: false } } };
+
+async function addProject(canvasElement: HTMLElement, name = "Agent Extensions") {
+ const canvas = within(canvasElement), screen = within(canvasElement.ownerDocument.body);
+ await userEvent.click(await canvas.findByRole("button", { name: "Add project" }));
+ await userEvent.click(await screen.findByRole("checkbox", { name }));
+ await userEvent.click(screen.getByRole("button", { name: "Done" }));
+}
+
+export const RepairJourney: Story = { play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step("Explain the denied project before any provider work", async () => {
+ await expect(await canvas.findByText("Reviewer can’t access this project")).toBeVisible();
+ await expect(canvas.getByRole("alert")).toHaveTextContent("The agent hasn’t started. Your message is saved.");
+ await expect(canvas.queryByRole("button", { name: "Retry task" })).not.toBeInTheDocument();
+ await userEvent.click(canvas.getByRole("button", { name: "Manage project access" }));
+ });
+ await step("Add a project while preserving existing containment", async () => {
+ await expect(canvas.getByRole("button", { name: "Save project access" })).toBeDisabled();
+ await addProject(canvasElement);
+ await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed");
+ await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("To add");
+ await expect(canvas.getByText("Low-trust review", { exact: true })).toBeVisible();
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await expect(await canvas.findByText("Project access saved.")).toBeVisible();
+ await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed");
+ await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("Allowed");
+ });
+ await step("Explicitly retry the saved message after saving access", async () => {
+ await userEvent.click(canvas.getByRole("button", { name: "Return to task" }));
+ await expect(canvas.getByText("Project access is ready")).toBeVisible();
+ await waitFor(() => expect(canvas.getByText("Are you there?")).toBeVisible());
+ await userEvent.click(canvas.getByRole("button", { name: "Retry task" }));
+ await waitFor(() => expect(canvas.getByText("Yes, I’m here. What would you like me to look at?")).toBeVisible());
+ await expect(canvas.queryByRole("alert")).not.toBeInTheDocument();
+ });
+} };
+
+export const CancelJourney: Story = { args: { initialView: "permissions" }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await addProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Cancel" }));
+ await expect(canvas.getByText("Reviewer can’t access this project")).toBeVisible();
+ await userEvent.click(canvas.getByRole("button", { name: "Manage project access" }));
+ await expect(canvas.queryByTestId("project-extensions")).not.toBeInTheDocument();
+ await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed");
+} };
+
+export const SaveFailure: Story = { args: { initialView: "permissions", failSave: true }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await addProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await expect(await canvas.findByRole("alert")).toHaveTextContent("No permissions changed. Your selection is kept.");
+ await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("To add");
+ await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed");
+} };
+export const SaveFailureJourney: Story = { args: { initialView: "permissions", failSave: true }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await addProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await userEvent.click(await canvas.findByRole("button", { name: "Try saving again" }));
+ await expect(await canvas.findByText("Project access saved.")).toBeVisible();
+ await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("Allowed");
+} };
+export const Saving: Story = { args: { initialView: "permissions", holdSaving: true }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await addProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await expect(canvas.getByRole("button", { name: "Saving access…" })).toBeDisabled();
+ await expect(canvas.getByRole("button", { name: "Cancel" })).toBeDisabled();
+} };
+export const RetryFailure: Story = { args: { initialState: "access_saved", failRetry: true }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(await canvas.findByRole("button", { name: "Retry task" }));
+ await expect(await canvas.findByRole("alert")).toHaveTextContent("Couldn’t start the retry. Project access is saved.");
+} };
+export const MultipleProjects: Story = { args: { initialView: "permissions" }, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await addProject(canvasElement);
+ await addProject(canvasElement, "Website");
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await expect(await canvas.findByText("Project access saved.")).toBeVisible();
+ for (const id of ["product", "extensions", "website"]) await expect(canvas.getByTestId(`project-${id}`)).toHaveTextContent("Allowed");
+} };
diff --git a/ui/storybook/stories/recovery/project-access.stories.tsx b/ui/storybook/stories/recovery/project-access.stories.tsx
new file mode 100644
index 00000000000..3349e6605b3
--- /dev/null
+++ b/ui/storybook/stories/recovery/project-access.stories.tsx
@@ -0,0 +1,77 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useState } from "react";
+import { expect, fn, userEvent, within } from "storybook/test";
+import { AllowedProjectsEditor } from "@/components/AllowedProjectsEditor";
+import { ProjectAccessNotice } from "@/components/ProjectAccessNotice";
+
+const meta = {
+ id: "task-project-access",
+ title: "Projects and execution/Recovery/Task chat/Project access",
+ component: ProjectAccessNotice,
+ parameters: { paperclip: {
+ capabilities: ["recovery/diagnosis", "agents/instructions"], implementation: "production", boundary: "fixtures",
+ notes: "Production notice and additive project editor. Local save callbacks cover selection, read-only, cancellation and failed-save states. API authorization, persistence and explicit retry admission are separate server regressions and local application verification.",
+ } },
+ decorators: [(Story) =>
],
+ args: { agentName: "Reviewer", projectName: "Agent Extensions", ready: false, canManage: true, canRetry: false, onManage: fn(), onRetry: fn() },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const BlockedTask: Story = { play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByRole("alert")).toHaveTextContent("Your message is saved.");
+ await expect(canvas.queryByRole("button", { name: "Retry task" })).not.toBeInTheDocument();
+ await expect(canvas.getByRole("button", { name: "Manage project access" })).toBeVisible();
+} };
+export const AccessSaved: Story = { args: { ready: true, canRetry: true } };
+export const ReadOnly: Story = { args: { canManage: false } };
+export const Light: Story = { globals: { theme: "light" } };
+export const Mobile: Story = { globals: { viewport: { value: "mobile1", isRotated: false } } };
+
+const projects = [{ id: "product", name: "Product" }, { id: "extensions", name: "Agent Extensions" }, { id: "website", name: "Website" }];
+function Editor({ fail = false, canManage = true }: { fail?: boolean; canManage?: boolean }) {
+ const [ids, setIds] = useState(["product"]);
+ const [failed, setFailed] = useState(false);
+ return {
+ if (fail && !failed) { setFailed(true); throw new Error("Connection interrupted."); }
+ setIds(current => [...new Set([...current, ...added])]);
+ }} />;
+}
+async function selectProject(canvasElement: HTMLElement) {
+ const canvas = within(canvasElement), screen = within(canvasElement.ownerDocument.body);
+ await userEvent.click(canvas.getByRole("button", { name: "Add project" }));
+ await expect(await screen.findByRole("checkbox", { name: "Product" })).toBeDisabled();
+ await userEvent.click(screen.getByRole("checkbox", { name: "Agent Extensions" }));
+ await userEvent.click(screen.getByRole("button", { name: "Done" }));
+}
+export const AgentSettings: Story = { render: () => };
+export const SaveAccess: Story = { ...AgentSettings, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await selectProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await expect(await canvas.findByRole("status")).toHaveTextContent("Project access saved");
+ await expect(canvas.getAllByText("Allowed", { exact: true })).toHaveLength(2);
+ await expect(canvas.queryByRole("button", { name: "Retry task" })).not.toBeInTheDocument();
+} };
+export const CancelAddition: Story = { ...AgentSettings, play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await selectProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Cancel" }));
+ await expect(canvas.queryByText("Agent Extensions", { exact: true })).not.toBeInTheDocument();
+ await expect(canvas.getByText("Product")).toBeVisible();
+} };
+export const SaveFailure: Story = { render: () => , play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await selectProject(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
+ await expect(await canvas.findByRole("alert")).toHaveTextContent("Your selection is kept");
+ await expect(canvas.getByText("To add")).toBeVisible();
+ await userEvent.click(canvas.getByRole("button", { name: "Try saving again" }));
+ await expect(await canvas.findByRole("status")).toHaveTextContent("Project access saved");
+} };
+export const ReadOnlySettings: Story = { render: () => , play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).queryByRole("button", { name: "Add project" })).not.toBeInTheDocument();
+} };
+export const MobileSettings: Story = { ...AgentSettings, globals: { viewport: { value: "mobile1", isRotated: false } } };
diff --git a/ui/storybook/support/design-proposals/ProjectAccessProposal.tsx b/ui/storybook/support/design-proposals/ProjectAccessProposal.tsx
new file mode 100644
index 00000000000..a8894e19af5
--- /dev/null
+++ b/ui/storybook/support/design-proposals/ProjectAccessProposal.tsx
@@ -0,0 +1,180 @@
+import { useState } from "react";
+import { ArrowLeft, ArrowRight, Check, FolderOpen, Loader2, Plus, ShieldCheck, ShieldX, X } from "lucide-react";
+import { AgentAvatar } from "@/components/AgentAvatar";
+import { DesignPageShell } from "@/components/DesignPageShell";
+import { DesignSidebar } from "@/components/DesignSidebar";
+import { IssueStatusBadge } from "@/components/StatusBadge";
+import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
+import { TaskChatLiveRunPill } from "@/components/task-chat/TaskChatLiveRunPill";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { DesignProposalAccess } from "./DesignProposalAccess";
+
+const projects = [
+ { id: "product", name: "Product", repository: "acme/product" },
+ { id: "extensions", name: "Agent Extensions", repository: "acme/agent-extensions" },
+ { id: "website", name: "Website", repository: "acme/website" },
+];
+const agent = { id: "reviewer-preview", name: "Reviewer" };
+const wait = () => new Promise(resolve => setTimeout(resolve, 650));
+
+export interface ProjectAccessProposalProps {
+ initialView?: "task" | "permissions";
+ initialState?: "blocked" | "access_saved";
+ canManage?: boolean;
+ failSave?: boolean;
+ failRetry?: boolean;
+ holdSaving?: boolean;
+}
+
+/** Design-only state machine. It never calls a permissions or execution API. */
+function Proposal({ initialView = "task", initialState = "blocked", canManage = true,
+ failSave = false, failRetry = false, holdSaving = false }: ProjectAccessProposalProps) {
+ const [view, setView] = useState(initialView);
+ const [allowed, setAllowed] = useState(initialState === "access_saved" ? ["product", "extensions"] : ["product"]);
+ const [draft, setDraft] = useState(allowed);
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(false);
+ const [saveAttempted, setSaveAttempted] = useState(false);
+ const [savedNotice, setSavedNotice] = useState(false);
+ const [run, setRun] = useState<"stopped" | "starting" | "replied">("stopped");
+ const [retryError, setRetryError] = useState(false);
+ const [retryAttempted, setRetryAttempted] = useState(false);
+ const [shellNotice, setShellNotice] = useState("");
+ const added = projects.filter(project => draft.includes(project.id) && !allowed.includes(project.id));
+ const accessReady = allowed.includes("extensions");
+
+ function openPermissions() {
+ setDraft(allowed);
+ setSavedNotice(false);
+ setSaveError(false);
+ setView("permissions");
+ }
+
+ async function save() {
+ if (!canManage || saving || !added.length) return;
+ setSaving(true);
+ setSaveError(false);
+ if (holdSaving) return;
+ await wait();
+ setSaving(false);
+ setSaveAttempted(true);
+ if (failSave && !saveAttempted) { setSaveError(true); return; }
+ setAllowed([...draft]);
+ setSavedNotice(true);
+ }
+
+ async function retry() {
+ if (!accessReady || run === "starting") return;
+ setRun("starting");
+ setRetryError(false);
+ await wait();
+ setRetryAttempted(true);
+ if (failRetry && !retryAttempted) { setRun("stopped"); setRetryError(true); return; }
+ setRun("replied");
+ }
+
+ const task =
+
+
+ DEMO-42 · Agent Extensions
+
+
Quick check-in
+
+
+ Reviewer
+
+
+ {run === "starting" ?
+ : run === "replied" ?
+ :
+
+ {accessReady ?
:
}
+
+
{accessReady ? "Project access is ready" : "Reviewer can’t access this project"}
+
{accessReady
+ ? "Agent Extensions is now in Reviewer’s allowed projects. Retry to continue with your saved message."
+ : "This task uses Agent Extensions, which isn’t in Reviewer’s allowed projects. The agent hasn’t started. Your message is saved."}
+ {!accessReady && !canManage &&
An organization admin can add this project in Reviewer’s Permissions / Trust settings.
}
+
+
+
+ {accessReady ?
void retry()}>Retry task
+ : canManage ?
Manage project access
+ :
View agent access }
+
+ {retryError && Couldn’t start the retry. Project access is saved. Try again.
}
+ {!accessReady && Run details
+ Stopped during Workspace setup Reason Project outside allowed scope Provider started No
+ }
+ }
+ {run === "replied" &&
View saved project access }
+
;
+
+ const permissions =
+
setView("task")} disabled={saving}> Back to task
+
Reviewer Permissions / Trust
+
+
+ Low-trust review Runs in an isolated sandbox. Untrusted output stays quarantined.
+
+
+
+
Allowed projects
+
Projects whose tasks and repositories this agent may access.
+
+ {canManage &&
+ Add project
+
+ setQuery(event.target.value)} />
+ {projects.filter(project => `${project.name} ${project.repository}`.toLowerCase().includes(query.toLowerCase())).map(project =>
+ { setDraft(current => checked ? [...new Set([...current, project.id])] : current.filter(id => id !== project.id)); setSavedNotice(false); }} />
+ {project.name} {allowed.includes(project.id) ? "Already allowed" : project.repository}
+ )}
+ {!projects.some(project => `${project.name} ${project.repository}`.toLowerCase().includes(query.toLowerCase())) && No projects found.
}
+ setPickerOpen(false)}>Done
+ }
+
+
+ {projects.filter(project => draft.includes(project.id)).map(project =>
+ {project.name}
{project.repository}
+ {allowed.includes(project.id) ? Allowed
+ : <>To add setDraft(current => current.filter(id => id !== project.id))}> >}
+ )}
+
+ {!accessReady && DEMO-42 is waiting for access to Agent Extensions .
}
+ {!canManage && Only people with agent configuration permission can change this access.
}
+ {added.length > 0 &&
+
Add access to {added.map(project => project.name).join(" and ")}
+
Reviewer will be able to access work and repositories in {added.length === 1 ? "this project" : "these projects"}. Existing project access and low-trust restrictions stay in place.
+
}
+ {saveError && Couldn’t save project access. No permissions changed. Your selection is kept.
}
+ {savedNotice &&
+
Project access saved.
+
setView("task")}>Return to task
+
}
+ {canManage &&
+ { setDraft(allowed); setSaveError(false); setView("task"); }}>Cancel
+ void save()}>{saving && }{saving ? "Saving access…" : saveError ? "Try saving again" : "Save project access"}
+
}
+
+
;
+
+ return
+
setShellNotice("This preview focuses on project access. Use Manage project access to try the repair.")} />}
+ headerActions={Design preview }>
+ {shellNotice && {shellNotice}
}
+ {view === "task" ? task : permissions}
+
+
;
+}
+
+export function ProjectAccessProposal(props: ProjectAccessProposalProps) {
+ return ;
+}