mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 21:02:34 +01:00
Reuse attempt-private domain probe credentials until cleanup
This commit is contained in:
1 parent
865a191bce
commit
a55eaa86e6
3 files changed
+76
-45
No files matched your search
@@ -7,19 +7,29 @@ interface Probe { status: number; body: unknown }
|
||||
interface InboxObservation extends InternalObservation {
|
||||
personalItems: Entity[]; policy: { mode: string; allowedAgentIds: string[] }; personalProbe: Probe; responsibleUserProbe: Probe; unreadProbe: Probe; archiveProbe?: Probe;
|
||||
}
|
||||
async function agentRequest(ctx: ChatCatalogContext, path: string, method = "GET", body?: unknown): Promise<Probe> {
|
||||
const id = ctx.input.fixtures.agent.id;
|
||||
const key = await ctx.input.api.postSensitive<{ id: string; token: string }>(`/api/agents/${id}/keys`, { name: `inbox-scope-control-${ctx.input.nonce}` });
|
||||
const inboxProbeKeys = new WeakMap<ChatCatalogContext, Promise<{ id: string; token: string }>>();
|
||||
async function inboxProbeKey(ctx: ChatCatalogContext) {
|
||||
if (!ctx.input.privateSecrets) throw new Error("Agent probe requires an attempt-private secret redactor");
|
||||
ctx.input.privateSecrets.push(key.token);
|
||||
try {
|
||||
const url = new URL(path, ctx.input.api.baseURL);
|
||||
if (url.origin !== ctx.input.api.baseURL || !url.pathname.startsWith("/api/")) throw new Error("Inbox probe escaped fixture API");
|
||||
const response = await fetch(url, { method, redirect: "error", signal: AbortSignal.timeout(15_000),
|
||||
headers: { Authorization: `Bearer ${key.token}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
return { status: response.status, body: await response.json() };
|
||||
} finally { await ctx.input.api.delete(`/api/agents/${id}/keys/${key.id}`); }
|
||||
let pending = inboxProbeKeys.get(ctx);
|
||||
if (!pending) {
|
||||
pending = ctx.input.api.postSensitive<{ id: string; token: string }>(`/api/agents/${ctx.input.fixtures.agent.id}/keys`, { name: `inbox-scope-control-${ctx.input.nonce}` })
|
||||
.then(key => { ctx.input.privateSecrets!.push(key.token); return key; });
|
||||
inboxProbeKeys.set(ctx, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
async function cleanupInboxProbe(ctx: ChatCatalogContext) {
|
||||
const pending = inboxProbeKeys.get(ctx); inboxProbeKeys.delete(ctx);
|
||||
if (pending) { const key = await pending; await ctx.input.api.delete(`/api/agents/${ctx.input.fixtures.agent.id}/keys/${key.id}`, { allowNotFound: true }); }
|
||||
}
|
||||
async function agentRequest(ctx: ChatCatalogContext, path: string, method = "GET", body?: unknown): Promise<Probe> {
|
||||
const key = await inboxProbeKey(ctx);
|
||||
const url = new URL(path, ctx.input.api.baseURL);
|
||||
if (url.origin !== ctx.input.api.baseURL || !url.pathname.startsWith("/api/")) throw new Error("Inbox probe escaped fixture API");
|
||||
const response = await fetch(url, { method, redirect: "error", signal: AbortSignal.timeout(15_000),
|
||||
headers: { Authorization: `Bearer ${key.token}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
function minePath(ctx: ChatCatalogContext) {
|
||||
return `/api/companies/${ctx.input.fixtures.company.id}/issues?touchedByUserId=me&inboxArchivedByUserId=me&includeAgentCreatedTasks=true&limit=500`;
|
||||
@@ -74,6 +84,7 @@ export function gradeInbox(value: unknown) {
|
||||
}
|
||||
function inboxBoundary(scenarioId: string): ChatCatalogCase {
|
||||
const base = makeDefinition(scenarioId, async ctx => {
|
||||
try {
|
||||
const session = await ctx.input.api.get<{ user?: { id: string } }>("/api/auth/get-session");
|
||||
if (!session.user?.id) throw new Error("Personal inbox fixture requires a real signed-in person");
|
||||
const p = await project(ctx); const tasks: Entity[] = [];
|
||||
@@ -94,6 +105,7 @@ function inboxBoundary(scenarioId: string): ChatCatalogCase {
|
||||
throw new Error("Production Mine fixture does not reflect human participation independently of automated work");
|
||||
return { project: p, task: tasks[0], doneTask: tasks[2], personId: session.user.id, originalTasks: [...tasks, automatedTask],
|
||||
originalPersonalIds: personalItems.map(row => row.id), automatedTask };
|
||||
} catch (error) { await cleanupInboxProbe(ctx); throw error; }
|
||||
}, gradeInbox);
|
||||
return { ...base, authentication: "human-session",
|
||||
async observe(ctx, state): Promise<InboxObservation> {
|
||||
@@ -124,6 +136,7 @@ function inboxBoundary(scenarioId: string): ChatCatalogCase {
|
||||
await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" });
|
||||
await expect(ctx.input.page.getByTestId("task-chat-composer-input")).toBeVisible();
|
||||
},
|
||||
async cleanup(ctx) { await cleanupInboxProbe(ctx); },
|
||||
};
|
||||
}
|
||||
export const inboxSearchCases: readonly ChatCatalogCase[] = [inboxBoundary("inbox-search.show-my-work"), inboxBoundary("inbox-search.archive-without-grant")];
|
||||
@@ -30,15 +30,30 @@ async function personRead(person: Person, path: string) {
|
||||
await response.body?.cancel();
|
||||
return { status: response.status, body };
|
||||
}
|
||||
const privacyProbeKeys = new WeakMap<ChatCatalogContext, Map<string, Promise<{ id: string; token: string }>>>();
|
||||
async function privacyProbeKey(ctx: ChatCatalogContext, agentId: string) {
|
||||
if (!ctx.input.privateSecrets) throw new Error("Privacy probes require attempt-private credential redaction");
|
||||
let keys = privacyProbeKeys.get(ctx); if (!keys) { keys = new Map(); privacyProbeKeys.set(ctx, keys); }
|
||||
let pending = keys.get(agentId);
|
||||
if (!pending) {
|
||||
pending = ctx.input.api.postSensitive<{ id: string; token: string }>(`/api/agents/${agentId}/keys`, { name: `privacy-probe-${ctx.input.nonce}` })
|
||||
.then(key => { ctx.input.privateSecrets!.push(key.token); return key; });
|
||||
keys.set(agentId, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
async function cleanupPrivacyProbes(ctx: ChatCatalogContext) {
|
||||
const keys = privacyProbeKeys.get(ctx); privacyProbeKeys.delete(ctx);
|
||||
for (const [agentId, pending] of keys ?? []) {
|
||||
const key = await pending; await ctx.input.api.delete(`/api/agents/${agentId}/keys/${key.id}`, { allowNotFound: true });
|
||||
}
|
||||
}
|
||||
async function agentStatus(ctx: ChatCatalogContext, agentId: string, path: string) {
|
||||
const key = await ctx.input.api.postSensitive<{ id: string; token: string }>(`/api/agents/${agentId}/keys`, { name: `privacy-probe-${ctx.input.nonce}` });
|
||||
ctx.input.privateSecrets?.push(key.token);
|
||||
try {
|
||||
const url = new URL(path, ctx.input.api.baseURL);
|
||||
if (url.origin !== ctx.input.api.baseURL || !url.pathname.startsWith("/api/")) throw new Error("Privacy probe escaped its isolated API");
|
||||
const response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${key.token}` } });
|
||||
await response.body?.cancel(); return response.status;
|
||||
} finally { await ctx.input.api.delete(`/api/agents/${agentId}/keys/${key.id}`); }
|
||||
const key = await privacyProbeKey(ctx, agentId);
|
||||
const url = new URL(path, ctx.input.api.baseURL);
|
||||
if (url.origin !== ctx.input.api.baseURL || !url.pathname.startsWith("/api/")) throw new Error("Privacy probe escaped its isolated API");
|
||||
const response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${key.token}` } });
|
||||
await response.body?.cancel(); return response.status;
|
||||
}
|
||||
function privacyObservation(value: unknown): PrivacyObservation | undefined {
|
||||
const o = observation(value) as PrivacyObservation | undefined;
|
||||
@@ -111,7 +126,7 @@ function privateProject(): ChatCatalogCase {
|
||||
denied: true, outcome: "The copied project link presents an access error, without the protected project heading." });
|
||||
} finally { await observerPage.close(); }
|
||||
},
|
||||
async cleanup() { await observer?.dispose(); observer = undefined; },
|
||||
async cleanup(ctx) { try { await cleanupPrivacyProbes(ctx); } finally { await observer?.dispose(); observer = undefined; } },
|
||||
};
|
||||
}
|
||||
export const projectPrivacyCases: readonly ChatCatalogCase[] = [privateProject(), shareProject()];
|
||||
@@ -144,15 +159,12 @@ export function gradeShareProject(value: unknown) {
|
||||
}
|
||||
async function agentMutationStatus(ctx: ChatCatalogContext, path: string, data: unknown) {
|
||||
const id = ctx.input.fixtures.agent.id;
|
||||
const key = await ctx.input.api.postSensitive<{ id: string; token: string }>(`/api/agents/${id}/keys`, { name: `privacy-write-control-${ctx.input.nonce}` });
|
||||
ctx.input.privateSecrets?.push(key.token);
|
||||
try {
|
||||
const url = new URL(path, ctx.input.api.baseURL);
|
||||
if (url.origin !== ctx.input.api.baseURL || !url.pathname.startsWith("/api/")) throw new Error("Privacy mutation probe escaped fixture API");
|
||||
const response = await fetch(url, { method: "POST", redirect: "error", signal: AbortSignal.timeout(15_000),
|
||||
headers: { Authorization: `Bearer ${key.token}`, "content-type": "application/json" }, body: JSON.stringify(data) });
|
||||
await response.body?.cancel(); return response.status;
|
||||
} finally { await ctx.input.api.delete(`/api/agents/${id}/keys/${key.id}`); }
|
||||
const key = await privacyProbeKey(ctx, id);
|
||||
const url = new URL(path, ctx.input.api.baseURL);
|
||||
if (url.origin !== ctx.input.api.baseURL || !url.pathname.startsWith("/api/")) throw new Error("Privacy mutation probe escaped fixture API");
|
||||
const response = await fetch(url, { method: "POST", redirect: "error", signal: AbortSignal.timeout(15_000),
|
||||
headers: { Authorization: `Bearer ${key.token}`, "content-type": "application/json" }, body: JSON.stringify(data) });
|
||||
await response.body?.cancel(); return response.status;
|
||||
}
|
||||
function shareProject(): ChatCatalogCase {
|
||||
let observer: Person | undefined;
|
||||
@@ -217,7 +229,7 @@ function shareProject(): ChatCatalogCase {
|
||||
await ctx.input.evidence("chat-catalog-owner-project-sharing-opened.json", { observerId: observer.id, projectId: p.id, url: observerPage.url(), visible: true });
|
||||
} finally { await observerPage.close(); }
|
||||
},
|
||||
async cleanup() { await observer?.dispose(); observer = undefined; },
|
||||
async cleanup(ctx) { try { await cleanupPrivacyProbes(ctx); } finally { await observer?.dispose(); observer = undefined; } },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -272,7 +284,7 @@ function sharePrivateTask(): ChatCatalogCase {
|
||||
await ctx.input.evidence("chat-catalog-task-sharing-denied-browser.json", { taskId: t.id, observerId: observer.id, denied: true });
|
||||
} finally { await page.close(); }
|
||||
},
|
||||
async cleanup() { await observer?.dispose(); observer = undefined; },
|
||||
async cleanup(ctx) { try { await cleanupPrivacyProbes(ctx); } finally { await observer?.dispose(); observer = undefined; } },
|
||||
};
|
||||
}
|
||||
export const taskPrivacyCases: readonly ChatCatalogCase[] = [sharePrivateTask()];
|
||||
@@ -13,6 +13,7 @@ interface BriefObservation extends InternalObservation {
|
||||
workerTaskStatus?: number; workerDownloadStatus?: number; workerDownloadedHash?: string; durablePath?: string;
|
||||
}
|
||||
const hash = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
||||
const workerProbeKeys = new WeakMap<ChatCatalogContext, { id: string; token: string; workerId: string }>();
|
||||
export function gradeAttachmentBrief(value: unknown) {
|
||||
const c = checks(); const o = observation(value) as BriefObservation | undefined;
|
||||
c.add("independent-upload-and-worker-evidence", !!o && Array.isArray(o.sourceAttachments) && Array.isArray(o.workAttachments) && Array.isArray(o.workDocuments),
|
||||
@@ -41,23 +42,20 @@ export function gradeAttachmentBrief(value: unknown) {
|
||||
return c.result();
|
||||
}
|
||||
async function workerReads(ctx: ChatCatalogContext, workerId: string, taskId: string, path: string | undefined) {
|
||||
if (!ctx.input.privateSecrets) throw new Error("Worker brief probe requires private secret redaction");
|
||||
const key = await ctx.input.api.postSensitive<{ id: string; token: string }>(`/api/agents/${workerId}/keys`, { name: "delegated-brief-read-control" });
|
||||
ctx.input.privateSecrets.push(key.token);
|
||||
const key = workerProbeKeys.get(ctx);
|
||||
if (!key || key.workerId !== workerId) throw new Error("Attempt-private Morgan probe credential is unavailable");
|
||||
const get = (value: string) => {
|
||||
const url = new URL(value, ctx.input.api.baseURL);
|
||||
if (url.origin !== ctx.input.api.baseURL || !/^\/api\/(?:issues|attachments)\//.test(url.pathname)) throw new Error("Worker brief read escaped isolated resources");
|
||||
return fetch(url, { redirect: "error", signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${key.token}` } });
|
||||
};
|
||||
try {
|
||||
const taskRead = await get(`/api/issues/${taskId}`); const workerTaskStatus = taskRead.status; await taskRead.body?.cancel();
|
||||
if (!path) return { workerTaskStatus };
|
||||
const response = await get(path); const workerDownloadStatus = response.status;
|
||||
if (!response.ok) { await response.body?.cancel(); return { workerTaskStatus, workerDownloadStatus }; }
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > 65_536) throw new Error("Delegated brief unexpectedly exceeds its fixture size bound");
|
||||
return { workerTaskStatus, workerDownloadStatus, workerDownloadedHash: hash(bytes) };
|
||||
} finally { await ctx.input.api.delete(`/api/agents/${workerId}/keys/${key.id}`); }
|
||||
const taskRead = await get(`/api/issues/${taskId}`); const workerTaskStatus = taskRead.status; await taskRead.body?.cancel();
|
||||
if (!path) return { workerTaskStatus };
|
||||
const response = await get(path); const workerDownloadStatus = response.status;
|
||||
if (!response.ok) { await response.body?.cancel(); return { workerTaskStatus, workerDownloadStatus }; }
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > 65_536) throw new Error("Delegated brief unexpectedly exceeds its fixture size bound");
|
||||
return { workerTaskStatus, workerDownloadStatus, workerDownloadedHash: hash(bytes) };
|
||||
}
|
||||
const base = makeDefinition("tasks.attachment-brief", async ctx => {
|
||||
const worker = await fixtureWorker(ctx); const source = await openStandingChat(ctx);
|
||||
@@ -70,7 +68,10 @@ const base = makeDefinition("tasks.attachment-brief", async ctx => {
|
||||
const receipt = uploaded.find(row => row.originalFilename === filename)!;
|
||||
await expect(ctx.input.page.getByTestId("task-chat-composer-attachments")).toContainText(filename);
|
||||
await expect(ctx.input.page.getByTestId("task-chat-composer-attachments")).not.toContainText("Uploading");
|
||||
return { worker, sourceId: source.id, briefAttachment: receipt, briefHash: hash(Buffer.from(briefBody)) };
|
||||
if (!ctx.input.privateSecrets) throw new Error("Worker brief probe requires private secret redaction");
|
||||
const key = await ctx.input.api.postSensitive<{ id: string; token: string }>(`/api/agents/${worker.id}/keys`, { name: "delegated-brief-read-control" });
|
||||
ctx.input.privateSecrets.push(key.token); workerProbeKeys.set(ctx, { ...key, workerId: worker.id });
|
||||
return { worker, sourceId: source.id, briefAttachment: receipt, briefHash: hash(Buffer.from(briefBody)), workerProbeKeyId: key.id };
|
||||
}, gradeAttachmentBrief);
|
||||
export const taskAttachmentCases: readonly ChatCatalogCase[] = [{ ...base, authentication: "human-session",
|
||||
queryAttachments: [{ stateKey: "briefAttachment", filename: "website-brief.md" }], expectedRunCount: 3, minimumRunCount: 2,
|
||||
@@ -93,4 +94,9 @@ export const taskAttachmentCases: readonly ChatCatalogCase[] = [{ ...base, authe
|
||||
const reads = saved ? await workerReads(ctx, entity(state, "worker").id, saved.id, durablePath) : {};
|
||||
return { ...o, sourceId: source?.id, sourceAttachments, originalBytesHash, workAttachments, workDocuments, durablePath, ...reads };
|
||||
},
|
||||
async cleanup(ctx, state) {
|
||||
const key = workerProbeKeys.get(ctx); workerProbeKeys.delete(ctx);
|
||||
const id = key?.id ?? state.workerProbeKeyId;
|
||||
if (id) await ctx.input.api.delete(`/api/agents/${entity(state, "worker").id}/keys/${id}`, { allowNotFound: true });
|
||||
},
|
||||
}];
|
||||
Reference in new issue
Block a user