From a3675ddcdff1a6ff66dcb28fa7b295d0a491eed8 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 24 Oct 2026 15:39:28 -0700 Subject: [PATCH 1/3] Add bounded database response size diagnostics (#17504) Count database response value sizes by public caller and static table for one explicit UTC window of at most 60 seconds. Keep diagnostics off by default and exclude SQL, parameters, returned values, errors, and credentials from output. Preserve lazy execution, transaction boundaries, and no-replay behavior. Verification: full build and recursive typecheck; 63 focused database and PostgreSQL tests; eight physical disconnect tests with capture enabled. Co-Authored-By: Paperclip --- doc/DATABASE.md | 25 ++ packages/db/src/client.ts | 2 + .../db/src/response-capture-postgres.test.ts | 190 ++++++++++++ packages/db/src/response-capture.test.ts | 133 +++++++++ packages/db/src/response-capture.ts | 273 ++++++++++++++++++ 5 files changed, 623 insertions(+) create mode 100644 packages/db/src/response-capture-postgres.test.ts create mode 100644 packages/db/src/response-capture.test.ts create mode 100644 packages/db/src/response-capture.ts 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/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; +} From d0c8f0b0907afb7706ab78359e11fa226ea4a92e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:16:29 -0700 Subject: [PATCH 2/3] fix: explain project access denials and add an authorized repair path (#17509) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Low-trust agents may read only the projects in their allowed scope. > - Workspace setup correctly stops a task outside that scope. > - The task previously showed a generic failure, and agent settings did not offer an additive repair. > - This pull request explains the denial and lets authorized operators add projects while preserving other restrictions. > - The saved task then offers an explicit retry. Saving access does not start work. ## Linked Issues or Issue Description **What happened?** A project access denial appeared as an ordinary failed attempt. Operators could not add a second allowed project from agent settings. **Expected behavior** The task should explain the denied access and link to a working permission editor. The editor should preserve existing scope and restrictions, enforce operator authority, and retain drafts after a failed save. **Steps to reproduce** 1. Assign a low-trust agent to a task whose project is outside its allowed projects. 2. Let workspace setup deny project access. 3. Open the task, follow Manage project access, add the project, save, reload, and return to the task. 4. Confirm that the saved message remains and Retry task is a separate action. ## What Changed - Record a typed, non-retryable project denial before provider dispatch. Project denials do not enter automatic continuation or provider comment policy. - Project current and historical setup denials into a safe task notice. Keep unrelated failures, prior assignees, and approval/pause/budget gates separate. Recheck current project authority, including standard trust and root-issue grants. Accepted retries keep their working state. - Add board-only project-access reads and additive saves under the agent row lock. Enforce company scope, agent-management authority, pending-approval restrictions, and an audit record for new additions. - Add the production notice and searchable multi-project editor, including read-only, saving, cancellation, and failed-save states. Preserve the return-to-task query through canonical agent redirects. - Register board-only project-access routes in OpenAPI with a strict request schema. - Add production Storybook examples and API/persistence/concurrency regressions. Retain the approved design snapshot and update the behavior maps and implementation contract. ## Verification - Passed: `pnpm -r typecheck`, `pnpm build`, `pnpm build-storybook`, `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node scripts/storybook-catalog.mjs`. - Passed: 11 permission/projection tests and 30 run-completion tests against an isolated PostgreSQL database. These cover historical failures, concurrent additions, idempotent audit, preserved restrictions, foreign projects, read-only users, agent self-grants, and no automatic wakeup. - Passed: all 27 OpenAPI route tests, including route coverage and the new board-only request/response contract. - Browser: the real app displayed a historical denial; Manage project access opened the editor; saving retained the original project and survived reload; Return to task showed the ready notice. Saving created one audit event and zero runs. Explicit Retry returned 202 and reached the separate sandbox requirement, which remained enforced. No external provider was used. - All five production Storybook interaction checks pass: blocked task, save, cancellation, failed-save recovery, and read-only settings. These use fixture callbacks; durable authorization is covered separately above. - `pnpm test:run` was attempted locally but stopped after repeated embedded PostgreSQL startup failures caused by macOS semaphore exhaustion. Full-suite CI is required before merge. The separate UI suite passed all 9,095 tests. The 41 focused server regressions also passed after rebasing onto the latest master. A further 26 recovery-classification tests pass, including a red-to-green regression for automatic continuation after a project denial. ## Risks - No schema migration. The new endpoint only adds same-company project IDs and preserves the rest of the stored policy. - The historical notice recognizes one exact setup denial message. It does not display arbitrary run errors or disclose a different source project's identity. - The retry still passes all current admission and isolation checks. The notice does not promise that other execution requirements are satisfied. - Related open work: #13309 covers inherited secret restrictions; #11234 covers the broader repository catalog. This change does not replace either policy surface. ## Model Used OpenAI Codex (GPT-6), with code execution and browser tools. The precise runtime model ID and context-window size were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (focused server and full UI suites; full-suite infrastructure limit above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/SPEC-implementation.md | 9 + maps/agent-access-map/agents.md | 6 + maps/feature-map/agents.md | 6 + maps/feature-map/recovery.md | 6 + packages/shared/src/index.ts | 2 + packages/shared/src/types/agent.ts | 5 + .../shared/src/types/execution-projection.ts | 4 +- packages/shared/src/types/index.ts | 1 + packages/shared/src/validators/agent.ts | 4 + packages/shared/src/validators/index.ts | 1 + .../__tests__/agent-project-access.test.ts | 107 +++++++++++ server/src/__tests__/openapi-routes.test.ts | 16 ++ server/src/routes/agent-project-access.ts | 25 +++ server/src/routes/agents.ts | 3 + server/src/routes/openapi.ts | 23 +++ server/src/services/agent-project-access.ts | 69 +++++++ .../services/heartbeat/run-completion.test.ts | 25 ++- .../src/services/heartbeat/run-completion.ts | 27 ++- .../src/services/heartbeat/run-lifecycle.ts | 1 + server/src/services/project-access-denial.ts | 10 + .../provider-failure-classification.test.ts | 4 + server/src/services/recovery/service.ts | 1 + .../task-interruption-presentation.test.ts | 55 ++++++ .../task-interruption-presentation.ts | 52 ++++- server/src/services/task-workspace-access.ts | 8 +- ui/src/api/agents.ts | 3 + .../components/AgentProjectAccessSettings.tsx | 34 ++++ ui/src/components/AllowedProjectsEditor.tsx | 72 +++++++ ui/src/components/ExecutionBlockerNotice.tsx | 9 +- ui/src/components/ProjectAccessNotice.tsx | 38 ++++ ui/src/components/TaskProjectAccessNotice.tsx | 23 +++ ui/src/components/TrustPresetSection.tsx | 12 +- ui/src/lib/queryKeys.ts | 1 + ui/src/pages/AgentDetail.production.tsx | 9 +- ui/src/pages/AgentDetail.tsx | 9 +- ui/src/pages/Inbox.tsx | 1 + ui/src/pages/IssueDetail.tsx | 1 + .../project-access.stories.tsx | 121 ++++++++++++ .../recovery/project-access.stories.tsx | 77 ++++++++ .../ProjectAccessProposal.tsx | 180 ++++++++++++++++++ 40 files changed, 1030 insertions(+), 30 deletions(-) create mode 100644 server/src/__tests__/agent-project-access.test.ts create mode 100644 server/src/routes/agent-project-access.ts create mode 100644 server/src/services/agent-project-access.ts create mode 100644 server/src/services/project-access-denial.ts create mode 100644 server/src/services/task-interruption-presentation.test.ts create mode 100644 ui/src/components/AgentProjectAccessSettings.tsx create mode 100644 ui/src/components/AllowedProjectsEditor.tsx create mode 100644 ui/src/components/ProjectAccessNotice.tsx create mode 100644 ui/src/components/TaskProjectAccessNotice.tsx create mode 100644 ui/storybook/stories/design-proposals/project-access.stories.tsx create mode 100644 ui/storybook/stories/recovery/project-access.stories.tsx create mode 100644 ui/storybook/support/design-proposals/ProjectAccessProposal.tsx diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 13067391ef2..1048f886509 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -891,6 +891,15 @@ grant privileged tools. Backlog assignments retain the existing human requester without starting a run. Reassignment transactionally cancels prior human requests, including service/plugin writes; cancelled runs cannot authorize later retries. See `doc/LOW-TRUST-PRESETS.md` for containment details. +Project or repository access is a separate boundary from exact-task direction. +A workspace project denial appears on the task with a link to the agent's +Permissions / Trust settings. Board operators with `agents:create` can add +same-company projects through `POST /api/agents/:id/project-access`; the operation +locks the agent, preserves existing permissions and audits added projects. +Saving does not retry work. The task offers explicit Retry after the required +project is allowed, and dispatch rechecks authorization. Existing setup failures +with the exact project-boundary denial also receive the explanation on reload. + Cross-issue writes are contained per heartbeat run. An agent-authored comment may wake the target assignee, including an explicit `resume: true` comment on a `done` or `cancelled` issue, but the wake remains agent-class and is subject to diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index efc593ec743..1fa58307434 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -104,6 +104,12 @@ Review: `baseline`. API: `present` — representative operation IDs: - `GET /api/agents/{id}/instructions-bundle/file`. +- `GET /api/agents/{id}/project-access` and `POST /api/agents/{id}/project-access` + are board-only. The POST requires `agents:create`, checks selected projects in + the same company, and adds IDs under an agent row lock. It preserves existing + containment, audits new grants and rejects pending-approval agents. Agent keys + cannot use this route to grant themselves access; a read-only board viewer + receives `canManage: false`. Saving never creates a run or wakeup. - `PUT /api/agents/{id}/instructions-bundle/file`. - `PATCH /api/agents/{id}` with an explicit `runner: "paperclip"` upgrade. - `POST /api/agents/{id}/qualify-runner` to qualify the saved effective sandbox before upgrading. diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index 95fed092ec1..bb3b705cd1d 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -41,6 +41,12 @@ existing preparation and verification lifecycle. Open `/agents/:agentId` and the configuration/instruction surfaces it exposes. +For a low-trust agent, open **Permissions / Trust → Allowed projects**. An operator +with agent-management permission can select additional projects and explicitly +save them. Existing projects and other containment restrictions remain intact. +Cancel discards the draft; a failed save retains it. Reload to verify persistence. +This does not start or retry a task. + In `/agents/all`, select roster checkboxes and choose **Edit connection & model**. A mixed selection offers one action per harness. Each field is opt-in. The editor previews each changed value and excludes agents already at the target value. diff --git a/maps/feature-map/recovery.md b/maps/feature-map/recovery.md index 4294a1876a3..6624fbc67f7 100644 --- a/maps/feature-map/recovery.md +++ b/maps/feature-map/recovery.md @@ -48,6 +48,12 @@ Open the affected task from Tasks, Inbox/Blocked, search, or a direct task link. Observe one working, backoff, failed or waiting-for-user state. Use Retry after an infrastructure failure. No process or file certification is required. +A workspace project denial names the blocked access and links to the agent's +**Permissions / Trust** settings. After an authorized person adds the required +project, return to the saved task for an explicit **Retry task** action. Saving +permissions alone does not wake the agent. Historical setup denials receive the +same notice on reload; unrelated errors and prior assignees do not. + Thinking appears immediately after Retry, before the request completes. Technical source/run details remain in deliberate diagnostics. The [task recovery contract](../../doc/task-recovery.md) describes receipt identity, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 99f0a885507..a45052d7fe0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -889,6 +889,7 @@ export type { AgentDetail, ClearAgentErrorResponse, AgentPermissions, + AgentProjectAccess, AgentPublicIdentity, AgentInstructionsBundleMode, AgentInstructionsFileSummary, @@ -1882,6 +1883,7 @@ export { qualifyAgentRunnerSchema, agentPermissionsSchema, updateAgentPermissionsSchema, + addAgentProjectAccessSchema, type CreateAgent, type BuiltInAgentProvision, type BuiltInAgentReset, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index ac1970305b7..ba4b594389b 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -25,6 +25,11 @@ export interface AgentPermissions extends Record { authorizationPolicy?: TrustAuthorizationPolicy; } +export interface AgentProjectAccess { + projectIds: string[]; + canManage: boolean; +} + export type AgentRuntimeConfig = Record & { aiConnection?: import("../ai-connection-router.js").AiRuntimeConnectionBinding; }; diff --git a/packages/shared/src/types/execution-projection.ts b/packages/shared/src/types/execution-projection.ts index 23c7d3d688d..ff28fd01dc1 100644 --- a/packages/shared/src/types/execution-projection.ts +++ b/packages/shared/src/types/execution-projection.ts @@ -104,7 +104,9 @@ export interface TaskInterruptionPresentation { state: "working" | "retry_scheduled" | "failed" | "waiting_for_user" | "resolved"; nextRetryAt: string | null; canRetry: boolean; - userAction: "approval" | "reconnect" | "assign_agent" | "choose_model" | "resume" | "budget" | null; + userAction: "approval" | "reconnect" | "assign_agent" | "choose_model" | "resume" | "budget" | "project_access" | null; + /** Only the task's own project id is exposed. Other source projects stay unnamed. */ + projectAccess?: { agentId: string; projectId: string | null; ready: boolean }; sourceRunIds: string[]; observedRunId: string | null; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c89aa62b1e3..b10aa2c33ea 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -280,6 +280,7 @@ export type { AgentDetail, ClearAgentErrorResponse, AgentPermissions, + AgentProjectAccess, AgentPublicIdentity, AgentRuntimeConfig, AgentInstructionsBundleMode, diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 32d65555e6d..1e9e42e2876 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -322,6 +322,10 @@ export const updateAgentPermissionsSchema = z.object({ export type UpdateAgentPermissions = z.infer; +export const addAgentProjectAccessSchema = z.object({ + projectIds: z.array(z.string().uuid()).min(1).max(100), +}).strict(); + export const resolveAgentInstructionCandidateSchema = z.object({ baseRevisionId: z.string().uuid().nullable(), content: z.string().max(1024 * 1024), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 05519dd262f..8f1ba1bf907 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -370,6 +370,7 @@ export { qualifyAgentRunnerSchema, agentPermissionsSchema, updateAgentPermissionsSchema, + addAgentProjectAccessSchema, type CreateAgent, type BuiltInAgentProvision, type BuiltInAgentReset, diff --git a/server/src/__tests__/agent-project-access.test.ts b/server/src/__tests__/agent-project-access.test.ts new file mode 100644 index 00000000000..38618a3669c --- /dev/null +++ b/server/src/__tests__/agent-project-access.test.ts @@ -0,0 +1,107 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, agentWakeupRequests, activityLog, authUsers, companies, companyMemberships, createDb, + heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { registerAgentProjectAccessRoutes } from "../routes/agent-project-access.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { assertTaskWorkspaceSourceProjectAccess } from "../services/task-workspace-access.js"; +import { taskInterruptionPresentation } from "../services/task-interruption-presentation.js"; +import { ProjectAccessDeniedError } from "../services/project-access-denial.js"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +describe("additive project access", () => { + let database: Awaited> | undefined; + let db: ReturnType; + beforeAll(async () => { + const external = process.env.PAPERCLIP_TEST_DATABASE_URL; + if (external) db = createDb(external); + else { database = await startEmbeddedPostgresTestDatabase("agent-project-access-"); db = createDb(database.connectionString); } + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + + async function seed() { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Project access", issuePrefix: `A${companyId.slice(0, 6)}` }); + const selected = await db.insert(projects).values(["Existing", "Needed", "Third"].map(name => ({ companyId, name }))).returning(); + const permissions = { canCreateAgents: false, canCreateSkills: false, trustPreset: "low_trust_review", + authorizationPolicy: { reviewPreset: { id: "low_trust_review", version: 1, rawOutputDisposition: "quarantine" }, + customPolicy: { preserve: true }, trustBoundary: { mode: "low_trust_review", companyId, + projectIds: [selected[0]!.id], issueIds: [issueId], allowedToolClasses: ["git.read"], allowedSecretBindingIds: [] } } }; + await db.insert(agents).values({ id: agentId, companyId, name: "Reviewer", adapterType: "process", status: "idle", permissions }); + await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" }); + const [issue] = await db.insert(issues).values({ id: issueId, companyId, title: "Saved message", status: "blocked", assigneeAgentId: agentId, projectId: selected[1]!.id }).returning(); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, status: "failed", errorCode: "setup_failed", + error: "Project is outside this low-trust boundary.", contextSnapshot: { issueId } }); + return { companyId, agentId, issue: issue!, runId, selected, permissions }; + } + function app(actor: express.Request["actor"]) { + const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); }); + registerAgentProjectAccessRoutes(app, db); app.use(errorHandler); return app; + } + const board = { type: "board" as const, source: "local_implicit" as const }; + + it("preserves containment, audits concurrent additions, and changes the persisted denial into an explicit retry", async () => { + const f = await seed(); + const actor = { type: "agent" as const, source: "agent_key" as const, agentId: f.agentId, companyId: f.companyId }; + await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, f.companyId, f.selected[1]!.id)).rejects.toBeInstanceOf(ProjectAccessDeniedError); + expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ userAction: "project_access", canRetry: false }); + const api = app(board); + const results = await Promise.all(f.selected.slice(1).map(project => request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [project.id] }))); + expect(results.map(result => result.status)).toEqual([200, 200]); + const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId)); + expect(saved!.permissions).toEqual({ ...f.permissions, authorizationPolicy: { ...f.permissions.authorizationPolicy, + trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, projectIds: expect.arrayContaining(f.selected.map(project => project.id)) } } }); + expect((saved!.permissions as typeof f.permissions).authorizationPolicy.trustBoundary.projectIds).toHaveLength(3); + await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, f.companyId, f.selected[1]!.id)).resolves.toBeUndefined(); + expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ canRetry: true, projectAccess: { ready: true } }); + const again = await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] }); + expect(again.status).toBe(200); + expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.action, "agent.project_access_added")))).toHaveLength(2); + expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(0); + expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1); + }); + + it("rejects foreign projects, agent self-grants, unknown policy fields, and pending-approval agents", async () => { + const f = await seed(), other = await seed(), api = app(board); + expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [other.selected[1]!.id] })).status).toBe(404); + expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id], trustPreset: "standard" })).status).toBe(400); + expect((await request(app({ type: "agent", agentId: f.agentId, companyId: f.companyId, source: "agent_key" })) + .post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403); + await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agentId)); + expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(409); + const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId)); expect(saved!.permissions).toEqual(f.permissions); + }); + + it.each(["standard", "root_issue"])("rechecks actual project authority after a %s repair and acknowledges an accepted retry", async repair => { + const f = await seed(); + const permissions = repair === "standard" ? { canCreateAgents: false, trustPreset: "standard", authorizationPolicy: {} } + : { ...f.permissions, authorizationPolicy: { ...f.permissions.authorizationPolicy, + trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, rootIssueId: f.issue.id } } }; + await db.update(agents).set({ permissions }).where(eq(agents.id, f.agentId)); + expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ canRetry: true, projectAccess: { ready: true } }); + await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.agentId, + source: "on_demand", status: "deferred_issue_execution", requestedByActorType: "user", payload: { issueId: f.issue.id } }); + const accepted = await taskInterruptionPresentation(db, f.issue); + expect(accepted).toMatchObject({ state: "working", canRetry: false }); + expect(accepted).not.toHaveProperty("projectAccess"); + // Current project policy remains authoritative even after agent access changes. + await db.update(projects).set({ executionWorkspacePolicy: { trustPreset: "low_trust_review", authorizationPolicy: { + trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.selected[0]!.id] }, + } } }).where(eq(projects.id, f.selected[1]!.id)); + if (repair === "standard") expect(await taskInterruptionPresentation(db, f.issue)) + .toMatchObject({ canRetry: false, userAction: "project_access" }); + }); + + it("shows read-only access to viewers and denies their saves and cross-company reads", async () => { + const f = await seed(), other = await seed(), userId = randomUUID(); + await db.insert(authUsers).values({ id: userId, name: "Viewer", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, membershipRole: "viewer", status: "active" }); + const api = app({ type: "board", source: "session", userId, companyIds: [f.companyId], memberships: [{ companyId: f.companyId, membershipRole: "viewer", status: "active" }] }); + expect((await request(api).get(`/agents/${f.agentId}/project-access`)).body).toMatchObject({ canManage: false }); + expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403); + expect((await request(api).get(`/agents/${other.agentId}/project-access`)).status).toBe(404); + }); +}); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index d0abe4e4b4b..ff43e560394 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -21,6 +21,7 @@ const apiPrefixes: Record = { "activity.ts": "/api", "adapters.ts": "/api", "agents.ts": "/api", + "agent-project-access.ts": "/api", "agent-commentary.ts": "/api", "agent-avatars.ts": "/api", "agent-profile-avatar.ts": "/api", @@ -987,6 +988,21 @@ describe("openapi routes", () => { }); }); + it("documents additive project access as board-only with a strict request", () => { + const { spec } = loadSpecRoutes(); + const path = spec.paths["/api/agents/{id}/project-access"]; + for (const operation of [path.get, path.post]) { + expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]); + expect(operation.responses["200"].content["application/json"].schema.required).toEqual(["projectIds", "canManage"]); + } + expect(path.post.requestBody.content["application/json"].schema).toMatchObject({ + additionalProperties: false, required: ["projectIds"], + properties: { projectIds: { type: "array", items: { type: "string", format: "uuid" } } }, + }); + expect(path.post.responses["409"]).toBeDefined(); + }); + it("documents the authenticated personal primary-agent contract", () => { const { spec } = loadSpecRoutes(); const path = spec.paths["/api/companies/{companyId}/primary-agent/me"]; diff --git a/server/src/routes/agent-project-access.ts b/server/src/routes/agent-project-access.ts new file mode 100644 index 00000000000..08346180aab --- /dev/null +++ b/server/src/routes/agent-project-access.ts @@ -0,0 +1,25 @@ +import type { Router } from "express"; +import { agents, type Db } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; +import { addAgentProjectAccessSchema } from "@paperclipai/shared"; +import { notFound } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { addAgentProjectAccess, readAgentProjectAccess } from "../services/agent-project-access.js"; +import { assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js"; + +export function registerAgentProjectAccessRoutes(router: Router, db: Db) { + router.get("/agents/:id/project-access", async (req, res) => { + assertBoard(req); + const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, req.params.id as string)); + if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found"); + assertCompanyAccess(req, agent.companyId); + res.json(await readAgentProjectAccess(db, req.actor, agent.companyId, agent.id)); + }); + router.post("/agents/:id/project-access", validate(addAgentProjectAccessSchema), async (req, res) => { + assertBoard(req); + const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, req.params.id as string)); + if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found"); + assertCompanyAccess(req, agent.companyId); + res.json(await addAgentProjectAccess(db, req.actor, agent.companyId, agent.id, req.body.projectIds)); + }); +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 540e1f7d8cc..0aba86abb8f 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -98,6 +98,7 @@ import { } from "@paperclipai/adapter-utils/server-utils"; import { trackAgentCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; +import { registerAgentProjectAccessRoutes } from "./agent-project-access.js"; import { inheritNativeRunnerAdapterConfig } from "../services/native-runtime/native-agent-runtime-inheritance.js"; import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js"; import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js"; @@ -4918,6 +4919,8 @@ export function agentRoutes( res.status(201).json({ ...redactAgentRowForResponse(agent), ...(configurationWarnings.length ? { configurationWarnings } : {}) }); }); + registerAgentProjectAccessRoutes(router, db); + router.patch("/agents/:id/permissions", validate(updateAgentPermissionsSchema), async (req, res) => { const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 650eb0670eb..bbb50ac7f55 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -48,6 +48,7 @@ import { updateAgentSchema, updatePrimaryAgentSchema, updateAgentPermissionsSchema, + addAgentProjectAccessSchema, updateAgentInstructionsPathSchema, updateAgentInstructionsBundleSchema, upsertAgentInstructionsFileSchema, @@ -1390,6 +1391,8 @@ const browserUseOperations = [ ] as const; const BOARD_ONLY_OPERATIONS = new Set([ + "GET /api/agents/{id}/project-access", + "POST /api/agents/{id}/project-access", "POST /api/issues/{id}/interactions/{interactionId}/dismiss", "POST /api/issues/{id}/retry", "GET /api/companies/{companyId}/prompt-injection-prevention", @@ -3790,6 +3793,26 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +const agentProjectAccessResponse = z.object({ projectIds: z.array(z.string().uuid()), canManage: z.boolean() }); +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/project-access", + tags: ["agents"], + summary: "Read an agent's allowed projects and the current operator's management capability", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(agentProjectAccessResponse), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/project-access", + tags: ["agents"], + summary: "Add allowed projects without replacing an agent's other permissions", + description: "Requires board access and agents:create in the agent's company. Accepts 1 to 100 project IDs. Selected projects must be readable and in that company. Locks the low-trust agent, preserves existing containment, audits new additions, and rejects pending-approval agents or changed trust settings. Repeated additions are idempotent. Saving does not start or retry a task.", + request: { params: z.object({ id: z.string() }), body: jsonBody(addAgentProjectAccessSchema) }, + responses: { 200: r.ok(agentProjectAccessResponse), 400: r.badRequest, 401: r.unauthorized, + 403: r.forbidden, 404: r.notFound, 409: r.conflict }, +}); + registry.registerPath({ method: "patch", path: "/api/agents/{id}/instructions-path", diff --git a/server/src/services/agent-project-access.ts b/server/src/services/agent-project-access.ts new file mode 100644 index 00000000000..4c6ee81659a --- /dev/null +++ b/server/src/services/agent-project-access.ts @@ -0,0 +1,69 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { agents, projects, type Db } from "@paperclipai/db"; +import { trustAuthorizationPolicySchema, type AgentProjectAccess } from "@paperclipai/shared"; +import { conflict, forbidden, notFound } from "../errors.js"; +import { normalizeAgentPermissions } from "../lib/agent-permissions.js"; +import { accessService } from "./access.js"; +import type { AuthorizationActor } from "./authorization.js"; +import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js"; + +export async function readAgentProjectAccess(db: Db, actor: AuthorizationActor, companyId: string, agentId: string): Promise { + const [agent] = await db.select({ permissions: agents.permissions, status: agents.status }).from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))); + if (!agent) throw notFound("Agent not found"); + const decision = await accessService(db).decide({ actor, action: "agents:create", resource: { type: "company", companyId } }); + const permissions = normalizeAgentPermissions(agent.permissions); + const policy = trustAuthorizationPolicySchema.safeParse(permissions.authorizationPolicy ?? {}); + const boundary = policy.success ? policy.data.trustBoundary : undefined; + return { projectIds: policy.success ? policy.data.trustBoundary?.projectIds ?? [] : [], + canManage: actor.type === "board" && decision.allowed && agent.status !== "pending_approval" + && policy.success && permissions.trustPreset === "low_trust_review" + && (!boundary || (boundary.mode === "low_trust_review" && (!boundary.companyId || boundary.companyId === companyId))) }; +} + +/** An additive operation under the agent row lock, never a stale policy replacement. */ +export async function addAgentProjectAccess(db: Db, actor: AuthorizationActor, companyId: string, agentId: string, projectIds: string[]) { + if (actor.type !== "board") throw forbidden("Board access required"); + const publications: ActivityPublication[] = []; + const result = await db.transaction(async tx => { + const [agent] = await tx.select().from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update"); + if (!agent) throw notFound("Agent not found"); + const transactionDb = tx as unknown as Db; + const access = accessService(transactionDb); + const decision = await access.decide({ actor, action: "agents:create", resource: { type: "company", companyId } }); + if (!decision.allowed) throw forbidden(decision.explanation); + if (agent.status === "pending_approval") throw conflict("Pending approval agent permissions cannot be changed before board approval"); + const permissions = normalizeAgentPermissions(agent.permissions); + const parsedPolicy = trustAuthorizationPolicySchema.safeParse(permissions.authorizationPolicy ?? {}); + if (!parsedPolicy.success) throw conflict("The agent’s trust settings need review before adding project access."); + const policy = parsedPolicy.data; + const boundary = policy?.trustBoundary; + if (permissions.trustPreset !== "low_trust_review" || (boundary && (boundary.mode !== "low_trust_review" + || (boundary.companyId && boundary.companyId !== companyId)))) { + throw conflict("The agent’s trust settings changed. Refresh before adding project access."); + } + const requested = [...new Set(projectIds)]; + const rows = await tx.select({ id: projects.id }).from(projects) + .where(and(eq(projects.companyId, companyId), inArray(projects.id, requested))); + if (rows.length !== requested.length) throw notFound("A selected project is unavailable"); + for (const project of rows) { + const projectDecision = await access.decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId: project.id } }); + if (!projectDecision.allowed) throw forbidden("A selected project is unavailable"); + } + const previous = boundary?.projectIds ?? []; + const added = requested.filter(id => !previous.includes(id)); + const next = [...previous, ...added]; + if (added.length) { + await tx.update(agents).set({ permissions: { ...agent.permissions, authorizationPolicy: { + ...policy, trustBoundary: { ...boundary, mode: "low_trust_review", companyId, projectIds: next }, + } }, updatedAt: new Date() }).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))); + await logActivity(transactionDb, { companyId, actorType: "user", actorId: actor.userId ?? "local-board", + action: "agent.project_access_added", entityType: "agent", entityId: agentId, + details: { addedProjectIds: added, trustPreset: "low_trust_review" } }, publications); + } + return { projectIds: next, canManage: true } satisfies AgentProjectAccess; + }); + for (const publication of publications) publishActivity(publication); + return result; +} diff --git a/server/src/services/heartbeat/run-completion.test.ts b/server/src/services/heartbeat/run-completion.test.ts index 39df53e1d52..1dc15b06e9c 100644 --- a/server/src/services/heartbeat/run-completion.test.ts +++ b/server/src/services/heartbeat/run-completion.test.ts @@ -9,6 +9,7 @@ import { createAgentIdentityRedactor } from "../agent-identity-redaction.js"; import { UnresolvedWorkspaceBaseRefError } from "../workspace-runtime.js"; import { LegacyControllerLeaseLostError } from "../legacy-controller-lease.js"; import { requestedRunCancellation } from "../run-cancellation.js"; +import { ProjectAccessDeniedError } from "../project-access-denial.js"; import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from "./run-lifecycle.js"; import { createHeartbeatRunPreparation, ConfigurationIncompleteFailure } from "./run-preparation.js"; import { createHeartbeatRunState, getAdapterSessionCodec } from "./run-state.js"; @@ -77,15 +78,19 @@ it("constructs completion handlers without querying or starting work", () => { }); const support = await getEmbeddedPostgresTestSupport(); -describe.skipIf(!support.supported)("heartbeat run completion boundary", () => { - let database: Awaited>; +describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)("heartbeat run completion boundary", () => { + let database: Awaited> | undefined; let db: Db; beforeAll(async () => { // Keep the accounting transaction real without reading provider receipt files. vi.spyOn(usageReceipts, "indexPendingUsageReceipts").mockResolvedValue(new Map()); vi.spyOn(usageReceipts, "recoverPendingRunUsageReceipts").mockResolvedValue([]); - database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-completion"); - db = createDb(database.connectionString); + const external = process.env.PAPERCLIP_TEST_DATABASE_URL; + if (external) db = createDb(external); + else { + database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-completion"); + db = createDb(database.connectionString); + } await db.execute(sql`set client_min_messages = warning`); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterAll(async () => { await db?.$client.end(); await database?.cleanup(); vi.restoreAllMocks(); }); @@ -334,6 +339,18 @@ describe.skipIf(!support.supported)("heartbeat run completion boundary", () => { expect(f.deps.releaseIssueExecutionAndPromote).toHaveBeenCalledWith(expect.objectContaining({ id: f.run.id }), { suppressImmediateRecovery: true }); }); + it("persists a project denial without provider work or an automatic retry", async () => { + const f = await fixture(); + const projectId = randomUUID(); + await f.completion.failRunSetup(f.setup(new ProjectAccessDeniedError(projectId))); + expect(await read(f.run.id)).toMatchObject({ status: "failed", errorCode: "project_access_denied", + resultJson: { projectAccessDenied: { projectId }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } }); + expect(f.deps.scheduleBoundedRetryForRun).not.toHaveBeenCalled(); + expect(f.deps.enqueueWakeup).not.toHaveBeenCalled(); + expect(f.deps.scheduleInteractionContinuationInfrastructureRetryIfEligible).not.toHaveBeenCalled(); + expect(f.deps.finalizeIssueCommentPolicy).not.toHaveBeenCalled(); + }); + it("records a setup configuration gap without inventing provider work or applying comment policy", async () => { const f = await fixture(); await f.completion.failRunSetup(f.setup(new ConfigurationIncompleteFailure("missing credential", { configurationIncomplete: { reason: "missing_binding" } }))); diff --git a/server/src/services/heartbeat/run-completion.ts b/server/src/services/heartbeat/run-completion.ts index 7f2b486dfe7..95026919171 100644 --- a/server/src/services/heartbeat/run-completion.ts +++ b/server/src/services/heartbeat/run-completion.ts @@ -1,4 +1,5 @@ import { measureRunPhase } from "../execution-preparation-timing.js"; +import { PROJECT_ACCESS_DENIED, ProjectAccessDeniedError } from "../project-access-denial.js"; import { pendingCaseHandoffForRun } from "./case-handoff-lifecycle.js"; import { NON_RETRYABLE_PREFLIGHT_FAILURE_CODES, isHeartbeatRunTerminalStatus } from "./run-lifecycle.js"; import { ChatControlRecoveryUnresolvedError } from "./queue.js"; @@ -145,6 +146,7 @@ export interface FailHeartbeatRunSetupInput { } function nonRetryablePreflightFailureCode(error: unknown): string | null { + if (error instanceof ProjectAccessDeniedError) return PROJECT_ACCESS_DENIED; if (error instanceof ChatControlRecoveryUnresolvedError) return CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE; if ( @@ -1344,6 +1346,8 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC // action, so it is persisted even when the agent lookup failed and the // agent-scoped stop metadata cannot be merged in. const setupFailureDetails = + (outerErr instanceof ProjectAccessDeniedError + ? { projectAccessDenied: { projectId: outerErr.projectId } } : null) ?? (workspaceGitScanFailure ? { workspaceGitScan: { code: workspaceGitScanFailure.code, @@ -1453,7 +1457,8 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC ).catch(() => undefined); if ( !isWorkspaceValidationFailedRun(livenessRun) && - !isConfigurationIncompleteFailedRun(livenessRun) + !isConfigurationIncompleteFailedRun(livenessRun) && + livenessRun.errorCode !== PROJECT_ACCESS_DENIED ) { await finalizeIssueCommentPolicy(livenessRun, failedAgent).catch( () => undefined, @@ -1462,15 +1467,17 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC // No provider work began. Retry temporary host scan failures with // the existing durable failure budget, before releasing execution. // Generic recovery must not grant a second budget on exhaustion. - await (isTransientWorkspaceGitScanCode(livenessRun.errorCode) - ? scheduleBoundedRetryForRun(livenessRun, failedAgent) - : scheduleInteractionContinuationInfrastructureRetryIfEligible(livenessRun, failedAgent) - ).catch((retryError) => { - logger.warn( - { err: retryError, runId: livenessRun.id }, - "failed to schedule interaction continuation retry after setup failure", - ); - }); + if (livenessRun.errorCode !== PROJECT_ACCESS_DENIED) { + await (isTransientWorkspaceGitScanCode(livenessRun.errorCode) + ? scheduleBoundedRetryForRun(livenessRun, failedAgent) + : scheduleInteractionContinuationInfrastructureRetryIfEligible(livenessRun, failedAgent) + ).catch((retryError) => { + logger.warn( + { err: retryError, runId: livenessRun.id }, + "failed to schedule interaction continuation retry after setup failure", + ); + }); + } } await releaseIssueExecutionAndPromote(livenessRun, { suppressImmediateRecovery: diff --git a/server/src/services/heartbeat/run-lifecycle.ts b/server/src/services/heartbeat/run-lifecycle.ts index 5f299e855fa..3bbb6f30b40 100644 --- a/server/src/services/heartbeat/run-lifecycle.ts +++ b/server/src/services/heartbeat/run-lifecycle.ts @@ -244,6 +244,7 @@ const LIVENESS_BOOKKEEPING_EVENT_TYPES = [ ]; export const NON_RETRYABLE_PREFLIGHT_FAILURE_CODES = new Set([ + "project_access_denied", "low_trust_isolation_unavailable", "low_trust_requires_isolated_workspace", "low_trust_boundary_mismatch", diff --git a/server/src/services/project-access-denial.ts b/server/src/services/project-access-denial.ts new file mode 100644 index 00000000000..f9e699d1bdc --- /dev/null +++ b/server/src/services/project-access-denial.ts @@ -0,0 +1,10 @@ +import { HttpError } from "../errors.js"; + +export const PROJECT_ACCESS_DENIED = "project_access_denied"; + +/** Carries a safe, typed setup failure without changing the authorization decision. */ +export class ProjectAccessDeniedError extends HttpError { + constructor(readonly projectId: string) { + super(403, "Project is outside this low-trust boundary."); + } +} diff --git a/server/src/services/recovery/provider-failure-classification.test.ts b/server/src/services/recovery/provider-failure-classification.test.ts index 31407ac82c3..29dded8d4d6 100644 --- a/server/src/services/recovery/provider-failure-classification.test.ts +++ b/server/src/services/recovery/provider-failure-classification.test.ts @@ -10,6 +10,10 @@ import { legacyExecutionNeedsReconciliation } from "../legacy-execution-recovery import { isConfigurationIncompleteFailedRun } from "../../modules/wake-queue/domain/values.js"; describe("classifyAdapterFailureForRecovery", () => { + it("does not automatically replay a project access denial", () => { + expect(classifyContinuationFailure({ errorCode: "project_access_denied", resultJson: null } as never)) + .toMatchObject({ kind: "non_retryable", maxAttempts: 0 }); + }); it("waits for model configuration changes instead of retrying a typed native model rejection", () => { const run = { errorCode: "native_provider_model_rejected", error: "Fixed model configuration message", resultJson: null }; expect(classifyAdapterFailureForRecovery(run)).toEqual({ kind: "configuration_incomplete" }); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index e72811ec8d0..331122e10d6 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -516,6 +516,7 @@ const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set([ ]); const NON_RETRYABLE_CONTINUATION_ERROR_CODES = new Set([ + "project_access_denied", "native_provider_model_rejected", "provider_tool_definition_invalid", "adapter_engine_unavailable", diff --git a/server/src/services/task-interruption-presentation.test.ts b/server/src/services/task-interruption-presentation.test.ts new file mode 100644 index 00000000000..9c730d1c5fc --- /dev/null +++ b/server/src/services/task-interruption-presentation.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { projectTaskInterruption } from "./task-interruption-presentation.js"; + +const issue = { id: "task", companyId: "11111111-1111-4111-8111-111111111111", status: "blocked", assigneeAgentId: "agent", projectId: "22222222-2222-4222-8222-222222222222" }; +const run = { id: "run", agentId: "agent", status: "failed", scheduledRetryAt: null, + errorCode: "project_access_denied", error: null, livenessState: null, + resultJson: { projectAccessDenied: { projectId: "22222222-2222-4222-8222-222222222222" } } }; +const agent = { status: "idle", pauseReason: null }; + +describe("task project-access interruption", () => { + it("explains a project denial without offering a futile retry", () => { + expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: false })).toMatchObject({ + state: "waiting_for_user", responsibility: "user", userAction: "project_access", canRetry: false, + projectAccess: { agentId: "agent", projectId: "22222222-2222-4222-8222-222222222222", ready: false }, + }); + }); + + it("projects the historical setup denial without requiring another failed run", () => { + expect(projectTaskInterruption(issue, { run: { ...run, errorCode: "setup_failed", + error: "Project is outside this low-trust boundary.", resultJson: null }, agent, paused: false, approval: false })) + .toMatchObject({ userAction: "project_access", canRetry: false }); + }); + + it("offers an explicit retry after the project is added, without changing the failed run", () => { + expect(projectTaskInterruption(issue, { run, agent, projectAccessAllowed: true, paused: false, approval: false })) + .toMatchObject({ state: "failed", canRetry: true, projectAccess: { ready: true } }); + expect(run.status).toBe("failed"); + }); + + it("shows an accepted retry as working after access is repaired", () => { + const input = { run, agent, paused: false, approval: false, wait: { id: "request", requestedAt: new Date() } }; + expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true })) + .toMatchObject({ state: "working", canRetry: false }); + expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true })).not.toHaveProperty("projectAccess"); + expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: false })) + .toMatchObject({ state: "waiting_for_user", userAction: "project_access", canRetry: false }); + }); + + it("does not reinterpret unrelated failures or an old assignee's denial", () => { + for (const otherRun of [{ ...run, errorCode: "setup_failed", error: "Disk full", resultJson: null }, + { ...run, agentId: "previous-agent" }]) { + expect(projectTaskInterruption(issue, { run: otherRun, agent, paused: false, approval: false })) + .not.toHaveProperty("projectAccess"); + } + }); + + it("keeps approval, pause, running and completed states authoritative", () => { + expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: true })).toMatchObject({ userAction: "approval" }); + expect(projectTaskInterruption(issue, { run, agent, paused: true, approval: false })).toMatchObject({ userAction: "resume" }); + expect(projectTaskInterruption(issue, { run: { ...run, status: "running" }, agent, paused: false, approval: false })) + .toMatchObject({ state: "working" }); + expect(projectTaskInterruption({ ...issue, status: "done" }, { run, agent, paused: false, approval: false })) + .toMatchObject({ state: "resolved" }); + }); +}); diff --git a/server/src/services/task-interruption-presentation.ts b/server/src/services/task-interruption-presentation.ts index 249ff55d8e5..f8dd6664e33 100644 --- a/server/src/services/task-interruption-presentation.ts +++ b/server/src/services/task-interruption-presentation.ts @@ -4,21 +4,35 @@ import { agents, agentWakeupRequests, approvals, companies, heartbeatRuns, issue import type { TaskInterruptionPresentation } from "@paperclipai/shared"; import { isAiAuthenticationBlocked } from "./ai-auth-failure.js"; import { readSystemRecovery } from "./system-recovery-state.js"; +import { PROJECT_ACCESS_DENIED } from "./project-access-denial.js"; +import { authorizationService } from "./authorization.js"; type ProjectionIssue = Pick; type ProjectionRun = Pick; + "id" | "status" | "scheduledRetryAt" | "errorCode" | "livenessState" | "resultJson"> & + Partial>; type ProjectionState = { run?: ProjectionRun; action?: typeof issueRecoveryActions.$inferSelect; wait?: { id: string; requestedAt: Date }; agent?: Pick; + projectAccessAllowed?: boolean; project?: Pick; company?: Pick; paused: boolean; approval: boolean; }; +/** Recognize old persisted failures without displaying arbitrary run error text. */ +function deniedProjectId(issue: ProjectionIssue, run?: ProjectionRun): string | null | undefined { + const historical = run?.errorCode === "setup_failed" && run.error === "Project is outside this low-trust boundary."; + if (!run || run.status !== "failed" || run.agentId !== issue.assigneeAgentId + || (run.errorCode !== PROJECT_ACCESS_DENIED && !historical)) return undefined; + const detail = run.resultJson?.projectAccessDenied; + return detail && typeof detail === "object" && "projectId" in detail && typeof detail.projectId === "string" + ? detail.projectId : historical ? issue.projectId : null; +} + /** Presentation never grants authority. Dispatch repeats all gates under its * locks. The same projection is used in detail, lists and attention surfaces. */ export function projectTaskInterruption(issue: ProjectionIssue, input: ProjectionState, now = Date.now()): TaskInterruptionPresentation | null { @@ -36,6 +50,14 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio ? "budget" : input.paused || project?.pausedAt || agent?.status === "paused" || company?.status === "paused" ? "resume" : null; if (userAction) return { ...base, responsibility: "user", state: "waiting_for_user", userAction }; if (["running", "queued"].includes(run.status)) return { ...base, state: "working" }; + const deniedProject = deniedProjectId(issue, run); + let projectAccess: TaskInterruptionPresentation["projectAccess"]; + if (deniedProject !== undefined) { + const ready = input.projectAccessAllowed === true; + projectAccess = { agentId: run.agentId!, projectId: deniedProject === issue.projectId ? deniedProject : null, ready }; + if (!ready) return { ...base, responsibility: "user", state: "waiting_for_user", + userAction: "project_access", projectAccess }; + } const retryAt = system?.nextAttemptAt ?? run.scheduledRetryAt?.getTime(); if (retryAt && retryAt > now) return { ...base, state: "retry_scheduled", nextRetryAt: new Date(retryAt).toISOString(), canRetry: true }; @@ -44,7 +66,7 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio activeObservation > now - 45_000) return { ...base, state: "working" }; if (wait && wait.requestedAt.getTime() > now - 10_000 && system?.state !== "exhausted") return { ...base, state: "working" }; - return { ...base, state: "failed", canRetry: true }; + return { ...base, state: "failed", canRetry: true, ...(projectAccess ? { projectAccess } : {}) }; } /** Batch list reads rather than doing seven queries per row. Keep run logs and @@ -61,8 +83,12 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i const projectIds = selected.flatMap(issue => issue.projectId ? [issue.projectId] : []); const [runs, waits, agentRows, projectRows, pauseRows, approvalRows, companyRows] = await Promise.all([ db.selectDistinctOn([runIssue], { issueId: runIssue, id: heartbeatRuns.id, status: heartbeatRuns.status, + agentId: heartbeatRuns.agentId, + error: sql`case when ${heartbeatRuns.errorCode} = 'setup_failed' + and ${heartbeatRuns.error} = 'Project is outside this low-trust boundary.' then ${heartbeatRuns.error} else null end`, errorCode: heartbeatRuns.errorCode, livenessState: heartbeatRuns.livenessState, scheduledRetryAt: heartbeatRuns.scheduledRetryAt, - resultJson: sql>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete')`, + resultJson: sql>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete', + 'projectAccessDenied', ${heartbeatRuns.resultJson}->'projectAccessDenied')`, }).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), inArray(runIssue, ids))) .orderBy(runIssue, desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)), db.selectDistinctOn([wakeIssue], { issueId: wakeIssue, id: agentWakeupRequests.id, requestedAt: agentWakeupRequests.requestedAt }) @@ -91,12 +117,32 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i )).orderBy(issueRecoveryActions.sourceIssueId, desc(issueRecoveryActions.createdAt)) : []; const byIssue = (rows: T[]) => new Map(rows.map(row => [row.issueId, row])); const runMap = byIssue(runs), waitMap = byIssue(waits); + // Recheck current authority, including standard trust, root-issue grants and + // project policy. Do not reuse the failed run's frozen policy for a new retry. + // Only denied tasks need these reads; deduplicate projects and batch per agent. + const deniedByAgent = new Map>(); + for (const issue of selected) { + const projectId = deniedProjectId(issue, runMap.get(issue.id)); + if (!projectId || !issue.assigneeAgentId || ["done", "cancelled"].includes(issue.status)) continue; + const ids = deniedByAgent.get(issue.assigneeAgentId) ?? new Set(); + ids.add(projectId); + deniedByAgent.set(issue.assigneeAgentId, ids); + } + const currentAccess = new Map(); + const authorization = authorizationService(db); + for (const [agentId, ids] of deniedByAgent) { + const projectIds = [...ids]; + const decisions = await authorization.decideReads({ type: "agent", agentId, companyId, source: "agent_key" }, + projectIds.map(projectId => ({ type: "project" as const, companyId, projectId }))); + projectIds.forEach((projectId, index) => currentAccess.set(`${agentId}:${projectId}`, decisions[index]?.allowed === true)); + } const actionMap = new Map(actions.map(action => [action.sourceIssueId, action])); const agentMap = new Map(agentRows.map(agent => [agent.id, agent])); const projectMap = new Map(projectRows.map(project => [project.id, project])); const pauses = new Set(pauseRows.map(row => row.issueId)), approvalsSet = new Set(approvalRows.map(row => row.issueId)); for (const issue of selected) result.set(issue.id, projectTaskInterruption(issue, { run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id), + projectAccessAllowed: currentAccess.get(`${issue.assigneeAgentId}:${deniedProjectId(issue, runMap.get(issue.id))}`), agent: agentMap.get(issue.assigneeAgentId ?? ""), project: projectMap.get(issue.projectId ?? ""), company: companyRows[0], paused: pauses.has(issue.id), approval: approvalsSet.has(issue.id), })); diff --git a/server/src/services/task-workspace-access.ts b/server/src/services/task-workspace-access.ts index b49ba9d9432..31d40f79655 100644 --- a/server/src/services/task-workspace-access.ts +++ b/server/src/services/task-workspace-access.ts @@ -8,6 +8,7 @@ import { canonicalExecutionUuid } from "./execution-identity.js"; import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns, taskRepositoryResponsibleUserMatches } from "./task-repository-acquisition.js"; import { resolveCoreTrustPreset } from "./trust-preset-resolver.js"; import { isDeepStrictEqual } from "node:util"; +import { ProjectAccessDeniedError } from "./project-access-denial.js"; import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection } from "@paperclipai/shared"; type DbTransaction = Parameters[0]>[0]; @@ -19,7 +20,12 @@ export async function assertTaskWorkspaceSourceProjectAccess( const decision = await measureRunPhase("workspace.access.authorization", () => accessService(db as Db).decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId }, })); - if (!decision.allowed) throw forbidden(decision.explanation); + if (!decision.allowed) { + if (actor.type === "agent" && decision.reason === "deny_low_trust_boundary") { + throw new ProjectAccessDeniedError(projectId); + } + throw forbidden(decision.explanation); + } } export async function assertExecutionWorkspaceRecordAccess( diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 5d31460ea38..9f97ce68bfe 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,4 +1,5 @@ import type { AgentPublicIdentity } from "@paperclipai/shared"; +import type { AgentProjectAccess } from "@paperclipai/shared"; import type { Agent, EnvironmentProbeResult, @@ -111,6 +112,8 @@ function agentPath(id: string, companyId?: string, suffix = "") { } export const agentsApi = { + projectAccess: (id: string) => api.get(`/agents/${id}/project-access`), + addProjectAccess: (id: string, projectIds: string[]) => api.post(`/agents/${id}/project-access`, { projectIds }), qualifyRunner: (id: string, expectedEnvironmentId: string, companyId?: string) => api.post(agentPath(id, companyId, "/qualify-runner"), { expectedEnvironmentId }), setAvatar: (companyId: string, agentId: string, imageBase64: string | null) => diff --git a/ui/src/components/AgentProjectAccessSettings.tsx b/ui/src/components/AgentProjectAccessSettings.tsx new file mode 100644 index 00000000000..2320755c85f --- /dev/null +++ b/ui/src/components/AgentProjectAccessSettings.tsx @@ -0,0 +1,34 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { isUuidLike } from "@paperclipai/shared"; +import { agentsApi } from "../api/agents"; +import { projectsApi } from "../api/projects"; +import { queryKeys } from "../lib/queryKeys"; +import { Link, useSearchParams } from "../lib/router"; +import { AllowedProjectsEditor } from "./AllowedProjectsEditor"; +import { Button } from "./ui/button"; + +export function AgentProjectAccessSettings({ agent }: { agent: { id: string; companyId: string; name: string; urlKey?: string } }) { + const client = useQueryClient(); + const [params] = useSearchParams(); + const fromTask = params.get("fromTask"); + const access = useQuery({ queryKey: queryKeys.agents.projectAccess(agent.id), queryFn: () => agentsApi.projectAccess(agent.id) }); + const projects = useQuery({ queryKey: queryKeys.projects.list(agent.companyId), queryFn: () => projectsApi.list(agent.companyId) }); + if (access.error || projects.error) return
+

Couldn’t load project access.

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

Loading project access…

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

Allowed projects

+

Projects whose tasks and repositories this agent may access.

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

No projects found.

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

No projects allowed yet.

} + {!canManage &&

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

} + {pending.length > 0 &&
+

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

+

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

+
} + {error &&

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

} + {saved &&

Project access saved. Return to your task to retry.

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

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

+

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

+ {!ready && !canManage &&

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

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

{error.message}

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

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

)} -

