diff --git a/tests/runner-e2e/chat-catalog-domains/internal-task-attachment.ts b/tests/runner-e2e/chat-catalog-domains/internal-task-attachment.ts new file mode 100644 index 00000000000..3c705d05a7e --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/internal-task-attachment.ts @@ -0,0 +1,96 @@ +import { createHash } from "node:crypto"; +import { expect } from "@playwright/test"; +import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js"; +import type { ChatQueryAttachmentReceipt } from "../chat-query-attachments.js"; +import { pollUntil } from "../api.js"; +import { openStandingChat } from "./identity-support.js"; +import { fixtureWorker, makeDefinition } from "./internal-definition.js"; +import { checks, entity, hasLink, observation, observeInternal, type Entity, type InternalObservation } from "./internal-support.js"; + +interface BriefObservation extends InternalObservation { + sourceId?: string; originalBytesHash: string; sourceAttachments: ChatQueryAttachmentReceipt[]; + workAttachments: ChatQueryAttachmentReceipt[]; workDocuments: Entity[]; + workerTaskStatus?: number; workerDownloadStatus?: number; workerDownloadedHash?: string; durablePath?: string; +} +const hash = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); +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), + "The original UI upload, actual work context and independently authenticated worker download are required."); + if (!o || !Array.isArray(o.sourceAttachments) || !Array.isArray(o.workAttachments) || !Array.isArray(o.workDocuments)) return c.result(); + const receipt = o.state.briefAttachment as ChatQueryAttachmentReceipt | undefined; + const worker = entity(o.state, "worker"); const work = o.tasks.filter(row => row.id !== o.sourceId); const saved = work[0]; + c.add("actual-human-upload-retained", !!receipt && receipt.companyId === o.companyId && o.sourceAttachments.some(row => row.id === receipt.id && row.issueId === o.sourceId && row.companyId === o.companyId + && row.createdByUserId === receipt.createdByUserId && row.createdByAgentId == null && row.issueCommentId && row.sha256 === o.state.briefHash) + && o.originalBytesHash === o.state.briefHash, "The original signed-in person's uploaded brief and its exact bytes remain attached to the actual source human comment."); + c.add("one-contextual-ordinary-work-task", work.length === 1 && !!saved && saved.companyId === o.companyId && saved.assigneeAgentId === worker.id + && /\b(?:website|landing.page|web page)\b/i.test(`${saved.title} ${saved.description ?? ""}`) + && o.activity.some(row => row.action === "issue.created" && row.entityId === saved.id && row.agentId === o.leadId) + && hasLink(o, "issues", saved), "One actual CEO-created website task is delegated to the intended Morgan and linked in chat."); + const context = `${saved?.description ?? ""}\n${o.workDocuments.map(row => row.body ?? "").join("\n")}`; + const copied = o.workAttachments.some(row => row.issueId === saved?.id && row.companyId === o.companyId && row.sha256 === o.state.briefHash + && o.durablePath === row.contentPath); + const originalReference = !!receipt && context.includes(receipt.contentPath) && o.durablePath === receipt.contentPath; + c.add("durable-brief-in-work-context", copied || originalReference, + "The delegated task owns an exact-byte copy or a durable actual attachment reference; an unavailable filename or source-chat-only attachment fails."); + c.add("morgan-can-read-the-real-brief", o.workerTaskStatus === 200 && o.workerDownloadStatus === 200 && o.workerDownloadedHash === o.state.briefHash, + "An independent real Morgan bearer reads the work task and downloads the actual referenced brief bytes from its authorized context."); + const runs = saved ? o.runs.filter(row => row.contextSnapshot?.issueId === saved.id || row.contextSnapshot?.taskId === saved.id) : []; + c.add("separate-real-worker-admission", runs.length === 1 && runs[0]?.agentId === worker.id && ["queued", "running", "succeeded"].includes(runs[0]?.status ?? ""), + "Morgan's real admitted run belongs to the new work task, separate from the source conversation."); + 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 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 base = makeDefinition("tasks.attachment-brief", async ctx => { + const worker = await fixtureWorker(ctx); const source = await openStandingChat(ctx); + const briefBody = `# Acorn Field Notes\nBuild a simple accessible website for small research teams.\nUse headline: Research without the busywork.\nOffer early signup with invitation code ORCHID-${ctx.input.nonce}.\n`; + const filename = "website-brief.md"; + await ctx.input.page.locator('input[type="file"]').first().setInputFiles({ name: filename, mimeType: "text/markdown", buffer: Buffer.from(briefBody) }); + const uploaded = await pollUntil({ label: "Actual human chat brief upload", deadlineAt: Date.now() + 30_000, + load: () => ctx.input.api.get(`/api/issues/${source.id}/attachments`), + accept: rows => rows.filter(row => row.originalFilename === filename).length === 1 }); + 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)) }; +}, gradeAttachmentBrief); +export const taskAttachmentCases: readonly ChatCatalogCase[] = [{ ...base, authentication: "human-session", + queryAttachments: [{ stateKey: "briefAttachment", filename: "website-brief.md" }], expectedRunCount: 3, minimumRunCount: 2, + runPolicy: { kind: "source-settled-with-active-work", maximumActiveWorkerRuns: 1 }, + sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-task-attachment.ts", "chat-catalog-domains/identity-support.ts", "chat-query-attachments.ts"], + async observe(ctx, state): Promise { + const o = await observeInternal(ctx, state); const source = ctx.issue(); + const sourceAttachments = source ? await ctx.input.api.get(`/api/issues/${source.id}/attachments`) : []; + const receipt = state.briefAttachment as ChatQueryAttachmentReceipt; + const originalBytesHash = hash(await ctx.input.api.getAttachmentContent(receipt.id)); + const saved = o.tasks.filter(row => row.id !== source?.id)[0]; const workAttachments: ChatQueryAttachmentReceipt[] = []; const workDocuments: Entity[] = []; + if (saved) { + workAttachments.push(...await ctx.input.api.get(`/api/issues/${saved.id}/attachments`)); + for (const summary of await ctx.input.api.get(`/api/issues/${saved.id}/documents`)) + workDocuments.push(await ctx.input.api.get(`/api/issues/${saved.id}/documents/${encodeURIComponent(summary.key!)}`)); + } + const context = `${saved?.description ?? ""}\n${workDocuments.map(row => row.body ?? "").join("\n")}`; + const durablePath = workAttachments.find(row => row.sha256 === state.briefHash)?.contentPath + ?? (context.includes(receipt.contentPath) ? receipt.contentPath : undefined); + const reads = saved ? await workerReads(ctx, entity(state, "worker").id, saved.id, durablePath) : {}; + return { ...o, sourceId: source?.id, sourceAttachments, originalBytesHash, workAttachments, workDocuments, durablePath, ...reads }; + }, +}]; diff --git a/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts b/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts index 7245e0bee2e..3df3c44f078 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts @@ -1,6 +1,7 @@ import { delegatedTaskCases } from "./internal-task-creation.js"; import { taskPrivacyCases } from "./internal-privacy.js"; import { taskInventoryCases } from "./internal-task-inventory.js"; +import { taskAttachmentCases } from "./internal-task-attachment.js"; import type { ChatCatalogCase } from "../chat-catalog-contract.js"; import { project, task, goal, putDocument, entity, observation, checks, hasLink, sameTask, type Entity } from "./internal-support.js"; import { makeDefinition, retryDefinition, fixtureWorker } from "./internal-definition.js"; @@ -60,6 +61,7 @@ export const tasksCases: readonly ChatCatalogCase[] = [ ...delegatedTaskCases, ...taskPrivacyCases, ...taskInventoryCases, + ...taskAttachmentCases, ambiguousTask(), define("tasks.edit-properties", async ctx => { const p = await project(ctx); const worker = await fixtureWorker(ctx); diff --git a/tests/runner-e2e/chat-catalog-domains/internal.test.ts b/tests/runner-e2e/chat-catalog-domains/internal.test.ts index 01d6c29fa49..f33ef763f34 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal.test.ts @@ -15,6 +15,17 @@ function event(action: string, entityId: string, details?: Record void> = { + "tasks.attachment-brief": o => { Object.assign(o, { workerDownloadStatus: 403 }); }, "projects.add-repository": o => { Object.assign(o, { repositoryProject: { ...o.projects[0], workspaces: [{ id: "wrong", repoUrl: "https://github.com/acorn/unapproved" }] } }); }, "projects.replace-repository": o => { (o.state.beforeOwnerReplacement as { agentStatus: number }).agentStatus = 200; }, "tasks.organize-open-work": o => { o.reply = o.reply.replace("## Unassigned", "## Morgan"); }, @@ -444,3 +456,19 @@ describe("repository fixture prerequisites and readback", () => { expect(gradeInternal(o).passed).toBe(false); }); }); + +describe("delegated attachment context calibration", () => { + it("rejects a source-only brief even when the original human download works", () => { + const o = valid("tasks.attachment-brief"); Object.assign(o, { workAttachments: [], durablePath: undefined }); + expect(gradeInternal(o).passed).toBe(false); + }); + it("accepts a real durable original attachment reference that Morgan can independently retrieve", () => { + const o = valid("tasks.attachment-brief"); o.tasks[0]!.description = "Use [the attached brief](/api/attachments/original/content)."; + Object.assign(o, { workAttachments: [], durablePath: "/api/attachments/original/content" }); + expect(gradeInternal(o).passed).toBe(true); + }); + it("rejects stale bytes under an available copied filename", () => { + const o = valid("tasks.attachment-brief"); Object.assign(o, { workerDownloadedHash: "b".repeat(64) }); + expect(gradeInternal(o).passed).toBe(false); + }); +});