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

Couldn’t load project access.

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

Loading project access…

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

Allowed projects

+

Projects whose tasks and repositories this agent may access.

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

No projects found.

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

No projects allowed yet.

} + {!canManage &&

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

} + {pending.length > 0 &&
+

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

+

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

+
} + {error &&

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

} + {saved &&

Project access saved. Return to your task to retry.

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

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

+

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

+ {!ready && !canManage &&

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

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

{error.message}

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

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

)} -

+ {!projectAccessEditor &&

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

+

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

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

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

Quick check-in

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

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

+

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

+ {!accessReady && !canManage &&

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

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

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

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

Reviewer

Permissions / Trust

+
+ +

Low-trust review

Runs in an isolated sandbox. Untrusted output stays quarantined.

+
+
+
+

Allowed projects

+

Projects whose tasks and repositories this agent may access.

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

No projects found.

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

    {project.name}

    {project.repository}

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

DEMO-42 is waiting for access to Agent Extensions.

} + {!canManage &&

Only people with agent configuration permission can change this access.

} + {added.length > 0 &&
+

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

+

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

+
} + {saveError &&

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

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

{shellNotice}

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