+ {!projectAccessEditor &&

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

+

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

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

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

Quick check-in

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

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

+

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

+ {!accessReady && !canManage &&

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

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

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

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

Reviewer

Permissions / Trust

+
+ +

Low-trust review

Runs in an isolated sandbox. Untrusted output stays quarantined.

+
+
+
+

Allowed projects

+

Projects whose tasks and repositories this agent may access.

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

No projects found.

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

    {project.name}

    {project.repository}

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

DEMO-42 is waiting for access to Agent Extensions.

} + {!canManage &&

Only people with agent configuration permission can change this access.

} + {added.length > 0 &&
+

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

+

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

+
} + {saveError &&

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

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

{shellNotice}

} + {view === "task" ? task : permissions} +
+
; +} + +export function ProjectAccessProposal(props: ProjectAccessProposalProps) { + return ; +} From 5c3dce09b5cb80911a65a03ec09d20bf8fbf78dd Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:44:50 -0700 Subject: [PATCH 3/3] fix(runner): preserve verified remote artifacts across controller releases (#17512) ## Thinking Path > - Paperclip manages AI agents and owns their task authority. > - Remote native agents can keep working while their controller restarts. > - The controller must authenticate the same runner and verify its provider files before recovery. > - The current path compares a retained provider pack with the new controller release. A release change can reject intact files. > - The new controller binary digest can also reject the original daemon during PRP authentication. > - This PR records the admitted release authority in private controller state and uses it for exact process reattachment. ## Linked Issues or Issue Description **What happened?** A remote Claude session started and used tools. After controller shutdown, recovery failed with `runner_remote_provider_artifact_incompatible`. The transport combined stderr into stdout, so the error omitted the verifier reason. The historical rejected manifest is unavailable. This PR reproduces controller release drift as a cause; it does not claim that the historical mismatch is proven. Reaching pack verification implies the earlier exact-process/controller-identity checks passed. That supports a preserved-state recovery path but is not a direct inspection of the deployed volume. **Expected behavior** A compatible, unchanged remote runner can reattach after a controller release changes. Full package hashes, exact process scope, protocol capabilities, and authenticated runner identity must still pass. Fresh launches use the current release. **Steps to reproduce** 1. Verify and admit a remote native session with release A. 2. Preserve its runner process and replace the controller with release B. 3. Reattach the original process. The old path compares its pack with B and fails at `manifest mismatch`. 4. With this fix, the controller reads its saved A authority and verifies all A hashes. Changed files still fail. **Paperclip version or commit** Reproduced from master `470cb34c0e42cb9d0b232d0bcf5d45cc7b0a81ca`; rebased onto `a3675ddcdf`. **Deployment mode** Native remote runner on a Daytona-backed Paperclip Computer. Local acceptance uses isolated Linux containers and actual packaged artifacts. Related work: #17361 and #17448 handle runner upgrade and qualification permissions. #17097 and #17439 add durable controller checkpoints and server adoption. That stack can skip remote artifact preparation with a saved checkpoint. This PR fixes the current file-backed path and retains full provider-pack verification; it has no checkpoint schema or empty-disk controller recovery. ## What Changed - Save the complete verified pack manifest and authenticated runner version/digest after successful session admission. - Bind the receipt to company, agent, workspace, session, runner, environment lease, remote directory, and provider descriptor. Keep it outside guest backup directories. - Reuse the saved authority for exact reattachment. Keep current release selection for fresh launches and explicit session replacement. - Fail closed for mismatched or corrupt receipts. Historical sessions without a receipt retain the current release integrity gate. - Report bounded, redacted verifier output from both transport streams. Warn on receipt write failure while returning the already admitted session to its owner. - Add scope, persistence, replacement, storage failure, and backend wiring regressions. Update recovery documentation and the feature map. ## Verification - Test-first red: the original selection chose release B instead of A after controller recreation. The new receipt regression passes. A separate red test showed the missing stdout diagnostic; it passes after the diagnostic fix. - Authority and diagnostic suites: 49 tests pass. Backend pack, process adoption, and harness persistence selection: 47 tests pass. - Actual Linux provider image: complete A manifest/artifact verification passed; controller source-revision drift failed on the old path; saved A authority passed; a modified ACPX sidecar failed its SHA-256 check. - Actual native Linux runner: authenticated a session, detached the controller, changed the controller manifest and binary, then authenticated the original PID and provider session with saved authority. There were zero duplicate spawns, zero new provider turns, no stop/suspend, and the workspace file remained. This probe used the real runner with a deterministic Codex fixture; it is separate from the real ACPX package integrity proof. - Full workspace typecheck and build passed. The full native executor suite first hit host PostgreSQL semaphore exhaustion. The complete changed files then passed 864/864 tests with separate real PostgreSQL databases in a disposable Linux container. The temporary local fixture changed only database provisioning and used normal repository migrations. Current-head CI also passed the full required test, Runner, browser, typecheck, and build lanes. - `node scripts/check-maps.mjs` and `git diff --check` passed. These structural checks do not prove live recovery. - Current head `48fca2ae33d99f7f6d2fe12f431cf76d25185c70`: 55 successful checks, two conditional skips, no pending or failed checks; fresh Greptile 5/5 with zero findings and zero unresolved review threads. Commitperclip quality review passed. - No original task was retried or changed. Existing Daytona access could not resolve the original sandbox or its image; a fresh staging Claude recovery is not claimed. ## Risks This prevents recurrence for sessions admitted with the new receipt. It does not retroactively recover the historical failed run. Saved authority exists only after this change has admitted a session. It cannot repair an ambiguous historical launch or restore a receipt on an empty replacement controller. A stale protocol or modified pack remains a hard failure. Receipt write errors warn without losing the live session handle, but later recovery still needs matching authority. The broader checkpoint stack changes the same server preparation and runner-artifact option and will require a deliberate integration. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, and code execution. The runtime does not expose a more specific model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/execution-semantics.md | 6 +- doc/native-workspace-finalization-recovery.md | 24 ++- maps/feature-map/recovery.md | 8 + .../native-session-executor.test.ts | 41 +++++- .../native-runtime/native-session-executor.ts | 137 ++++++++---------- .../remote-provider-pack-authority.test.ts | 121 ++++++++++++++++ .../remote-provider-pack-authority.ts | 107 ++++++++++++++ .../remote-provider-pack-verification.ts | 85 +++++++++++ .../remote-provider-pack.test.ts | 8 + .../native-runtime/remote-provider-pack.ts | 5 +- 10 files changed, 458 insertions(+), 84 deletions(-) create mode 100644 server/src/services/native-runtime/remote-provider-pack-authority.test.ts create mode 100644 server/src/services/native-runtime/remote-provider-pack-authority.ts create mode 100644 server/src/services/native-runtime/remote-provider-pack-verification.ts 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/feature-map/recovery.md b/maps/feature-map/recovery.md index 6624fbc67f7..b7de12dddc4 100644 --- a/maps/feature-map/recovery.md +++ b/maps/feature-map/recovery.md @@ -176,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/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})`);