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