mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 06:15:21 +02:00
## Thinking Path > - Paperclip manages agent work and shows run progress to operators. > - Run lists, live events, transcripts, and workspace details must remain responsive as usage grows. > - Run-list redaction rereads the full context for every run. Hidden tabs can still trigger requests through live events and manual timers. > - Workspace detail reads repeat Git inspection even when concurrent callers request the same state. > - This pull request batches registry reads, pauses hidden-tab refreshes, and caches Git inspection for display. > - Cleanup keeps fresh Git checks, and redaction keeps company and run boundaries. ## Linked Issues **What happened?** Run-list responses perform one extra database read per run and parse full context JSON to obtain small secret registries. Hidden tabs continue transcript reads and event-triggered refetches. Workspace detail requests repeat Git scans. **Expected behavior** A run list reads registries once. Hidden tabs stop recurring run reads and reconcile when visible. Concurrent workspace detail reads share a short-lived Git result. **Steps to reproduce** 1. Open run lists and task transcripts in several tabs while agents run. 2. Hide some tabs and observe transcript and event-triggered requests. 3. Request a 200-run list and count redaction database queries. 4. Request the same workspace detail concurrently and count Git inspections. Related: #5255 adjusts polling cadence. This change addresses hidden-tab lifecycle, batched registry reads, and workspace inspection reuse. No duplicate with this scope was found. ## What Changed - Batch heartbeat and live-run redaction into one company-scoped registry query. Select only registry JSON for run and issue redaction. - Resolve duplicate secret values once per request. Preserve each run's registry and remove registry material from responses. - Suspend company event sockets and transcript reads while hidden. Refresh active queries and resume transcript offsets on return. - Prevent queued event invalidations and developer health polling from fetching in hidden tabs. Gate legacy run-log readers in both UI variants. - Exclude legacy plugin placeholder connections from remote health probes. Select only due connection IDs in SQL before the sweep limit. Preserve existing plugin records. - Cache concurrent Git display inspections for five seconds, with at most 256 entries. Leave close-readiness and cleanup checks uncached. - Add regression coverage and document the performance behavior. - Stabilize the existing Rust descendant-lineage fixture: allow a bounded 30 seconds for 300 durable notifications under concurrent test load, retaining every correctness assertion and adding timeout diagnostics. ## Verification - Regression coverage verifies one registry query for 200 runs, per-run isolation, request-local secret resolution, decryption failures, Git cache expiry/bounds, hidden-tab pause, and visibility recovery. - Real PostgreSQL redaction/run-route suites passed all 57 tests; workspace-service coverage passed. The health-sweep regression verifies plugin placeholders and chat connections remain untouched and do not consume the sweep limit. - Both legacy transcript viewers retain history and resume their byte offset after visibility changes. The related visibility/progress/chunk suites passed all 29 tests. Other focused UI suites and token gates passed. - Full `pnpm -r typecheck` and `pnpm build` passed. Affected-package typechecks/builds passed after review fixes. The concurrent Rust provider suite passed 84 tests (two ignored), and Rust formatting passed. - Full local `pnpm test:run` stopped after the general-server group: 10,538 passed, 65 skipped, four failed. Fresh chat-delivery and health-sweep reruns passed; building the debug runner fixture cleared the native-event test. One unchanged native-session recovery assertion still fails locally with a semantic-digest error instead of the expected settled-session message. The full local command is therefore not green. CI runs the later groups separately and skips the two native-session tests requiring a prebuilt runner binary (confirmed in its 37-test native-session suite). - All CI gates pass on final head `ee610e737`: typechecking, general and serialized tests, browser tests, runner verification, build, and canary dry run. One server shard passed on its single retry after exposure fixtures encountered port 42001 where they assumed 42000; that suite also passed locally (25 passed, three platform-specific skips). - Greptile reviewed the final head at 5/5 with no actionable findings. ## Risks - Workspace delivery display can lag local Git changes by five seconds. Destructive operations still inspect current state. - Hidden tabs do not receive company live-event notifications until visible. Active queries refresh on return. - This change preserves legacy plugin records and does not repair instance-specific workspace rows. There is no database migration. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, code execution, and browser inspection. The exact model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `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 (targeted regressions; full-suite limitation documented above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdirSync, rmSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
companies,
|
|
companySecretBindings,
|
|
companySecretProviderConfigs,
|
|
companySecretVersions,
|
|
companySecrets,
|
|
createDb,
|
|
heartbeatRuns,
|
|
secretAccessEvents,
|
|
} from "@paperclipai/db";
|
|
import { LOW_TRUST_REVIEW_PRESET, type AgentApiKeyScope } from "@paperclipai/shared";
|
|
import { REDACTED_EVENT_VALUE } from "../redaction.js";
|
|
import { errorHandler } from "../middleware/error-handler.js";
|
|
import { secretRoutes } from "../routes/secrets.js";
|
|
import { secretService } from "../services/secrets.js";
|
|
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
describeEmbeddedPostgres("agent secret routes", () => {
|
|
let stopDb: (() => Promise<void>) | null = null;
|
|
let db!: ReturnType<typeof createDb>;
|
|
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-agent-secret-routes-${randomUUID()}`);
|
|
|
|
beforeAll(async () => {
|
|
mkdirSync(secretsTmpDir, { recursive: true });
|
|
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
|
|
const started = await startEmbeddedPostgresTestDatabase("agent-secret-routes");
|
|
stopDb = started.cleanup;
|
|
db = createDb(started.connectionString);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.delete(activityLog);
|
|
await db.delete(secretAccessEvents);
|
|
await db.delete(companySecretBindings);
|
|
await db.delete(companySecretVersions);
|
|
await db.delete(companySecrets);
|
|
await db.delete(companySecretProviderConfigs);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await stopDb?.();
|
|
if (previousKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
|
rmSync(secretsTmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function seedAgentRun(permissions: Record<string, unknown> = {}) {
|
|
const companyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const heartbeatRunId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Agent secret routes",
|
|
issuePrefix: `S${companyId.slice(0, 7)}`.toUpperCase(),
|
|
status: "active",
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Secret reader",
|
|
role: "engineer",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
permissions,
|
|
status: "idle",
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: heartbeatRunId,
|
|
companyId,
|
|
agentId,
|
|
status: "running",
|
|
contextSnapshot: {},
|
|
});
|
|
return { companyId, agentId, heartbeatRunId };
|
|
}
|
|
|
|
function createApp(
|
|
fixture: Awaited<ReturnType<typeof seedAgentRun>>,
|
|
keyScope: AgentApiKeyScope = { kind: "standard" },
|
|
source: "agent_jwt" | "agent_key" = "agent_jwt",
|
|
) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = {
|
|
type: "agent",
|
|
agentId: fixture.agentId,
|
|
companyId: fixture.companyId,
|
|
runId: fixture.heartbeatRunId,
|
|
keyScope,
|
|
keyId: source === "agent_key" ? randomUUID() : undefined,
|
|
source,
|
|
};
|
|
next();
|
|
});
|
|
app.use("/api", secretRoutes(db));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
it("lists metadata only, reads env and access grants, and audits success and failure", async () => {
|
|
const fixture = await seedAgentRun();
|
|
const svc = secretService(db);
|
|
const envSecret = await svc.create(fixture.companyId, {
|
|
key: "ENV_ONLY_KEY",
|
|
name: "Env only",
|
|
description: "Injected and API-readable",
|
|
provider: "local_encrypted",
|
|
value: "env-secret-value",
|
|
});
|
|
const apiSecret = await svc.create(fixture.companyId, {
|
|
key: "API_ONLY_KEY",
|
|
name: "API only",
|
|
provider: "local_encrypted",
|
|
value: "api-secret-value",
|
|
});
|
|
const unboundSecret = await svc.create(fixture.companyId, {
|
|
key: "UNBOUND_KEY",
|
|
name: "Unbound",
|
|
provider: "local_encrypted",
|
|
value: "unbound-secret-value",
|
|
});
|
|
const projectSecret = await svc.create(fixture.companyId, {
|
|
key: "PROJECT_KEY",
|
|
name: "Project layer",
|
|
provider: "local_encrypted",
|
|
value: "project-secret-value",
|
|
});
|
|
await svc.createBinding({
|
|
companyId: fixture.companyId,
|
|
secretId: envSecret.id,
|
|
targetType: "agent",
|
|
targetId: fixture.agentId,
|
|
configPath: "env.ENV_ONLY_KEY",
|
|
});
|
|
await svc.createBinding({
|
|
companyId: fixture.companyId,
|
|
secretId: apiSecret.id,
|
|
targetType: "agent",
|
|
targetId: fixture.agentId,
|
|
configPath: "access.API_ONLY_KEY",
|
|
projectionClass: "class_2_runtime_only",
|
|
});
|
|
const projectBinding = await svc.createBinding({
|
|
companyId: fixture.companyId,
|
|
secretId: projectSecret.id,
|
|
targetType: "project",
|
|
targetId: randomUUID(),
|
|
configPath: "env.PROJECT_KEY",
|
|
});
|
|
await db.update(heartbeatRuns).set({
|
|
contextSnapshot: {
|
|
paperclipSecrets: {
|
|
manifest: [{
|
|
bindingId: projectBinding.id,
|
|
secretId: projectSecret.id,
|
|
configPath: projectBinding.configPath,
|
|
}],
|
|
},
|
|
},
|
|
}).where(eq(heartbeatRuns.id, fixture.heartbeatRunId));
|
|
|
|
const list = await request(createApp(fixture)).get("/api/agents/me/secrets");
|
|
expect(list.status).toBe(200);
|
|
expect(list.body.secrets).toEqual([
|
|
expect.objectContaining({ key: "api_only_key", delivery: "api", projectionClass: "class_2_runtime_only" }),
|
|
expect.objectContaining({ key: "env_only_key", delivery: "env" }),
|
|
expect.objectContaining({ key: "project_key", delivery: "env" }),
|
|
]);
|
|
expect(JSON.stringify(list.body)).not.toContain("secret-value");
|
|
expect(await db.select().from(secretAccessEvents)).toEqual([]);
|
|
expect(await db.select().from(activityLog)).toEqual([
|
|
expect.objectContaining({ action: "secret.access.listed", runId: fixture.heartbeatRunId }),
|
|
]);
|
|
|
|
const fetched = await request(createApp(fixture)).post("/api/agents/me/secrets/env_only_key/value");
|
|
expect(fetched.status).toBe(200);
|
|
expect(fetched.headers["cache-control"]).toBe("no-store");
|
|
expect(fetched.body).toEqual({ key: "env_only_key", value: "env-secret-value", version: 1 });
|
|
const [registeredRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.heartbeatRunId));
|
|
expect(JSON.stringify(registeredRun.contextSnapshot)).not.toContain("env-secret-value");
|
|
expect(registeredRun.contextSnapshot).toMatchObject({
|
|
paperclipSecretRedactions: [expect.objectContaining({ fingerprintSha256: expect.any(String), material: expect.any(Object) })],
|
|
});
|
|
expect(await db.select().from(secretAccessEvents)).toEqual([
|
|
expect.objectContaining({ secretId: envSecret.id, outcome: "success", consumerType: "agent_api" }),
|
|
]);
|
|
expect(await db.select().from(activityLog)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ action: "secret.value.read", entityId: envSecret.id }),
|
|
]));
|
|
|
|
const projectFetched = await request(createApp(fixture)).post("/api/agents/me/secrets/project_key/value");
|
|
expect(projectFetched.status).toBe(200);
|
|
expect(projectFetched.body).toEqual({ key: "project_key", value: "project-secret-value", version: 1 });
|
|
expect(await db.select().from(secretAccessEvents)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ secretId: projectSecret.id, outcome: "success", consumerType: "agent_api" }),
|
|
]));
|
|
|
|
const denied = await request(createApp(fixture)).post("/api/agents/me/secrets/unbound_key/value");
|
|
expect(denied.status).toBe(403);
|
|
expect(await db.select().from(secretAccessEvents)).not.toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ secretId: unboundSecret.id }),
|
|
]));
|
|
expect(await db.select().from(activityLog)).not.toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ action: "secret.value.read", entityId: unboundSecret.id }),
|
|
]));
|
|
});
|
|
|
|
it("fails closed when run redaction registration cannot be persisted", async () => {
|
|
const fixture = await seedAgentRun();
|
|
await expect(createRunSecretRedactionRegistry(db).register(fixture.companyId, randomUUID(), "must-not-return"))
|
|
.rejects.toThrow("Heartbeat run redaction registration failed");
|
|
});
|
|
|
|
it("deduplicates concurrent redaction registrations for the same run and value", async () => {
|
|
const fixture = await seedAgentRun();
|
|
const registry = createRunSecretRedactionRegistry(db);
|
|
|
|
await Promise.all([
|
|
registry.register(fixture.companyId, fixture.heartbeatRunId, "duplicate-secret"),
|
|
registry.register(fixture.companyId, fixture.heartbeatRunId, "duplicate-secret"),
|
|
]);
|
|
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.heartbeatRunId));
|
|
expect(run.contextSnapshot).toMatchObject({
|
|
paperclipSecretRedactions: [expect.objectContaining({ fingerprintSha256: expect.any(String) })],
|
|
});
|
|
expect((run.contextSnapshot as { paperclipSecretRedactions: unknown[] }).paperclipSecretRedactions).toHaveLength(1);
|
|
});
|
|
|
|
it("redacts batched runs from projected registries and enforces company scope", async () => {
|
|
const first = await seedAgentRun();
|
|
const foreign = await seedAgentRun();
|
|
const registry = createRunSecretRedactionRegistry(db);
|
|
await registry.register(first.companyId, first.heartbeatRunId, "first-secret-value");
|
|
await registry.register(foreign.companyId, foreign.heartbeatRunId, "foreign-secret-value");
|
|
const runs = [
|
|
{ id: first.heartbeatRunId, text: "first-secret-value foreign-secret-value", createdAt: new Date() },
|
|
{ id: foreign.heartbeatRunId, text: "foreign-secret-value", createdAt: new Date() },
|
|
];
|
|
const redacted = await registry.redactForRuns(first.companyId, runs);
|
|
expect(redacted[0].text).toBe(`${REDACTED_EVENT_VALUE} foreign-secret-value`);
|
|
expect(redacted[0].createdAt).toEqual(runs[0].createdAt);
|
|
expect(redacted[1].text).toBe("foreign-secret-value");
|
|
expect(await registry.redactForRun(first.companyId, first.heartbeatRunId, runs[0].text))
|
|
.toBe(redacted[0].text);
|
|
});
|
|
|
|
it("denies low-trust, task-bridge, and skill-test callers on both routes", async () => {
|
|
const lowTrust = await seedAgentRun({
|
|
trustPreset: LOW_TRUST_REVIEW_PRESET,
|
|
authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, projectIds: [randomUUID()] } },
|
|
});
|
|
const standard = await seedAgentRun();
|
|
const cases = [
|
|
{ name: "low trust", fixture: lowTrust, scope: { kind: "standard" } as const, source: "agent_jwt" as const },
|
|
{ name: "task bridge", fixture: standard, scope: { kind: "task_bridge", parentIssueId: randomUUID() } as const, source: "agent_key" as const },
|
|
{ name: "skill test", fixture: standard, scope: { kind: "skill_test", issueId: randomUUID() } as const, source: "agent_jwt" as const },
|
|
];
|
|
for (const testCase of cases) {
|
|
expect(
|
|
(await request(createApp(testCase.fixture, testCase.scope, testCase.source)).get("/api/agents/me/secrets")).status,
|
|
`${testCase.name} list`,
|
|
).toBe(403);
|
|
expect(
|
|
(await request(createApp(testCase.fixture, testCase.scope, testCase.source)).post("/api/agents/me/secrets/ANY/value")).status,
|
|
`${testCase.name} fetch`,
|
|
).toBe(403);
|
|
}
|
|
});
|
|
});
|