mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
Merge current master into reviewed Claude behavior
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/claude-runner-protocol: fix(ci): finish large Honeycomb profile exports (#16423) perf: batch retained-reader authorization reads (#16424)
This commit is contained in:
commit
9e3dac873b
9 files changed
+267
-36
No files matched your search
@@ -105,6 +105,21 @@ test("coverage is conservative after failures and does not merge framework invoc
|
||||
assert.equal(trace.events[0].data["ci.reported_invocations"], 1);
|
||||
});
|
||||
|
||||
test("complete reporter pairs count without launcher intent, and long test names remain timed", () => {
|
||||
const records = [
|
||||
record({ kind: "job" }), record({ kind: "job", job: "second" }),
|
||||
record({ framework: "node", kind: "start" }), record({ framework: "node", kind: "end" }),
|
||||
record({ kind: "test", id: "long", parent: "file", name: "x".repeat(5000),
|
||||
file: "a.test.ts", start: Date.parse(at(4)), duration: 100, status: "passed" }),
|
||||
record({ kind: "file", id: "file", name: "a.test.ts", file: "a.test.ts",
|
||||
start: Date.parse(at(4)), duration: 100, status: "passed" }),
|
||||
];
|
||||
const trace = buildTrace({ ...run, conclusion: "success" }, jobs, records);
|
||||
assert.equal(trace.events[0].data["ci.profile_complete"], true);
|
||||
assert.equal(trace.events[0].data["ci.test_count"], 1);
|
||||
assert.equal(trace.events.find((event) => event.data["ci.kind"] === "test").data.name.length, 256);
|
||||
});
|
||||
|
||||
test("trusted policy commands work with both current and pre-profile checkouts", () => {
|
||||
const source = readFileSync(new URL("../../workflows/pr-trusted.yml", import.meta.url), "utf8");
|
||||
const block = source.split(" - name: Select compatible Node test command\n")[1].split("\n - name:")[0];
|
||||
@@ -191,18 +206,30 @@ test("profiling uses only infrastructure CODEOWNERS resolved at trusted master",
|
||||
]);
|
||||
});
|
||||
|
||||
test("export checks per-event ingestion results, including partial failures", async () => {
|
||||
test("export paces batches and retries only rejected transient events", async () => {
|
||||
const events = Array.from({ length: 501 }, () => ({ data: {} }));
|
||||
const sizes = [];
|
||||
const pauses = [];
|
||||
await sendEvents(events, "test-only", "paperclip-ci", async (url, options) => {
|
||||
assert.equal(url, "https://api.honeycomb.io/1/batch/paperclip-ci");
|
||||
assert.equal(options.redirect, "error");
|
||||
const size = JSON.parse(options.body).length;
|
||||
sizes.push(size);
|
||||
return { ok: true, json: async () => Array.from({ length: size }, () => ({ status: 202 })) };
|
||||
});
|
||||
assert.deepEqual(sizes, [500, 1]);
|
||||
await assert.rejects(sendEvents([events[0]], "test-only", "paperclip-ci", async () => ({ ok: true, json: async () => [{ status: 400 }] })), /every span/);
|
||||
}, async (ms) => { pauses.push(ms); });
|
||||
assert.deepEqual(sizes, [250, 250, 1]);
|
||||
assert.deepEqual(pauses, [1000, 1000]);
|
||||
let calls = 0;
|
||||
const retried = [];
|
||||
await sendEvents([{ data: { id: 1 } }, { data: { id: 2 } }], "test-only", "paperclip-ci", async (_url, options) => {
|
||||
const batch = JSON.parse(options.body);
|
||||
retried.push(batch.map((event) => event.data.id));
|
||||
calls += 1;
|
||||
return { ok: true, json: async () => calls === 1 ? [{ status: 202 }, { status: 429 }] : [{ status: 202 }] };
|
||||
}, async (ms) => { pauses.push(ms); });
|
||||
assert.deepEqual(retried, [[1, 2], [2]]);
|
||||
assert.equal(pauses.at(-1), 1000);
|
||||
await assert.rejects(sendEvents([events[0]], "test-only", "paperclip-ci", async () => ({ ok: true, json: async () => [{ status: 400 }] })), /status 400/);
|
||||
});
|
||||
|
||||
test("workflow exporter runs trusted code and keeps keys out of the PR jobs", () => {
|
||||
|
||||
+14
-5
@@ -17,7 +17,8 @@ The PR author, original workflow actor, and rerun actor must all be individual
|
||||
users in the trusted `master` branch's `.github/**` CODEOWNERS rule. GitHub user
|
||||
IDs are resolved from that rule; PR edits to CODEOWNERS cannot grant access.
|
||||
A label on an outside contributor's PR does not enable profiling, even when a
|
||||
maintainer applies it. Failed identity lookups disable profiling.
|
||||
maintainer applies it. The PR still runs normal CI and needs no Honeycomb
|
||||
access. Failed identity lookups disable profiling.
|
||||
|
||||
Ordinary PRs do not write timing artifacts or export traces. The exporter job is
|
||||
skipped for ordinary run names. Adding another label does not run the CI matrix.
|
||||
@@ -42,6 +43,10 @@ The expandable hierarchy is **workflow → job → step → test filename → te
|
||||
Vitest, Playwright, and Node file rows use repository-relative filenames, also
|
||||
available in `test.file`. Separate files retain their own children even when
|
||||
individual test names repeat across files.
|
||||
Honeycomb shows only the 32,000 spans closest to the root in a very large trace.
|
||||
All file rows are near the root, but the chart can omit some individual test
|
||||
rows. Use **View events** to query all recorded test spans. Test names are
|
||||
limited to 256 characters in the trace; file paths remain intact.
|
||||
|
||||
- Compare the jobs that finish last (`ci.finishes_last`) with other overlapping
|
||||
work. A long job that finishes early is not necessarily delaying CI.
|
||||
@@ -79,7 +84,9 @@ artifacts, interrupted reporters, rejected data, and absent test details visible
|
||||
Test launchers write an expected-invocation record before starting each Vitest,
|
||||
Playwright, or Node process. Each reporter must write matching start/end records,
|
||||
so a missing reporter cannot hide behind another successful invocation in the
|
||||
same job. Compare `ci.expected_invocations` with `ci.reported_invocations`.
|
||||
same job. Direct package scripts can start a reporter without the launcher;
|
||||
their start/end pair proves collection. Compare `ci.expected_invocations` with
|
||||
`ci.reported_invocations`.
|
||||
Failed or cancelled workflows are conservatively incomplete because a failed
|
||||
command can prevent later tests or steps from running.
|
||||
|
||||
@@ -90,9 +97,10 @@ unambiguous full-suite baseline. GitHub may carry earlier successful jobs into
|
||||
the new attempt's job inventory. Jobs completed before the new attempt started
|
||||
are excluded and counted in `ci.carried_job_count`.
|
||||
|
||||
If export fails, inspect **CI profile** and rerun that exporter after fixing the
|
||||
cause. A partial HTTP export may already have sent some spans; retries use the
|
||||
same IDs but the Events API does not promise deduplication. Timing artifacts
|
||||
The exporter sends bounded batches with pauses and retries only events that
|
||||
Honeycomb rejects temporarily. If export still fails, inspect **CI profile**
|
||||
before rerunning it. A partial HTTP export may already have sent some spans;
|
||||
reruns use the same IDs but the Events API does not promise deduplication. Timing artifacts
|
||||
remain available for 14 days. Invalid/oversized artifacts stop export instead of
|
||||
silently truncating the suite. No logs, environment dumps, error messages, stack
|
||||
traces, or credentials are included in the exported field allowlist.
|
||||
@@ -144,4 +152,5 @@ workflow.
|
||||
|
||||
References: [Honeycomb trace fields](https://docs.honeycomb.io/get-started/best-practices/organizing-data/),
|
||||
[Events API](https://docs.honeycomb.io/api/events/create-events),
|
||||
[large trace display limit](https://docs.honeycomb.io/troubleshoot/common-issues/data-in-honeycomb/#your-very-large-trace-displays-only-32000-spans),
|
||||
[GitHub workflow_run](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_run).
|
||||
@@ -0,0 +1,42 @@
|
||||
# Bound retained-reader authorization database work
|
||||
|
||||
## Measured problem
|
||||
|
||||
A synthetic local service fixture uses 383 workspace paths and 64 retained runs
|
||||
with the same agent and responsible user. It exercises the production publication
|
||||
guard against disposable PostgreSQL. It does not represent a live tenant or a
|
||||
Product E2E evaluation.
|
||||
|
||||
The original guard made 1,431 queries. A declared 4ms round-trip delay in a
|
||||
loopback proxy increased one admission to 15.19 seconds and three concurrent
|
||||
admissions to 54.69 seconds. The company root fence serializes those admissions.
|
||||
With this change, one admission makes 680 queries and takes 7.13 seconds under
|
||||
the same proxy; three take 20.93 seconds. The zero-injected-delay samples took
|
||||
359ms before and 146ms after. Timing samples are controlled local evidence, not
|
||||
an agent-response or hosted latency guarantee.
|
||||
|
||||
## Change boundary
|
||||
|
||||
Retained readers still need individual saved-run identity and execution-policy
|
||||
checks. Current readers still need the existing write checks. Existing company
|
||||
root fences, row locks, source privacy and publication ordering stay in place.
|
||||
|
||||
Batch retained-reader project reads through the existing multi-actor read API.
|
||||
Share only database read promises within one call. Keep actor-supplied membership
|
||||
snapshots and per-actor privacy results separate. Key raw reads by their company,
|
||||
principal and run as applicable. Each later call reads current grants again.
|
||||
Select only the run identity and saved policy fields needed by the guard,
|
||||
including both native and legacy issue-binding fallbacks.
|
||||
|
||||
## Verification and remaining proof
|
||||
|
||||
The query-count regression must fail on the original code without changing its
|
||||
800-query bound. Distinct reader identities and stored policies must still fail
|
||||
closed. Grant revocation must affect the next call. Supplied membership snapshots
|
||||
must match individual decisions in both actor orders without leaking into database
|
||||
snapshots. Run the complete affected authorization and source-trust suites.
|
||||
|
||||
Full repository typecheck and build plus fresh current-head CI and review precede
|
||||
merge. A real hosted warm agent reply remains the acceptance test for the separate
|
||||
under-30-second objective. Neither this service benchmark nor a fresh local
|
||||
provider run establishes that hosted result.
|
||||
@@ -14,19 +14,35 @@ const get = (endpoint) => JSON.parse(gh([endpoint]).toString());
|
||||
const pages = (endpoint, field) => JSON.parse(gh(["--paginate", "--slurp", endpoint]).toString())
|
||||
.flatMap((page) => field ? page[field] : page);
|
||||
|
||||
export async function sendEvents(events, apiKey, dataset, fetcher = fetch) {
|
||||
export async function sendEvents(events, apiKey, dataset, fetcher = fetch, pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
||||
if (!apiKey) throw new Error("HONEYCOMB_CI_API_KEY is not configured");
|
||||
for (let offset = 0; offset < events.length; offset += 500) {
|
||||
const batch = events.slice(offset, offset + 500);
|
||||
const response = await fetcher(`https://api.honeycomb.io/1/batch/${encodeURIComponent(dataset)}`, {
|
||||
method: "POST", headers: { "X-Honeycomb-Team": apiKey, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(batch), redirect: "error", signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Honeycomb export failed (HTTP ${response.status})`);
|
||||
const results = await response.json();
|
||||
if (!Array.isArray(results) || results.length !== batch.length || results.some((result) => result.status !== 202)) {
|
||||
throw new Error("Honeycomb did not accept every span in the batch");
|
||||
const transient = new Set([429, 500, 502, 503, 504]);
|
||||
for (let offset = 0; offset < events.length; offset += 250) {
|
||||
let pending = events.slice(offset, offset + 250);
|
||||
for (let attempt = 0; pending.length; attempt += 1) {
|
||||
if (attempt >= 8) throw new Error(`Honeycomb did not accept ${pending.length} spans after retries`);
|
||||
const response = await fetcher(`https://api.honeycomb.io/1/batch/${encodeURIComponent(dataset)}`, {
|
||||
method: "POST", headers: { "X-Honeycomb-Team": apiKey, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(pending), redirect: "error", signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (!transient.has(response.status)) throw new Error(`Honeycomb export failed (HTTP ${response.status})`);
|
||||
await pause(Math.min(30_000, 1000 * 2 ** attempt));
|
||||
continue;
|
||||
}
|
||||
const results = await response.json();
|
||||
if (!Array.isArray(results) || results.length !== pending.length) {
|
||||
throw new Error("Honeycomb returned an unexpected batch response");
|
||||
}
|
||||
const rejected = results.flatMap((result, index) => result.status === 202 ? [] : [{ event: pending[index], status: result.status }]);
|
||||
const permanent = rejected.find(({ status }) => !transient.has(status));
|
||||
if (permanent) throw new Error(`Honeycomb rejected ${rejected.length} spans (status ${permanent.status})`);
|
||||
pending = rejected.map(({ event }) => event);
|
||||
if (pending.length) await pause(Math.min(30_000, 1000 * 2 ** attempt));
|
||||
}
|
||||
// The full suite can exceed Honeycomb's burst allowance. Leave capacity
|
||||
// for the team's other telemetry rather than exhausting it in one export.
|
||||
if (offset + 250 < events.length) await pause(1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,8 @@ export function buildTrace(run, jobs, records = []) {
|
||||
invocations.set(invocation, state);
|
||||
continue;
|
||||
}
|
||||
if (!["file", "test"].includes(record.kind) || !text(record.id) || !text(record.name)
|
||||
if (!["file", "test"].includes(record.kind) || !text(record.id)
|
||||
|| (record.kind === "file" ? !text(record.name) : typeof record.name !== "string")
|
||||
|| !text(record.file) || !["vitest", "playwright", "node"].includes(record.framework)
|
||||
|| !Number.isFinite(record.start) || !Number.isFinite(record.duration) || record.duration < 0
|
||||
|| record.start < millis(job.started_at) - 1000
|
||||
@@ -128,7 +129,7 @@ export function buildTrace(run, jobs, records = []) {
|
||||
&& candidate.conclusion !== "skipped");
|
||||
const parent = files.get(`${invocation}:${record.parent}`)
|
||||
|| (step ? id(`${jobId}:step:${step.number}`) : jobId);
|
||||
span(recordId(entry), parent, record.name, record.start, record.start + record.duration, {
|
||||
span(recordId(entry), parent, record.kind === "test" ? record.name.slice(0, 256) : record.name, record.start, record.start + record.duration, {
|
||||
"ci.kind": record.kind, "ci.job": job.name, "test.framework": record.framework,
|
||||
"test.file": record.file, "test.status": typeof record.status === "string" ? record.status.slice(0, 32) : "unknown", error: failed(record.status),
|
||||
"test.retries": Number.isFinite(record.retries) ? record.retries : 0,
|
||||
@@ -140,7 +141,9 @@ export function buildTrace(run, jobs, records = []) {
|
||||
const expectedReceipts = jobs.filter((job) => job.steps?.some((step) => step.name === "Initialize CI profile"));
|
||||
const missing = expectedReceipts.filter((job) => !receipts.has(job.id));
|
||||
if (missing.length) warnings.push(`Missing timing artifacts for ${missing.length} jobs`);
|
||||
const incomplete = [...invocations.values()].filter((value) => !value.expected || !value.start || !value.end).length;
|
||||
// Some package scripts invoke a reporter without using the launcher. Its
|
||||
// start/end pair proves collection even when no intent record was written.
|
||||
const incomplete = [...invocations.values()].filter((value) => !value.start || !value.end).length;
|
||||
if (incomplete) warnings.push(`${incomplete} test invocations did not finish reporting`);
|
||||
if (rejected) warnings.push(`${rejected} invalid timing records ignored`);
|
||||
if (run.conclusion !== "success") warnings.push("Workflow did not succeed; later tests or steps may not have run");
|
||||
@@ -159,8 +162,8 @@ export function buildTrace(run, jobs, records = []) {
|
||||
if (expectedReceipts.length && missingTests.length) warnings.push(`Missing test detail for ${missingTests.length} test jobs (including jobs that failed before testing)`);
|
||||
root["ci.test_count"] = valid.filter(({ record }) => record.kind === "test").length;
|
||||
root["ci.file_count"] = valid.filter(({ record }) => record.kind === "file").length;
|
||||
root["ci.expected_invocations"] = [...invocations.values()].filter((value) => value.expected).length;
|
||||
root["ci.reported_invocations"] = [...invocations.values()].filter((value) => value.expected && value.start && value.end).length;
|
||||
root["ci.expected_invocations"] = invocations.size;
|
||||
root["ci.reported_invocations"] = [...invocations.values()].filter((value) => value.start && value.end).length;
|
||||
root["ci.profile_complete"] = warnings.length === 0 && run.run_attempt === 1;
|
||||
root["ci.coverage_warnings"] = warnings.join("; ");
|
||||
return { traceId, events, warnings, wallMs: end - start, jobCount: jobs.length };
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { afterAll, beforeAll, expect, it, vi } from "vitest";
|
||||
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositoryAccess,
|
||||
executionWorkspaces, heartbeatRuns, issues, projects, projectAccessMembers, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
|
||||
import { authorizationService, type AuthorizationActor } from "../services/authorization.js";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let cwd: string;
|
||||
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), issueId = randomUUID(), projectId = randomUUID();
|
||||
const runIds = Array.from({ length: 64 }, () => randomUUID());
|
||||
let workspaceId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
database = await startEmbeddedPostgresTestDatabase("root-reader-query-");
|
||||
db = createDb(database.connectionString);
|
||||
cwd = await realpath(await mkdtemp(path.join(tmpdir(), "root-reader-query-")));
|
||||
await db.insert(companies).values({ id: companyId, name: "Retained readers", issuePrefix: "RRQ" });
|
||||
await db.insert(authUsers).values({ id: userId, name: "Sponsor", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "process" });
|
||||
await db.insert(companyMemberships).values([
|
||||
{ companyId, principalType: "user", principalId: userId, membershipRole: "operator", status: "active" },
|
||||
{ companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" },
|
||||
]);
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Source" });
|
||||
const [workspace] = await db.insert(executionWorkspaces).values({ companyId, projectId, cwd, name: "Task source", mode: "shared_workspace", strategyType: "operator_directory" }).returning();
|
||||
workspaceId = workspace.id;
|
||||
await db.insert(issues).values({ id: issueId, companyId, projectId, title: "Task", assigneeAgentId: agentId, executionWorkspaceId: workspaceId });
|
||||
await db.insert(heartbeatRuns).values(runIds.map(id => ({ id, companyId, agentId, responsibleUserId: userId,
|
||||
status: "succeeded", contextSnapshot: { issueId }, resultJson: { unusedPayload: "x".repeat(65536) } })));
|
||||
const rootKey = createHash("sha256").update(cwd).digest("hex");
|
||||
await db.insert(executionWorkspaceRepositoryAccess).values(runIds.map(scopeId => ({ companyId, rootKey,
|
||||
canonicalCwd: cwd, kind: "reader", scopeId, issueId, agentId, responsibleUserId: userId })));
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await database?.cleanup();
|
||||
if (cwd) await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const input = () => ({ companyId, issueId, workspaceId, cwd, agentId, runId: runIds[0] });
|
||||
|
||||
it("bounds repeated-principal queries without loading retained run payloads", async () => {
|
||||
const queries: string[] = [];
|
||||
const recorded = drizzle(db.$client, { schema: db._.fullSchema, logger: { logQuery: query => queries.push(query) } }) as unknown as typeof db;
|
||||
await executionWorkspaceRepositoryService(recorded).guardForPublication(input());
|
||||
expect(queries.length).toBeLessThan(800);
|
||||
const runs = queries.filter(query => query.includes('from "heartbeat_runs"'));
|
||||
expect(runs.length).toBeGreaterThan(0);
|
||||
expect(runs.every(query => !query.includes('"result_json"') && !query.includes('"runner_profile_json"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps each retained run identity and policy distinct for the same principals", async () => {
|
||||
const service = executionWorkspaceRepositoryService(db);
|
||||
const invalidId = runIds[31];
|
||||
try {
|
||||
await db.update(executionWorkspaceRepositoryAccess).set({ responsibleUserId: randomUUID() }).where(eq(executionWorkspaceRepositoryAccess.scopeId, invalidId));
|
||||
await expect(service.guardForPublication(input())).rejects.toThrow("identity");
|
||||
await db.update(executionWorkspaceRepositoryAccess).set({ responsibleUserId: userId }).where(eq(executionWorkspaceRepositoryAccess.scopeId, invalidId));
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionPolicy: { trustPreset: "unsupported" } } }).where(eq(heartbeatRuns.id, invalidId));
|
||||
await expect(service.guardForPublication(input())).rejects.toThrow();
|
||||
} finally {
|
||||
await db.update(executionWorkspaceRepositoryAccess).set({ responsibleUserId: userId }).where(eq(executionWorkspaceRepositoryAccess.scopeId, invalidId));
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, invalidId));
|
||||
}
|
||||
await expect(service.guardForPublication(input())).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("reloads project grants on every publication attempt", async () => {
|
||||
const service = executionWorkspaceRepositoryService(db);
|
||||
await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, projectId));
|
||||
const grants = await db.insert(projectAccessMembers).values([
|
||||
{ companyId, projectId, subjectType: "agent", subjectId: agentId },
|
||||
{ companyId, projectId, subjectType: "user", subjectId: userId },
|
||||
]).returning();
|
||||
try {
|
||||
await expect(service.guardForPublication(input())).resolves.toBeUndefined();
|
||||
await db.delete(projectAccessMembers).where(and(eq(projectAccessMembers.projectId, projectId), eq(projectAccessMembers.subjectType, "user")));
|
||||
await expect(service.guardForPublication(input())).rejects.toThrow();
|
||||
} finally {
|
||||
await db.delete(projectAccessMembers).where(inArray(projectAccessMembers.id, grants.map(grant => grant.id)));
|
||||
await db.update(projects).set({ visibility: "open" }).where(eq(projects.id, projectId));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps supplied membership snapshots separate from fresh database snapshots", async () => {
|
||||
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId, agentId,
|
||||
runId: runIds[0], onBehalfOfUserId: userId };
|
||||
const supplied = { ...actor, onBehalfOfMemberships: [{ companyId, membershipRole: "operator", status: "active" }] };
|
||||
const service = authorizationService(db);
|
||||
const resource = { type: "company" as const, companyId };
|
||||
await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, userId)));
|
||||
try {
|
||||
for (const candidates of [[supplied, actor], [actor, supplied]]) {
|
||||
const decisions = await service.decideReadsForActors(candidates, [resource]);
|
||||
for (let index = 0; index < candidates.length; index++) {
|
||||
expect(decisions[index][0]).toEqual(await service.decide({ actor: { ...candidates[index] }, action: "company_scope:read", resource }));
|
||||
expect(decisions[index][0].code).toBe(candidates[index] === supplied
|
||||
? "RESPONSIBLE_USER_UNAUTHORIZED" : "RESPONSIBLE_USER_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await db.update(companyMemberships).set({ status: "active" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, userId)));
|
||||
}
|
||||
});
|
||||
@@ -37,7 +37,7 @@ it("runs every active nested/parameterized fixture case exactly once through the
|
||||
const run = (index: number, count: number) => spawnSync(process.execPath, [
|
||||
path.join(repoRoot, "scripts/run-vitest-stable.mjs"), "--mode", "general", "--group", "general-chat",
|
||||
"--shard-index", String(index), "--shard-count", String(count),
|
||||
], { cwd: root, env: { ...process.env, CI: "true" }, encoding: "utf8", timeout: 45_000, maxBuffer: 4 * 1024 * 1024 });
|
||||
], { cwd: root, env: { ...process.env, CI: "true", PAPERCLIP_CI_PROFILE: "false" }, encoding: "utf8", timeout: 45_000, maxBuffer: 4 * 1024 * 1024 });
|
||||
for (const index of [0, 1]) {
|
||||
const result = run(index, 2);
|
||||
expect(result.error, result.stderr).toBeUndefined();
|
||||
|
||||
@@ -755,7 +755,8 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
|
||||
|
||||
async function decideReadsForActors(actors: AuthorizationActor[], resources: ReadAuthorizationResource[]): Promise<AuthorizationDecision[][]> {
|
||||
if (!actors.length) return [];
|
||||
const snapshots: ReadAuthorizationSnapshot[] = actors.map(() => ({ issues: new Map(), projects: new Map(), memo: new Map() }));
|
||||
const memo = new Map<string, Promise<unknown>>();
|
||||
const snapshots: ReadAuthorizationSnapshot[] = actors.map(() => ({ issues: new Map(), projects: new Map(), memo }));
|
||||
const batchActors: ResponsibleUserActorWithMemo[] = actors.map(actor => ({ ...actor, __responsibleUserSnapshotMemo: undefined }));
|
||||
const privacyKey = (actor: AuthorizationActor) => JSON.stringify([actor.type, actor.agentId, actor.userId, actor.onBehalfOfUserId]);
|
||||
const privacyActors = [...new Map(batchActors.map(actor => [privacyKey(actor), actor])).values()];
|
||||
@@ -892,7 +893,8 @@ function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadA
|
||||
return promise;
|
||||
}
|
||||
|
||||
const promise = loadResponsibleUserSnapshot(input.companyId, input.userId);
|
||||
const promise = batchRead(`responsible:${input.companyId}:${input.userId}`,
|
||||
() => loadResponsibleUserSnapshot(input.companyId, input.userId));
|
||||
actorWithMemo.__responsibleUserSnapshotMemo.set(key, promise);
|
||||
void promise.catch(() => {
|
||||
if (actorWithMemo.__responsibleUserSnapshotMemo?.get(key) === promise) {
|
||||
|
||||
@@ -29,6 +29,11 @@ type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
const controllerAcquisitionMessage = "New controller repository acquisition requires a trusted task. Existing verified public checkouts remain available.";
|
||||
const lowTrustRepositoryMessage = "Low-trust repository reuse permits anonymous public URLs only; new acquisition is unavailable. Use an authorized configured source for private repositories.";
|
||||
const rootReaderRunColumns = {
|
||||
id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId, nativeIssueId: heartbeatRuns.nativeIssueId, issueId: heartbeatRuns.issueId,
|
||||
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId', 'taskId', ${heartbeatRuns.contextSnapshot}->'taskId', 'executionPolicy', ${heartbeatRuns.contextSnapshot}->'executionPolicy')`,
|
||||
};
|
||||
|
||||
function resolveAcquisitionSource(request: PrepareWorkspaceRepository["repository"], available: ProjectRepository[], anonymousOnly: boolean) {
|
||||
if (anonymousOnly && request.kind === "catalog") throw forbidden(lowTrustRepositoryMessage);
|
||||
@@ -180,8 +185,17 @@ async function assertRootProjectAccess(db: Db | DbTransaction, actor: Authorizat
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRootProjectsReadAccessForActors(db: Db | DbTransaction, actors: AuthorizationActor[], companyId: string, projectIds: Set<string>) {
|
||||
if (!actors.length || !projectIds.size) return;
|
||||
const decisions = await authorizationService(db).decideReadsForActors(actors,
|
||||
[...projectIds].map(projectId => ({ type: "project" as const, companyId, projectId })));
|
||||
if (decisions.some(batch => batch.some(decision => !decision.allowed))) {
|
||||
throw forbidden("Workspace source project is no longer accessible");
|
||||
}
|
||||
}
|
||||
|
||||
async function retainedRootReader(tx: DbTransaction, companyId: string, reader: Pick<typeof executionWorkspaceRepositoryAccess.$inferSelect, "scopeId" | "issueId" | "agentId" | "responsibleUserId">) {
|
||||
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, reader.scopeId), eq(heartbeatRuns.companyId, companyId)));
|
||||
const [run] = await tx.select(rootReaderRunColumns).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, reader.scopeId), eq(heartbeatRuns.companyId, companyId)));
|
||||
const [agent] = reader.agentId ? await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.id, reader.agentId), eq(agents.companyId, companyId))) : [];
|
||||
const [issue] = reader.issueId ? await tx.select().from(issues).where(and(eq(issues.id, reader.issueId), eq(issues.companyId, companyId))) : [];
|
||||
if (!run || !agent || !issue || run.agentId !== reader.agentId || run.responsibleUserId !== reader.responsibleUserId
|
||||
@@ -260,8 +274,8 @@ async function retainGitMetadataOwners(tx: DbTransaction, input: {
|
||||
for (const reader of closure.overlappingEntries.filter(entry => entry.kind === "reader")) {
|
||||
const { actor } = await retainedRootReader(tx, input.companyId, reader);
|
||||
readerActors.push(actor);
|
||||
await assertRootProjectAccess(tx, actor, input.companyId, projectIds);
|
||||
}
|
||||
await assertRootProjectsReadAccessForActors(tx, readerActors, input.companyId, projectIds);
|
||||
await assertTaskWorkspacesReadAccessForActors(tx, readerActors, input.companyId, workspaceIds);
|
||||
for (const [kind, ids] of [["source", workspaceIds], ["project_source", projectIds]] as const) {
|
||||
for (const scopeId of ids) if (!closure.entries.some(entry => entry.kind === kind && entry.scopeId === scopeId)) {
|
||||
@@ -356,8 +370,8 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: {
|
||||
const { actor, issue } = await retainedRootReader(tx, input.companyId, reader);
|
||||
readerActors.push(actor);
|
||||
await assertRootProjectTrust(tx, { companyId: input.companyId, actor, issueId: issue.id, projectIds: target.sourceProjectIds });
|
||||
await assertRootProjectAccess(tx, actor, input.companyId, target.sourceProjectIds);
|
||||
}
|
||||
await assertRootProjectsReadAccessForActors(tx, readerActors, input.companyId, target.sourceProjectIds);
|
||||
await assertTaskWorkspacesReadAccessForActors(tx, readerActors, input.companyId, target.sourceIds);
|
||||
} catch (error) { failure ??= error; }
|
||||
if (!failure && verifiedCopyProjectIds) {
|
||||
@@ -385,7 +399,7 @@ async function assertRootProjectTrust(db: Db | DbTransaction, input: {
|
||||
if (!restricted.length) return;
|
||||
const incompatible = () => forbidden("Physical workspace source trust is incompatible with the task's execution policy");
|
||||
const runId = input.actor.runId;
|
||||
const [run] = runId ? await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, input.companyId))) : [];
|
||||
const [run] = runId ? await db.select(rootReaderRunColumns).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, input.companyId))) : [];
|
||||
const creation = !input.issueId && input.actor.type === "agent" && runId ? input.taskCreation : undefined;
|
||||
const runIssueId = run?.nativeIssueId ?? run?.contextSnapshot?.issueId ?? run?.contextSnapshot?.taskId ?? run?.issueId;
|
||||
const issueId = input.issueId ?? (creation && typeof runIssueId === "string" ? runIssueId : null);
|
||||
@@ -471,7 +485,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
const [project] = issue.projectId ? await reader.select().from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, issue.companyId))) : [];
|
||||
const [workspaceSourceProject] = workspace?.projectId ? await reader.select().from(projects).where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, issue.companyId))) : [];
|
||||
const runId = input.runId ?? issue.executionRunId;
|
||||
const [run] = runId ? await reader.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId))) : [];
|
||||
const [run] = runId ? await reader.select(rootReaderRunColumns).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId))) : [];
|
||||
if (runId && (!run || (input.runId && run.agentId !== agentId) || (run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.issueId) !== issue.id)) {
|
||||
throw forbidden("Task repository run identity is unavailable");
|
||||
}
|
||||
@@ -545,12 +559,14 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
const sourceProjects = sourceIds.size ? await tx.selectDistinct({ projectId: executionWorkspaces.projectId }).from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.companyId, input.companyId), inArray(executionWorkspaces.id, [...sourceIds]))) : [];
|
||||
const sourceActors: AuthorizationActor[] = [];
|
||||
const projectReaders: AuthorizationActor[] = [];
|
||||
for (const reader of readers) {
|
||||
const { actor, issue } = await retainedRootReader(tx, input.companyId, reader);
|
||||
await assertRootProjectTrust(tx, { companyId: input.companyId, projectIds: sourceProjectIds, actor, issueId: issue.id,
|
||||
sourceWorkspaceId: input.reader?.runId === reader.scopeId ? input.sourceWorkspaceId : undefined });
|
||||
await assertRootProjectAccess(tx, actor, input.companyId, sourceProjectIds,
|
||||
input.reader?.runId === reader.scopeId ? { sharedProjectIds, issueId: issue.id } : undefined);
|
||||
if (input.reader?.runId === reader.scopeId) {
|
||||
await assertRootProjectAccess(tx, actor, input.companyId, sourceProjectIds, { sharedProjectIds, issueId: issue.id });
|
||||
} else projectReaders.push(actor);
|
||||
if (ownAdmissionOnly) {
|
||||
const [ownSource] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.sourceWorkspaceId!),
|
||||
@@ -566,6 +582,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
}
|
||||
}
|
||||
}
|
||||
await assertRootProjectsReadAccessForActors(tx, projectReaders, input.companyId, sourceProjectIds);
|
||||
if (input.actor) {
|
||||
if (!input.issueId) throw forbidden("Workspace write authorization requires a task");
|
||||
await assertRootProjectTrust(tx, { ...input, projectIds: sourceProjectIds, actor: input.actor });
|
||||
@@ -676,7 +693,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
throw forbidden("Task repository publication binding is unavailable");
|
||||
}
|
||||
await requiresAnonymousAcquisition(tx, { issue, workspace, agentId: input.agentId, runId: input.runId });
|
||||
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
const [run] = await tx.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
const { actor } = await retainedRootReader(tx, input.companyId, {
|
||||
scopeId: input.runId, issueId: input.issueId, agentId: input.agentId, responsibleUserId: run?.responsibleUserId ?? null,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user