diff --git a/tests/runner-e2e/chat-catalog-domains/internal-inbox-search.ts b/tests/runner-e2e/chat-catalog-domains/internal-inbox-search.ts index ac5ae46a9c0..86f094198d3 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-inbox-search.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-inbox-search.ts @@ -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 { - 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>(); +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 { + 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 { @@ -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")]; diff --git a/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts b/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts index 391b1cdc247..cb0b477bbd0 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts @@ -30,15 +30,30 @@ async function personRead(person: Person, path: string) { await response.body?.cancel(); return { status: response.status, body }; } +const privacyProbeKeys = new WeakMap>>(); +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()]; diff --git a/tests/runner-e2e/chat-catalog-domains/internal-task-attachment.ts b/tests/runner-e2e/chat-catalog-domains/internal-task-attachment.ts index 3c705d05a7e..8c7ecd0e10e 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-task-attachment.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-task-attachment.ts @@ -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(); 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 }); + }, }];