fix(adapter-utils): allow sandbox agents to read run diagnostics (#16575)

## Thinking Path

> - Paperclip manages agent work and its execution history.
> - Sandbox agents use a callback bridge to reach the control plane.
> - The API already provides authorized run diagnostics.
> - The bridge denied those read routes before the API could check
access.
> - Health scans could see task activity but could not inspect failed
runs.
> - This pull request admits exact GET routes on both bridge transports.
> - Linked task results also apply the caller's task-read filter.
> - Agents can inspect failures through their existing run identity.

## Linked Issues or Issue Description

**What happened?**

A sandbox health scan received `403 Route not allowed` for run history,
run details, events, and task recovery records. The same agent could
read task activity and connected provider state. The bridge stopped
these diagnostic requests before they reached the API.

**Expected behavior**

The bridge should admit the existing diagnostic GET routes. The API must
still enforce company and task access and redact run content. Execution
and recovery writes must stay denied by these rules.

**Steps to reproduce**

1. Start an agent in a sandbox with the normal callback bridge.
2. Use its injected API credential to request company run history.
3. Request a run's details or events.
4. Observe the bridge's route denial before the API handles the request.

**Paperclip version or commit**

Reproduced with the callback route list on master at `9f4a956b906`.

**Deployment mode**

Authenticated server with a managed sandbox. Both queue and HTTP/2
transports use this route list.

Related work: Refs #15854 adds separate cost-read routes to this list.
It does not add run diagnostics. Searches found no duplicate
run-diagnostic change.

## What Changed

- Admit nine existing diagnostic GET paths: company history and live
runs; run details, events, logs, workspace operations and task links;
task runs and recovery records.
- Reject other methods, extra path segments, execution control, recovery
resolution, and provider-trace administration.
- Filter linked tasks by the caller's task-read predicate in both
activity and original-task queries. Keep those results in the run's
company. A readable run must not expose private task titles.
- Test both route lists and the HTTP/2 request path. Preserve query
parameters and controller denial responses. Add a database regression
with public and private tasks linked to a readable company run.
- Document bounded diagnostic reads, private-run stubs, access checks,
and credential handling in the agent API reference. Update both affected
run access maps with the sandbox entry points and verification limits.

## Verification

- Passed both bridge suites: 139 tests.
- Passed the server run-route, activity-route, and secret-redaction
suites: 117 tests, including the new task-link filter test. The cold
route-import test needed a 30-second timeout on this host.
- Passed `pnpm -r typecheck`, `pnpm build`, the adapter-utils typecheck,
both generated capability checks, and `node scripts/check-maps.mjs`.
- Attempted `pnpm test:run`. The bundled PostgreSQL binary cannot start
on this host. The native session suite reported 15 database startup
failures; the privacy suite skipped its 15 cases. Stopped that full
local attempt after those failures. No full local-suite pass is claimed.
The final 16-case privacy suite also skipped locally for the same host
limitation; Linux CI ran all 16 cases successfully.
- One unchanged queue-response test hit a file-read race during the
combined local run. A separate rerun of both complete bridge suites
passed all 139 cases. The transient race was not independently isolated;
the original failure is retained in this record.
- All CI gates pass on head `97c65cec459d3fc2787083d05ae6ec51e3fd7b70`.
Linux CI passed all 16 database-backed run privacy cases, including the
new public/private task-link regression. These also cover private-run
denial, authorized owner/reviewer reads, redacted list stubs, credential
diagnostics, and deleted-task handling: [serialized shard
1](https://github.com/paperclipai/paperclip/actions/runs/38925932547/job/117057907621).
- Apex reviewed the final head at 5/5. Both findings are addressed and
their review threads are resolved.
- After deployment, run a new sandbox audit. Confirm it can list and
inspect authorized failed runs without a board credential. This has not
been deployed or live-tested by this PR.

## Risks

- Sandbox agents can reach more diagnostic GET routes. The existing
server access checks and redaction still apply.
- Linked task results now omit tasks that the caller cannot read.
Authorized task links remain visible.
- This change does not grant execution control or recover a failed
workspace.
- Existing bridge sessions may need a new run to load the updated route
list.
- No schema, credential, UI, workflow, or lockfile change.

## Model Used

OpenAI Codex, based on GPT-6, assisted with implementation and
verification. The exact serving model ID and context-window limit are
not exposed in this session. Capabilities used: code editing, shell
execution, and test execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-16 09:04:07 -07:00
1 parent 06689c1b34
commit 647116dd4b
11 files changed
+242 -20

No files matched your search

+33 -9
View File
@@ -74,25 +74,49 @@ Review: `baseline`.
API: `present` — representative operation IDs:
- `GET /api/companies/{companyId}/heartbeat-runs`.
- `GET /api/companies/{companyId}/live-runs`.
- `GET /api/heartbeat-runs/{runId}`.
- `GET /api/heartbeat-runs/{runId}/events`.
- `GET /api/heartbeat-runs/{runId}/log`.
- `GET /api/heartbeat-runs/{runId}/workspace-operations`.
- `GET /api/heartbeat-runs/{runId}/issues`.
- `GET /api/issues/{id}/runs`.
- `GET /api/issues/{id}/recovery-actions`.
Agent access: `available` — REST / native call_api / CLI run.
Agent access: `available` — REST / native call_api / CLI run. Sandbox agents can
use these diagnostic GET paths through the default queue and HTTP/2
[callback bridge](../../packages/adapter-utils/src/sandbox-callback-bridge.ts).
Use the injected run credential; see the [bounded read workflow](../../skills/paperclip/references/run-diagnostics.md).
Policy: Same company plus visibility/audit/cost/run permissions where the route requires
them.
Policy: Same-company and `company_scope:read` checks apply to company run
inventory. Run content requires run/task visibility; private run list entries
may be redacted stubs. Task run/recovery reads require `issue:read`. Run-linked
tasks also pass the caller's task-read SQL predicate individually, including
activity links and the original task. Existing credential redaction remains.
Bridge admission grants no cancellation, retry, recovery resolution or
provider-trace administration authority.
Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at
the reviewed commit above.
Evidence: `source-only` — scoped review of `f10210990a3c9cad1a4359318388f237de3169dd`:
[run routes](../../server/src/routes/agents.ts),
[task links](../../server/src/routes/activity.ts),
[task filtering](../../server/src/services/activity.ts),
[bridge regressions](../../packages/adapter-utils/src/sandbox-callback-bridge.test.ts),
[HTTP/2 transport regressions](../../packages/adapter-utils/src/http2-bridge-server.test.ts),
and [private-run regressions](../../server/src/__tests__/heartbeat-run-privacy-routes.test.ts).
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
Assert the relevant denied actor, owner, company or mode from Policy. Review the
remaining operations before qualifying the whole capability.
Verification: `not-run` for a deployed sandbox agent with these new routes.
Isolated regressions cover admitted GETs, denied writes/extra segments, query
forwarding, controller denials, private run stubs, and mixed public/private task
links. They do not qualify a live run. After deployment, use a real agent run to
read an authorized failure and assert denial/redaction for another company,
private tasks and restricted key modes. Do not count a skipped database suite
as successful verification.
Gaps: Telemetry/run visibility is checked independently from seeing the source task.
Company heartbeat history defaults to 200 rows and caps explicit limits at 1,000
in both the route and service. The response remains an array. Scope, visibility,
and secret redaction checks apply to the bounded result set.
Live updated-bridge admission and useful incident diagnosis remain unverified.
## `routine-audit`
+32 -9
View File
@@ -139,22 +139,45 @@ API: `present` — representative operation IDs:
- `GET /api/heartbeat-runs/{runId}`.
- `GET /api/heartbeat-runs/{runId}/log`.
- `GET /api/heartbeat-runs/{runId}/events`.
- `GET /api/heartbeat-runs/{runId}/workspace-operations`.
- `GET /api/heartbeat-runs/{runId}/issues`.
- `GET /api/companies/{companyId}/heartbeat-runs`.
- `GET /api/companies/{companyId}/live-runs`.
- `GET /api/issues/{id}/runs`.
- `GET /api/issues/{id}/recovery-actions`.
Agent access: `available` — REST / native call_api / CLI run.
Agent access: `available` — REST / native call_api / CLI run, plus the listed
read-only paths through the default queue and HTTP/2 sandbox callback bridge.
The sandbox uses its injected run credential. See
[run diagnostics](../../skills/paperclip/references/run-diagnostics.md) and the
[activity access contract](activity.md#runs).
Policy: Run/cost visibility and agent configuration policy; adapter installation/account
login/session control require board/admin.
Policy: Same company, company-scope read authority and run/task visibility still
apply. Task-bound history and recovery records require task read authority.
Each run-linked task is filtered by the caller's task-read predicate; a readable
company run cannot expose inaccessible private task titles. Existing content
and credential redaction apply. Adapter installation, account login, session
control, recovery writes and raw provider-trace administration retain their
existing board/admin or dedicated-tool gates.
Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L6608) at
the reviewed commit above.
Evidence: `source-only` — diagnostic scope reviewed at
`f10210990a3c9cad1a4359318388f237de3169dd` in the
[bridge route list](../../packages/adapter-utils/src/sandbox-callback-bridge.ts),
[run routes](../../server/src/routes/agents.ts),
[task-link route](../../server/src/routes/activity.ts), and
[private-run regression suite](../../server/src/__tests__/heartbeat-run-privacy-routes.test.ts).
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
Assert the relevant denied actor, owner, company or mode from Policy. Review the
remaining operations before qualifying the whole capability.
Verification: `not-run` for a live sandbox using the updated routes. Bridge and
controller fixtures cover permitted reads, denied writes, redaction and private
task-link filtering. After deployment, verify with an actual run identity and
assert foreign-company, private-task and restricted-mode denial. Source review,
board reads and skipped fixtures do not qualify that live path.
Gaps: Provider trace reveals/downloads require instance-admin and cannot be counted as
ordinary run output access.
The new bridge surface does not qualify retry, cancellation, workspace repair or
session-control access. Deployed diagnostic usefulness remains unverified.
## `session`
@@ -1967,6 +1967,60 @@ describe("createHttp2BridgeServer + createSandboxHttp2BridgeGateway", () => {
}
});
it("relays diagnostic reads and controller denials without admitting execution writes", async () => {
const seen: Http2BridgeForwardRequest[] = [];
const { gateway, handle } = createTestPair({
routes: HTTP2_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST,
forwardRequest: async (request) => {
seen.push(request);
return {
status: request.pathname.includes("other-company") ? 403 : 200,
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify({ path: request.pathname })),
};
},
});
const query = "?limit=25&summary=true";
try {
for (const requestPath of [
"/api/companies/co-1/heartbeat-runs",
"/api/companies/co-1/live-runs",
"/api/heartbeat-runs/run-1",
"/api/heartbeat-runs/run-1/events",
"/api/heartbeat-runs/run-1/log",
"/api/heartbeat-runs/run-1/workspace-operations",
"/api/heartbeat-runs/run-1/issues",
"/api/issues/issue-1/runs",
"/api/issues/issue-1/recovery-actions",
"/api/companies/other-company/heartbeat-runs",
]) {
const response = await gateway.forwardRequest({
method: "GET", path: requestPath, query, headers: {},
body: Buffer.alloc(0), receivedToken: BRIDGE_TOKEN,
});
expect(response.status, requestPath).toBe(requestPath.includes("other-company") ? 403 : 200);
expect(JSON.parse(response.body.toString("utf8"))).toEqual({ path: requestPath });
expect(seen.at(-1)).toMatchObject({ method: "GET", pathname: requestPath, query });
expect(seen.at(-1)?.headers.authorization).toBeUndefined();
}
const forwardedCount = seen.length;
for (const requestPath of [
"/api/heartbeat-runs/run-1/cancel",
"/api/issues/issue-1/recovery-actions/resolve",
]) {
const response = await gateway.forwardRequest({
method: "POST", path: requestPath, query: "", headers: {},
body: Buffer.from("{}"), receivedToken: BRIDGE_TOKEN,
});
expect(response.status).toBe(403);
}
expect(seen).toHaveLength(forwardedCount);
} finally {
await gateway.close();
await handle.close();
}
});
it("test_a_maximum_size_multipart_attachment_upload_fits_through_the_bridge", async () => {
// A file at the exact attachment content ceiling (10 MiB — the same
// figure `MAX_ATTACHMENT_BYTES` in `server/src/attachment-types.ts`
@@ -1510,6 +1510,42 @@ describe("sandbox callback bridge", () => {
await expect(readFile(remotePath, "utf8")).rejects.toThrow();
});
it.each([
"/api/companies/co-1/heartbeat-runs",
"/api/companies/co-1/live-runs",
"/api/heartbeat-runs/run-1",
"/api/heartbeat-runs/run-1/events",
"/api/heartbeat-runs/run-1/log",
"/api/heartbeat-runs/run-1/workspace-operations",
"/api/heartbeat-runs/run-1/issues",
"/api/issues/issue-1/runs",
"/api/issues/issue-1/recovery-actions",
])("admits only GET for diagnostic path %s on both transports", (requestPath) => {
for (const routes of [undefined, HTTP2_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST]) {
expect(authorizeSandboxCallbackBridgeRequestWithRoutes({ method: "GET", path: requestPath }, routes)).toBeNull();
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
expect(authorizeSandboxCallbackBridgeRequestWithRoutes({ method, path: requestPath }, routes))
.toBe(`Route not allowed: ${method} ${requestPath}`);
}
expect(authorizeSandboxCallbackBridgeRequestWithRoutes({ method: "GET", path: `${requestPath}/extra` }, routes))
.toBe(`Route not allowed: GET ${requestPath}/extra`);
}
});
it.each([
{ method: "POST", path: "/api/heartbeat-runs/run-1/cancel" },
{ method: "POST", path: "/api/heartbeat-runs/run-1/retry" },
{ method: "POST", path: "/api/heartbeat-runs/run-1/watchdog-decisions" },
{ method: "POST", path: "/api/issues/issue-1/recovery-actions/resolve" },
{ method: "POST", path: "/api/issues/issue-1/recovery-actions/retry-workspace-export" },
{ method: "GET", path: "/api/heartbeat-runs/run-1/provider-trace" },
{ method: "GET", path: "/api/companies/co-1/provider-traces" },
{ method: "GET", path: "/api/companies/co-1/execution-workspaces" },
])("keeps execution control and unrelated reads denied: $method $path", (request) => {
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request))
.toBe(`Route not allowed: ${request.method} ${request.path}`);
});
it("permits the documented heartbeat surface and denies unrelated routes", () => {
const allowed: Array<{ method: string; path: string }> = [
{ method: "POST", path: "/api/companies/co-1/agent-commentary" },
@@ -154,6 +154,12 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa
{ method: "GET", path: /^\/api\/projects\/[^/]+$/ },
{ method: "GET", path: /^\/api\/goals\/[^/]+$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/heartbeat-runs$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/live-runs$/ },
{ method: "GET", path: /^\/api\/heartbeat-runs\/[^/]+$/ },
{ method: "GET", path: /^\/api\/heartbeat-runs\/[^/]+\/(?:events|log|workspace-operations|issues)$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/(?:runs|recovery-actions)$/ },
// Task-bound email actions. Company, inbox ownership, task/run authority,
// and action policies are enforced by the controller; mailbox setup stays denied.
{ method: "GET", path: /^\/api\/companies\/[^/]+\/email\/inboxes$/ },
@@ -334,6 +334,22 @@ describe("activity routes", () => {
expect(mockActivityService.runsForIssue).not.toHaveBeenCalled();
});
it("passes task read filtering when listing links for a readable company run", async () => {
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1", companyId: "company-1", scopeKind: "company", issueId: null,
});
mockActivityService.issuesForRun.mockResolvedValue([{ issueId: "public-issue", title: "Visible task" }]);
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" });
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/heartbeat-runs/run-1/issues"));
expect(res.status).toBe(200);
const { issueReadSqlCondition } = await import("../services/authorization.js");
expect(issueReadSqlCondition).toHaveBeenCalledWith({}, expect.objectContaining({
type: "agent", companyId: "company-1", agentId: "agent-1",
}));
expect(mockActivityService.issuesForRun).toHaveBeenCalledWith("run-1", {});
expect(res.body).toEqual([{ issueId: "public-issue", title: "Visible task" }]);
});
it("returns 200 [] (not 404) when listing issues for another company's run, preserving API contract and the cross-tenant oracle", async () => {
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-2",
@@ -322,6 +322,36 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
}
});
it("filters private task links from a readable company run while preserving authorized links", async () => {
const fixture = await seedFixture();
const publicIssueId = randomUUID();
await db.insert(issues).values({
id: publicIssueId, companyId: fixture.companyId, identifier: "RPR-2",
title: "Public follow-up", visibility: "open", status: "todo",
});
await db.insert(activityLog).values([fixture.issueId, publicIssueId].map(entityId => ({
companyId: fixture.companyId, runId: fixture.maintenanceRunId,
agentId: fixture.ownerAgentId, actorType: "agent", actorId: fixture.ownerAgentId,
action: "issue.comment_added", entityType: "issue", entityId,
})));
const outsider = await request(createApp(fixture.companyId, fixture.otherAgentId))
.get(`/api/heartbeat-runs/${fixture.maintenanceRunId}/issues`);
expect(outsider.status).toBe(200);
expect(outsider.body).toEqual([expect.objectContaining({ issueId: publicIssueId, title: "Public follow-up" })]);
expect(JSON.stringify(outsider.body)).not.toContain(fixture.issueId);
expect(JSON.stringify(outsider.body)).not.toContain("Confidential email triage");
expect(JSON.stringify(outsider.body)).not.toContain("RPR-1");
for (const agentId of [fixture.ownerAgentId, fixture.grantedAgentId]) {
const response = await request(createApp(fixture.companyId, agentId))
.get(`/api/heartbeat-runs/${fixture.maintenanceRunId}/issues`);
expect(response.status).toBe(200);
expect(response.body.map((row: { issueId: string }) => row.issueId).sort())
.toEqual([fixture.issueId, publicIssueId].sort());
}
});
it("filters credential diagnostics in full-run and history responses by viewer", async () => {
const fixture = await seedFixture();
const readerId = `reader-${randomUUID()}`;
+1 -1
View File
@@ -377,7 +377,7 @@ export function activityRoutes(db: Db) {
res.json([]);
return;
}
const result = await svc.issuesForRun(runId);
const result = await svc.issuesForRun(runId, await issueReadSqlCondition(db, req.actor));
res.json(result);
});
+4 -1
View File
@@ -587,7 +587,7 @@ export function activityService(db: Db) {
});
},
issuesForRun: async (runId: string) => {
issuesForRun: async (runId: string, readCondition: SQL<boolean>) => {
const run = await db
.select({
companyId: heartbeatRuns.companyId,
@@ -613,7 +613,9 @@ export function activityService(db: Db) {
eq(activityLog.companyId, run.companyId),
eq(activityLog.runId, runId),
eq(activityLog.entityType, "issue"),
eq(issues.companyId, run.companyId),
visibleIssueCondition(),
readCondition,
),
)
.orderBy(issueIdAsText);
@@ -636,6 +638,7 @@ export function activityService(db: Db) {
eq(issues.companyId, run.companyId),
eq(issues.id, contextIssueId),
visibleIssueCondition(),
readCondition,
),
)
.then((rows) => rows[0] ?? null);
@@ -1697,3 +1697,5 @@ per-call write approval, records activity, and dispatches the existing continuat
An unauthorized approver receives 403; changed tool definitions, assignment,
identity, or a closed task receive 409; unrelated connections receive 404.
No credentials or provider authorization URL appear in the card payload.
For sandbox run inspection, see [Run diagnostics from a sandbox](run-diagnostics.md).
@@ -0,0 +1,28 @@
# Run diagnostics from a sandbox
The queue and HTTP/2 sandbox bridges admit these read-only routes through the
normal injected Paperclip API credential. The server still checks company,
authorization-boundary and private-task access, and applies its existing run
content and credential redaction. Admission through the bridge does not grant
access to another company's runs or a private task. A list can return a redacted
metadata stub for an unreadable run; count that as unread coverage rather than
inferring a failure cause.
| Method | Path | Description |
| ------ | ---- | ----------- |
| GET | `/api/companies/:companyId/heartbeat-runs` | Recent run history; use `?limit=200&summary=true` first (maximum 1000) |
| GET | `/api/companies/:companyId/live-runs` | Current active runs visible to this actor |
| GET | `/api/heartbeat-runs/:runId` | Run status, failure and execution details |
| GET | `/api/heartbeat-runs/:runId/events` | Run events; request bounded pages with `afterSeq` and `limit` |
| GET | `/api/heartbeat-runs/:runId/log` | Redacted log excerpt; use `offset` and `limitBytes` (maximum 1 MiB per call) |
| GET | `/api/heartbeat-runs/:runId/workspace-operations` | Workspace operation receipts for the run |
| GET | `/api/heartbeat-runs/:runId/issues` | Authorized task links for the run |
| GET | `/api/issues/:issueId/runs` | Run history for an authorized task |
| GET | `/api/issues/:issueId/recovery-actions` | Current and historical recovery records for an authorized task |
Start with recent run summaries, correlate exact task/run/workspace IDs, then
read only the necessary events or log excerpts. Report result caps and denied
reads explicitly. These routes do not authorize retry, cancellation, replay,
workspace repair, recovery resolution, or provider resource changes. Execution
control writes and provider-trace administration remain outside this bridge
diagnostic surface. Do not borrow a board token to work around a denial.