Support durable assistant image attachments in Agent clients

This commit is contained in:
2026-10-20 14:14:09 -07:00
parent 7b48201e28
commit 489c347976
15 changed files with 227 additions and 40 deletions
+44 -1
View File
@@ -708,11 +708,54 @@ Global `SYBIL_AGENT_ATTACHMENT_QUOTA_BYTES` defaults to 1 GiB. Concurrent reserv
Release requires native `SYBIL_AGENT_AUTH_MODE=token` with nonempty `SYBIL_AGENT_API_KEY`, plus backend `ADMIN_TOKEN` and matching server-side `SYBIL_AGENT_API_KEY` / `SYBIL_AGENT_BASE_URL`. Native open mode cannot advertise/store/download attachments. A runtime containing attachment records/files refuses open startup; raw/model APIs and sockets are protected in token mode. An open Sybil proxy also refuses access to an attachment-capable native runtime, rather than exposing its history through a private server credential. Explicit text-only open development remains supported with an open/legacy runtime lacking attachment capability. Native keys never enter browser/iOS payloads. No insecure development opt-in is enabled. Release requires native `SYBIL_AGENT_AUTH_MODE=token` with nonempty `SYBIL_AGENT_API_KEY`, plus backend `ADMIN_TOKEN` and matching server-side `SYBIL_AGENT_API_KEY` / `SYBIL_AGENT_BASE_URL`. Native open mode cannot advertise/store/download attachments. A runtime containing attachment records/files refuses open startup; raw/model APIs and sockets are protected in token mode. An open Sybil proxy also refuses access to an attachment-capable native runtime, rather than exposing its history through a private server credential. Explicit text-only open development remains supported with an open/legacy runtime lacking attachment capability. Native keys never enter browser/iOS payloads. No insecure development opt-in is enabled.
The runtime source is built into its container: rebuild it with pinned busboy/sharp dependencies for the target Node/Linux platform before release. The deployed open auth mode must be changed separately; no deployed config or mount is modified by this implementation. Backend and web must be upgraded with runtime capability/auth configured before clients advertise uploads. The runtime source is built into its container: rebuild it with pinned busboy/sharp dependencies for the target Node/Linux platform before release. Deployment must use native token auth and matching backend credentials before clients advertise attachments; no additional public mount is needed.
Browser drafts retain upload IDs and completed descriptors across pane changes and reload. Incomplete uploads require reselection of the original filename/type/size and, once network delivery has begun, matching SHA-256 bytes; retries reuse uploadId. Sending persists the attachment-bearing outbox before clearing the draft. Message confirmation requires the same ordered attachment IDs in receipts and canonical echoes. Mismatched echoes must retain local attachments and delivery errors; they are not acknowledgement of a file submission. Browser drafts retain upload IDs and completed descriptors across pane changes and reload. Incomplete uploads require reselection of the original filename/type/size and, once network delivery has begun, matching SHA-256 bytes; retries reuse uploadId. Sending persists the attachment-bearing outbox before clearing the draft. Message confirmation requires the same ordered attachment IDs in receipts and canonical echoes. Mismatched echoes must retain local attachments and delivery errors; they are not acknowledgement of a file submission.
### Assistant image output (v1)
The independent optional capability `capabilities.assistantImages: {"version":1}`
advertises the runtime's root-only `output_image` tool. It does **not** enable input
upload controls; those still require `capabilities.attachments.version == 1`.
Authenticated downloads accept either supported capability. Open proxies refuse
history and socket access if either private attachment capability is advertised.
Capability absence does not hide previously stored assistant image messages.
The agent invokes `output_image({path, uploadId, filename?, caption?})` with a stable
UUID retry identity and a local persistent raster exported beneath the workspace's
`artifacts/` or `downloads/` directory. It copies validated bytes into the existing
attachment store and publishes one durable assistant message. This is an internal
agent tool, with no browser/iOS publishing endpoint or client-supplied role.
Workers cannot publish through it. No URL fetch, remote image proxy, file execution
or implicit forwarding of native tool image blocks is performed. Generation is
available only when a separate generation provider/tool is actually installed.
Output uses the same original-byte/hash/quota limits (PNG/JPEG/GIF/WebP, 6 MiB,
50 million pixels, 16,384 pixels per axis), authenticated content route and backup
policy as uploads. Sources must be confined regular files without symlink/hardlink
substitution or private path segments. Upload IDs are bound to output provenance;
conflicting arguments fail and identical retries retain their message/attachment IDs.
Retries reuse the stored snapshot even if the original source moves or changes;
publishing changed bytes requires a new upload UUID.
Public output rows have `id: "outbound:<entryId>"`, `role: "assistant"`,
`metadata.kind: "message"`, `metadata.status: "complete"`, and
`metadata.agentAttachments` containing image descriptors. `content` is the optional
caption, otherwise an empty string; attachment-only rows remain visible. Descriptors
contain only `id`, `kind`, safe `filename`, `mimeType`, `sizeBytes` and `sha256`.
They contain no workspace/source path, URL, credential, or image bytes. The public
tool row redacts output tool arguments/results; internal task/submission provenance
stays in the existing durable outbox.
Web/iOS automatically render assistant rasters in fixed preview frames, retrieving
only authenticated ID-based originals, checking size and SHA-256, rejecting
redirects, and offering retry/download. Web limits downloads to two at a time and
uses revocable blob URLs. Clients discard assistant descriptor paths and unsupported
types. Agent Markdown cannot load images; external links remain explicit navigation.
Image-bearing push alerts use **You have a new message.**, including when a caption
exists, and never include attachment names, paths, bytes or URLs.
## Agent push notifications (v1) ## Agent push notifications (v1)
All push/config/presence routes require a configured `ADMIN_TOKEN` and bearer authentication; open development mode returns 503. Missing/wrong bearer returns 401/403. Bodies are limited to 16 KiB, responses use `Cache-Control: no-store`, invalid bodies return 400, and unavailable configuration returns 503. Do not put credentials, device tokens or subscription endpoints into URLs. Ownership is one **shared authenticated personal principal**, derived on the server from the configured credential generation. Anyone possessing that shared token has authority over all its installations; UUIDs identify installations and do not create per-human isolation. No `ownerId` is accepted. Changing `ADMIN_TOKEN` invalidates all prior push opt-ins. Protect the SQLite database/backups because endpoint credentials are stored there. All push/config/presence routes require a configured `ADMIN_TOKEN` and bearer authentication; open development mode returns 503. Missing/wrong bearer returns 401/403. Bodies are limited to 16 KiB, responses use `Cache-Control: no-store`, invalid bodies return 400, and unavailable configuration returns 503. Do not put credentials, device tokens or subscription endpoints into URLs. Ownership is one **shared authenticated personal principal**, derived on the server from the configured credential generation. Anyone possessing that shared token has authority over all its installations; UUIDs identify installations and do not create per-human isolation. No `ownerId` is accepted. Changing `ADMIN_TOKEN` invalidates all prior push opt-ins. Protect the SQLite database/backups because endpoint credentials are stored there.
+14
View File
@@ -351,6 +351,20 @@ for unloaded-history count semantics and rendering limits.
### Agent attachment history, queued activity and replay ### Agent attachment history, queued activity and replay
Assistant image output uses the same `messages` frames and durable history rows,
with `role: "assistant"`, `id: "outbound:<entryId>"`, `metadata.kind: "message"`,
`metadata.status: "complete"`, and `metadata.agentAttachments`. Each image descriptor
contains `id`, `kind: "image"`, `filename`, `mimeType`, `sizeBytes` and `sha256` only;
there is no source/workspace path or URL. An omitted caption yields empty `content`;
clients must retain this image-only row. Merge/replay by the unchanged message ID,
render authenticated image bytes inline in a fixed frame, and retain loaded history
even if the optional `capabilities.assistantImages.version: 1` is absent later.
That capability does not authorize input uploads. Native tool image blocks are not
automatically forwarded; the root agent explicitly calls `output_image`. Public
output tool rows redact arguments/results, and Agent Markdown does not fetch images.
See [assistant image output](./rest.md#assistant-image-output-v1) for publishing,
retry, auth and generic push-preview semantics. No new socket event is introduced.
Uploads and attached sends use REST, as specified in [rest.md](./rest.md#agent-attachments-v1). WebSocket authentication, event types, stable message IDs, cursor semantics and raw tool rows are unchanged. Capability discovery is optional `GET /v1/agent` → `capabilities.attachments.version: 1`; absence disables new attachment sends. Upgrade/authenticate the native runtime and backend before advertising it. Uploads and attached sends use REST, as specified in [rest.md](./rest.md#agent-attachments-v1). WebSocket authentication, event types, stable message IDs, cursor semantics and raw tool rows are unchanged. Capability discovery is optional `GET /v1/agent` → `capabilities.attachments.version: 1`; absence disables new attachment sends. Upgrade/authenticate the native runtime and backend before advertising it.
Both `messages.messages[]` and `activity.activity.queuedMessages[]` carry attachment descriptors under **`metadata.agentAttachments`**, distinct from ordinary chat's inline `metadata.attachments`: Both `messages.messages[]` and `activity.activity.queuedMessages[]` carry attachment descriptors under **`metadata.agentAttachments`**, distinct from ordinary chat's inline `metadata.attachments`:
+5 -4
View File
@@ -18,11 +18,12 @@ export type AgentProxyJson = (path: string, method?: string, body?: unknown, sig
function apiError(statusCode: number, code: string, message: string) { function apiError(statusCode: number, code: string, message: string) {
return Object.assign(new Error(message), { statusCode, code }); return Object.assign(new Error(message), { statusCode, code });
} }
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) { export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal, download = false) {
if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.'); if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.');
const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal); const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal);
const value = descriptor.data as { capabilities?: { attachments?: { version?: number } } } | null; const value = descriptor.data as { capabilities?: { attachments?: { version?: number }; assistantImages?: { version?: number } } } | null;
if (descriptor.status !== 200 || value?.capabilities?.attachments?.version !== 1) if (descriptor.status !== 200 || !(value?.capabilities?.attachments?.version === 1
|| (download && value?.capabilities?.assistantImages?.version === 1)))
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.'); throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
} }
@@ -52,7 +53,7 @@ export async function registerAgentAttachmentRoutes(app: FastifyInstance, option
if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); } if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); }
if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT) if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT)
throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.'); throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
await requireAgentAttachments(options.proxyJson, controller.signal); await requireAgentAttachments(options.proxyJson, controller.signal, !uploading);
controller.signal.throwIfAborted(); controller.signal.throwIfAborted();
}); });
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); }); routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
+6 -2
View File
@@ -14,7 +14,8 @@ export type MessageText = (message: PublicMessage) => Promise<string>;
const Page = z.object({ conversationId: z.string(), cursor: Cursor, beforeCursor: Cursor, const Page = z.object({ conversationId: z.string(), cursor: Cursor, beforeCursor: Cursor,
messages: z.array(z.unknown()).max(1000) }); messages: z.array(z.unknown()).max(1000) });
const Message = z.object({ id: z.string(), role: z.literal('assistant'), content: z.string(), const Message = z.object({ id: z.string(), role: z.literal('assistant'), content: z.string(),
metadata: z.object({ kind: z.literal('message'), status: z.literal('complete'), entryId: z.number().int().safe() }) }); metadata: z.object({ kind: z.literal('message'), status: z.literal('complete'), entryId: z.number().int().safe(),
agentAttachments: z.unknown().optional() }) });
/** Resolve only an already eligible candidate from one immutable root public entry. */ /** Resolve only an already eligible candidate from one immutable root public entry. */
export function createMessageText(upstream: { baseUrl: string; apiKey?: string }): MessageText { export function createMessageText(upstream: { baseUrl: string; apiKey?: string }): MessageText {
@@ -47,7 +48,10 @@ export function createMessageText(upstream: { baseUrl: string; apiKey?: string }
const matches = page.messages.filter(value => value && typeof value === 'object' && 'id' in value && value.id === candidate.messageId); const matches = page.messages.filter(value => value && typeof value === 'object' && 'id' in value && value.id === candidate.messageId);
const message = matches.length === 1 ? Message.safeParse(matches[0]) : undefined; const message = matches.length === 1 ? Message.safeParse(matches[0]) : undefined;
if (!message?.success || message.data.metadata.entryId !== Number(candidate.cursor)) throw new Error('Public Agent message unavailable'); if (!message?.success || message.data.metadata.entryId !== Number(candidate.cursor)) throw new Error('Public Agent message unavailable');
// Never inspect metadata, tool results, activity, reasoning blocks or linked resources for text. // Attachment messages use the generic alert. Never expose captions, filenames,
// paths, image data or linked resources in an image notification.
if (message.data.metadata.agentAttachments !== undefined) return '';
// Never inspect tool results, activity, reasoning blocks or linked resources for text.
return message.data.content; return message.data.content;
}; };
} }
+3 -2
View File
@@ -43,8 +43,8 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
const response = await fetch(new URL('/api/conversations/root', baseUrl), { const response = await fetch(new URL('/api/conversations/root', baseUrl), {
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error', headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
}); });
const descriptor = await response.json() as { capabilities?: { attachments?: unknown } }; const descriptor = await response.json() as { capabilities?: { attachments?: unknown; assistantImages?: unknown } };
if (!response.ok || descriptor?.capabilities?.attachments) throw new Error('Private runtime'); if (!response.ok || descriptor?.capabilities?.attachments || descriptor?.capabilities?.assistantImages) throw new Error('Private runtime');
} catch { throw Object.assign(new Error('Authenticated Sybil access is required for this Agent runtime.'), { statusCode: 503, code: 'attachments_unavailable' }); } } catch { throw Object.assign(new Error('Authenticated Sybil access is required for this Agent runtime.'), { statusCode: 503, code: 'attachments_unavailable' }); }
}; };
const proxyJson = async (path: string, method = 'GET', body?: unknown, signal?: AbortSignal) => { const proxyJson = async (path: string, method = 'GET', body?: unknown, signal?: AbortSignal) => {
@@ -53,6 +53,7 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
let response: Response; let response: Response;
try { try {
response = await fetch(new URL(path, baseUrl), { method, response = await fetch(new URL(path, baseUrl), { method,
redirect: 'error',
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
}); });
+18 -2
View File
@@ -20,14 +20,14 @@ const [{ registerAgentRoutes }, { env }] = await Promise.all([import('../src/age
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, key = 'runtime-private-test-key'; const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, key = 'runtime-private-test-key';
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'résumé.txt',mimeType:'text/plain',sizeBytes:4,sha256:'a'.repeat(64),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/résumé.txt' }; const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'résumé.txt',mimeType:'text/plain',sizeBytes:4,sha256:'a'.repeat(64),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/résumé.txt' };
const bodies: any[] = []; const bodies: any[] = [];
let capable=true, badReceipt=false, errorStatus=0, uploadCalls=0; let capable=true, assistantImages=false, badReceipt=false, errorStatus=0, uploadCalls=0;
let holdDescriptor=false, holdDownload=false;const held:ServerResponse[]=[], closed:ServerResponse[]=[]; let holdDescriptor=false, holdDownload=false;const held:ServerResponse[]=[], closed:ServerResponse[]=[];
const source=createServer(async(req,res)=>{ const source=createServer(async(req,res)=>{
assert.equal(req.headers.authorization,`Bearer ${key}`); assert.equal(req.headers.authorization,`Bearer ${key}`);
const chunks: Buffer[]=[]; const chunks: Buffer[]=[];
try { for await(const chunk of req) chunks.push(chunk); } catch { return; } try { for await(const chunk of req) chunks.push(chunk); } catch { return; }
const bytes=Buffer.concat(chunks);res.setHeader('Content-Type','application/json'); const bytes=Buffer.concat(chunks);res.setHeader('Content-Type','application/json');
if(req.url==='/api/conversations/root'){if(holdDescriptor){held.push(res);res.once('close',()=>closed.push(res));return;}res.end(JSON.stringify({id:'1',...(capable?{capabilities:{attachments:{version:1}}}:{})}));return;} if(req.url==='/api/conversations/root'){if(holdDescriptor){held.push(res);res.once('close',()=>closed.push(res));return;}res.end(JSON.stringify({id:'1',capabilities:{...(capable?{attachments:{version:1}}:{}),...(assistantImages?{assistantImages:{version:1}}:{})}}));return;}
if(errorStatus){res.statusCode=errorStatus;res.end(JSON.stringify({error:true,message:'Storage quota reached',statusCode:errorStatus,code:'attachment_quota_exceeded',remainingBytes:12,quotaBytes:1024,requiredBytes:99}));return;} if(errorStatus){res.statusCode=errorStatus;res.end(JSON.stringify({error:true,message:'Storage quota reached',statusCode:errorStatus,code:'attachment_quota_exceeded',remainingBytes:12,quotaBytes:1024,requiredBytes:99}));return;}
if(req.url?.endsWith('/attachments')){uploadCalls++;assert.match(req.headers['content-type']!,/^multipart\/form-data; boundary=/);assert.ok(bytes.includes(Buffer.from('résumé.txt')));bodies.push(bytes.toString());res.statusCode=201;res.end(JSON.stringify({attachment:file}));return;} if(req.url?.endsWith('/attachments')){uploadCalls++;assert.match(req.headers['content-type']!,/^multipart\/form-data; boundary=/);assert.ok(bytes.includes(Buffer.from('résumé.txt')));bodies.push(bytes.toString());res.statusCode=201;res.end(JSON.stringify({attachment:file}));return;}
if(req.url?.endsWith('/content')){if(holdDownload){held.push(res);res.once('close',()=>closed.push(res));return;}res.writeHead(200,{'Content-Type':'text/plain; charset=utf-8','Content-Length':'4','Content-Disposition':"attachment; filename=notes.txt; filename*=UTF-8''notes.txt"});res.end('data');return;} if(req.url?.endsWith('/content')){if(holdDownload){held.push(res);res.once('close',()=>closed.push(res));return;}res.writeHead(200,{'Content-Type':'text/plain; charset=utf-8','Content-Length':'4','Content-Disposition':"attachment; filename=notes.txt; filename*=UTF-8''notes.txt"});res.end('data');return;}
@@ -57,6 +57,22 @@ test('multipart forwards Unicode filename/uploadId with private auth; quota deta
assert.equal((await app.inject({url:'/v1/agent/attachments/not-an-id/content',headers:auth})).statusCode,404); assert.equal((await app.inject({url:'/v1/agent/attachments/not-an-id/content',headers:auth})).statusCode,404);
}); });
test('assistant-only capability allows authenticated retrieval without granting input uploads or open history',async()=>{
capable=false;assistantImages=true;
const saved=env.ADMIN_TOKEN;
try {
const descriptor=(await app.inject({url:'/v1/agent',headers:auth})).json();
assert.deepEqual(descriptor.capabilities,{assistantImages:{version:1}});
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth})).status,200);
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`)).status,401);
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,503);
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{content:'',requestId:'assistant-only',attachments:[{id:file.id}]}})).statusCode,503);
assert.equal((await app.inject({method:'POST',url:'/v1/agent/output_image',headers:auth,payload:{path:'https://never-fetch.invalid/image'}})).statusCode,404);
env.ADMIN_TOKEN=undefined;
for(const url of ['/v1/agent','/v1/agent/messages',`/v1/agent/attachments/${file.id}/content`]) assert.equal((await app.inject({url})).statusCode,503);
} finally {env.ADMIN_TOKEN=saved;capable=true;assistantImages=false;}
});
test('ID-only sends require matching receipts; history retains attachment metadata and raw tools',async()=>{ test('ID-only sends require matching receipts; history retains attachment metadata and raw tools',async()=>{
const payload={content:'',requestId:'send',attachments:[{id:file.id}]}; const payload={content:'',requestId:'send',attachments:[{id:file.id}]};
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,202);assert.deepEqual(bodies.at(-1),{...payload,channel:'sybil',whenBusy:'followUp',wait:false}); assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,202);assert.deepEqual(bodies.at(-1),{...payload,channel:'sybil',whenBusy:'followUp',wait:false});
+5
View File
@@ -41,6 +41,11 @@ test('preview lookup uses only the exact eligible completed root assistant conte
]) { page = invalid; await assert.rejects(load(candidate)); } ]) { page = invalid; await assert.rejects(load(candidate)); }
page = { ...original, messages: [{ ...message, content: '' }] }; page = { ...original, messages: [{ ...message, content: '' }] };
assert.equal(await load(candidate), '', 'empty content never borrows metadata, tool output or reasoning'); assert.equal(await load(candidate), '', 'empty content never borrows metadata, tool output or reasoning');
page = { ...original, messages: [{ ...message, content: 'Caption stays inside authenticated chat.', metadata: { ...message.metadata,
agentAttachments: [{ filename: 'private-name.png', workspacePath: '/private/source.png', url: 'https://never-fetch.invalid/image' }] } }] };
const imageText = await load(candidate);
assert.equal(imageText, '', 'image captions and metadata use only a generic notification');
assert.deepEqual(JSON.parse(previewPayload(imageText, body => ({ body }), 4096).toString()), { body: 'You have a new message.' });
const before = requests.length; const before = requests.length;
for (const messageId of ['tool:1:11', 'outbound:12', 'https://never-fetch.invalid/', 'entry:11:0/../../state']) { for (const messageId of ['tool:1:11', 'outbound:12', 'https://never-fetch.invalid/', 'entry:11:0/../../state']) {
await assert.rejects(load({ ...candidate, messageId })); await assert.rejects(load({ ...candidate, messageId }));
+20 -11
View File
@@ -1,19 +1,20 @@
import { useEffect, useRef, useState } from "preact/hooks"; import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { Copy, Download, FileText, Image, LoaderCircle } from "lucide-preact"; import { Copy, Download, FileText, Image, LoaderCircle } from "lucide-preact";
import { attachmentSize, type AgentAttachment } from "@/lib/agent-attachments"; import { attachmentSize, type AgentAttachment } from "@/lib/agent-attachments";
type Props = { file: AgentAttachment; download(file: AgentAttachment, signal?: AbortSignal): Promise<Blob> }; type Props = { file: AgentAttachment; inline?: boolean; download(file: AgentAttachment, signal?: AbortSignal): Promise<Blob> };
/** Authenticated bytes only; no native credential, remote href, or public file serving. */ /** Authenticated bytes only; no native credential, remote href, or public file serving. */
export function AgentAttachmentCard({ file, download }: Props) { export function AgentAttachmentCard({ file, download, inline = false }: Props) {
const [preview, setPreview] = useState<string>(); const [preview, setPreview] = useState<string>();
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const controller = useRef<AbortController>(); const controller = useRef<AbortController>();
useEffect(() => () => { controller.current?.abort(); }, []); const transferring = useRef(false);
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]); useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]);
const retrieve = async (show: boolean) => { const retrieve = useCallback(async (show: boolean) => {
if (busy) return; if (transferring.current) return;
transferring.current = true;
const request = new AbortController(); controller.current = request; setBusy(true); setError(undefined); const request = new AbortController(); controller.current = request; setBusy(true); setError(undefined);
try { try {
const blob = await download(file, request.signal); if (request.signal.aborted) return; const blob = await download(file, request.signal); if (request.signal.aborted) return;
@@ -24,14 +25,22 @@ export function AgentAttachmentCard({ file, download }: Props) {
setTimeout(() => URL.revokeObjectURL(url), 1000); setTimeout(() => URL.revokeObjectURL(url), 1000);
} }
} catch (failure) { if (!request.signal.aborted) setError(failure instanceof Error ? failure.message : "Download failed."); } } catch (failure) { if (!request.signal.aborted) setError(failure instanceof Error ? failure.message : "Download failed."); }
finally { if (!request.signal.aborted) setBusy(false); } finally { if (controller.current === request) { transferring.current = false; if (!request.signal.aborted) setBusy(false); } }
}; }, [download, file.id, file.sha256, file.mimeType, file.kind, file.sizeBytes, file.filename]);
useEffect(() => {
setPreview(undefined); setError(undefined); setBusy(false); transferring.current = false;
if (inline && file.kind === "image") void retrieve(true);
return () => { controller.current?.abort(); };
}, [inline, retrieve]);
return <div className="agent-attachment"> return <div className="agent-attachment">
<div className="agent-attachment__title"><FileText size={15} /><span>{file.filename}</span><small>{attachmentSize(file.sizeBytes)}</small></div> <div className="agent-attachment__title"><FileText size={15} /><span>{file.filename}</span><small>{attachmentSize(file.sizeBytes)}</small></div>
{file.workspacePath ? <div className="agent-attachment__path"><code title={file.workspacePath}>{file.workspacePath}</code><button type="button" aria-label={`Copy path for ${file.filename}`} title={copied ? "Copied" : "Copy workspace path"} onClick={() => { void navigator.clipboard.writeText(file.workspacePath!).then(() => setCopied(true)).catch(() => setError("Could not copy the path.")); }}><Copy size={13} /></button></div> : null} {!inline && file.workspacePath ? <div className="agent-attachment__path"><code title={file.workspacePath}>{file.workspacePath}</code><button type="button" aria-label={`Copy path for ${file.filename}`} title={copied ? "Copied" : "Copy workspace path"} onClick={() => { void navigator.clipboard.writeText(file.workspacePath!).then(() => setCopied(true)).catch(() => setError("Could not copy the path.")); }}><Copy size={13} /></button></div> : null}
{preview ? <img className="agent-attachment__preview" src={preview} alt={file.filename} /> : null} {inline ? <div className="agent-attachment__image-frame" aria-busy={busy}>
{preview ? <img className="agent-attachment__preview" src={preview} alt={file.filename} onError={() => { setPreview(undefined); setError("Image could not be displayed."); }} />
: <span role="status">{error ? "Image unavailable" : "Loading image…"}</span>}
</div> : preview ? <img className="agent-attachment__preview" src={preview} alt={file.filename} /> : null}
<div className="agent-attachment__actions"> <div className="agent-attachment__actions">
{file.kind === "image" ? <button type="button" disabled={busy} onClick={() => preview ? setPreview(undefined) : void retrieve(true)}><Image size={13} />{preview ? "Hide preview" : "Preview"}</button> : null} {file.kind === "image" && (!inline || error) ? <button type="button" disabled={busy} onClick={() => preview ? setPreview(undefined) : void retrieve(true)}><Image size={13} />{inline ? "Retry image" : preview ? "Hide preview" : "Preview"}</button> : null}
<button type="button" disabled={busy} onClick={() => void retrieve(false)}>{busy ? <LoaderCircle size={13} className="animate-spin" /> : <Download size={13} />}Download</button> <button type="button" disabled={busy} onClick={() => void retrieve(false)}>{busy ? <LoaderCircle size={13} className="animate-spin" /> : <Download size={13} />}Download</button>
</div> </div>
{error ? <span className="agent-attachment__error" role="alert">{error} Retry using the buttons above.</span> : null} {error ? <span className="agent-attachment__error" role="alert">{error} Retry using the buttons above.</span> : null}
@@ -476,4 +476,6 @@
.agent-attachment__actions button { display: flex; align-items: center; gap: 5px; color: #ddd6fe; } .agent-attachment__actions button { display: flex; align-items: center; gap: 5px; color: #ddd6fe; }
.agent-attachment__actions button:disabled { opacity: .5; } .agent-attachment__actions button:disabled { opacity: .5; }
.agent-attachment__preview { display: block; max-height: 260px; max-width: 100%; margin-top: 8px; border-radius: 6px; } .agent-attachment__preview { display: block; max-height: 260px; max-width: 100%; margin-top: 8px; border-radius: 6px; }
.agent-attachment__image-frame { display: flex; align-items: center; justify-content: center; height: 260px; width: min(480px, 100%); margin-top: 8px; }
.agent-attachment__image-frame .agent-attachment__preview { width: 100%; height: 100%; object-fit: contain; margin: 0; }
.agent-attachment__error { display: block; width: 100%; color: #fda4af; font-size: 12px; margin-top: 5px; } .agent-attachment__error { display: block; width: 100%; color: #fda4af; font-size: 12px; margin-top: 5px; }
@@ -61,8 +61,8 @@ const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, downl
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null} {!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
<div className="agent-room__message-content"> <div className="agent-room__message-content">
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}> <div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
{own ? <p className="agent-room__plain-message">{message.content}</p> : <MarkdownContent markdown={message.content} openLinksInNewTab />} {own ? <p className="agent-room__plain-message">{message.content}</p> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
{agentAttachments(message.metadata).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} />)} {agentAttachments(message.metadata, message.role).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} inline={message.role === "assistant"} />)}
</div> </div>
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null} {own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
<div className="agent-room__message-meta"> <div className="agent-room__message-meta">
@@ -11,6 +11,8 @@ type Props = {
mode?: MarkdownMode; mode?: MarkdownMode;
resolveCitationIndex?: (href: string) => number | undefined; resolveCitationIndex?: (href: string) => number | undefined;
openLinksInNewTab?: boolean; openLinksInNewTab?: boolean;
/** Agent images are delivered only through authenticated attachment IDs. */
allowImages?: boolean;
}; };
function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) { function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) {
@@ -35,24 +37,28 @@ function setNewTabLinkAttributes(currentNode: Element) {
currentNode.setAttribute("rel", "noopener noreferrer"); currentNode.setAttribute("rel", "noopener noreferrer");
} }
function renderMarkdown(markdown: string, openLinksInNewTab: boolean) { function renderMarkdown(markdown: string, openLinksInNewTab: boolean, allowImages: boolean) {
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string; const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] }); const config = { ADD_ATTR: ["class", "target", "rel"], ...(!allowImages ? {
FORBID_TAGS: ["img", "picture", "source", "video", "audio", "svg", "math", "style"],
FORBID_ATTR: ["style", "srcset", "background", "poster"],
} : {}) };
if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, config);
DOMPurify.addHook("afterSanitizeAttributes", setNewTabLinkAttributes); DOMPurify.addHook("afterSanitizeAttributes", setNewTabLinkAttributes);
try { try {
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] }); return DOMPurify.sanitize(rawHtml, config);
} finally { } finally {
DOMPurify.removeHook("afterSanitizeAttributes", setNewTabLinkAttributes); DOMPurify.removeHook("afterSanitizeAttributes", setNewTabLinkAttributes);
} }
} }
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false }: Props) { export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false, allowImages = true }: Props) {
const html = useMemo(() => { const html = useMemo(() => {
const prepared = const prepared =
mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown; mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown;
return renderMarkdown(prepared, openLinksInNewTab); return renderMarkdown(prepared, openLinksInNewTab, allowImages);
}, [markdown, mode, openLinksInNewTab, resolveCitationIndex]); }, [markdown, mode, openLinksInNewTab, resolveCitationIndex, allowImages]);
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />; return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
} }
+15 -2
View File
@@ -19,10 +19,23 @@ export function parseAgentAttachment(value: unknown): AgentAttachment {
return { id: item.id, kind: item.kind, filename: item.filename, mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256, return { id: item.id, kind: item.kind, filename: item.filename, mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256,
...(item.workspacePath ? { workspacePath: item.workspacePath } : {}) }; ...(item.workspacePath ? { workspacePath: item.workspacePath } : {}) };
} }
export function agentAttachments(metadata: unknown): AgentAttachment[] { export function agentAttachments(metadata: unknown, role = "user"): AgentAttachment[] {
if (role !== "user" && role !== "assistant") return [];
const values = (metadata as { agentAttachments?: unknown } | null)?.agentAttachments; const values = (metadata as { agentAttachments?: unknown } | null)?.agentAttachments;
if (!Array.isArray(values) || values.length > 8) return []; if (!Array.isArray(values) || values.length > 8) return [];
try { return values.map(parseAgentAttachment); } catch { return []; } try {
const files = values.map(value => {
// Source paths are not part of the assistant output contract, even if an
// older/malformed server supplies one. Only the ID authorizes retrieval.
const file = parseAgentAttachment(role === "assistant" ? { ...value, workspacePath: undefined } : value);
if (role === "assistant" && (file.kind !== "image" || !["image/png", "image/jpeg", "image/gif", "image/webp"].includes(file.mimeType)
|| file.sizeBytes > 6 * 1024 * 1024 || file.filename.length > 255 || /[\\/\u0000-\u001f\u007f]/.test(file.filename)))
throw new Error("Invalid assistant image.");
return file;
});
if (new Set(files.map(file => file.id)).size !== files.length || files.reduce((total, file) => total + file.sizeBytes, 0) > 32 * 1024 * 1024) return [];
return files;
} catch { return []; }
} }
export function attachmentCaption(content: string, files: AgentAttachment[]): string { export function attachmentCaption(content: string, files: AgentAttachment[]): string {
return content.trim() ? content : `Attached: ${files.map(file => file.filename).join(", ")}.`; return content.trim() ? content : `Attached: ${files.map(file => file.filename).join(", ")}.`;
+1 -1
View File
@@ -20,7 +20,7 @@ export type AgentRoomPresenterOptions = {
typingDelayMs?: number; typingDelayMs?: number;
clock?: Scheduler; clock?: Scheduler;
}; };
const quietTools = new Set(['user_message', 'notify_parent', 'clock_wait', 'event_decision']); const quietTools = new Set(['user_message', 'output_image', 'notify_parent', 'clock_wait', 'event_decision']);
const data = (message: Message): Record<string, unknown> => message.metadata && typeof message.metadata === 'object' const data = (message: Message): Record<string, unknown> => message.metadata && typeof message.metadata === 'object'
&& !Array.isArray(message.metadata) ? message.metadata as Record<string, unknown> : {}; && !Array.isArray(message.metadata) ? message.metadata as Record<string, unknown> : {};
const entryId = (message: Message): bigint | undefined => { const entryId = (message: Message): bigint | undefined => {
+43 -7
View File
@@ -18,7 +18,7 @@ export type AgentMessagesPage = {
}; };
export type AgentDescriptor = { export type AgentDescriptor = {
id: string; title: string; model: string; activity: AgentActivity; id: string; title: string; model: string; activity: AgentActivity;
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability }; websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability; assistantImages?: { version: 1 } };
}; };
export type AgentReadState = { cursor: string; updatedAt: string | null; sourceId?: string | null }; export type AgentReadState = { cursor: string; updatedAt: string | null; sourceId?: string | null };
export type AgentRoomState = { export type AgentRoomState = {
@@ -175,6 +175,8 @@ export class AgentRoomClient {
private readonly baseUrl: string; private readonly baseUrl: string;
private readonly fetcher: typeof globalThis.fetch; private readonly fetcher: typeof globalThis.fetch;
private readonly token: () => string | null; private readonly token: () => string | null;
private activeDownloads = 0;
private readonly downloadWaiters = new Set<() => void>();
constructor(options: AgentRoomClientOptions = {}) { constructor(options: AgentRoomClientOptions = {}) {
this.baseUrl = (options.baseUrl || "/api").replace(/\/+$/, ""); this.baseUrl = (options.baseUrl || "/api").replace(/\/+$/, "");
this.fetcher = (options.fetch || globalThis.fetch).bind(globalThis); this.fetcher = (options.fetch || globalThis.fetch).bind(globalThis);
@@ -223,12 +225,46 @@ export class AgentRoomClient {
} }
async download(attachment: AgentAttachment, signal?: AbortSignal): Promise<Blob> { async download(attachment: AgentAttachment, signal?: AbortSignal): Promise<Blob> {
const file = parseAgentAttachment(attachment), headers = new Headers(), token = this.token(); const file = parseAgentAttachment(attachment), headers = new Headers(), token = this.token();
if (token) headers.set("Authorization", `Bearer ${token}`); signal?.throwIfAborted();
const response = await this.fetcher(`${this.baseUrl}/v1/agent/attachments/${encodeURIComponent(file.id)}/content`, { headers, signal, cache: "no-store", redirect: "error" }); while (this.activeDownloads >= 2) {
if (!response.ok) { const error = await response.json().catch(() => null); throw new Error(error?.message || "Attachment download failed."); } await new Promise<void>((resolve, reject) => {
const blob = await response.blob(); const cleanup = () => { this.downloadWaiters.delete(wake); signal?.removeEventListener("abort", abort); };
if (blob.size !== file.sizeBytes || blob.size > 8 * 1024 * 1024) throw new Error("Attachment download was incomplete."); const wake = () => { cleanup(); resolve(); };
return blob; const abort = () => { cleanup(); reject(signal?.reason || new Error("Download canceled.")); };
this.downloadWaiters.add(wake); signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
});
signal?.throwIfAborted();
}
this.activeDownloads++;
try {
if (token !== this.token()) throw new Error("Authentication changed. Retry the download.");
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await this.fetcher(`${this.baseUrl}/v1/agent/attachments/${encodeURIComponent(file.id)}/content`, { headers, signal, cache: "no-store", redirect: "error" });
if (!response.ok) { await response.body?.cancel(); throw new Error("Attachment download failed. Retry the download."); }
const reader = response.body?.getReader();
if (!reader) throw new Error("Attachment download was incomplete.");
const bytes = new Uint8Array(file.sizeBytes); let size = 0;
try {
for (;;) {
const { done, value } = await reader.read(); if (done) break;
signal?.throwIfAborted();
if (size + value.length > bytes.length) throw new Error("Attachment download exceeds its size limit.");
bytes.set(value, size); size += value.length;
}
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
if (size !== file.sizeBytes) throw new Error("Attachment download was incomplete.");
const sha256 = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(n => n.toString(16).padStart(2, "0")).join("");
if (sha256 !== file.sha256) throw new Error("Attachment checksum did not match.");
if (file.kind === "image" && response.headers.get("content-type")?.split(";")[0].trim() !== file.mimeType)
throw new Error("Attachment image type did not match.");
signal?.throwIfAborted();
if (token !== this.token()) throw new Error("Authentication changed. Retry the download.");
return new Blob([bytes], { type: response.headers.get("content-type") || "application/octet-stream" });
} finally {
this.activeDownloads--;
this.downloadWaiters.values().next().value?.();
}
} }
websocketUrl(pageUrl: string): string { websocketUrl(pageUrl: string): string {
const url = new URL(`${this.baseUrl}/v1/agent/ws`, pageUrl); const url = new URL(`${this.baseUrl}/v1/agent/ws`, pageUrl);
+37
View File
@@ -5,11 +5,48 @@ import { createAgentUploadQueue, agentAttachments, parseAgentAttachment } from '
import { AgentRoomClient } from '../src/lib/agent-room.ts'; import { AgentRoomClient } from '../src/lib/agent-room.ts';
import { createAgentOptimisticOutbox } from '../src/lib/agent-optimistic-outbox.ts'; import { createAgentOptimisticOutbox } from '../src/lib/agent-optimistic-outbox.ts';
import { agentTranscriptMessages } from '../src/lib/agent-room-presentation.ts'; import { agentTranscriptMessages } from '../src/lib/agent-room-presentation.ts';
import { createAgentRoomState, applyAgentEvent, mergeAgentMessages } from '../src/lib/agent-room.ts';
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'notes.txt',mimeType:'text/plain',sizeBytes:4,sha256:createHash('sha256').update('data').digest('hex'),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt' }; const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'notes.txt',mimeType:'text/plain',sizeBytes:4,sha256:createHash('sha256').update('data').digest('hex'),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt' };
const cap = {version:1,maxCount:8,maxTotalBytes:32*1024*1024,maxImageBytes:6*1024*1024,maxFileBytes:8*1024*1024,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp']}; const cap = {version:1,maxCount:8,maxTotalBytes:32*1024*1024,maxImageBytes:6*1024*1024,maxFileBytes:8*1024*1024,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp']};
const tick=()=>new Promise(r=>setTimeout(r,0)); const tick=()=>new Promise(r=>setTimeout(r,0));
const waitFor=async check=>{const until=Date.now()+5000;while(!check()){assert.ok(Date.now()<until,'async condition timed out');await tick();}}; const waitFor=async check=>{const until=Date.now()+5000;while(!check()){assert.ok(Date.now()<until,'async condition timed out');await tick();}};
test('assistant images retain stable live/history identity, omit paths, and never enable input capability',async()=>{
const image={...file,kind:'image',mimeType:'image/png',filename:'image.png',url:'https://never-fetch.invalid/image'};
const safe={...image};delete safe.workspacePath;delete safe.url;
const message={id:'outbound:42',role:'assistant',content:'',createdAt:new Date().toISOString(),metadata:{kind:'message',status:'complete',entryId:42,agentAttachments:[image]}};
const page={type:'messages',conversationId:'1',messages:[message],cursor:'42',beforeCursor:'42',hasMore:false};
const live=applyAgentEvent(createAgentRoomState(),page);
assert.deepEqual(agentAttachments(live.messages[0].metadata,'assistant'),[safe]);
const history=mergeAgentMessages(live.messages,[structuredClone(message)]);
assert.equal(history.length,1);assert.equal(history[0].id,'outbound:42');
assert.deepEqual(agentAttachments(history[0].metadata,'assistant'),[safe]);
assert.equal(agentTranscriptMessages(history).length,1,'image-only assistant bubble remains visible');
for(const invalid of [{...image,kind:'file'},{...image,mimeType:'image/svg+xml'},{...image,sizeBytes:6*1024*1024+1},{...image,filename:'/private/source.png'}])
assert.deepEqual(agentAttachments({agentAttachments:[invalid]},'assistant'),[]);
assert.deepEqual(agentAttachments(message.metadata,'tool'),[]);
assert.deepEqual(agentAttachments({agentAttachments:[image,image]},'assistant'),[]);
const descriptor={capabilities:{assistantImages:{version:1}}};
const queue=createAgentUploadQueue({capability:()=>descriptor.capabilities.attachments,changed:()=>{},upload:async()=>{throw new Error('Must not upload');}});
assert.throws(()=>queue.add([new File(['data'],'notes.txt')]),/not available/);queue.dispose();
});
test('downloads bound concurrent bytes, validate hashes/types, cancel queued work, and reject changed credentials',async()=>{
const complete=[];let calls=0,token='one';
const client=new AgentRoomClient({token:()=>token,fetch:async()=>{calls++;return new Promise(resolve=>complete.push(resolve));}});
const a=client.download(file), b=client.download(file), cancel=new AbortController();
const canceled=client.download(file,cancel.signal);const canceledAssertion=assert.rejects(canceled);cancel.abort();
await canceledAssertion;assert.equal(calls,2);
complete.shift()(new Response('data'));await a;
const c=client.download(file);assert.equal(calls,3);
complete.shift()(new Response('evil'));await assert.rejects(b,/checksum/);
token='two';complete.shift()(new Response('data'));await assert.rejects(c,/Authentication changed/);
const huge=new AgentRoomClient({fetch:async()=>new Response('data-exceeds-receipt')});
await assert.rejects(huge.download(file),/size limit/);
const wrongType=new AgentRoomClient({fetch:async()=>new Response('data',{headers:{'content-type':'text/html'}})});
await assert.rejects(wrongType.download({...file,kind:'image',mimeType:'image/png'}),/type did not match/);
});
test('composer gates unsupported servers, count/type/size and batches before upload; preserves retry ID and remove races',async()=>{ test('composer gates unsupported servers, count/type/size and batches before upload; preserves retry ID and remove races',async()=>{
let capability, attempts=[], next=0, changed=0, finish; let capability, attempts=[], next=0, changed=0, finish;
const queue=createAgentUploadQueue({capability:()=>capability,newId:()=>`upload-${++next}`,changed:()=>changed++,upload:async(_file,id,signal)=>{attempts.push({id,signal});if(attempts.length===1)throw new Error('Quota full');return new Promise(resolve=>finish=resolve);}}); const queue=createAgentUploadQueue({capability:()=>capability,newId:()=>`upload-${++next}`,changed:()=>changed++,upload:async(_file,id,signal)=>{attempts.push({id,signal});if(attempts.length===1)throw new Error('Quota full');return new Promise(resolve=>finish=resolve);}});