Merge remote-tracking branch 'origin/master' into codex/runner-hosted-admission

* origin/master:
  fix(runner): preserve verified remote artifacts across controller releases (#17512)
  fix: explain project access denials and add an authorized repair path (#17509)
  Add bounded database response size diagnostics (#17504)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-24 17:21:17 -07:00
commit d0c6fd902f
54 files changed
+2111 -114

No files matched your search

+25
View File
@@ -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=<start>/<end>` 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
+9
View File
@@ -891,6 +891,15 @@ grant privileged tools. Backlog assignments retain the existing human requester
without starting a run. Reassignment transactionally cancels prior human requests,
including service/plugin writes; cancelled runs cannot authorize later retries. See `doc/LOW-TRUST-PRESETS.md` for containment details.
Project or repository access is a separate boundary from exact-task direction.
A workspace project denial appears on the task with a link to the agent's
Permissions / Trust settings. Board operators with `agents:create` can add
same-company projects through `POST /api/agents/:id/project-access`; the operation
locks the agent, preserves existing permissions and audits added projects.
Saving does not retry work. The task offers explicit Retry after the required
project is allowed, and dispatch rechecks authorization. Existing setup failures
with the exact project-boundary denial also receive the explanation on reload.
Cross-issue writes are contained per heartbeat run. An agent-authored comment
may wake the target assignee, including an explicit `resume: true` comment on a
`done` or `cancelled` issue, but the wake remains agent-class and is subject to
+5 -1
View File
@@ -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.
+23 -1
View File
@@ -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
+6
View File
@@ -104,6 +104,12 @@ Review: `baseline`.
API: `present` — representative operation IDs:
- `GET /api/agents/{id}/instructions-bundle/file`.
- `GET /api/agents/{id}/project-access` and `POST /api/agents/{id}/project-access`
are board-only. The POST requires `agents:create`, checks selected projects in
the same company, and adds IDs under an agent row lock. It preserves existing
containment, audits new grants and rejects pending-approval agents. Agent keys
cannot use this route to grant themselves access; a read-only board viewer
receives `canManage: false`. Saving never creates a run or wakeup.
- `PUT /api/agents/{id}/instructions-bundle/file`.
- `PATCH /api/agents/{id}` with an explicit `runner: "paperclip"` upgrade.
- `POST /api/agents/{id}/qualify-runner` to qualify the saved effective sandbox before upgrading.
+6
View File
@@ -41,6 +41,12 @@ existing preparation and verification lifecycle.
Open `/agents/:agentId` and the configuration/instruction surfaces it exposes.
For a low-trust agent, open **Permissions / Trust → Allowed projects**. An operator
with agent-management permission can select additional projects and explicitly
save them. Existing projects and other containment restrictions remain intact.
Cancel discards the draft; a failed save retains it. Reload to verify persistence.
This does not start or retry a task.
In `/agents/all`, select roster checkboxes and choose **Edit connection & model**.
A mixed selection offers one action per harness. Each field is opt-in. The editor
previews each changed value and excludes agents already at the target value.
+14
View File
@@ -48,6 +48,12 @@ Open the affected task from Tasks, Inbox/Blocked, search, or a direct task link.
Observe one working, backoff, failed or waiting-for-user state. Use Retry after
an infrastructure failure. No process or file certification is required.
A workspace project denial names the blocked access and links to the agent's
**Permissions / Trust** settings. After an authorized person adds the required
project, return to the saved task for an explicit **Retry task** action. Saving
permissions alone does not wake the agent. Historical setup denials receive the
same notice on reload; unrelated errors and prior assignees do not.
Thinking appears immediately after Retry, before the request completes.
Technical source/run details remain in deliberate diagnostics. The
[task recovery contract](../../doc/task-recovery.md) describes receipt identity,
@@ -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.
+2
View File
@@ -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));
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
const clients: Array<ReturnType<typeof createDb>> = [];
const captures: Array<ReturnType<typeof createDatabaseResponseCapture>> = [];
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<Record<string, unknown>> = [];
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<Record<string, unknown>>) { 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<typeof f.db, "select">, 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<string, unknown>);
expect(summary(events)).toMatchObject({ startedQueries: 1, completedQueries: 1 });
expect(logs.join("\n")).not.toContain("dedicated-value");
expect(logs.join("\n")).not.toContain(temporary.connectionString);
});
});
+133
View File
@@ -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<ReturnType<typeof createDatabaseResponseCapture>> = [];
afterEach(() => { for (const capture of captures.splice(0)) capture.flush(); vi.unstubAllEnvs(); });
function fixture(emitOverride?: (event: Record<string, unknown>) => void) {
let clock = epoch;
const events: Array<Record<string, unknown>> = [];
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<string, unknown> = {}; 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<unknown[]>(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<unknown[]>(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 });
});
});
+273
View File
@@ -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<string, unknown>;
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<string, Entry>();
const roots = new WeakSet<object>(), queries = new WeakSet<object>(), factories = new WeakSet<object>();
const clients = new WeakSet<object>(), driverQueries = new WeakSet<object>();
const scope = new AsyncLocalStorage<Metadata>();
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<typeof createDatabaseResponseCapture>;
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<T extends object>(db: T, schema: Record<string, unknown>, 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<string, unknown> => value !== null && typeof value === "object";
function replace(target: Record<string, unknown>, 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<unknown>;
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<string, unknown>;
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<string, unknown>).$client); root(db); } catch {}
return db;
}
+2
View File
@@ -889,6 +889,7 @@ export type {
AgentDetail,
ClearAgentErrorResponse,
AgentPermissions,
AgentProjectAccess,
AgentPublicIdentity,
AgentInstructionsBundleMode,
AgentInstructionsFileSummary,
@@ -1882,6 +1883,7 @@ export {
qualifyAgentRunnerSchema,
agentPermissionsSchema,
updateAgentPermissionsSchema,
addAgentProjectAccessSchema,
type CreateAgent,
type BuiltInAgentProvision,
type BuiltInAgentReset,
+5
View File
@@ -25,6 +25,11 @@ export interface AgentPermissions extends Record<string, unknown> {
authorizationPolicy?: TrustAuthorizationPolicy;
}
export interface AgentProjectAccess {
projectIds: string[];
canManage: boolean;
}
export type AgentRuntimeConfig = Record<string, unknown> & {
aiConnection?: import("../ai-connection-router.js").AiRuntimeConnectionBinding;
};
@@ -104,7 +104,9 @@ export interface TaskInterruptionPresentation {
state: "working" | "retry_scheduled" | "failed" | "waiting_for_user" | "resolved";
nextRetryAt: string | null;
canRetry: boolean;
userAction: "approval" | "reconnect" | "assign_agent" | "choose_model" | "resume" | "budget" | null;
userAction: "approval" | "reconnect" | "assign_agent" | "choose_model" | "resume" | "budget" | "project_access" | null;
/** Only the task's own project id is exposed. Other source projects stay unnamed. */
projectAccess?: { agentId: string; projectId: string | null; ready: boolean };
sourceRunIds: string[];
observedRunId: string | null;
}
+1
View File
@@ -280,6 +280,7 @@ export type {
AgentDetail,
ClearAgentErrorResponse,
AgentPermissions,
AgentProjectAccess,
AgentPublicIdentity,
AgentRuntimeConfig,
AgentInstructionsBundleMode,
+4
View File
@@ -322,6 +322,10 @@ export const updateAgentPermissionsSchema = z.object({
export type UpdateAgentPermissions = z.infer<typeof updateAgentPermissionsSchema>;
export const addAgentProjectAccessSchema = z.object({
projectIds: z.array(z.string().uuid()).min(1).max(100),
}).strict();
export const resolveAgentInstructionCandidateSchema = z.object({
baseRevisionId: z.string().uuid().nullable(),
content: z.string().max(1024 * 1024),
+1
View File
@@ -370,6 +370,7 @@ export {
qualifyAgentRunnerSchema,
agentPermissionsSchema,
updateAgentPermissionsSchema,
addAgentProjectAccessSchema,
type CreateAgent,
type BuiltInAgentProvision,
type BuiltInAgentReset,
@@ -0,0 +1,107 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, agentWakeupRequests, activityLog, authUsers, companies, companyMemberships, createDb,
heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { registerAgentProjectAccessRoutes } from "../routes/agent-project-access.js";
import { errorHandler } from "../middleware/error-handler.js";
import { assertTaskWorkspaceSourceProjectAccess } from "../services/task-workspace-access.js";
import { taskInterruptionPresentation } from "../services/task-interruption-presentation.js";
import { ProjectAccessDeniedError } from "../services/project-access-denial.js";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
describe("additive project access", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
if (external) db = createDb(external);
else { database = await startEmbeddedPostgresTestDatabase("agent-project-access-"); db = createDb(database.connectionString); }
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
async function seed() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Project access", issuePrefix: `A${companyId.slice(0, 6)}` });
const selected = await db.insert(projects).values(["Existing", "Needed", "Third"].map(name => ({ companyId, name }))).returning();
const permissions = { canCreateAgents: false, canCreateSkills: false, trustPreset: "low_trust_review",
authorizationPolicy: { reviewPreset: { id: "low_trust_review", version: 1, rawOutputDisposition: "quarantine" },
customPolicy: { preserve: true }, trustBoundary: { mode: "low_trust_review", companyId,
projectIds: [selected[0]!.id], issueIds: [issueId], allowedToolClasses: ["git.read"], allowedSecretBindingIds: [] } } };
await db.insert(agents).values({ id: agentId, companyId, name: "Reviewer", adapterType: "process", status: "idle", permissions });
await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" });
const [issue] = await db.insert(issues).values({ id: issueId, companyId, title: "Saved message", status: "blocked", assigneeAgentId: agentId, projectId: selected[1]!.id }).returning();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, status: "failed", errorCode: "setup_failed",
error: "Project is outside this low-trust boundary.", contextSnapshot: { issueId } });
return { companyId, agentId, issue: issue!, runId, selected, permissions };
}
function app(actor: express.Request["actor"]) {
const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); });
registerAgentProjectAccessRoutes(app, db); app.use(errorHandler); return app;
}
const board = { type: "board" as const, source: "local_implicit" as const };
it("preserves containment, audits concurrent additions, and changes the persisted denial into an explicit retry", async () => {
const f = await seed();
const actor = { type: "agent" as const, source: "agent_key" as const, agentId: f.agentId, companyId: f.companyId };
await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, f.companyId, f.selected[1]!.id)).rejects.toBeInstanceOf(ProjectAccessDeniedError);
expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ userAction: "project_access", canRetry: false });
const api = app(board);
const results = await Promise.all(f.selected.slice(1).map(project => request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [project.id] })));
expect(results.map(result => result.status)).toEqual([200, 200]);
const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId));
expect(saved!.permissions).toEqual({ ...f.permissions, authorizationPolicy: { ...f.permissions.authorizationPolicy,
trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, projectIds: expect.arrayContaining(f.selected.map(project => project.id)) } } });
expect((saved!.permissions as typeof f.permissions).authorizationPolicy.trustBoundary.projectIds).toHaveLength(3);
await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, f.companyId, f.selected[1]!.id)).resolves.toBeUndefined();
expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
const again = await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] });
expect(again.status).toBe(200);
expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.action, "agent.project_access_added")))).toHaveLength(2);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(0);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
});
it("rejects foreign projects, agent self-grants, unknown policy fields, and pending-approval agents", async () => {
const f = await seed(), other = await seed(), api = app(board);
expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [other.selected[1]!.id] })).status).toBe(404);
expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id], trustPreset: "standard" })).status).toBe(400);
expect((await request(app({ type: "agent", agentId: f.agentId, companyId: f.companyId, source: "agent_key" }))
.post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403);
await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agentId));
expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(409);
const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId)); expect(saved!.permissions).toEqual(f.permissions);
});
it.each(["standard", "root_issue"])("rechecks actual project authority after a %s repair and acknowledges an accepted retry", async repair => {
const f = await seed();
const permissions = repair === "standard" ? { canCreateAgents: false, trustPreset: "standard", authorizationPolicy: {} }
: { ...f.permissions, authorizationPolicy: { ...f.permissions.authorizationPolicy,
trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, rootIssueId: f.issue.id } } };
await db.update(agents).set({ permissions }).where(eq(agents.id, f.agentId));
expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.agentId,
source: "on_demand", status: "deferred_issue_execution", requestedByActorType: "user", payload: { issueId: f.issue.id } });
const accepted = await taskInterruptionPresentation(db, f.issue);
expect(accepted).toMatchObject({ state: "working", canRetry: false });
expect(accepted).not.toHaveProperty("projectAccess");
// Current project policy remains authoritative even after agent access changes.
await db.update(projects).set({ executionWorkspacePolicy: { trustPreset: "low_trust_review", authorizationPolicy: {
trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.selected[0]!.id] },
} } }).where(eq(projects.id, f.selected[1]!.id));
if (repair === "standard") expect(await taskInterruptionPresentation(db, f.issue))
.toMatchObject({ canRetry: false, userAction: "project_access" });
});
it("shows read-only access to viewers and denies their saves and cross-company reads", async () => {
const f = await seed(), other = await seed(), userId = randomUUID();
await db.insert(authUsers).values({ id: userId, name: "Viewer", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, membershipRole: "viewer", status: "active" });
const api = app({ type: "board", source: "session", userId, companyIds: [f.companyId], memberships: [{ companyId: f.companyId, membershipRole: "viewer", status: "active" }] });
expect((await request(api).get(`/agents/${f.agentId}/project-access`)).body).toMatchObject({ canManage: false });
expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403);
expect((await request(api).get(`/agents/${other.agentId}/project-access`)).status).toBe(404);
});
});
@@ -21,6 +21,7 @@ const apiPrefixes: Record<string, string> = {
"activity.ts": "/api",
"adapters.ts": "/api",
"agents.ts": "/api",
"agent-project-access.ts": "/api",
"agent-commentary.ts": "/api",
"agent-avatars.ts": "/api",
"agent-profile-avatar.ts": "/api",
@@ -987,6 +988,21 @@ describe("openapi routes", () => {
});
});
it("documents additive project access as board-only with a strict request", () => {
const { spec } = loadSpecRoutes();
const path = spec.paths["/api/agents/{id}/project-access"];
for (const operation of [path.get, path.post]) {
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(operation.responses["200"].content["application/json"].schema.required).toEqual(["projectIds", "canManage"]);
}
expect(path.post.requestBody.content["application/json"].schema).toMatchObject({
additionalProperties: false, required: ["projectIds"],
properties: { projectIds: { type: "array", items: { type: "string", format: "uuid" } } },
});
expect(path.post.responses["409"]).toBeDefined();
});
it("documents the authenticated personal primary-agent contract", () => {
const { spec } = loadSpecRoutes();
const path = spec.paths["/api/companies/{companyId}/primary-agent/me"];
+25
View File
@@ -0,0 +1,25 @@
import type { Router } from "express";
import { agents, type Db } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { addAgentProjectAccessSchema } from "@paperclipai/shared";
import { notFound } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { addAgentProjectAccess, readAgentProjectAccess } from "../services/agent-project-access.js";
import { assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js";
export function registerAgentProjectAccessRoutes(router: Router, db: Db) {
router.get("/agents/:id/project-access", async (req, res) => {
assertBoard(req);
const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, req.params.id as string));
if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found");
assertCompanyAccess(req, agent.companyId);
res.json(await readAgentProjectAccess(db, req.actor, agent.companyId, agent.id));
});
router.post("/agents/:id/project-access", validate(addAgentProjectAccessSchema), async (req, res) => {
assertBoard(req);
const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, req.params.id as string));
if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found");
assertCompanyAccess(req, agent.companyId);
res.json(await addAgentProjectAccess(db, req.actor, agent.companyId, agent.id, req.body.projectIds));
});
}
+3
View File
@@ -98,6 +98,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { trackAgentCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { registerAgentProjectAccessRoutes } from "./agent-project-access.js";
import { inheritNativeRunnerAdapterConfig } from "../services/native-runtime/native-agent-runtime-inheritance.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
@@ -4918,6 +4919,8 @@ export function agentRoutes(
res.status(201).json({ ...redactAgentRowForResponse(agent), ...(configurationWarnings.length ? { configurationWarnings } : {}) });
});
registerAgentProjectAccessRoutes(router, db);
router.patch("/agents/:id/permissions", validate(updateAgentPermissionsSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
+23
View File
@@ -48,6 +48,7 @@ import {
updateAgentSchema,
updatePrimaryAgentSchema,
updateAgentPermissionsSchema,
addAgentProjectAccessSchema,
updateAgentInstructionsPathSchema,
updateAgentInstructionsBundleSchema,
upsertAgentInstructionsFileSchema,
@@ -1390,6 +1391,8 @@ const browserUseOperations = [
] as const;
const BOARD_ONLY_OPERATIONS = new Set([
"GET /api/agents/{id}/project-access",
"POST /api/agents/{id}/project-access",
"POST /api/issues/{id}/interactions/{interactionId}/dismiss",
"POST /api/issues/{id}/retry",
"GET /api/companies/{companyId}/prompt-injection-prevention",
@@ -3790,6 +3793,26 @@ registry.registerPath({
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
const agentProjectAccessResponse = z.object({ projectIds: z.array(z.string().uuid()), canManage: z.boolean() });
registry.registerPath({
method: "get",
path: "/api/agents/{id}/project-access",
tags: ["agents"],
summary: "Read an agent's allowed projects and the current operator's management capability",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(agentProjectAccessResponse), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "post",
path: "/api/agents/{id}/project-access",
tags: ["agents"],
summary: "Add allowed projects without replacing an agent's other permissions",
description: "Requires board access and agents:create in the agent's company. Accepts 1 to 100 project IDs. Selected projects must be readable and in that company. Locks the low-trust agent, preserves existing containment, audits new additions, and rejects pending-approval agents or changed trust settings. Repeated additions are idempotent. Saving does not start or retry a task.",
request: { params: z.object({ id: z.string() }), body: jsonBody(addAgentProjectAccessSchema) },
responses: { 200: r.ok(agentProjectAccessResponse), 400: r.badRequest, 401: r.unauthorized,
403: r.forbidden, 404: r.notFound, 409: r.conflict },
});
registry.registerPath({
method: "patch",
path: "/api/agents/{id}/instructions-path",
@@ -0,0 +1,69 @@
import { and, eq, inArray } from "drizzle-orm";
import { agents, projects, type Db } from "@paperclipai/db";
import { trustAuthorizationPolicySchema, type AgentProjectAccess } from "@paperclipai/shared";
import { conflict, forbidden, notFound } from "../errors.js";
import { normalizeAgentPermissions } from "../lib/agent-permissions.js";
import { accessService } from "./access.js";
import type { AuthorizationActor } from "./authorization.js";
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
export async function readAgentProjectAccess(db: Db, actor: AuthorizationActor, companyId: string, agentId: string): Promise<AgentProjectAccess> {
const [agent] = await db.select({ permissions: agents.permissions, status: agents.status }).from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
if (!agent) throw notFound("Agent not found");
const decision = await accessService(db).decide({ actor, action: "agents:create", resource: { type: "company", companyId } });
const permissions = normalizeAgentPermissions(agent.permissions);
const policy = trustAuthorizationPolicySchema.safeParse(permissions.authorizationPolicy ?? {});
const boundary = policy.success ? policy.data.trustBoundary : undefined;
return { projectIds: policy.success ? policy.data.trustBoundary?.projectIds ?? [] : [],
canManage: actor.type === "board" && decision.allowed && agent.status !== "pending_approval"
&& policy.success && permissions.trustPreset === "low_trust_review"
&& (!boundary || (boundary.mode === "low_trust_review" && (!boundary.companyId || boundary.companyId === companyId))) };
}
/** An additive operation under the agent row lock, never a stale policy replacement. */
export async function addAgentProjectAccess(db: Db, actor: AuthorizationActor, companyId: string, agentId: string, projectIds: string[]) {
if (actor.type !== "board") throw forbidden("Board access required");
const publications: ActivityPublication[] = [];
const result = await db.transaction(async tx => {
const [agent] = await tx.select().from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update");
if (!agent) throw notFound("Agent not found");
const transactionDb = tx as unknown as Db;
const access = accessService(transactionDb);
const decision = await access.decide({ actor, action: "agents:create", resource: { type: "company", companyId } });
if (!decision.allowed) throw forbidden(decision.explanation);
if (agent.status === "pending_approval") throw conflict("Pending approval agent permissions cannot be changed before board approval");
const permissions = normalizeAgentPermissions(agent.permissions);
const parsedPolicy = trustAuthorizationPolicySchema.safeParse(permissions.authorizationPolicy ?? {});
if (!parsedPolicy.success) throw conflict("The agent’s trust settings need review before adding project access.");
const policy = parsedPolicy.data;
const boundary = policy?.trustBoundary;
if (permissions.trustPreset !== "low_trust_review" || (boundary && (boundary.mode !== "low_trust_review"
|| (boundary.companyId && boundary.companyId !== companyId)))) {
throw conflict("The agent’s trust settings changed. Refresh before adding project access.");
}
const requested = [...new Set(projectIds)];
const rows = await tx.select({ id: projects.id }).from(projects)
.where(and(eq(projects.companyId, companyId), inArray(projects.id, requested)));
if (rows.length !== requested.length) throw notFound("A selected project is unavailable");
for (const project of rows) {
const projectDecision = await access.decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId: project.id } });
if (!projectDecision.allowed) throw forbidden("A selected project is unavailable");
}
const previous = boundary?.projectIds ?? [];
const added = requested.filter(id => !previous.includes(id));
const next = [...previous, ...added];
if (added.length) {
await tx.update(agents).set({ permissions: { ...agent.permissions, authorizationPolicy: {
...policy, trustBoundary: { ...boundary, mode: "low_trust_review", companyId, projectIds: next },
} }, updatedAt: new Date() }).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)));
await logActivity(transactionDb, { companyId, actorType: "user", actorId: actor.userId ?? "local-board",
action: "agent.project_access_added", entityType: "agent", entityId: agentId,
details: { addedProjectIds: added, trustPreset: "low_trust_review" } }, publications);
}
return { projectIds: next, canManage: true } satisfies AgentProjectAccess;
});
for (const publication of publications) publishActivity(publication);
return result;
}
@@ -9,6 +9,7 @@ import { createAgentIdentityRedactor } from "../agent-identity-redaction.js";
import { UnresolvedWorkspaceBaseRefError } from "../workspace-runtime.js";
import { LegacyControllerLeaseLostError } from "../legacy-controller-lease.js";
import { requestedRunCancellation } from "../run-cancellation.js";
import { ProjectAccessDeniedError } from "../project-access-denial.js";
import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from "./run-lifecycle.js";
import { createHeartbeatRunPreparation, ConfigurationIncompleteFailure } from "./run-preparation.js";
import { createHeartbeatRunState, getAdapterSessionCodec } from "./run-state.js";
@@ -77,15 +78,19 @@ it("constructs completion handlers without querying or starting work", () => {
});
const support = await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("heartbeat run completion boundary", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)("heartbeat run completion boundary", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: Db;
beforeAll(async () => {
// Keep the accounting transaction real without reading provider receipt files.
vi.spyOn(usageReceipts, "indexPendingUsageReceipts").mockResolvedValue(new Map());
vi.spyOn(usageReceipts, "recoverPendingRunUsageReceipts").mockResolvedValue([]);
database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-completion");
db = createDb(database.connectionString);
const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
if (external) db = createDb(external);
else {
database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-completion");
db = createDb(database.connectionString);
}
await db.execute(sql`set client_min_messages = warning`);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); vi.restoreAllMocks(); });
@@ -334,6 +339,18 @@ describe.skipIf(!support.supported)("heartbeat run completion boundary", () => {
expect(f.deps.releaseIssueExecutionAndPromote).toHaveBeenCalledWith(expect.objectContaining({ id: f.run.id }), { suppressImmediateRecovery: true });
});
it("persists a project denial without provider work or an automatic retry", async () => {
const f = await fixture();
const projectId = randomUUID();
await f.completion.failRunSetup(f.setup(new ProjectAccessDeniedError(projectId)));
expect(await read(f.run.id)).toMatchObject({ status: "failed", errorCode: "project_access_denied",
resultJson: { projectAccessDenied: { projectId }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } });
expect(f.deps.scheduleBoundedRetryForRun).not.toHaveBeenCalled();
expect(f.deps.enqueueWakeup).not.toHaveBeenCalled();
expect(f.deps.scheduleInteractionContinuationInfrastructureRetryIfEligible).not.toHaveBeenCalled();
expect(f.deps.finalizeIssueCommentPolicy).not.toHaveBeenCalled();
});
it("records a setup configuration gap without inventing provider work or applying comment policy", async () => {
const f = await fixture();
await f.completion.failRunSetup(f.setup(new ConfigurationIncompleteFailure("missing credential", { configurationIncomplete: { reason: "missing_binding" } })));
+17 -10
View File
@@ -1,4 +1,5 @@
import { measureRunPhase } from "../execution-preparation-timing.js";
import { PROJECT_ACCESS_DENIED, ProjectAccessDeniedError } from "../project-access-denial.js";
import { pendingCaseHandoffForRun } from "./case-handoff-lifecycle.js";
import { NON_RETRYABLE_PREFLIGHT_FAILURE_CODES, isHeartbeatRunTerminalStatus } from "./run-lifecycle.js";
import { ChatControlRecoveryUnresolvedError } from "./queue.js";
@@ -145,6 +146,7 @@ export interface FailHeartbeatRunSetupInput {
}
function nonRetryablePreflightFailureCode(error: unknown): string | null {
if (error instanceof ProjectAccessDeniedError) return PROJECT_ACCESS_DENIED;
if (error instanceof ChatControlRecoveryUnresolvedError)
return CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE;
if (
@@ -1344,6 +1346,8 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
// action, so it is persisted even when the agent lookup failed and the
// agent-scoped stop metadata cannot be merged in.
const setupFailureDetails =
(outerErr instanceof ProjectAccessDeniedError
? { projectAccessDenied: { projectId: outerErr.projectId } } : null) ??
(workspaceGitScanFailure ? {
workspaceGitScan: {
code: workspaceGitScanFailure.code,
@@ -1453,7 +1457,8 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
).catch(() => undefined);
if (
!isWorkspaceValidationFailedRun(livenessRun) &&
!isConfigurationIncompleteFailedRun(livenessRun)
!isConfigurationIncompleteFailedRun(livenessRun) &&
livenessRun.errorCode !== PROJECT_ACCESS_DENIED
) {
await finalizeIssueCommentPolicy(livenessRun, failedAgent).catch(
() => undefined,
@@ -1462,15 +1467,17 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
// No provider work began. Retry temporary host scan failures with
// the existing durable failure budget, before releasing execution.
// Generic recovery must not grant a second budget on exhaustion.
await (isTransientWorkspaceGitScanCode(livenessRun.errorCode)
? scheduleBoundedRetryForRun(livenessRun, failedAgent)
: scheduleInteractionContinuationInfrastructureRetryIfEligible(livenessRun, failedAgent)
).catch((retryError) => {
logger.warn(
{ err: retryError, runId: livenessRun.id },
"failed to schedule interaction continuation retry after setup failure",
);
});
if (livenessRun.errorCode !== PROJECT_ACCESS_DENIED) {
await (isTransientWorkspaceGitScanCode(livenessRun.errorCode)
? scheduleBoundedRetryForRun(livenessRun, failedAgent)
: scheduleInteractionContinuationInfrastructureRetryIfEligible(livenessRun, failedAgent)
).catch((retryError) => {
logger.warn(
{ err: retryError, runId: livenessRun.id },
"failed to schedule interaction continuation retry after setup failure",
);
});
}
}
await releaseIssueExecutionAndPromote(livenessRun, {
suppressImmediateRecovery:
@@ -244,6 +244,7 @@ const LIVENESS_BOOKKEEPING_EVENT_TYPES = [
];
export const NON_RETRYABLE_PREFLIGHT_FAILURE_CODES = new Set<string>([
"project_access_denied",
"low_trust_isolation_unavailable",
"low_trust_requires_isolated_workspace",
"low_trust_boundary_mismatch",
@@ -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 });
@@ -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]<minimum[0]||(version[0]===minimum[0]&&(version[1]<minimum[1]||(version[1]===minimum[1]&&version[2]<minimum[2]))))throw new Error('Node version incompatible')",
"if(process.platform!==manifest.payload.target.platform||process.arch!==manifest.payload.target.architecture)throw new Error('provider pack target mismatch')",
"const packageVersion=(pkg)=>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 };
}
@@ -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");
});
@@ -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<T extends ProviderPackIdentity>(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<void>,
): Promise<boolean> {
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;
}
}
@@ -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<void> {
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]<minimum[0]||(version[0]===minimum[0]&&(version[1]<minimum[1]||(version[1]===minimum[1]&&version[2]<minimum[2]))))throw new Error('Node version incompatible')",
"if(process.platform!==manifest.payload.target.platform||process.arch!==manifest.payload.target.architecture)throw new Error('provider pack target mismatch')",
"const packageVersion=(pkg)=>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",
);
}
}
@@ -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);
@@ -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})`);
@@ -0,0 +1,10 @@
import { HttpError } from "../errors.js";
export const PROJECT_ACCESS_DENIED = "project_access_denied";
/** Carries a safe, typed setup failure without changing the authorization decision. */
export class ProjectAccessDeniedError extends HttpError {
constructor(readonly projectId: string) {
super(403, "Project is outside this low-trust boundary.");
}
}
@@ -10,6 +10,10 @@ import { legacyExecutionNeedsReconciliation } from "../legacy-execution-recovery
import { isConfigurationIncompleteFailedRun } from "../../modules/wake-queue/domain/values.js";
describe("classifyAdapterFailureForRecovery", () => {
it("does not automatically replay a project access denial", () => {
expect(classifyContinuationFailure({ errorCode: "project_access_denied", resultJson: null } as never))
.toMatchObject({ kind: "non_retryable", maxAttempts: 0 });
});
it("waits for model configuration changes instead of retrying a typed native model rejection", () => {
const run = { errorCode: "native_provider_model_rejected", error: "Fixed model configuration message", resultJson: null };
expect(classifyAdapterFailureForRecovery(run)).toEqual({ kind: "configuration_incomplete" });
+1
View File
@@ -516,6 +516,7 @@ const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set<string>([
]);
const NON_RETRYABLE_CONTINUATION_ERROR_CODES = new Set<string>([
"project_access_denied",
"native_provider_model_rejected",
"provider_tool_definition_invalid",
"adapter_engine_unavailable",
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { projectTaskInterruption } from "./task-interruption-presentation.js";
const issue = { id: "task", companyId: "11111111-1111-4111-8111-111111111111", status: "blocked", assigneeAgentId: "agent", projectId: "22222222-2222-4222-8222-222222222222" };
const run = { id: "run", agentId: "agent", status: "failed", scheduledRetryAt: null,
errorCode: "project_access_denied", error: null, livenessState: null,
resultJson: { projectAccessDenied: { projectId: "22222222-2222-4222-8222-222222222222" } } };
const agent = { status: "idle", pauseReason: null };
describe("task project-access interruption", () => {
it("explains a project denial without offering a futile retry", () => {
expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: false })).toMatchObject({
state: "waiting_for_user", responsibility: "user", userAction: "project_access", canRetry: false,
projectAccess: { agentId: "agent", projectId: "22222222-2222-4222-8222-222222222222", ready: false },
});
});
it("projects the historical setup denial without requiring another failed run", () => {
expect(projectTaskInterruption(issue, { run: { ...run, errorCode: "setup_failed",
error: "Project is outside this low-trust boundary.", resultJson: null }, agent, paused: false, approval: false }))
.toMatchObject({ userAction: "project_access", canRetry: false });
});
it("offers an explicit retry after the project is added, without changing the failed run", () => {
expect(projectTaskInterruption(issue, { run, agent, projectAccessAllowed: true, paused: false, approval: false }))
.toMatchObject({ state: "failed", canRetry: true, projectAccess: { ready: true } });
expect(run.status).toBe("failed");
});
it("shows an accepted retry as working after access is repaired", () => {
const input = { run, agent, paused: false, approval: false, wait: { id: "request", requestedAt: new Date() } };
expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true }))
.toMatchObject({ state: "working", canRetry: false });
expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true })).not.toHaveProperty("projectAccess");
expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: false }))
.toMatchObject({ state: "waiting_for_user", userAction: "project_access", canRetry: false });
});
it("does not reinterpret unrelated failures or an old assignee's denial", () => {
for (const otherRun of [{ ...run, errorCode: "setup_failed", error: "Disk full", resultJson: null },
{ ...run, agentId: "previous-agent" }]) {
expect(projectTaskInterruption(issue, { run: otherRun, agent, paused: false, approval: false }))
.not.toHaveProperty("projectAccess");
}
});
it("keeps approval, pause, running and completed states authoritative", () => {
expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: true })).toMatchObject({ userAction: "approval" });
expect(projectTaskInterruption(issue, { run, agent, paused: true, approval: false })).toMatchObject({ userAction: "resume" });
expect(projectTaskInterruption(issue, { run: { ...run, status: "running" }, agent, paused: false, approval: false }))
.toMatchObject({ state: "working" });
expect(projectTaskInterruption({ ...issue, status: "done" }, { run, agent, paused: false, approval: false }))
.toMatchObject({ state: "resolved" });
});
});
@@ -4,21 +4,35 @@ import { agents, agentWakeupRequests, approvals, companies, heartbeatRuns, issue
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
import { isAiAuthenticationBlocked } from "./ai-auth-failure.js";
import { readSystemRecovery } from "./system-recovery-state.js";
import { PROJECT_ACCESS_DENIED } from "./project-access-denial.js";
import { authorizationService } from "./authorization.js";
type ProjectionIssue = Pick<typeof issues.$inferSelect, "id" | "companyId" | "status" | "assigneeAgentId" | "projectId">;
type ProjectionRun = Pick<typeof heartbeatRuns.$inferSelect,
"id" | "status" | "scheduledRetryAt" | "errorCode" | "livenessState" | "resultJson">;
"id" | "status" | "scheduledRetryAt" | "errorCode" | "livenessState" | "resultJson"> &
Partial<Pick<typeof heartbeatRuns.$inferSelect, "agentId" | "error">>;
type ProjectionState = {
run?: ProjectionRun;
action?: typeof issueRecoveryActions.$inferSelect;
wait?: { id: string; requestedAt: Date };
agent?: Pick<typeof agents.$inferSelect, "status" | "pauseReason">;
projectAccessAllowed?: boolean;
project?: Pick<typeof projects.$inferSelect, "pausedAt" | "pauseReason">;
company?: Pick<typeof companies.$inferSelect, "status" | "pauseReason">;
paused: boolean;
approval: boolean;
};
/** Recognize old persisted failures without displaying arbitrary run error text. */
function deniedProjectId(issue: ProjectionIssue, run?: ProjectionRun): string | null | undefined {
const historical = run?.errorCode === "setup_failed" && run.error === "Project is outside this low-trust boundary.";
if (!run || run.status !== "failed" || run.agentId !== issue.assigneeAgentId
|| (run.errorCode !== PROJECT_ACCESS_DENIED && !historical)) return undefined;
const detail = run.resultJson?.projectAccessDenied;
return detail && typeof detail === "object" && "projectId" in detail && typeof detail.projectId === "string"
? detail.projectId : historical ? issue.projectId : null;
}
/** Presentation never grants authority. Dispatch repeats all gates under its
* locks. The same projection is used in detail, lists and attention surfaces. */
export function projectTaskInterruption(issue: ProjectionIssue, input: ProjectionState, now = Date.now()): TaskInterruptionPresentation | null {
@@ -36,6 +50,14 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio
? "budget" : input.paused || project?.pausedAt || agent?.status === "paused" || company?.status === "paused" ? "resume" : null;
if (userAction) return { ...base, responsibility: "user", state: "waiting_for_user", userAction };
if (["running", "queued"].includes(run.status)) return { ...base, state: "working" };
const deniedProject = deniedProjectId(issue, run);
let projectAccess: TaskInterruptionPresentation["projectAccess"];
if (deniedProject !== undefined) {
const ready = input.projectAccessAllowed === true;
projectAccess = { agentId: run.agentId!, projectId: deniedProject === issue.projectId ? deniedProject : null, ready };
if (!ready) return { ...base, responsibility: "user", state: "waiting_for_user",
userAction: "project_access", projectAccess };
}
const retryAt = system?.nextAttemptAt ?? run.scheduledRetryAt?.getTime();
if (retryAt && retryAt > now) return { ...base, state: "retry_scheduled",
nextRetryAt: new Date(retryAt).toISOString(), canRetry: true };
@@ -44,7 +66,7 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio
activeObservation > now - 45_000) return { ...base, state: "working" };
if (wait && wait.requestedAt.getTime() > now - 10_000 && system?.state !== "exhausted")
return { ...base, state: "working" };
return { ...base, state: "failed", canRetry: true };
return { ...base, state: "failed", canRetry: true, ...(projectAccess ? { projectAccess } : {}) };
}
/** Batch list reads rather than doing seven queries per row. Keep run logs and
@@ -61,8 +83,12 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
const projectIds = selected.flatMap(issue => issue.projectId ? [issue.projectId] : []);
const [runs, waits, agentRows, projectRows, pauseRows, approvalRows, companyRows] = await Promise.all([
db.selectDistinctOn([runIssue], { issueId: runIssue, id: heartbeatRuns.id, status: heartbeatRuns.status,
agentId: heartbeatRuns.agentId,
error: sql<string | null>`case when ${heartbeatRuns.errorCode} = 'setup_failed'
and ${heartbeatRuns.error} = 'Project is outside this low-trust boundary.' then ${heartbeatRuns.error} else null end`,
errorCode: heartbeatRuns.errorCode, livenessState: heartbeatRuns.livenessState, scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
resultJson: sql<Record<string, unknown>>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete')`,
resultJson: sql<Record<string, unknown>>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete',
'projectAccessDenied', ${heartbeatRuns.resultJson}->'projectAccessDenied')`,
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), inArray(runIssue, ids)))
.orderBy(runIssue, desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)),
db.selectDistinctOn([wakeIssue], { issueId: wakeIssue, id: agentWakeupRequests.id, requestedAt: agentWakeupRequests.requestedAt })
@@ -91,12 +117,32 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
)).orderBy(issueRecoveryActions.sourceIssueId, desc(issueRecoveryActions.createdAt)) : [];
const byIssue = <T extends { issueId: string }>(rows: T[]) => new Map(rows.map(row => [row.issueId, row]));
const runMap = byIssue(runs), waitMap = byIssue(waits);
// Recheck current authority, including standard trust, root-issue grants and
// project policy. Do not reuse the failed run's frozen policy for a new retry.
// Only denied tasks need these reads; deduplicate projects and batch per agent.
const deniedByAgent = new Map<string, Set<string>>();
for (const issue of selected) {
const projectId = deniedProjectId(issue, runMap.get(issue.id));
if (!projectId || !issue.assigneeAgentId || ["done", "cancelled"].includes(issue.status)) continue;
const ids = deniedByAgent.get(issue.assigneeAgentId) ?? new Set<string>();
ids.add(projectId);
deniedByAgent.set(issue.assigneeAgentId, ids);
}
const currentAccess = new Map<string, boolean>();
const authorization = authorizationService(db);
for (const [agentId, ids] of deniedByAgent) {
const projectIds = [...ids];
const decisions = await authorization.decideReads({ type: "agent", agentId, companyId, source: "agent_key" },
projectIds.map(projectId => ({ type: "project" as const, companyId, projectId })));
projectIds.forEach((projectId, index) => currentAccess.set(`${agentId}:${projectId}`, decisions[index]?.allowed === true));
}
const actionMap = new Map(actions.map(action => [action.sourceIssueId, action]));
const agentMap = new Map(agentRows.map(agent => [agent.id, agent]));
const projectMap = new Map(projectRows.map(project => [project.id, project]));
const pauses = new Set(pauseRows.map(row => row.issueId)), approvalsSet = new Set(approvalRows.map(row => row.issueId));
for (const issue of selected) result.set(issue.id, projectTaskInterruption(issue, {
run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id),
projectAccessAllowed: currentAccess.get(`${issue.assigneeAgentId}:${deniedProjectId(issue, runMap.get(issue.id))}`),
agent: agentMap.get(issue.assigneeAgentId ?? ""), project: projectMap.get(issue.projectId ?? ""),
company: companyRows[0], paused: pauses.has(issue.id), approval: approvalsSet.has(issue.id),
}));
+7 -1
View File
@@ -8,6 +8,7 @@ import { canonicalExecutionUuid } from "./execution-identity.js";
import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns, taskRepositoryResponsibleUserMatches } from "./task-repository-acquisition.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { isDeepStrictEqual } from "node:util";
import { ProjectAccessDeniedError } from "./project-access-denial.js";
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection } from "@paperclipai/shared";
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
@@ -19,7 +20,12 @@ export async function assertTaskWorkspaceSourceProjectAccess(
const decision = await measureRunPhase("workspace.access.authorization", () => accessService(db as Db).decide({
actor, action: "project:read", resource: { type: "project", companyId, projectId },
}));
if (!decision.allowed) throw forbidden(decision.explanation);
if (!decision.allowed) {
if (actor.type === "agent" && decision.reason === "deny_low_trust_boundary") {
throw new ProjectAccessDeniedError(projectId);
}
throw forbidden(decision.explanation);
}
}
export async function assertExecutionWorkspaceRecordAccess(
+3
View File
@@ -1,4 +1,5 @@
import type { AgentPublicIdentity } from "@paperclipai/shared";
import type { AgentProjectAccess } from "@paperclipai/shared";
import type {
Agent,
EnvironmentProbeResult,
@@ -111,6 +112,8 @@ function agentPath(id: string, companyId?: string, suffix = "") {
}
export const agentsApi = {
projectAccess: (id: string) => api.get<AgentProjectAccess>(`/agents/${id}/project-access`),
addProjectAccess: (id: string, projectIds: string[]) => api.post<AgentProjectAccess>(`/agents/${id}/project-access`, { projectIds }),
qualifyRunner: (id: string, expectedEnvironmentId: string, companyId?: string) =>
api.post<EnvironmentProbeResult>(agentPath(id, companyId, "/qualify-runner"), { expectedEnvironmentId }),
setAvatar: (companyId: string, agentId: string, imageBase64: string | null) =>
@@ -0,0 +1,34 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { isUuidLike } from "@paperclipai/shared";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { Link, useSearchParams } from "../lib/router";
import { AllowedProjectsEditor } from "./AllowedProjectsEditor";
import { Button } from "./ui/button";
export function AgentProjectAccessSettings({ agent }: { agent: { id: string; companyId: string; name: string; urlKey?: string } }) {
const client = useQueryClient();
const [params] = useSearchParams();
const fromTask = params.get("fromTask");
const access = useQuery({ queryKey: queryKeys.agents.projectAccess(agent.id), queryFn: () => agentsApi.projectAccess(agent.id) });
const projects = useQuery({ queryKey: queryKeys.projects.list(agent.companyId), queryFn: () => projectsApi.list(agent.companyId) });
if (access.error || projects.error) return <div role="alert" className="space-y-2 text-sm">
<p>Couldn’t load project access.</p><Button variant="outline" size="sm" onClick={() => { void access.refetch(); void projects.refetch(); }}>Try again</Button>
</div>;
if (!access.data || !projects.data) return <p role="status" className="text-sm text-muted-foreground">Loading project access…</p>;
return <div className="space-y-4">
<AllowedProjectsEditor key={agent.id} agentName={agent.name} projects={projects.data} projectIds={access.data.projectIds} canManage={access.data.canManage}
onSave={async projectIds => {
const saved = await agentsApi.addProjectAccess(agent.id, projectIds);
client.setQueryData(queryKeys.agents.projectAccess(agent.id), saved);
await Promise.all([
client.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }),
...(agent.urlKey ? [client.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) })] : []),
client.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) }),
client.invalidateQueries({ queryKey: ["issues"] }),
]);
}} />
{fromTask && isUuidLike(fromTask) && <Button variant="outline" size="sm" asChild><Link to={`/issues/${fromTask}`}>Return to task</Link></Button>}
</div>;
}
@@ -0,0 +1,72 @@
import { useState } from "react";
import { Check, FolderOpen, Loader2, Plus, X } from "lucide-react";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
export interface AllowedProjectsEditorProps {
projects: Array<{ id: string; name: string }>;
projectIds: string[];
canManage: boolean;
agentName: string;
onSave: (projectIds: string[]) => Promise<unknown>;
}
export function AllowedProjectsEditor({ projects, projectIds, canManage, agentName, onSave }: AllowedProjectsEditorProps) {
const [added, setAdded] = useState<string[]>([]);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const pending = added.filter(id => !projectIds.includes(id));
const visibleIds = [...new Set([...projectIds, ...pending])];
const name = (id: string) => projects.find(project => project.id === id)?.name ?? "Unavailable project";
const matches = projects.filter(project => project.name.toLowerCase().includes(query.toLowerCase()));
async function save() {
if (!canManage || saving || !pending.length) return;
setSaving(true); setError(null); setSaved(false);
try { await onSave(pending); setAdded([]); setSaved(true); }
catch (failure) { setError(failure instanceof Error ? failure.message : "Couldn’t save project access."); }
finally { setSaving(false); }
}
return <section aria-label="Allowed projects" className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1"><h3 className="text-sm font-semibold">Allowed projects</h3>
<p className="text-sm text-muted-foreground">Projects whose tasks and repositories this agent may access.</p></div>
{canManage && <Popover open={open} onOpenChange={setOpen}><PopoverTrigger asChild>
<Button variant="outline" size="sm" disabled={saving}><Plus className="size-4" aria-hidden />Add project</Button>
</PopoverTrigger><PopoverContent align="end" className="w-72 space-y-3">
<Input aria-label="Search projects" placeholder="Search projects…" value={query} onChange={event => setQuery(event.target.value)} />
<div className="max-h-64 space-y-1 overflow-y-auto">{matches.map(project => <label key={project.id} className="flex cursor-pointer items-start gap-3 rounded-md px-2 py-2 hover:bg-accent">
<Checkbox aria-label={project.name} checked={projectIds.includes(project.id) || pending.includes(project.id)} disabled={projectIds.includes(project.id)} className="mt-0.5"
onCheckedChange={checked => { setAdded(current => checked ? [...new Set([...current, project.id])] : current.filter(id => id !== project.id)); setSaved(false); }} />
<span className="min-w-0 text-sm">{project.name}{projectIds.includes(project.id) && <span className="block text-xs text-muted-foreground">Already allowed</span>}</span>
</label>)}</div>
{!matches.length && <p className="text-sm text-muted-foreground">No projects found.</p>}
<Button variant="secondary" size="sm" className="w-full" onClick={() => setOpen(false)}>Done</Button>
</PopoverContent></Popover>}
</div>
{visibleIds.length ? <ul aria-label="Allowed projects" className="divide-y divide-border rounded-lg border">
{visibleIds.map(id => <li key={id} className="flex items-center gap-3 p-4">
<FolderOpen className="size-4 shrink-0 text-muted-foreground" aria-hidden /><span className="min-w-0 flex-1 break-words text-sm font-medium">{name(id)}</span>
{projectIds.includes(id) ? <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"><Check className="size-3.5" aria-hidden />Allowed</span>
: <><span className="text-xs text-muted-foreground">To add</span><Button variant="ghost" size="icon-sm" disabled={saving} aria-label={`Undo adding ${name(id)}`} onClick={() => setAdded(current => current.filter(value => value !== id))}><X className="size-4" aria-hidden /></Button></>}
</li>)}
</ul> : <p className="text-sm text-muted-foreground">No projects allowed yet.</p>}
{!canManage && <p className="text-sm text-muted-foreground">A person with agent-management permission can add projects here.</p>}
{pending.length > 0 && <div className="space-y-2 rounded-md bg-muted p-4 text-sm">
<p className="font-medium">Add access to {pending.map(name).join(", ")}</p>
<p className="text-muted-foreground">{agentName} will be able to access work and repositories in {pending.length === 1 ? "this project" : "these projects"}. Existing project access and low-trust restrictions stay in place.</p>
</div>}
{error && <p role="alert" className="text-sm text-destructive">Couldn’t save project access. Your selection is kept. {error}</p>}
{saved && <p role="status" className="inline-flex items-center gap-2 text-sm"><Check className="size-4" aria-hidden />Project access saved. Return to your task to retry.</p>}
{canManage && <div className="flex items-center justify-between gap-3 border-t pt-4">
<Button variant="ghost" disabled={saving || !pending.length} onClick={() => { setAdded([]); setError(null); setSaved(false); }}>Cancel</Button>
<Button disabled={saving || !pending.length} onClick={() => void save()}>{saving && <Loader2 className="size-4 animate-spin" aria-hidden />}{saving ? "Saving access…" : error ? "Try saving again" : "Save project access"}</Button>
</div>}
</section>;
}
+8 -1
View File
@@ -2,9 +2,10 @@ import type { TaskInterruptionPresentation } from "@paperclipai/shared";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
import { useSecondTick } from "../hooks/useSecondTick";
import { TaskProjectAccessNotice } from "./TaskProjectAccessNotice";
/** One ordinary interruption surface. Technical records remain in diagnostics. */
export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimistic, takingLonger, error, className }: {
export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimistic, takingLonger, error, className, issueId, agentName, projectName }: {
presentation: TaskInterruptionPresentation | null | undefined;
canRetry: boolean;
onRetry: () => void;
@@ -12,8 +13,14 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi
takingLonger?: boolean;
error?: Error | null;
className?: string;
issueId?: string;
agentName?: string;
projectName?: string;
}) {
useSecondTick(presentation?.state === "retry_scheduled");
if (presentation?.projectAccess && !optimistic && !takingLonger) return <TaskProjectAccessNotice
access={presentation.projectAccess} issueId={issueId} agentName={agentName} projectName={projectName}
canRetry={canRetry && presentation.canRetry} onRetry={onRetry} error={error} className={className} />;
if (error && !optimistic && !takingLonger) return <div role="alert" className={cn("flex items-center justify-between gap-2 text-sm text-destructive", className)}><span>{error.message}</span>{canRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}</div>;
if (takingLonger) return <div role="status" className={cn("flex items-center justify-between gap-2 text-sm text-muted-foreground", className)}><span>Taking longer than usual…</span>{canRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}</div>;
if (optimistic || !presentation || presentation.responsibility !== "system" ||
+38
View File
@@ -0,0 +1,38 @@
import { ShieldCheck, ShieldX, ArrowRight } from "lucide-react";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
export interface ProjectAccessNoticeProps {
agentName?: string;
projectName?: string;
ready: boolean;
canManage: boolean;
canRetry: boolean;
onManage: () => void;
onRetry: () => void;
error?: Error | null;
className?: string;
}
export function ProjectAccessNotice({ agentName = "This agent", projectName, ready, canManage, canRetry, onManage, onRetry, error, className }: ProjectAccessNoticeProps) {
return <section role={ready ? "status" : "alert"} aria-label="Project access" className={cn("space-y-4 rounded-lg border border-border p-4", className)}>
<div className="flex items-start gap-3">
{ready ? <ShieldCheck className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden /> : <ShieldX className="mt-0.5 size-5 shrink-0 text-destructive" aria-hidden />}
<div className="min-w-0 space-y-2">
<h3 className="text-sm font-semibold">{ready ? "Project access is ready" : `${agentName} can’t access this project`}</h3>
<p className="text-sm text-muted-foreground">{ready
? "The agent can now access the required project. Retry to continue with your saved message."
: `This task needs ${projectName ?? "a project"} outside the agent’s allowed projects. The agent hasn’t started. Your message is saved.`}</p>
{!ready && !canManage && <p className="text-sm text-muted-foreground">A person with agent-management permission can add this project in the agent’s Permissions / Trust settings.</p>}
</div>
</div>
<div className="flex flex-wrap gap-2 pl-8">
{ready ? canRetry && <Button size="sm" onClick={onRetry}>Retry task<ArrowRight className="size-4" aria-hidden /></Button>
: <Button size="sm" variant="outline" onClick={onManage}>{canManage ? "Manage project access" : "View agent access"}<ArrowRight className="size-4" aria-hidden /></Button>}
</div>
{error && <p role="alert" className="text-sm text-destructive">{error.message}</p>}
{!ready && <details className="pl-8 text-xs text-muted-foreground"><summary className="cursor-pointer">Run details</summary>
<dl className="mt-3 grid grid-cols-2 gap-2"><dt>Stopped during</dt><dd>Workspace setup</dd><dt>Reason</dt><dd>Project outside allowed scope</dd><dt>Provider started</dt><dd>No</dd></dl>
</details>}
</section>;
}
@@ -0,0 +1,23 @@
import { useQuery } from "@tanstack/react-query";
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
import { agentsApi } from "../api/agents";
import { useNavigate } from "../lib/router";
import { queryKeys } from "../lib/queryKeys";
import { ProjectAccessNotice } from "./ProjectAccessNotice";
export function TaskProjectAccessNotice({ access, issueId, agentName, projectName, ...props }: {
access: NonNullable<TaskInterruptionPresentation["projectAccess"]>;
issueId?: string;
agentName?: string;
projectName?: string;
canRetry: boolean;
onRetry: () => void;
error?: Error | null;
className?: string;
}) {
const navigate = useNavigate();
const permission = useQuery({ queryKey: queryKeys.agents.projectAccess(access.agentId), queryFn: () => agentsApi.projectAccess(access.agentId) });
return <ProjectAccessNotice {...props} agentName={agentName} projectName={access.projectId ? projectName : undefined}
ready={access.ready} canManage={permission.data?.canManage === true}
onManage={() => navigate(`/agents/${access.agentId}/permissions${issueId ? `?fromTask=${encodeURIComponent(issueId)}` : ""}`)} />;
}
+8 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentPermissions, TrustPreset } from "@paperclipai/shared";
import { Lock, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -59,6 +59,7 @@ export function TrustPresetSection({
issueCandidates = [],
candidatesLoading,
allowSingleIssue = true,
projectAccessEditor,
}: {
permissions: Partial<AgentPermissions> | null | undefined;
onChange: (permissions: Partial<AgentPermissions>) => void;
@@ -68,6 +69,7 @@ export function TrustPresetSection({
issueCandidates?: LowTrustBoundaryCandidate[];
candidatesLoading?: boolean;
allowSingleIssue?: boolean;
projectAccessEditor?: ReactNode;
}) {
const [policyOpen, setPolicyOpen] = useState(false);
const preset = getTrustPreset(permissions);
@@ -148,7 +150,7 @@ export function TrustPresetSection({
: "This agent is set to low-trust review, but no project, root issue, or issue scope is set in the core policy. Add a scope before this agent can run without denial."}
</p>
</div>
{boundaryEditable ? (
{projectAccessEditor && (boundary?.projectIds?.length ?? 0) > 0 ? null : boundaryEditable ? (
<div className="rounded-md border border-border/70 bg-background/70 p-3 text-foreground space-y-3">
<div className="grid gap-3 sm:grid-cols-(--gtc-12)">
<Field label="Boundary type">
@@ -211,7 +213,7 @@ export function TrustPresetSection({
</p>
</div>
)}
<p className="text-xs text-muted-foreground">
{!projectAccessEditor && <p className="text-xs text-muted-foreground">
Want to set more than one containment boundary?{" "}
<a
className="underline underline-offset-2 hover:text-foreground"
@@ -221,7 +223,7 @@ export function TrustPresetSection({
>
Get Paperclip EE.
</a>
</p>
</p>}
<CollapsibleSection
title="View policy"
open={policyOpen}
@@ -249,6 +251,8 @@ export function TrustPresetSection({
</div>
) : null}
{lowTrust && projectAccessEditor}
{managedPermissions.authorizationPolicy?.reviewPreset ? null : (
<p className="text-xs text-muted-foreground">
Advanced permissions remain editable through the EE permissions extension when installed.
+1
View File
@@ -222,6 +222,7 @@ export const queryKeys = {
["team-catalog", "installed", companyId] as const,
},
agents: {
projectAccess: (id: string) => ["agents", "project-access", id] as const,
identity: (id: string) => ["agents", "identity", id] as const,
list: (companyId: string) => ["agents", companyId] as const,
detail: (id: string) => ["agents", "detail", id] as const,
+6 -3
View File
@@ -4,7 +4,7 @@ import { characterStateForAgent } from "@paperclipai/shared";
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
import { getPageVisibility, usePageVisibility } from "../lib/page-visibility";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useParams, useNavigate, useLocation, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
agentsApi,
@@ -58,6 +58,7 @@ import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel";
import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { TrustPresetSection } from "../components/TrustPresetSection";
import { AgentProjectAccessSettings } from "../components/AgentProjectAccessSettings";
import { FileTree, buildFileTree } from "../components/FileTree";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
@@ -768,6 +769,7 @@ export function AgentDetail() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const [actionError, setActionError] = useState<string | null>(null);
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => new Set());
const { enabled: chatEnabled, githubEnabled, loaded: chatConnectorsLoaded } = useChatConnectorsEnabled();
@@ -987,10 +989,10 @@ export function AgentDetail() {
? "budget"
: "dashboard";
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}${location.search}`, { replace: true });
return;
}
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate, chatConnectorsLoaded]);
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate, chatConnectorsLoaded, location.search]);
useEffect(() => {
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
@@ -2209,6 +2211,7 @@ function ConfigurationTab({
) : null}
{content === "configuration" ? <TrustPresetSection
projectAccessEditor={<AgentProjectAccessSettings agent={agent} />}
permissions={agent.permissions}
disabled={updatePermissions.isPending}
companyId={companyId}
+6 -3
View File
@@ -11,7 +11,7 @@ import { characterStateForAgent } from "@paperclipai/shared";
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
import { getPageVisibility, usePageVisibility } from "../lib/page-visibility";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useParams, useNavigate, useLocation, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
agentsApi,
@@ -63,6 +63,7 @@ import { InlineBanner } from "../components/InlineBanner";
import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel";
import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal";
import { TrustPresetSection } from "../components/TrustPresetSection";
import { AgentProjectAccessSettings } from "../components/AgentProjectAccessSettings";
import { FileTree, buildFileTree } from "../components/FileTree";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
@@ -780,6 +781,7 @@ export function AgentDetail() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const [actionError, setActionError] = useState<string | null>(null);
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => new Set());
const activeView: AgentDetailView = urlRunId ? "run-detail"
@@ -983,10 +985,10 @@ export function AgentDetail() {
if (legacyAuditSection) return;
const canonicalTab = activeView === "run-detail" ? "overview" : activeView;
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(agentDetailHref(canonicalAgentRef, canonicalTab), { replace: true });
navigate(`${agentDetailHref(canonicalAgentRef, canonicalTab)}${location.search}`, { replace: true });
return;
}
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate]);
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, location.search]);
useEffect(() => {
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
@@ -2135,6 +2137,7 @@ export function ConfigurationTab({
{content === "permissions" ? <TrustPresetSection
projectAccessEditor={<AgentProjectAccessSettings agent={agent} />}
permissions={agent.permissions}
disabled={updatePermissions.isPending}
companyId={companyId}
+1
View File
@@ -394,6 +394,7 @@ export function FailedRunInboxRow({
: interruption?.state === "waiting_for_user" ? ({
approval: "Approval required", reconnect: "Reconnect account", assign_agent: "Assign an agent", choose_model: "Choose a supported model",
resume: "Work is paused", budget: "Budget limit reached",
project_access: "Agent needs project access",
}[interruption.userAction ?? "resume"]) : runFailureMessage(run);
const retryAvailable = !interruption || interruption.canRetry;
const showUnreadSlot = unreadState !== null;
+1
View File
@@ -7814,6 +7814,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
)}
{resolvedDetailTab === "chat" && (
<ExecutionBlockerNotice className={shellSectionClass} presentation={issue.interruption}
issueId={issue.id} agentName={agentMap.get(issue.assigneeAgentId ?? "")?.name} projectName={issue.project?.name}
canRetry={canResolveBoardRecoveryAction} onRetry={taskRetry.retry}
optimistic={taskRetry.optimistic} takingLonger={taskRetry.takingLonger} error={taskRetry.error} />
)}
@@ -0,0 +1,121 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { ProjectAccessProposal } from "../../support/design-proposals/ProjectAccessProposal";
const meta = {
id: "design-project-access",
title: "Design proposals/Patterns/Project access recovery",
component: ProjectAccessProposal,
parameters: {
layout: "fullscreen",
initialEntries: ["/PAP/issues/DEMO-42"],
docs: { description: { component: "Start at Blocked task. Manage project access opens the proposed agent settings. Add Agent Extensions, review the additive access change, save, return to the task and retry. Low-trust review stays active. Settings supports multiple allowed projects without replacing the existing one. Read-only, pending save, failed save and failed retry states remain actionable. The reply and every state transition are local fixtures; no project permissions change and no provider runs. Reset the story to restore the starting state." } },
paperclip: {
capabilities: ["recovery/diagnosis", "recovery/source-action", "agents/instructions"],
home: "design-proposals", status: "proposed",
exception: "Requested design for a visible project denial and additive project access repair before production implementation.",
implementation: "prototype", boundary: "fixtures",
notes: "Reuses DesignPageShell, DesignSidebar, AgentAvatar, IssueStatusBadge, TaskChatBubble, TaskChatLiveRunPill and shared form primitives. Notice, access editor and navigation are a local prototype. Synthetic names and repositories only. Save retains existing projects; failed saves preserve draft but grant nothing. Retry is unavailable before a saved grant and does not run on save. Read-only viewers cannot edit. No production API, persistence, permission, provider or recovery proof. The implemented notice and editor are covered by the task-project-access stories; this retains the original design snapshot. Server regression tests and application verification cover authorization, persistence and explicit retry separately.",
},
},
} satisfies Meta<typeof ProjectAccessProposal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const BlockedTask: Story = {};
export const AgentSettings: Story = { args: { initialView: "permissions" } };
export const AccessSaved: Story = { args: { initialState: "access_saved" } };
export const ReadOnly: Story = { args: { canManage: false }, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText("Reviewer can’t access this project")).toBeVisible();
await expect(canvas.queryByRole("button", { name: "Manage project access" })).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "View agent access" }));
await expect(canvas.getByText("Only people with agent configuration permission can change this access.")).toBeVisible();
await expect(canvas.queryByRole("button", { name: "Add project" })).not.toBeInTheDocument();
} };
export const Light: Story = { globals: { theme: "light" } };
export const Mobile: Story = { globals: { viewport: { value: "mobile1", isRotated: false } } };
export const MobileSettings: Story = { args: { initialView: "permissions" }, globals: { viewport: { value: "mobile1", isRotated: false } } };
async function addProject(canvasElement: HTMLElement, name = "Agent Extensions") {
const canvas = within(canvasElement), screen = within(canvasElement.ownerDocument.body);
await userEvent.click(await canvas.findByRole("button", { name: "Add project" }));
await userEvent.click(await screen.findByRole("checkbox", { name }));
await userEvent.click(screen.getByRole("button", { name: "Done" }));
}
export const RepairJourney: Story = { play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step("Explain the denied project before any provider work", async () => {
await expect(await canvas.findByText("Reviewer can’t access this project")).toBeVisible();
await expect(canvas.getByRole("alert")).toHaveTextContent("The agent hasn’t started. Your message is saved.");
await expect(canvas.queryByRole("button", { name: "Retry task" })).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Manage project access" }));
});
await step("Add a project while preserving existing containment", async () => {
await expect(canvas.getByRole("button", { name: "Save project access" })).toBeDisabled();
await addProject(canvasElement);
await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed");
await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("To add");
await expect(canvas.getByText("Low-trust review", { exact: true })).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
await expect(await canvas.findByText("Project access saved.")).toBeVisible();
await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed");
await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("Allowed");
});
await step("Explicitly retry the saved message after saving access", async () => {
await userEvent.click(canvas.getByRole("button", { name: "Return to task" }));
await expect(canvas.getByText("Project access is ready")).toBeVisible();
await waitFor(() => expect(canvas.getByText("Are you there?")).toBeVisible());
await userEvent.click(canvas.getByRole("button", { name: "Retry task" }));
await waitFor(() => expect(canvas.getByText("Yes, I’m here. What would you like me to look at?")).toBeVisible());
await expect(canvas.queryByRole("alert")).not.toBeInTheDocument();
});
} };
export const CancelJourney: Story = { args: { initialView: "permissions" }, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await addProject(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Cancel" }));
await expect(canvas.getByText("Reviewer can’t access this project")).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Manage project access" }));
await expect(canvas.queryByTestId("project-extensions")).not.toBeInTheDocument();
await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed");
} };
export const SaveFailure: Story = { args: { initialView: "permissions", failSave: true }, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await addProject(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
await expect(await canvas.findByRole("alert")).toHaveTextContent("No permissions changed. Your selection is kept.");
await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("To add");
await expect(canvas.getByTestId("project-product")).toHaveTextContent("Allowed");
} };
export const SaveFailureJourney: Story = { args: { initialView: "permissions", failSave: true }, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await addProject(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
await userEvent.click(await canvas.findByRole("button", { name: "Try saving again" }));
await expect(await canvas.findByText("Project access saved.")).toBeVisible();
await expect(canvas.getByTestId("project-extensions")).toHaveTextContent("Allowed");
} };
export const Saving: Story = { args: { initialView: "permissions", holdSaving: true }, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await addProject(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
await expect(canvas.getByRole("button", { name: "Saving access…" })).toBeDisabled();
await expect(canvas.getByRole("button", { name: "Cancel" })).toBeDisabled();
} };
export const RetryFailure: Story = { args: { initialState: "access_saved", failRetry: true }, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("button", { name: "Retry task" }));
await expect(await canvas.findByRole("alert")).toHaveTextContent("Couldn’t start the retry. Project access is saved.");
} };
export const MultipleProjects: Story = { args: { initialView: "permissions" }, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await addProject(canvasElement);
await addProject(canvasElement, "Website");
await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
await expect(await canvas.findByText("Project access saved.")).toBeVisible();
for (const id of ["product", "extensions", "website"]) await expect(canvas.getByTestId(`project-${id}`)).toHaveTextContent("Allowed");
} };
@@ -0,0 +1,77 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { expect, fn, userEvent, within } from "storybook/test";
import { AllowedProjectsEditor } from "@/components/AllowedProjectsEditor";
import { ProjectAccessNotice } from "@/components/ProjectAccessNotice";
const meta = {
id: "task-project-access",
title: "Projects and execution/Recovery/Task chat/Project access",
component: ProjectAccessNotice,
parameters: { paperclip: {
capabilities: ["recovery/diagnosis", "agents/instructions"], implementation: "production", boundary: "fixtures",
notes: "Production notice and additive project editor. Local save callbacks cover selection, read-only, cancellation and failed-save states. API authorization, persistence and explicit retry admission are separate server regressions and local application verification.",
} },
decorators: [(Story) => <div className="mx-auto max-w-2xl space-y-4 p-4"><Story /></div>],
args: { agentName: "Reviewer", projectName: "Agent Extensions", ready: false, canManage: true, canRetry: false, onManage: fn(), onRetry: fn() },
} satisfies Meta<typeof ProjectAccessNotice>;
export default meta;
type Story = StoryObj<typeof meta>;
export const BlockedTask: Story = { play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("alert")).toHaveTextContent("Your message is saved.");
await expect(canvas.queryByRole("button", { name: "Retry task" })).not.toBeInTheDocument();
await expect(canvas.getByRole("button", { name: "Manage project access" })).toBeVisible();
} };
export const AccessSaved: Story = { args: { ready: true, canRetry: true } };
export const ReadOnly: Story = { args: { canManage: false } };
export const Light: Story = { globals: { theme: "light" } };
export const Mobile: Story = { globals: { viewport: { value: "mobile1", isRotated: false } } };
const projects = [{ id: "product", name: "Product" }, { id: "extensions", name: "Agent Extensions" }, { id: "website", name: "Website" }];
function Editor({ fail = false, canManage = true }: { fail?: boolean; canManage?: boolean }) {
const [ids, setIds] = useState(["product"]);
const [failed, setFailed] = useState(false);
return <AllowedProjectsEditor agentName="Reviewer" projects={projects} projectIds={ids} canManage={canManage}
onSave={async added => {
if (fail && !failed) { setFailed(true); throw new Error("Connection interrupted."); }
setIds(current => [...new Set([...current, ...added])]);
}} />;
}
async function selectProject(canvasElement: HTMLElement) {
const canvas = within(canvasElement), screen = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole("button", { name: "Add project" }));
await expect(await screen.findByRole("checkbox", { name: "Product" })).toBeDisabled();
await userEvent.click(screen.getByRole("checkbox", { name: "Agent Extensions" }));
await userEvent.click(screen.getByRole("button", { name: "Done" }));
}
export const AgentSettings: Story = { render: () => <Editor /> };
export const SaveAccess: Story = { ...AgentSettings, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await selectProject(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
await expect(await canvas.findByRole("status")).toHaveTextContent("Project access saved");
await expect(canvas.getAllByText("Allowed", { exact: true })).toHaveLength(2);
await expect(canvas.queryByRole("button", { name: "Retry task" })).not.toBeInTheDocument();
} };
export const CancelAddition: Story = { ...AgentSettings, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await selectProject(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Cancel" }));
await expect(canvas.queryByText("Agent Extensions", { exact: true })).not.toBeInTheDocument();
await expect(canvas.getByText("Product")).toBeVisible();
} };
export const SaveFailure: Story = { render: () => <Editor fail />, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await selectProject(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Save project access" }));
await expect(await canvas.findByRole("alert")).toHaveTextContent("Your selection is kept");
await expect(canvas.getByText("To add")).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Try saving again" }));
await expect(await canvas.findByRole("status")).toHaveTextContent("Project access saved");
} };
export const ReadOnlySettings: Story = { render: () => <Editor canManage={false} />, play: async ({ canvasElement }) => {
await expect(within(canvasElement).queryByRole("button", { name: "Add project" })).not.toBeInTheDocument();
} };
export const MobileSettings: Story = { ...AgentSettings, globals: { viewport: { value: "mobile1", isRotated: false } } };
@@ -0,0 +1,180 @@
import { useState } from "react";
import { ArrowLeft, ArrowRight, Check, FolderOpen, Loader2, Plus, ShieldCheck, ShieldX, X } from "lucide-react";
import { AgentAvatar } from "@/components/AgentAvatar";
import { DesignPageShell } from "@/components/DesignPageShell";
import { DesignSidebar } from "@/components/DesignSidebar";
import { IssueStatusBadge } from "@/components/StatusBadge";
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
import { TaskChatLiveRunPill } from "@/components/task-chat/TaskChatLiveRunPill";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { DesignProposalAccess } from "./DesignProposalAccess";
const projects = [
{ id: "product", name: "Product", repository: "acme/product" },
{ id: "extensions", name: "Agent Extensions", repository: "acme/agent-extensions" },
{ id: "website", name: "Website", repository: "acme/website" },
];
const agent = { id: "reviewer-preview", name: "Reviewer" };
const wait = () => new Promise<void>(resolve => setTimeout(resolve, 650));
export interface ProjectAccessProposalProps {
initialView?: "task" | "permissions";
initialState?: "blocked" | "access_saved";
canManage?: boolean;
failSave?: boolean;
failRetry?: boolean;
holdSaving?: boolean;
}
/** Design-only state machine. It never calls a permissions or execution API. */
function Proposal({ initialView = "task", initialState = "blocked", canManage = true,
failSave = false, failRetry = false, holdSaving = false }: ProjectAccessProposalProps) {
const [view, setView] = useState(initialView);
const [allowed, setAllowed] = useState(initialState === "access_saved" ? ["product", "extensions"] : ["product"]);
const [draft, setDraft] = useState(allowed);
const [pickerOpen, setPickerOpen] = useState(false);
const [query, setQuery] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(false);
const [saveAttempted, setSaveAttempted] = useState(false);
const [savedNotice, setSavedNotice] = useState(false);
const [run, setRun] = useState<"stopped" | "starting" | "replied">("stopped");
const [retryError, setRetryError] = useState(false);
const [retryAttempted, setRetryAttempted] = useState(false);
const [shellNotice, setShellNotice] = useState("");
const added = projects.filter(project => draft.includes(project.id) && !allowed.includes(project.id));
const accessReady = allowed.includes("extensions");
function openPermissions() {
setDraft(allowed);
setSavedNotice(false);
setSaveError(false);
setView("permissions");
}
async function save() {
if (!canManage || saving || !added.length) return;
setSaving(true);
setSaveError(false);
if (holdSaving) return;
await wait();
setSaving(false);
setSaveAttempted(true);
if (failSave && !saveAttempted) { setSaveError(true); return; }
setAllowed([...draft]);
setSavedNotice(true);
}
async function retry() {
if (!accessReady || run === "starting") return;
setRun("starting");
setRetryError(false);
await wait();
setRetryAttempted(true);
if (failRetry && !retryAttempted) { setRun("stopped"); setRetryError(true); return; }
setRun("replied");
}
const task = <div className="mx-auto flex w-full max-w-3xl flex-col gap-8">
<section className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>DEMO-42</span><span aria-hidden>·</span><FolderOpen className="size-3.5" aria-hidden /><span>Agent Extensions</span>
</div>
<div className="flex flex-wrap items-center gap-3"><h2 className="text-2xl font-semibold">Quick check-in</h2>
<IssueStatusBadge status={run === "replied" ? "done" : run === "starting" ? "in_progress" : "blocked"} />
</div>
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground"><AgentAvatar agent={agent} size={24} />Reviewer</span>
</section>
<TaskChatBubble item={{ id: "check-in", kind: "message", author: "human", authorName: "You", isCurrentUser: true, text: "Are you there?" }} />
{run === "starting" ? <div role="status" aria-label="Retry in progress"><TaskChatLiveRunPill status="running" startedAtMs={null} toolSummary={null} /></div>
: run === "replied" ? <TaskChatBubble item={{ id: "reply", kind: "message", author: "agent", authorName: agent.name, text: "Yes, I’m here. What would you like me to look at?" }} />
: <section role={accessReady ? "status" : "alert"} aria-label="Project access" className="space-y-4 rounded-lg border border-border p-4">
<div className="flex items-start gap-3">
{accessReady ? <ShieldCheck className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden /> : <ShieldX className="mt-0.5 size-5 shrink-0 text-destructive" aria-hidden />}
<div className="min-w-0 space-y-2">
<h3 className="text-sm font-semibold">{accessReady ? "Project access is ready" : "Reviewer can’t access this project"}</h3>
<p className="text-sm text-muted-foreground">{accessReady
? "Agent Extensions is now in Reviewer’s allowed projects. Retry to continue with your saved message."
: "This task uses Agent Extensions, which isn’t in Reviewer’s allowed projects. The agent hasn’t started. Your message is saved."}</p>
{!accessReady && !canManage && <p className="text-sm text-muted-foreground">An organization admin can add this project in Reviewer’s Permissions / Trust settings.</p>}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pl-8">
{accessReady ? <Button size="sm" onClick={() => void retry()}>Retry task<ArrowRight className="size-4" aria-hidden /></Button>
: canManage ? <Button size="sm" variant="outline" onClick={openPermissions}>Manage project access<ArrowRight className="size-4" aria-hidden /></Button>
: <Button size="sm" variant="outline" onClick={openPermissions}>View agent access</Button>}
</div>
{retryError && <p role="alert" className="text-sm text-destructive">Couldn’t start the retry. Project access is saved. Try again.</p>}
{!accessReady && <details className="pl-8 text-xs text-muted-foreground"><summary className="cursor-pointer">Run details</summary>
<dl className="mt-3 grid grid-cols-2 gap-2"><dt>Stopped during</dt><dd>Workspace setup</dd><dt>Reason</dt><dd>Project outside allowed scope</dd><dt>Provider started</dt><dd>No</dd></dl>
</details>}
</section>}
{run === "replied" && <Button variant="ghost" size="sm" className="self-start" onClick={openPermissions}>View saved project access</Button>}
</div>;
const permissions = <div className="mx-auto w-full max-w-3xl space-y-6">
<Button variant="ghost" size="sm" onClick={() => setView("task")} disabled={saving}><ArrowLeft className="size-4" aria-hidden />Back to task</Button>
<div className="flex items-center gap-3"><AgentAvatar agent={agent} size={40} /><div><h2 className="text-xl font-semibold">Reviewer</h2><p className="text-sm text-muted-foreground">Permissions / Trust</p></div></div>
<section className="flex items-start gap-3 rounded-lg border p-4" aria-label="Trust preset">
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="space-y-1"><h3 className="text-sm font-semibold">Low-trust review</h3><p className="text-sm text-muted-foreground">Runs in an isolated sandbox. Untrusted output stays quarantined.</p></div>
</section>
<section className="space-y-4" aria-labelledby="allowed-projects-heading">
<div className="flex flex-wrap items-start justify-between gap-3"><div className="space-y-1">
<h3 id="allowed-projects-heading" className="text-sm font-semibold">Allowed projects</h3>
<p className="text-sm text-muted-foreground">Projects whose tasks and repositories this agent may access.</p>
</div>
{canManage && <Popover open={pickerOpen} onOpenChange={setPickerOpen}><PopoverTrigger asChild>
<Button variant="outline" size="sm" disabled={saving}><Plus className="size-4" aria-hidden />Add project</Button>
</PopoverTrigger><PopoverContent align="end" className="w-72 space-y-3">
<Input aria-label="Search projects" placeholder="Search projects…" value={query} onChange={event => setQuery(event.target.value)} />
<div className="space-y-1">{projects.filter(project => `${project.name} ${project.repository}`.toLowerCase().includes(query.toLowerCase())).map(project => <label key={project.id} className="flex cursor-pointer items-start gap-3 rounded-md px-2 py-2 hover:bg-accent">
<Checkbox aria-label={project.name} checked={draft.includes(project.id)} disabled={allowed.includes(project.id)} className="mt-0.5"
onCheckedChange={checked => { setDraft(current => checked ? [...new Set([...current, project.id])] : current.filter(id => id !== project.id)); setSavedNotice(false); }} />
<span className="min-w-0"><span className="block text-sm">{project.name}</span><span className="block text-xs text-muted-foreground">{allowed.includes(project.id) ? "Already allowed" : project.repository}</span></span>
</label>)}</div>
{!projects.some(project => `${project.name} ${project.repository}`.toLowerCase().includes(query.toLowerCase())) && <p className="px-2 text-sm text-muted-foreground">No projects found.</p>}
<Button variant="secondary" size="sm" className="w-full" onClick={() => setPickerOpen(false)}>Done</Button>
</PopoverContent></Popover>}
</div>
<ul aria-label="Allowed projects" className="divide-y divide-border rounded-lg border">
{projects.filter(project => draft.includes(project.id)).map(project => <li key={project.id} className="flex items-center gap-3 p-4" data-testid={`project-${project.id}`}>
<FolderOpen className="size-4 shrink-0 text-muted-foreground" aria-hidden /><div className="min-w-0 flex-1"><p className="text-sm font-medium">{project.name}</p><p className="break-all text-xs text-muted-foreground">{project.repository}</p></div>
{allowed.includes(project.id) ? <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"><Check className="size-3.5" aria-hidden />Allowed</span>
: <><span className="text-xs text-muted-foreground">To add</span><Button size="icon-sm" variant="ghost" aria-label={`Undo adding ${project.name}`} disabled={saving} onClick={() => setDraft(current => current.filter(id => id !== project.id))}><X className="size-4" aria-hidden /></Button></>}
</li>)}
</ul>
{!accessReady && <p className="text-sm text-muted-foreground">DEMO-42 is waiting for access to <span className="font-medium text-foreground">Agent Extensions</span>.</p>}
{!canManage && <p role="note" className="text-sm text-muted-foreground">Only people with agent configuration permission can change this access.</p>}
{added.length > 0 && <div aria-label="Changes to save" className="space-y-2 rounded-md bg-muted p-4 text-sm">
<p className="font-medium">Add access to {added.map(project => project.name).join(" and ")}</p>
<p className="text-muted-foreground">Reviewer will be able to access work and repositories in {added.length === 1 ? "this project" : "these projects"}. Existing project access and low-trust restrictions stay in place.</p>
</div>}
{saveError && <p role="alert" className="text-sm text-destructive">Couldn’t save project access. No permissions changed. Your selection is kept.</p>}
{savedNotice && <div role="status" className="flex flex-wrap items-center justify-between gap-3 rounded-md bg-muted p-4 text-sm">
<span className="inline-flex items-center gap-2"><Check className="size-4" aria-hidden />Project access saved.</span>
<Button variant="outline" size="sm" onClick={() => setView("task")}>Return to task<ArrowRight className="size-4" aria-hidden /></Button>
</div>}
{canManage && <div className="flex items-center justify-between gap-3 border-t pt-4">
<Button variant="ghost" disabled={saving} onClick={() => { setDraft(allowed); setSaveError(false); setView("task"); }}>Cancel</Button>
<Button disabled={!added.length || saving} onClick={() => void save()}>{saving && <Loader2 className="size-4 animate-spin" aria-hidden />}{saving ? "Saving access…" : saveError ? "Try saving again" : "Save project access"}</Button>
</div>}
</section>
</div>;
return <div className="[&_.design-page-navigation]:hidden md:[&_.design-page-navigation]:flex">
<DesignPageShell title={view === "task" ? "Tasks / DEMO-42" : "Agents / Reviewer"} sidebarStorageKey="paperclip.proposal.project-access.sidebar"
sidebar={<DesignSidebar onNewTask={() => setShellNotice("This preview focuses on project access. Use Manage project access to try the repair.")} />}
headerActions={<span className="text-xs text-muted-foreground">Design preview</span>}>
{shellNotice && <p role="status" className="text-xs text-muted-foreground">{shellNotice}</p>}
{view === "task" ? task : permissions}
</DesignPageShell>
</div>;
}
export function ProjectAccessProposal(props: ProjectAccessProposalProps) {
return <DesignProposalAccess><Proposal {...props} /></DesignProposalAccess>;
}