From d2e3b52fa89170338098dc0eceb931e3c719d022 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Thu, 3 Sep 2026 21:02:22 +0800 Subject: [PATCH 01/10] feat(schema): declare optional attachment capabilities and typed refusal --- .../__tests__/attachment-capability.test.ts | 107 ++++++++++++++++++ .../schema/src/model/agent/input.ts | 3 + .../foundation/schema/src/model/attachment.ts | 92 +++++++++++++++ .../host/engine/src/__tests__/failure.test.ts | 14 +++ packages/host/engine/src/failure.ts | 2 + 5 files changed, 218 insertions(+) create mode 100644 packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts diff --git a/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts b/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts new file mode 100644 index 000000000..df8c48c16 --- /dev/null +++ b/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { AgentCapabilitiesSchema } from '../agent/input'; +import { + AttachmentCapabilitySchema, + HOST_ATTACHMENT_LIMITS, + intersectAttachmentCapability, +} from '../attachment'; +import { MAX_ATTACHMENT_BYTES, SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES } from '../content'; + +const imageCapability = { + kinds: { + image: { + mimeTypes: [...SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES], + maxBytes: MAX_ATTACHMENT_BYTES, + maxCount: 16, + }, + }, + representations: ['inline_image'] as const, +}; + +describe('AttachmentCapability', () => { + it('parses a declared image capability and rejects an empty representation list', () => { + expect(AttachmentCapabilitySchema.safeParse(imageCapability).success).toBe(true); + expect( + AttachmentCapabilitySchema.safeParse({ + kinds: imageCapability.kinds, + representations: [], + }).success, + ).toBe(false); + }); +}); + +describe('AgentCapabilities.attachments', () => { + it('is optional so mixed-version peers still parse', () => { + expect( + AgentCapabilitiesSchema.safeParse({ slashCommands: true, shellCommand: false }).success, + ).toBe(true); + expect( + AgentCapabilitiesSchema.safeParse({ + slashCommands: true, + shellCommand: false, + attachments: imageCapability, + }).success, + ).toBe(true); + }); +}); + +describe('intersectAttachmentCapability', () => { + it('returns undefined when the adapter declared nothing', () => { + expect(intersectAttachmentCapability(undefined)).toBeUndefined(); + }); + + it('intersects mime types, byte caps, counts, and representations with the host', () => { + const effective = intersectAttachmentCapability({ + kinds: { + image: { + mimeTypes: ['image/png', 'image/svg+xml'], + maxBytes: MAX_ATTACHMENT_BYTES * 2, + maxCount: 4, + }, + }, + representations: ['inline_image', 'readonly_file'], + }); + expect(effective).toEqual({ + kinds: { + image: { + mimeTypes: ['image/png'], + maxBytes: MAX_ATTACHMENT_BYTES, + maxCount: 4, + }, + }, + representations: ['inline_image', 'readonly_file'], + }); + }); + + it('drops a kind whose mime types miss the host allowlist', () => { + expect( + intersectAttachmentCapability({ + kinds: { + image: { mimeTypes: ['image/svg+xml'], maxBytes: 1024, maxCount: 1 }, + }, + representations: ['inline_image'], + }), + ).toBeUndefined(); + }); + + it('does not advertise file when the host has no file kind', () => { + expect(HOST_ATTACHMENT_LIMITS.kinds.file).toBeUndefined(); + const effective = intersectAttachmentCapability({ + kinds: { + image: { + mimeTypes: ['image/png'], + maxBytes: MAX_ATTACHMENT_BYTES, + maxCount: 1, + }, + file: { mimeTypes: ['application/pdf'], maxBytes: MAX_ATTACHMENT_BYTES, maxCount: 1 }, + }, + representations: ['inline_image', 'readonly_file'], + }); + expect(effective?.kinds.file).toBeUndefined(); + expect(effective?.kinds.image).toEqual({ + mimeTypes: ['image/png'], + maxBytes: MAX_ATTACHMENT_BYTES, + maxCount: 1, + }); + }); +}); diff --git a/packages/foundation/schema/src/model/agent/input.ts b/packages/foundation/schema/src/model/agent/input.ts index 612916b72..2301d9bb1 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { AttachmentCapabilitySchema } from '../attachment'; import { ContentBlockSchema } from '../content'; import { ImPlatformSchema } from '../im'; import { PermissionOutcomeSchema } from '../permission'; @@ -149,6 +150,8 @@ export type AgentStartCatalog = z.infer; export const AgentCapabilitiesSchema = z.object({ slashCommands: z.boolean(), shellCommand: z.boolean(), + /** Absent means the harness accepts no attachments. Required would break mixed-version peers. */ + attachments: AttachmentCapabilitySchema.optional(), }); export type AgentCapabilities = z.infer; diff --git a/packages/foundation/schema/src/model/attachment.ts b/packages/foundation/schema/src/model/attachment.ts index bc328fe9e..ea0a49b0b 100644 --- a/packages/foundation/schema/src/model/attachment.ts +++ b/packages/foundation/schema/src/model/attachment.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { MAX_ATTACHMENT_BYTES, SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES } from './content'; import { AttachmentIdSchema, TimestampSchema } from './primitives'; /** @@ -84,3 +85,94 @@ export const UploadLeaseSchema = z.object({ createdAt: TimestampSchema, }); export type UploadLease = z.infer; + +/** Adapter-declared per-kind limits. Host ∩ adapter (∩ model, when known) is the effective cap. */ +export const AttachmentKindLimitsSchema = z.object({ + mimeTypes: z.array(z.string().min(1)).min(1), + maxBytes: z.number().int().positive(), + maxCount: z.number().int().positive(), +}); +export type AttachmentKindLimits = z.infer; + +/** How the engine hands bytes to a harness. `extracted_text` is later. */ +export const AttachmentRepresentationSchema = z.enum(['inline_image', 'readonly_file']); +export type AttachmentRepresentation = z.infer; + +export const AttachmentCapabilitySchema = z.object({ + kinds: z.object({ + image: AttachmentKindLimitsSchema.optional(), + file: AttachmentKindLimitsSchema.optional(), + }), + representations: z.array(AttachmentRepresentationSchema).min(1), +}); +export type AttachmentCapability = z.infer; + +/** Adapter-declared image count; the 12 MiB prompt aggregate is the tighter bound for large files. */ +export const DEFAULT_ATTACHMENT_IMAGE_MAX_COUNT = 16; + +/** What the host can materialize. Effective capability is this ∩ the adapter declaration. */ +export const HOST_ATTACHMENT_LIMITS: AttachmentCapability = { + kinds: { + image: { + mimeTypes: [...SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES], + maxBytes: MAX_ATTACHMENT_BYTES, + maxCount: DEFAULT_ATTACHMENT_IMAGE_MAX_COUNT, + }, + }, + representations: ['inline_image', 'readonly_file'], +}; + +function intersectKindLimits( + declared: AttachmentKindLimits | undefined, + host: AttachmentKindLimits | undefined, +): AttachmentKindLimits | undefined { + if (declared === undefined || host === undefined) return undefined; + const mimeTypes: string[] = []; + for (let i = 0, len = declared.mimeTypes.length; i < len; i++) { + const mimeType = declared.mimeTypes[i]; + if (host.mimeTypes.includes(mimeType)) mimeTypes.push(mimeType); + } + if (mimeTypes.length === 0) return undefined; + return { + mimeTypes, + maxBytes: Math.min(declared.maxBytes, host.maxBytes), + maxCount: Math.min(declared.maxCount, host.maxCount), + }; +} + +/** Absent declaration or empty intersection means the harness accepts no attachments. */ +export function intersectAttachmentCapability( + declared: AttachmentCapability | undefined, + host: AttachmentCapability = HOST_ATTACHMENT_LIMITS, +): AttachmentCapability | undefined { + if (declared === undefined) return undefined; + const representations: AttachmentRepresentation[] = []; + for (let i = 0, len = declared.representations.length; i < len; i++) { + const representation = declared.representations[i]; + if (host.representations.includes(representation)) representations.push(representation); + } + if (representations.length === 0) return undefined; + const image = intersectKindLimits(declared.kinds.image, host.kinds.image); + const file = intersectKindLimits(declared.kinds.file, host.kinds.file); + if (image === undefined && file === undefined) return undefined; + return { + kinds: { + ...(image !== undefined && { image }), + ...(file !== undefined && { file }), + }, + representations, + }; +} + +/** Locator a conversation.read user row uses so clients can `attachment.read` without bytes. */ +export const ATTACHMENT_URI_SCHEME = 'attachment:'; + +export function attachmentUri(attachmentId: string): string { + return `${ATTACHMENT_URI_SCHEME}${attachmentId}`; +} + +export function attachmentIdFromUri(uri: string): string | undefined { + if (!uri.startsWith(ATTACHMENT_URI_SCHEME)) return undefined; + const id = uri.slice(ATTACHMENT_URI_SCHEME.length); + return id.length > 0 ? id : undefined; +} diff --git a/packages/host/engine/src/__tests__/failure.test.ts b/packages/host/engine/src/__tests__/failure.test.ts index 8a7c504e6..ced99de23 100644 --- a/packages/host/engine/src/__tests__/failure.test.ts +++ b/packages/host/engine/src/__tests__/failure.test.ts @@ -16,6 +16,20 @@ describe('engine request failures', () => { expect(failure).toEqual({ code: 'not_found', message: 'Workspace not found' }); }); + it('keeps unsupported_attachment as a typed request code', () => { + const failure = toRequestFailure( + new RequestError({ + code: 'unsupported_attachment', + message: 'This harness does not accept image attachments', + }), + ); + + expect(failure).toEqual({ + code: 'unsupported_attachment', + message: 'This harness does not accept image attachments', + }); + }); + it('exposes only the public message from an operation failure', () => { const failure = toRequestFailure( new OperationError({ diff --git a/packages/host/engine/src/failure.ts b/packages/host/engine/src/failure.ts index 0a522b1db..c05fa1f1d 100644 --- a/packages/host/engine/src/failure.ts +++ b/packages/host/engine/src/failure.ts @@ -14,6 +14,8 @@ export type RequestErrorCode = /** The request was understood and refused — the user withheld consent, not a broken call. */ | 'forbidden' | 'unsupported' + /** A prompt attachment the harness did not declare, or that is missing/not ready. */ + | 'unsupported_attachment' | 'worktree_missing' | 'limit_exceeded' | 'cancelled'; From ef053720000be1803fe085bd60f57c8b955cea46 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Thu, 3 Sep 2026 21:13:05 +0800 Subject: [PATCH 02/10] feat(agent-adapter): declare per-harness attachment support and refuse undeclared blocks --- .../integration/dev-mock-transport.test.ts | 11 ++-- .../__tests__/attachment-capability.test.ts | 20 ++++++- .../schema/src/model/agent/input.ts | 56 +++++++++++++++++-- .../agent-adapter/src/__tests__/base.test.ts | 34 ++++++++++- .../src/__tests__/grok-build.test.ts | 4 ++ packages/host/agent-adapter/src/base.ts | 31 ++++++++++ 6 files changed, 142 insertions(+), 14 deletions(-) diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index 7d12744ff..7f5f957e8 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -7,6 +7,7 @@ import type { TerminalReplayEvent, ToolCall, } from '@linkcode/schema'; +import { AGENT_INPUT_CAPABILITIES } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; import { wait } from 'foxts/wait'; import { describe, expect, it } from 'vitest'; @@ -81,7 +82,7 @@ describe('dev mock transport', () => { expect(seededEvents[0]).toEqual({ type: 'status', status: 'idle' }); expect(seededEvents[3]).toEqual({ type: 'capabilities-update', - capabilities: { slashCommands: true, shellCommand: false }, + capabilities: AGENT_INPUT_CAPABILITIES['claude-code'], }); expect(seededEvents[4]).toMatchObject({ type: 'available-commands-update', @@ -212,7 +213,7 @@ describe('dev mock transport', () => { expect(events).toContainEqual({ type: 'capabilities-update', - capabilities: { slashCommands: true, shellCommand: true }, + capabilities: AGENT_INPUT_CAPABILITIES.codex, }); expect(events.find((event) => event.type === 'available-commands-update')).toEqual({ type: 'available-commands-update', @@ -316,7 +317,7 @@ describe('dev mock transport', () => { { type: 'effort-update', effort: 'medium' }, { type: 'capabilities-update', - capabilities: { slashCommands: true, shellCommand: true }, + capabilities: AGENT_INPUT_CAPABILITIES.codex, }, expect.objectContaining({ type: 'available-commands-update' }), ]); @@ -516,7 +517,7 @@ describe('dev mock transport', () => { await eventually(() => events.some((event) => event.type === 'capabilities-update')); expect(events).toContainEqual({ type: 'capabilities-update', - capabilities: { slashCommands: true, shellCommand: true }, + capabilities: AGENT_INPUT_CAPABILITIES.codex, }); expect(events.some((event) => event.type === 'available-commands-update')).toBe(true); const terminalId = await eventually(() => { @@ -797,7 +798,7 @@ describe('dev mock transport', () => { { type: 'effort-update', effort: 'xhigh' }, { type: 'capabilities-update', - capabilities: { slashCommands: true, shellCommand: true }, + capabilities: AGENT_INPUT_CAPABILITIES.codex, }, expect.objectContaining({ type: 'available-commands-update' }), ]); diff --git a/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts b/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts index df8c48c16..05d1a15fe 100644 --- a/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts +++ b/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { AgentCapabilitiesSchema } from '../agent/input'; +import type { AgentCapabilities } from '../agent/input'; +import { + AGENT_INPUT_CAPABILITIES, + AgentCapabilitiesSchema, + effectiveAttachmentCapability, +} from '../agent/input'; import { AttachmentCapabilitySchema, HOST_ATTACHMENT_LIMITS, @@ -84,6 +89,19 @@ describe('intersectAttachmentCapability', () => { ).toBeUndefined(); }); + it('keeps grok-build dark and the other harnesses on inline images', () => { + expect(effectiveAttachmentCapability('grok-build')).toBeUndefined(); + const grok: AgentCapabilities = AGENT_INPUT_CAPABILITIES['grok-build']; + expect(grok.attachments).toBeUndefined(); + const imageKinds = ['claude-code', 'codex', 'opencode', 'pi'] as const; + for (let i = 0, len = imageKinds.length; i < len; i++) { + const effective = effectiveAttachmentCapability(imageKinds[i]); + expect(effective?.representations).toEqual(['inline_image']); + expect(effective?.kinds.image?.mimeTypes).toEqual([...SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES]); + expect(effective?.kinds.file).toBeUndefined(); + } + }); + it('does not advertise file when the host has no file kind', () => { expect(HOST_ATTACHMENT_LIMITS.kinds.file).toBeUndefined(); const effective = intersectAttachmentCapability({ diff --git a/packages/foundation/schema/src/model/agent/input.ts b/packages/foundation/schema/src/model/agent/input.ts index 2301d9bb1..98c9ae30e 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -1,6 +1,15 @@ import { z } from 'zod'; -import { AttachmentCapabilitySchema } from '../attachment'; -import { ContentBlockSchema } from '../content'; +import type { AttachmentCapability } from '../attachment'; +import { + AttachmentCapabilitySchema, + DEFAULT_ATTACHMENT_IMAGE_MAX_COUNT, + intersectAttachmentCapability, +} from '../attachment'; +import { + ContentBlockSchema, + MAX_ATTACHMENT_BYTES, + SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES, +} from '../content'; import { ImPlatformSchema } from '../im'; import { PermissionOutcomeSchema } from '../permission'; import type { AgentKind } from '../primitives'; @@ -155,17 +164,52 @@ export const AgentCapabilitiesSchema = z.object({ }); export type AgentCapabilities = z.infer; +/** What `onPrompt` consumes today: image ContentBlocks inlined to the SDK. grok-build declares + * nothing — its prompt is a CLI argument. */ +const INLINE_IMAGE_ATTACHMENT_CAPABILITY = { + kinds: { + image: { + mimeTypes: [...SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES], + maxBytes: MAX_ATTACHMENT_BYTES, + maxCount: DEFAULT_ATTACHMENT_IMAGE_MAX_COUNT, + }, + }, + representations: ['inline_image'], +} as const satisfies AttachmentCapability; + /** Stable pre-session input capabilities. Live clients still trust each session's * `capabilities-update`; this complete matrix lets drafts and adapters share one source of truth * before that event stream exists. */ export const AGENT_INPUT_CAPABILITIES = { - 'claude-code': { slashCommands: true, shellCommand: false }, - codex: { slashCommands: true, shellCommand: true }, - opencode: { slashCommands: true, shellCommand: true }, - pi: { slashCommands: true, shellCommand: false }, + 'claude-code': { + slashCommands: true, + shellCommand: false, + attachments: INLINE_IMAGE_ATTACHMENT_CAPABILITY, + }, + codex: { + slashCommands: true, + shellCommand: true, + attachments: INLINE_IMAGE_ATTACHMENT_CAPABILITY, + }, + opencode: { + slashCommands: true, + shellCommand: true, + attachments: INLINE_IMAGE_ATTACHMENT_CAPABILITY, + }, + pi: { + slashCommands: true, + shellCommand: false, + attachments: INLINE_IMAGE_ATTACHMENT_CAPABILITY, + }, 'grok-build': { slashCommands: false, shellCommand: false }, } as const satisfies Readonly>; +/** Host limits ∩ the harness declaration. Undefined means the harness accepts no attachments. */ +export function effectiveAttachmentCapability(kind: AgentKind): AttachmentCapability | undefined { + const declared: AgentCapabilities = AGENT_INPUT_CAPABILITIES[kind]; + return intersectAttachmentCapability(declared.attachments); +} + /** Input sent up to the agent, normalized into discrete actions. */ export const AgentInputSchema = z.discriminatedUnion('type', [ /** A user prompt as one or more content blocks (text / image / resource …). */ diff --git a/packages/host/agent-adapter/src/__tests__/base.test.ts b/packages/host/agent-adapter/src/__tests__/base.test.ts index c121f67e3..f00c967ac 100644 --- a/packages/host/agent-adapter/src/__tests__/base.test.ts +++ b/packages/host/agent-adapter/src/__tests__/base.test.ts @@ -6,8 +6,9 @@ import type { ToolCallContent, ToolCallUpdate, } from '@linkcode/schema'; +import { AGENT_INPUT_CAPABILITIES } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; -import { BaseAgentAdapter } from '../base'; +import { BaseAgentAdapter, UnsupportedAttachmentError } from '../base'; import type { HistoryCheckpoint } from '../history-branch'; import { encodeHistoryBranchCursor } from '../history-branch'; import { asHistoryId } from '../history-util'; @@ -297,10 +298,39 @@ describe('BaseAgentAdapter command/shell defaults', () => { await a.start({ kind: 'pi', cwd: '/repo' }); expect(a.seen).toContainEqual({ type: 'capabilities-update', - capabilities: { slashCommands: true, shellCommand: false }, + capabilities: AGENT_INPUT_CAPABILITIES.pi, }); }); + it('throws when a prompt carries an image the harness did not declare', async () => { + class GrokAdapter extends BaseAgentAdapter { + readonly kind = 'grok-build' as const; + protected onStart(): Promise { + return Promise.resolve(); + } + protected onPrompt(_content: ContentBlock[]): Promise { + return Promise.resolve(); + } + } + const adapter = new GrokAdapter(); + await expect( + adapter.send({ + type: 'prompt', + content: [{ type: 'image', mimeType: 'image/png', data: 'AA==' }], + }), + ).rejects.toBeInstanceOf(UnsupportedAttachmentError); + }); + + it('accepts an image prompt when the harness declared inline_image', async () => { + const adapter = new TestAdapter(); + await expect( + adapter.send({ + type: 'prompt', + content: [{ type: 'image', mimeType: 'image/png', data: 'AA==' }], + }), + ).resolves.toBeUndefined(); + }); + it('rejects a command input unless the adapter overrides onCommand', async () => { const a = new TestAdapter(); await expect(a.send({ type: 'command', name: 'compact' })).rejects.toThrow( diff --git a/packages/host/agent-adapter/src/__tests__/grok-build.test.ts b/packages/host/agent-adapter/src/__tests__/grok-build.test.ts index 21606d6a7..9ba1bb671 100644 --- a/packages/host/agent-adapter/src/__tests__/grok-build.test.ts +++ b/packages/host/agent-adapter/src/__tests__/grok-build.test.ts @@ -141,6 +141,10 @@ describe('GrokBuildAdapter', () => { }); }); + it('declares no attachment support', () => { + expect(new GrokBuildAdapter().capabilities.attachments).toBeUndefined(); + }); + it('fails start when no CLI is resolved', async () => { vi.spyOn(agentRuntimeProber, 'resolveBinary').mockReturnValue(undefined); const adapter = new GrokBuildAdapter(); diff --git a/packages/host/agent-adapter/src/base.ts b/packages/host/agent-adapter/src/base.ts index 57c4d0a89..c5ad604b5 100644 --- a/packages/host/agent-adapter/src/base.ts +++ b/packages/host/agent-adapter/src/base.ts @@ -42,6 +42,14 @@ import { linkCodeGatewayError } from './gateway-error'; import type { HistoryCheckpoint } from './history-branch'; import { encodeHistoryBranchCursor } from './history-branch'; +/** An undeclared representation reached the adapter — silent drops are a contract violation. */ +export class UnsupportedAttachmentError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'UnsupportedAttachmentError'; + } +} + type PermissionResolver = (outcome: PermissionOutcome) => void; type QuestionResolver = (outcome: QuestionOutcome) => void; interface PendingQuestion { @@ -130,6 +138,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { async send(input: AgentInput): Promise { switch (input.type) { case 'prompt': + assertDeclaredAttachmentRepresentations(this.kind, this.capabilities, input.content); await this.onPrompt(input.content); return; case 'command': @@ -521,3 +530,25 @@ export abstract class BaseAgentAdapter implements AgentAdapter { } } } + +function assertDeclaredAttachmentRepresentations( + kind: AgentKind, + capabilities: AgentCapabilities, + content: ContentBlock[], +): void { + const representations = capabilities.attachments?.representations; + const allowsInlineImage = representations?.includes('inline_image') === true; + const allowsReadonlyFile = representations?.includes('readonly_file') === true; + for (let i = 0, len = content.length; i < len; i++) { + const block = content[i]; + if (!allowsInlineImage && block.type === 'image') { + throw new UnsupportedAttachmentError(`${kind}: image attachments are not supported`); + } + if ( + !allowsReadonlyFile && + (block.type === 'audio' || block.type === 'resource' || block.type === 'resource_link') + ) { + throw new UnsupportedAttachmentError(`${kind}: file attachments are not supported`); + } + } +} From cf17d555e8c10e334031418d321bd499e876e585 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 4 Sep 2026 02:27:42 +0800 Subject: [PATCH 03/10] feat(engine): admit attachment refs against harness capability --- .../src/__tests__/attachment-admit.test.ts | 139 +++++++++++++++ packages/host/engine/src/attachment/admit.ts | 165 ++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 packages/host/engine/src/__tests__/attachment-admit.test.ts create mode 100644 packages/host/engine/src/attachment/admit.ts diff --git a/packages/host/engine/src/__tests__/attachment-admit.test.ts b/packages/host/engine/src/__tests__/attachment-admit.test.ts new file mode 100644 index 000000000..a6944fefe --- /dev/null +++ b/packages/host/engine/src/__tests__/attachment-admit.test.ts @@ -0,0 +1,139 @@ +import { + AttachmentIdSchema, + blobIdFromSha256, + effectiveAttachmentCapability, + MAX_ATTACHMENT_BYTES, + MAX_ATTACHMENT_TOTAL_BYTES, +} from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { admitPromptAttachments, assertInlineAttachmentsSupported } from '../attachment/admit'; +import type { StoredAttachment } from '../attachment/attachment-store'; +import { RequestError } from '../failure'; + +const ATT_1 = AttachmentIdSchema.parse('att-1'); +const ATT_2 = AttachmentIdSchema.parse('att-2'); + +function stored( + partial: Partial & { attachmentId?: StoredAttachment['attachmentId'] }, +): StoredAttachment { + return { + attachmentId: partial.attachmentId ?? ATT_1, + kind: partial.kind ?? 'image', + name: partial.name ?? 'shot.png', + mimeType: partial.mimeType ?? 'image/png', + sizeBytes: partial.sizeBytes ?? 16, + metadata: {}, + createdAt: 1, + blobId: partial.blobId ?? blobIdFromSha256('a'.repeat(64)), + }; +} + +describe('admitPromptAttachments', () => { + const capability = effectiveAttachmentCapability('claude-code'); + + it('accepts a ready image within the effective capability', () => { + expect(() => + admitPromptAttachments( + [{ type: 'attachment_ref', attachmentId: ATT_1 }], + [stored({})], + capability, + ), + ).not.toThrow(); + }); + + it('refuses refs when the harness declared nothing', () => { + expect(() => + admitPromptAttachments( + [{ type: 'attachment_ref', attachmentId: ATT_1 }], + [stored({})], + undefined, + ), + ).toThrow(RequestError); + try { + admitPromptAttachments( + [{ type: 'attachment_ref', attachmentId: ATT_1 }], + [stored({})], + undefined, + ); + } catch (error) { + expect(error).toMatchObject({ code: 'unsupported_attachment' }); + } + }); + + it('refuses a missing attachment before persist', () => { + try { + admitPromptAttachments([{ type: 'attachment_ref', attachmentId: ATT_1 }], [], capability); + } catch (error) { + expect(error).toMatchObject({ + code: 'unsupported_attachment', + message: 'Unknown attachment', + }); + return; + } + expect.fail('expected a typed refusal'); + }); + + it('refuses an undeclared mime type', () => { + try { + admitPromptAttachments( + [{ type: 'attachment_ref', attachmentId: ATT_1 }], + [stored({ mimeType: 'image/svg+xml' })], + capability, + ); + } catch (error) { + expect(error).toMatchObject({ code: 'unsupported_attachment' }); + return; + } + expect.fail('expected a typed refusal'); + }); + + it('refuses a file that exceeds the per-attachment cap', () => { + try { + admitPromptAttachments( + [{ type: 'attachment_ref', attachmentId: ATT_1 }], + [stored({ sizeBytes: MAX_ATTACHMENT_BYTES + 1 })], + capability, + ); + } catch (error) { + expect(error).toMatchObject({ code: 'limit_exceeded' }); + return; + } + expect.fail('expected a typed refusal'); + }); + + it('refuses a prompt whose unique attachments exceed the aggregate cap', () => { + try { + admitPromptAttachments( + [ + { type: 'attachment_ref', attachmentId: ATT_1 }, + { type: 'attachment_ref', attachmentId: ATT_2 }, + ], + [ + stored({ attachmentId: ATT_1, sizeBytes: MAX_ATTACHMENT_TOTAL_BYTES / 2 }), + stored({ attachmentId: ATT_2, sizeBytes: MAX_ATTACHMENT_TOTAL_BYTES / 2 + 1 }), + ], + capability, + ); + } catch (error) { + expect(error).toMatchObject({ code: 'limit_exceeded' }); + return; + } + expect.fail('expected a typed refusal'); + }); +}); + +describe('assertInlineAttachmentsSupported', () => { + it('lets a claude-code image through and refuses grok-build', () => { + const image = { type: 'image' as const, mimeType: 'image/png', data: 'AA==' }; + expect(() => + assertInlineAttachmentsSupported([image], effectiveAttachmentCapability('claude-code')), + ).not.toThrow(); + try { + assertInlineAttachmentsSupported([image], effectiveAttachmentCapability('grok-build')); + } catch (error) { + expect(error).toMatchObject({ code: 'unsupported_attachment' }); + return; + } + expect.fail('expected a typed refusal'); + }); +}); diff --git a/packages/host/engine/src/attachment/admit.ts b/packages/host/engine/src/attachment/admit.ts new file mode 100644 index 000000000..82d120bc4 --- /dev/null +++ b/packages/host/engine/src/attachment/admit.ts @@ -0,0 +1,165 @@ +import type { + AttachmentCapability, + AttachmentId, + ContentBlock, + PromptBlock, +} from '@linkcode/schema'; +import { MAX_ATTACHMENT_TOTAL_BYTES } from '@linkcode/schema'; +import { RequestError } from '../failure'; +import type { StoredAttachment } from './attachment-store'; + +export function attachmentIdsFromBlocks(blocks: readonly PromptBlock[]): AttachmentId[] { + const ids: AttachmentId[] = []; + for (let i = 0, len = blocks.length; i < len; i++) { + const block = blocks[i]; + if (block.type === 'attachment_ref') ids.push(block.attachmentId); + } + return ids; +} + +/** Unique ids in first-seen order. */ +export function uniqueAttachmentIds(ids: readonly AttachmentId[]): AttachmentId[] { + const seen = new Set(); + const unique: AttachmentId[] = []; + for (let i = 0, len = ids.length; i < len; i++) { + const id = ids[i]; + if (seen.has(id)) continue; + seen.add(id); + unique.push(id); + } + return unique; +} + +function kindLimits(capability: AttachmentCapability, kind: string) { + if (kind === 'image') return capability.kinds.image; + return capability.kinds.file; +} + +function representationFor( + capability: AttachmentCapability, + kind: string, +): 'inline_image' | 'readonly_file' | undefined { + const representations = capability.representations; + if (kind === 'image' && representations.includes('inline_image')) return 'inline_image'; + if (representations.includes('readonly_file')) return 'readonly_file'; + return undefined; +} + +/** + * Admit a prompt's `attachment_ref`s against the effective capability. Throws `RequestError` + * (`unsupported_attachment` for missing/unknown/undeclared, `limit_exceeded` for size/count). + */ +export function admitPromptAttachments( + blocks: readonly PromptBlock[], + stored: readonly StoredAttachment[], + capability: AttachmentCapability | undefined, +): void { + const ids = uniqueAttachmentIds(attachmentIdsFromBlocks(blocks)); + if (ids.length === 0) return; + if (!capability) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'Prompt attachments are not supported by this harness', + }); + } + const byId = new Map(); + for (let i = 0, len = stored.length; i < len; i++) { + byId.set(stored[i].attachmentId, stored[i]); + } + let totalBytes = 0; + let imageCount = 0; + let fileCount = 0; + for (let i = 0, len = ids.length; i < len; i++) { + const id = ids[i]; + const attachment = byId.get(id); + if (!attachment) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'Unknown attachment', + }); + } + if (!representationFor(capability, attachment.kind)) { + throw new RequestError({ + code: 'unsupported_attachment', + message: `This harness does not accept ${attachment.kind} attachments`, + }); + } + const limits = kindLimits(capability, attachment.kind); + if (!limits) { + throw new RequestError({ + code: 'unsupported_attachment', + message: `This harness does not accept ${attachment.kind} attachments`, + }); + } + if (!limits.mimeTypes.includes(attachment.mimeType)) { + throw new RequestError({ + code: 'unsupported_attachment', + message: `Unsupported attachment type: ${attachment.mimeType}`, + }); + } + if (attachment.sizeBytes > limits.maxBytes) { + throw new RequestError({ + code: 'limit_exceeded', + message: 'Attachment exceeds the maximum allowed size', + }); + } + if (attachment.kind === 'image') imageCount += 1; + else fileCount += 1; + const maxCount = limits.maxCount; + if ( + (attachment.kind === 'image' && imageCount > maxCount) || + (attachment.kind === 'file' && fileCount > maxCount) + ) { + throw new RequestError({ + code: 'limit_exceeded', + message: 'Too many attachments', + }); + } + totalBytes += attachment.sizeBytes; + if (totalBytes > MAX_ATTACHMENT_TOTAL_BYTES) { + throw new RequestError({ + code: 'limit_exceeded', + message: 'Attachments exceed the maximum allowed total size', + }); + } + } +} + +/** Legacy `agent.input` images: size/mime already passed the inline guard; this is the capability gate. */ +export function assertInlineAttachmentsSupported( + content: ContentBlock[], + capability: AttachmentCapability | undefined, +): void { + for (let i = 0, len = content.length; i < len; i++) { + const block = content[i]; + if (block.type !== 'image' && block.type !== 'audio' && block.type !== 'resource') continue; + if (!capability) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'Prompt attachments are not supported by this harness', + }); + } + if (block.type === 'image') { + const limits = capability.kinds.image; + if (limits === undefined || !capability.representations.includes('inline_image')) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'This harness does not accept image attachments', + }); + } + if (!limits.mimeTypes.includes(block.mimeType)) { + throw new RequestError({ + code: 'unsupported_attachment', + message: `Unsupported attachment type: ${block.mimeType}`, + }); + } + continue; + } + if (!capability.representations.includes('readonly_file')) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'This harness does not accept file attachments', + }); + } + } +} From 207f3caa9fe5db342edb345db41844b75b3a23ce Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 4 Sep 2026 02:29:16 +0800 Subject: [PATCH 04/10] feat(engine): materialize prompt attachments for adapters --- .../src/__tests__/prompt-materializer.test.ts | 170 ++++++++++++ .../engine/src/attachment/materializer.ts | 250 ++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 packages/host/engine/src/__tests__/prompt-materializer.test.ts create mode 100644 packages/host/engine/src/attachment/materializer.ts diff --git a/packages/host/engine/src/__tests__/prompt-materializer.test.ts b/packages/host/engine/src/__tests__/prompt-materializer.test.ts new file mode 100644 index 000000000..9365af6b2 --- /dev/null +++ b/packages/host/engine/src/__tests__/prompt-materializer.test.ts @@ -0,0 +1,170 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AttachmentCapability, PromptRecord } from '@linkcode/schema'; +import { + AttachmentIdSchema, + blobIdFromSha256, + effectiveAttachmentCapability, + MAX_ATTACHMENT_BYTES, + PromptIdSchema, + RunIdSchema, + SessionIdSchema, +} from '@linkcode/schema'; +import { Effect } from 'effect'; +import { nullthrow } from 'foxts/guard'; +import { afterEach, describe, expect, it } from 'vitest'; +import { InMemoryAttachmentStore } from '../attachment/attachment-store'; +import { FsBlobStore } from '../attachment/blob-store'; +import { PromptMaterializer } from '../attachment/materializer'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +async function fixture(bytes: Uint8Array = PNG_1X1): Promise<{ + materializer: PromptMaterializer; + store: InMemoryAttachmentStore; + blobs: FsBlobStore; + prompt: PromptRecord; + capability: AttachmentCapability; + root: string; +}> { + const root = await mkdtemp(join(tmpdir(), 'linkcode-materializer-')); + temporaryDirectories.push(root); + const blobs = new FsBlobStore(join(root, 'blobs')); + const store = new InMemoryAttachmentStore(); + const digest = sha256(bytes); + const blobId = blobIdFromSha256(digest); + const stage = await blobs.stage('up-1'); + await stage.write(0, bytes); + await stage.commit({ sha256: digest, sizeBytes: bytes.byteLength }); + const attachmentId = AttachmentIdSchema.parse('att-1'); + await store.commitAttachment({ + blob: { blobId, sizeBytes: bytes.byteLength, createdAt: 1 }, + attachment: { + attachmentId, + kind: 'image', + name: 'shot.png', + mimeType: 'image/png', + sizeBytes: bytes.byteLength, + metadata: {}, + createdAt: 1, + }, + }); + const prompt: PromptRecord = { + promptId: PromptIdSchema.parse('prompt-1'), + blocks: [ + { type: 'text', text: 'see' }, + { type: 'attachment_ref', attachmentId }, + ], + contextAttachmentIds: [], + createdAt: 1, + }; + const capability = nullthrow( + effectiveAttachmentCapability('claude-code'), + 'claude-code must declare images', + ); + return { + materializer: new PromptMaterializer(store, blobs, root), + store, + blobs, + prompt, + capability, + root, + }; +} + +describe('PromptMaterializer', () => { + it('turns a ready image ref into the same base64 ContentBlock adapters already consume', async () => { + const { materializer, prompt, capability } = await fixture(); + const prepared = await Effect.runPromise( + materializer.prepare( + SessionIdSchema.parse('sess-1'), + RunIdSchema.parse('run-1'), + prompt, + capability, + ), + ); + expect(materializer.toContentBlocks(prepared)).toEqual([ + { type: 'text', text: 'see' }, + { + type: 'image', + data: PNG_1X1.toString('base64'), + mimeType: 'image/png', + name: 'shot.png', + }, + ]); + }); + + it('hardlinks a readonly_file projection at mode 0444 and sweeps it', async () => { + const { materializer, prompt, store, blobs } = await fixture(); + const stored = await store.getAttachment(AttachmentIdSchema.parse('att-1')); + if (!stored) throw new Error('fixture attachment missing'); + const fileCapability: AttachmentCapability = { + kinds: { + file: { + mimeTypes: ['image/png'], + maxBytes: MAX_ATTACHMENT_BYTES, + maxCount: 1, + }, + }, + representations: ['readonly_file'], + }; + await store.commitAttachment({ + blob: { blobId: stored.blobId, sizeBytes: stored.sizeBytes, createdAt: 1 }, + attachment: { ...stored, kind: 'file' }, + }); + const filePrompt: PromptRecord = { + ...prompt, + blocks: [{ type: 'attachment_ref', attachmentId: stored.attachmentId }], + }; + const sessionId = SessionIdSchema.parse('sess-1'); + const runId = RunIdSchema.parse('run-1'); + const prepared = await Effect.runPromise( + materializer.prepare(sessionId, runId, filePrompt, fileCapability), + ); + const file = prepared.blocks[0]; + expect(file.type).toBe('readonly_file'); + if (file.type !== 'readonly_file') return; + expect((await stat(file.path)).nlink).toBeGreaterThanOrEqual(2); + expect((await stat(file.path)).mode & 0o222).toBe(0); + expect(await blobs.stat(stored.blobId)).toEqual({ sizeBytes: PNG_1X1.byteLength }); + + await materializer.cleanupRun(sessionId, runId); + await expect(stat(file.path)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await blobs.stat(stored.blobId)).toEqual({ sizeBytes: PNG_1X1.byteLength }); + + const again = await Effect.runPromise( + materializer.prepare(sessionId, runId, filePrompt, fileCapability), + ); + const againFile = again.blocks[0]; + expect(againFile.type).toBe('readonly_file'); + if (againFile.type !== 'readonly_file') return; + await materializer.bootSweep(); + await expect(stat(againFile.path)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('does not traverse out of the materialized directory on cleanup', async () => { + const { materializer, root } = await fixture(); + const retained = join(root, 'retained.txt'); + await writeFile(retained, 'safe'); + await materializer.cleanupSession(SessionIdSchema.parse('..')); + await materializer.cleanupRun(SessionIdSchema.parse('..'), RunIdSchema.parse('..')); + expect(await readFile(retained, 'utf8')).toBe('safe'); + }); +}); diff --git a/packages/host/engine/src/attachment/materializer.ts b/packages/host/engine/src/attachment/materializer.ts new file mode 100644 index 000000000..1e9c6d412 --- /dev/null +++ b/packages/host/engine/src/attachment/materializer.ts @@ -0,0 +1,250 @@ +import { Buffer } from 'node:buffer'; +import { chmod, link, mkdir, rm } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import type { + AttachmentCapability, + ContentBlock, + PromptRecord, + RunId, + SessionId, +} from '@linkcode/schema'; +import { Effect } from 'effect'; +import { OperationError, RequestError } from '../failure'; +import { admitPromptAttachments, attachmentIdsFromBlocks, uniqueAttachmentIds } from './admit'; +import type { AttachmentStore, StoredAttachment } from './attachment-store'; +import type { BlobStore } from './blob-store'; +import { AttachmentIoMutex } from './io-mutex'; + +const rPathSep = /[/\\]/g; +const rUnsafePathSegment = /[/\\]|\.\./; + +export type PreparedAttachment = + | { + readonly type: 'inline_image'; + readonly attachment: StoredAttachment; + readonly block: Extract; + } + | { + readonly type: 'readonly_file'; + readonly attachment: StoredAttachment; + readonly path: string; + }; + +export interface PreparedPrompt { + readonly blocks: Array<{ readonly type: 'text'; readonly text: string } | PreparedAttachment>; +} + +/** + * Last hop before the adapter: the only place attachment bytes leave the store. + * Path materialization assumes the harness shares this filesystem — that assumption stays here. + */ +export class PromptMaterializer { + constructor( + private readonly attachments: AttachmentStore, + private readonly blobs: BlobStore, + private readonly stateDir: string, + private readonly io: AttachmentIoMutex = new AttachmentIoMutex(), + ) {} + + prepare( + sessionId: SessionId, + runId: RunId, + prompt: PromptRecord, + capability: AttachmentCapability | undefined, + ): Effect.Effect { + const load = this.loadStored.bind(this); + const convert = this.convert.bind(this); + return Effect.gen(function* () { + const ids = uniqueAttachmentIds(attachmentIdsFromBlocks(prompt.blocks)); + const stored = yield* load(ids); + admitPromptAttachments(prompt.blocks, stored, capability); + const byId = new Map(stored.map((attachment) => [attachment.attachmentId, attachment])); + const blocks: PreparedPrompt['blocks'] = []; + for (let i = 0, len = prompt.blocks.length; i < len; i++) { + const block = prompt.blocks[i]; + if (block.type === 'text') { + blocks.push(block); + continue; + } + const attachment = byId.get(block.attachmentId); + if (attachment === undefined) { + return yield* Effect.fail( + new RequestError({ + code: 'unsupported_attachment', + message: 'Unknown attachment', + }), + ); + } + blocks.push(yield* convert(sessionId, runId, attachment, capability)); + } + return { blocks }; + }); + } + + toContentBlocks(prepared: PreparedPrompt): ContentBlock[] { + const content: ContentBlock[] = []; + for (let i = 0, len = prepared.blocks.length; i < len; i++) { + const block = prepared.blocks[i]; + if (block.type === 'text') { + content.push({ type: 'text', text: block.text }); + continue; + } + if (block.type === 'inline_image') { + content.push(block.block); + continue; + } + throw new RequestError({ + code: 'unsupported_attachment', + message: 'This harness does not accept file attachments', + }); + } + return content; + } + + cleanupRun(sessionId: SessionId, runId: RunId): Promise { + const dir = this.runDir(sessionId, runId); + if (dir === undefined) return Promise.resolve(); + return rm(dir, { recursive: true, force: true }); + } + + cleanupSession(sessionId: SessionId): Promise { + const session = pathSegment(sessionId); + if (session === undefined) return Promise.resolve(); + return rm(join(this.stateDir, 'materialized', session), { recursive: true, force: true }); + } + + bootSweep(): Promise { + return rm(join(this.stateDir, 'materialized'), { recursive: true, force: true }); + } + + private runDir(sessionId: SessionId, runId: RunId): string | undefined { + const session = pathSegment(sessionId); + const run = pathSegment(runId); + if (session === undefined || run === undefined) return; + return join(this.stateDir, 'materialized', session, run); + } + + private loadStored( + ids: ReadonlyArray, + ): Effect.Effect { + if (ids.length === 0) return Effect.succeed([]); + return Effect.tryPromise({ + try: () => this.attachments.listAttachments(ids), + catch: (cause) => + new OperationError({ + subsystem: 'store', + operation: 'attachments.list', + publicMessage: 'Failed to load attachments', + cause, + }), + }); + } + + private convert( + sessionId: SessionId, + runId: RunId, + attachment: StoredAttachment, + capability: AttachmentCapability | undefined, + ): Effect.Effect { + const representations = capability?.representations ?? []; + if (attachment.kind === 'image' && representations.includes('inline_image')) { + return this.inlineImage(attachment); + } + if (representations.includes('readonly_file')) { + return this.readonlyFile(sessionId, runId, attachment); + } + return Effect.fail( + new RequestError({ + code: 'unsupported_attachment', + message: `This harness does not accept ${attachment.kind} attachments`, + }), + ); + } + + private inlineImage( + attachment: StoredAttachment, + ): Effect.Effect { + const { blobs } = this; + return Effect.tryPromise({ + async try() { + const bytes = await blobs.read(attachment.blobId, 0, attachment.sizeBytes); + if (bytes?.byteLength !== attachment.sizeBytes) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'Attachment is not ready', + }); + } + return { + type: 'inline_image' as const, + attachment, + block: { + type: 'image' as const, + data: Buffer.from(bytes).toString('base64'), + mimeType: attachment.mimeType, + name: attachment.name, + }, + }; + }, + catch(cause) { + return cause instanceof RequestError + ? cause + : new OperationError({ + subsystem: 'filesystem', + operation: 'attachments.materialize', + publicMessage: 'Failed to read attachment bytes', + cause, + }); + }, + }); + } + + private readonlyFile( + sessionId: SessionId, + runId: RunId, + attachment: StoredAttachment, + ): Effect.Effect { + const destDir = this.runDir(sessionId, runId); + if (destDir === undefined) { + return Effect.fail( + new RequestError({ + code: 'unsupported_attachment', + message: 'Failed to materialize attachment file', + }), + ); + } + const dest = join(destDir, destName(attachment)); + const source = this.blobs.pathOf(attachment.blobId); + const { io } = this; + return Effect.tryPromise({ + try() { + return io.run(async () => { + await mkdir(destDir, { recursive: true }); + await link(source, dest); + await chmod(dest, 0o444); + return { + type: 'readonly_file' as const, + attachment, + path: dest, + }; + }); + }, + catch(cause) { + return new OperationError({ + subsystem: 'filesystem', + operation: 'attachments.materialize', + publicMessage: 'Failed to materialize attachment file', + cause, + }); + }, + }); + } +} + +function destName(attachment: StoredAttachment): string { + return `${attachment.attachmentId}-${basename(attachment.name).replaceAll(rPathSep, '_')}`; +} + +function pathSegment(id: string): string | undefined { + if (id === '.' || id === '..' || rUnsafePathSegment.test(id)) return; + return id; +} From 52d0b4881774f435365a499f63838b323122a511 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 4 Sep 2026 02:30:45 +0800 Subject: [PATCH 05/10] feat(engine): persist admitted refs and project them without bytes --- .../engine-attachment-submit.test.ts | 225 ++++++++++++++++++ .../__tests__/engine-session-input.test.ts | 25 +- .../src/__tests__/engine-turn-submit.test.ts | 2 +- .../engine/src/conversation/turn-service.ts | 49 +++- packages/host/engine/src/engine.ts | 16 ++ .../engine/src/session/lifecycle-service.ts | 134 +++++++++-- .../host/engine/src/session/orchestrator.ts | 6 +- .../src/session/session-input-dispatcher.ts | 58 +++-- 8 files changed, 474 insertions(+), 41 deletions(-) create mode 100644 packages/host/engine/src/__tests__/engine-attachment-submit.test.ts diff --git a/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts b/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts new file mode 100644 index 000000000..515bc2d7a --- /dev/null +++ b/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts @@ -0,0 +1,225 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { WirePayload } from '@linkcode/schema'; +import { + AttachmentIdSchema, + attachmentUri, + blobIdFromSha256, + OperationIdSchema, +} from '@linkcode/schema'; +import { nullthrow } from 'foxts/guard'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { InMemoryAttachmentStore } from '../attachment/attachment-store'; +import { FsBlobStore } from '../attachment/blob-store'; +import { InMemoryConversationStore } from '../conversation/conversation-store'; +import { InMemorySessionStore } from '../session/session-store'; +import { + createSessionHarness as harness, + startedSessionId as startedId, +} from './fixtures/session-harness'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function failure(sent: WirePayload[], replyTo: string) { + const reply = sent.find( + (payload) => payload.kind === 'request.failed' && payload.replyTo === replyTo, + ); + if (reply?.kind !== 'request.failed') throw new Error(`no request.failed for ${replyTo}`); + return reply; +} + +async function started(kind: 'claude-code' | 'grok-build' = 'claude-code') { + const stateDir = await mkdtemp(join(tmpdir(), 'linkcode-attach-submit-')); + temporaryDirectories.push(stateDir); + const conversationStore = new InMemoryConversationStore(); + const attachmentStore = new InMemoryAttachmentStore(() => + conversationStore.referencedAttachmentIds(), + ); + const blobStore = new FsBlobStore(join(stateDir, 'blobs')); + const h = harness( + new InMemorySessionStore(), + undefined, + undefined, + undefined, + undefined, + undefined, + { + conversationStore, + attachmentStore, + blobStore, + stateDir, + }, + ); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind, cwd: stateDir }, + }); + return { + ...h, + conversationStore, + attachmentStore, + blobStore, + sessionId: startedId(h.sent, 'r1'), + adapter: nullthrow(h.adapters[0]), + }; +} + +async function readyPng(h: Awaited>) { + const digest = sha256(PNG_1X1); + const blobId = blobIdFromSha256(digest); + const stage = await h.blobStore.stage('up-1'); + await stage.write(0, PNG_1X1); + await stage.commit({ sha256: digest, sizeBytes: PNG_1X1.byteLength }); + const attachmentId = AttachmentIdSchema.parse('att-ready'); + await h.attachmentStore.commitAttachment({ + blob: { blobId, sizeBytes: PNG_1X1.byteLength, createdAt: 1 }, + attachment: { + attachmentId, + kind: 'image', + name: 'shot.png', + mimeType: 'image/png', + sizeBytes: PNG_1X1.byteLength, + metadata: {}, + createdAt: 1, + }, + }); + return attachmentId; +} + +describe('turn.submit attachment admit and materialize', () => { + it('refuses a missing ref at admit and persists nothing', async () => { + const h = await started(); + await h.inject({ + kind: 'turn.submit', + clientReqId: 's-missing', + sessionId: h.sessionId, + operationId: OperationIdSchema.parse('op-missing'), + input: { + type: 'prompt', + blocks: [{ type: 'attachment_ref', attachmentId: AttachmentIdSchema.parse('att-missing') }], + }, + }); + expect(failure(h.sent, 's-missing').code).toBe('unsupported_attachment'); + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0); + }); + + it('refuses an image on grok-build at admit', async () => { + const h = await started('grok-build'); + const attachmentId = await readyPng(h); + await h.inject({ + kind: 'turn.submit', + clientReqId: 's-grok', + sessionId: h.sessionId, + operationId: OperationIdSchema.parse('op-grok'), + input: { + type: 'prompt', + blocks: [ + { type: 'text', text: 'look' }, + { type: 'attachment_ref', attachmentId }, + ], + }, + }); + expect(failure(h.sent, 's-grok')).toMatchObject({ + code: 'unsupported_attachment', + }); + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0); + expect(h.adapter.sentInputs).toEqual([]); + }); + + it('materializes a declared image to the adapter without putting bytes on the echo or prompt row', async () => { + const h = await started(); + const attachmentId = await readyPng(h); + await h.inject({ + kind: 'turn.submit', + clientReqId: 's-ok', + sessionId: h.sessionId, + operationId: OperationIdSchema.parse('op-ok'), + input: { + type: 'prompt', + blocks: [ + { type: 'text', text: 'look' }, + { type: 'attachment_ref', attachmentId }, + ], + }, + }); + await vi.waitFor(() => { + expect(h.sent).toContainEqual( + expect.objectContaining({ kind: 'turn.submitted', replyTo: 's-ok' }), + ); + }); + expect(h.adapter.sentInputs).toEqual([ + { + type: 'prompt', + content: [ + { type: 'text', text: 'look' }, + { + type: 'image', + data: PNG_1X1.toString('base64'), + mimeType: 'image/png', + name: 'shot.png', + }, + ], + }, + ]); + const echo = h.sent.find( + (payload) => payload.kind === 'agent.event' && payload.event.type === 'user-message', + ); + if (echo?.kind !== 'agent.event' || echo.event.type !== 'user-message') { + throw new Error('no live prompt echo'); + } + expect(echo.event.content).toEqual([{ type: 'text', text: 'look' }]); + expect(JSON.stringify(echo.event.content)).not.toContain(PNG_1X1.toString('base64')); + + const turns = await h.conversationStore.listTurns(h.sessionId); + expect(turns).toHaveLength(1); + const turnInput = nullthrow(turns[0], 'expected a persisted turn').input; + expect(turnInput.type).toBe('prompt'); + if (turnInput.type !== 'prompt' || turnInput.promptId === null) return; + const prompt = await h.conversationStore.getPrompt(turnInput.promptId); + expect(prompt?.blocks).toEqual([ + { type: 'text', text: 'look' }, + { type: 'attachment_ref', attachmentId }, + ]); + + await h.inject({ kind: 'conversation.read', clientReqId: 'rr', sessionId: h.sessionId }); + const read = h.sent.find( + (payload) => payload.kind === 'conversation.read.result' && payload.replyTo === 'rr', + ); + if (read?.kind !== 'conversation.read.result') throw new Error('no conversation.read.result'); + const row = read.events.find((item) => 'event' in item && item.event.type === 'user-message'); + if (row === undefined || !('event' in row) || row.event.type !== 'user-message') { + throw new Error('no user row'); + } + expect(row.event.content).toEqual([ + { type: 'text', text: 'look' }, + { + type: 'resource_link', + uri: attachmentUri(attachmentId), + name: 'shot.png', + mimeType: 'image/png', + size: PNG_1X1.byteLength, + title: 'image', + }, + ]); + expect(JSON.stringify(row.event.content)).not.toContain(PNG_1X1.toString('base64')); + }); +}); diff --git a/packages/host/engine/src/__tests__/engine-session-input.test.ts b/packages/host/engine/src/__tests__/engine-session-input.test.ts index 59925594d..56bde1489 100644 --- a/packages/host/engine/src/__tests__/engine-session-input.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-input.test.ts @@ -50,13 +50,13 @@ function eventsAfter(sent: WirePayload[], mark: number): AgentEvent[] { return sent.slice(mark).flatMap((p) => (p.kind === 'agent.event' ? [p.event] : [])); } -async function startedHarness() { +async function startedHarness(kind: 'claude-code' | 'grok-build' = 'claude-code') { const h = harness(); await h.engine.start(); await h.inject({ kind: 'session.start', clientReqId: 'r1', - opts: { kind: 'claude-code', cwd: '/repo' }, + opts: { kind, cwd: '/repo' }, }); return { ...h, sessionId: startedId(h.sent, 'r1'), adapter: nullthrow(h.adapters[0]) }; } @@ -147,6 +147,27 @@ describe('engine session input', () => { }); }); + it('refuses an image prompt on a text-only harness', async () => { + const h = await startedHarness('grok-build'); + + await h.inject({ + kind: 'agent.input', + clientReqId: 'input', + sessionId: h.sessionId, + input: { + type: 'prompt', + content: [{ type: 'image', mimeType: 'image/png', data: 'AA==' }], + }, + }); + + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'input', + code: 'unsupported_attachment', + message: 'Prompt attachments are not supported by this harness', + }); + }); + it('echoes command and shell inputs as the text the user typed', async () => { const { sent, inject, adapter, sessionId } = await startedHarness(); adapter.emit({ diff --git a/packages/host/engine/src/__tests__/engine-turn-submit.test.ts b/packages/host/engine/src/__tests__/engine-turn-submit.test.ts index 02a6e05f1..db018d0ae 100644 --- a/packages/host/engine/src/__tests__/engine-turn-submit.test.ts +++ b/packages/host/engine/src/__tests__/engine-turn-submit.test.ts @@ -1217,7 +1217,7 @@ describe('turn.submit saga', () => { blocks: [{ type: 'attachment_ref', attachmentId: AttachmentIdSchema.parse('att-1') }], }, }); - expect(failure(h.sent, 's-attachment').code).toBe('unsupported'); + expect(failure(h.sent, 's-attachment').code).toBe('unsupported_attachment'); expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(0); }); diff --git a/packages/host/engine/src/conversation/turn-service.ts b/packages/host/engine/src/conversation/turn-service.ts index 492351eac..49f83c5a2 100644 --- a/packages/host/engine/src/conversation/turn-service.ts +++ b/packages/host/engine/src/conversation/turn-service.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import type { HistoryCheckpoint } from '@linkcode/agent-adapter'; import type { + AttachmentId, ContentBlock, ConversationOperation, ConversationTurn, @@ -16,9 +17,12 @@ import type { TurnId, TurnInput, } from '@linkcode/schema'; +import { attachmentUri } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { Effect } from 'effect'; +import type { AttachmentStore } from '../attachment/attachment-store'; +import { InMemoryAttachmentStore } from '../attachment/attachment-store'; import { OperationError, RequestError } from '../failure'; import type { SessionRecordRegistry } from '../session/session-record-registry'; import type { ConversationStore } from './conversation-store'; @@ -36,8 +40,7 @@ function mintPromptId(): PromptId { return `prompt-${randomUUID()}` as PromptId; } -/** Durable prompt blocks from legacy prompt content: text only for now — binary attachments - * become `attachment_ref`s once the attachment store lands. */ +/** Durable prompt blocks from legacy prompt content: text only. Inline images are not ingested. */ export function promptBlocksFromContent(content: ContentBlock[]): PromptBlock[] { return content.flatMap((block) => block.type === 'text' ? [{ type: 'text' as const, text: block.text }] : [], @@ -113,6 +116,7 @@ export class ConversationTurnService { private readonly records: SessionRecordRegistry, private readonly transport: Transport, private readonly runTask: (effect: Effect.Effect) => void, + private readonly attachments: AttachmentStore = new InMemoryAttachmentStore(), ) {} getOperation( @@ -158,12 +162,43 @@ export class ConversationTurnService { return Effect.succeed([{ type: 'text' as const, text: turnInputText(input) }]); } if (input.promptId === null) return Effect.undefined; + const { attachments } = this; return this.getPrompt(input.promptId).pipe( - Effect.map((prompt) => { - if (!prompt) return; - // attachment_ref blocks join the projection when the attachment store lands. - return prompt.blocks.flatMap((block) => - block.type === 'text' ? [{ type: 'text' as const, text: block.text }] : [], + Effect.flatMap((prompt) => { + if (!prompt) return Effect.undefined; + const ids: AttachmentId[] = []; + for (let i = 0, len = prompt.blocks.length; i < len; i++) { + const block = prompt.blocks[i]; + if (block.type === 'attachment_ref') ids.push(block.attachmentId); + } + const load = + ids.length === 0 + ? Effect.succeed([]) + : storeOperation('attachments.list', () => attachments.listAttachments(ids)); + return load.pipe( + Effect.map((stored) => { + const byId = new Map(stored.map((attachment) => [attachment.attachmentId, attachment])); + const content: ContentBlock[] = []; + for (let i = 0, len = prompt.blocks.length; i < len; i++) { + const block = prompt.blocks[i]; + if (block.type === 'text') { + content.push({ type: 'text', text: block.text }); + continue; + } + const attachment = byId.get(block.attachmentId); + content.push({ + type: 'resource_link', + uri: attachmentUri(block.attachmentId), + name: attachment?.name ?? block.attachmentId, + ...(attachment !== undefined && { + mimeType: attachment.mimeType, + size: attachment.sizeBytes, + title: attachment.kind, + }), + }); + } + return content; + }), ); }), ); diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index 4599f9079..b2974c22b 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -21,6 +21,7 @@ import { InMemoryAttachmentStore } from './attachment/attachment-store'; import { FsBlobStore } from './attachment/blob-store'; import { AttachmentGc } from './attachment/gc'; import { AttachmentIoMutex } from './attachment/io-mutex'; +import { PromptMaterializer } from './attachment/materializer'; import { AttachmentRequestHandler } from './attachment/request-handler'; import { AttachmentUploadService } from './attachment/upload-service'; import { @@ -137,6 +138,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( ); const attachmentIo = new AttachmentIoMutex(); const attachmentGc = new AttachmentGc(attachmentStore, blobStore, Date.now, attachmentIo); + const materializer = new PromptMaterializer(attachmentStore, blobStore, stateDir, attachmentIo); const resources = new ResourceService( transport, resourceStore, @@ -195,6 +197,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( records, transport, runTask, + attachmentStore, ); const conversationJournals = new ConversationLiveJournals(); const sessions = new SessionOrchestrator( @@ -215,6 +218,9 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( deps.browserToolsEnabled ? () => new BrowserReplHost((op, args) => browserBroker.dispatch(op, args)) : undefined, + (sessionId, runId) => { + void materializer.cleanupRun(sessionId, runId); + }, ); simulators?.setSessionValidator((id) => sessions.has(id)); terminals = deps.ptyBackend @@ -267,6 +273,8 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( worktrees, conversationTurns, conversationCheckpoints, + attachmentStore, + materializer, ); const sessionRequests = new SessionRequestHandler( transport, @@ -371,6 +379,14 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( 'Failed to sweep the attachment store', () => attachmentGc.bootSweep(), ).pipe(Effect.catch((error) => Effect.logWarning('Attachment boot sweep failed', error))); + yield* tryOperation( + 'filesystem', + 'attachments.materialized-sweep', + 'Failed to sweep materialized attachments', + () => materializer.bootSweep(), + ).pipe( + Effect.catch((error) => Effect.logWarning('Materialized attachment sweep failed', error)), + ); runTask(attachmentGc.cadence()); yield* worktrees.start(new Set(Array.from(records.values(), ({ sessionId }) => sessionId))); yield* tryOperation('store', 'workspaces.load', 'Failed to load workspaces', () => diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 1e4ad86b1..02ed29541 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -17,8 +17,17 @@ import type { WorkspaceRecord, WorktreeRecord, } from '@linkcode/schema'; +import { effectiveAttachmentCapability } from '@linkcode/schema'; import { Effect, Exit, Semaphore } from 'effect'; import { nullthrow } from 'foxts/guard'; +import { + admitPromptAttachments, + assertInlineAttachmentsSupported, + attachmentIdsFromBlocks, + uniqueAttachmentIds, +} from '../attachment/admit'; +import type { AttachmentStore } from '../attachment/attachment-store'; +import type { PromptMaterializer } from '../attachment/materializer'; import type { SessionDriver } from '../automation'; import type { ConversationCheckpointService, ForkCut } from '../conversation/checkpoint-service'; import { hasHiddenPrefix, pathToLeaf } from '../conversation/lineage-attribution'; @@ -39,6 +48,7 @@ import { } from '../failure'; import type { WorkspaceRegistry } from '../workspace/workspace-registry'; import type { WorktreeService } from '../worktree/worktree-service'; +import { assertAttachmentContentAllowed } from './attachment-guard'; import type { HistoryService } from './history-service'; import { decodeLiveBranchCursor } from './live-session'; import type { SessionOrchestrator } from './orchestrator'; @@ -76,9 +86,24 @@ type TurnLaunch = | { readonly type: 'resume'; readonly historyId?: AgentHistoryId } | { readonly type: 'fork'; readonly cut: ForkCut }; +function caughtEngineFailure(error: unknown): EngineFailure { + if ( + error instanceof RequestError || + error instanceof OperationError || + error instanceof OperationTimeout + ) { + return error; + } + return new OperationError({ + subsystem: 'store', + operation: 'attachments.admit', + publicMessage: 'Attachment validation failed', + cause: error, + }); +} + function toAgentInput(input: TurnSubmitInput): AgentInput { if (input.type !== 'prompt') return input; - // attachment_ref blocks are refused at admit until the attachment store lands. return { type: 'prompt', content: input.blocks.flatMap((block) => @@ -103,6 +128,8 @@ export class SessionLifecycleService { private readonly worktrees: WorktreeService, private readonly turns: ConversationTurnService, private readonly checkpoints: ConversationCheckpointService, + private readonly attachments: AttachmentStore, + private readonly materializer: PromptMaterializer, ) { this.driver = { createSession: ({ signal, ...options }) => @@ -126,10 +153,13 @@ export class SessionLifecycleService { } deleteSession(sessionId: SessionId): Effect.Effect { - const { sessions, workspaces, worktrees } = this; + const { materializer, sessions, workspaces, worktrees } = this; return Effect.gen(function* () { const worktree = worktrees.get(sessionId); yield* sessions.delete(sessionId); + // Best-effort: a missed directory is removed at the next boot sweep. Do not await the + // unlink on the delete reply — `session.delete` of `..` must not block or traverse. + void materializer.cleanupSession(sessionId); yield* worktrees.cleanupDeletedSession(sessionId); if (worktree && !worktrees.hasPath(worktree.worktreePath)) { const workspace = workspaces.findByCwd(worktree.worktreePath); @@ -314,6 +344,15 @@ export class SessionLifecycleService { const resolveForRecord = this.resolveForRecord.bind(this); const launchRun = this.launchRun.bind(this); return Effect.gen(function* () { + yield* Effect.try({ + try() { + assertAttachmentContentAllowed(content); + assertInlineAttachmentsSupported(content, effectiveAttachmentCapability(source.kind)); + }, + catch(error) { + return caughtEngineFailure(error); + }, + }); if (yield* turns.hasOpenOperation(sourceSessionId)) { return yield* Effect.fail( new RequestError({ @@ -414,6 +453,7 @@ export class SessionLifecycleService { const admitSubmit = this.admitSubmit.bind(this); const relaunch = this.relaunch.bind(this); const resumeSession = this.resumeSession.bind(this); + const materializeSubmitInput = this.materializeSubmitInput.bind(this); const { history } = this; return Effect.gen(function* () { // Replay before any validation: a reply lost to a disconnect must not duplicate a sibling. @@ -478,7 +518,9 @@ export class SessionLifecycleService { // the turn is visibly running is committed, not failed — pi-style send() spans the whole // turn. commitRunning completes before the race interrupts the losing send fiber, so its // exit backstop then sees an already-resolved operation and stands down. - yield* sessions.sendInput(request.sessionId, toAgentInput(request.input), intent).pipe( + const echoInput = toAgentInput(request.input); + const adapterInput = yield* materializeSubmitInput(request, intent); + yield* sessions.sendInput(request.sessionId, echoInput, intent, adapterInput).pipe( Effect.timeoutOrElse({ duration: TURN_SUBMIT_TIMEOUT_MS, orElse: (): Effect.Effect => @@ -559,20 +601,9 @@ export class SessionLifecycleService { new RequestError({ code: 'busy', message: `Session is busy: ${request.sessionId}` }), ); } - if ( - request.input.type === 'prompt' && - request.input.blocks.some((block) => block.type === 'attachment_ref') - ) { - // Seam: attachment existence/readiness/capability validation lands with the store. - return Effect.fail( - new RequestError({ - code: 'unsupported', - message: 'Prompt attachments are not supported yet', - }), - ); - } // Seam: the worktree co-leaseholder busy gate joins this critical section later. const { checkpoints, records, sessions, turns } = this; + const admitAttachments = this.admitPromptBlocks.bind(this); return Effect.gen(function* () { if (yield* turns.hasOpenOperation(request.sessionId)) { return yield* Effect.fail( @@ -699,6 +730,9 @@ export class SessionLifecycleService { const liveRunId = launch.type === 'continue' ? sessions.liveRunId(request.sessionId) : undefined; if (liveRunId === undefined && launch.type === 'continue') launch = { type: 'resume' }; + if (request.input.type === 'prompt') { + yield* admitAttachments(record.kind, request.input.blocks); + } const intent = yield* turns.persistIntent({ sessionId: request.sessionId, operationId: request.operationId, @@ -712,6 +746,76 @@ export class SessionLifecycleService { ); } + private admitPromptBlocks( + kind: AgentKind, + blocks: Extract['blocks'], + ): Effect.Effect { + const ids = uniqueAttachmentIds(attachmentIdsFromBlocks(blocks)); + if (ids.length === 0) return Effect.void; + const capability = effectiveAttachmentCapability(kind); + return Effect.tryPromise({ + try: () => this.attachments.listAttachments(ids), + catch: (cause) => + new OperationError({ + subsystem: 'store', + operation: 'attachments.list', + publicMessage: 'Failed to load attachments', + cause, + }), + }).pipe( + Effect.flatMap((stored) => + Effect.try({ + try: () => admitPromptAttachments(blocks, stored, capability), + catch: (error) => caughtEngineFailure(error), + }), + ), + ); + } + + private materializeSubmitInput( + request: TurnSubmitRequest, + intent: PersistedTurnIntent, + ): Effect.Effect { + if (request.input.type !== 'prompt') return Effect.succeed(request.input); + if (attachmentIdsFromBlocks(request.input.blocks).length === 0) { + return Effect.succeed(toAgentInput(request.input)); + } + const record = this.records.get(request.sessionId); + if (!record) { + return Effect.fail( + new RequestError({ + code: 'not_found', + message: `Unknown session: ${request.sessionId}`, + }), + ); + } + const promptId = intent.turn.input.type === 'prompt' ? intent.turn.input.promptId : null; + if (promptId === null) return Effect.succeed(toAgentInput(request.input)); + const { materializer } = this; + const capability = effectiveAttachmentCapability(record.kind); + return this.turns.getPrompt(promptId).pipe( + Effect.flatMap((prompt) => + prompt === undefined + ? Effect.fail( + new RequestError({ + code: 'not_found', + message: 'The prompt was not persisted', + }), + ) + : materializer.prepare(request.sessionId, intent.turn.runId, prompt, capability), + ), + Effect.flatMap((prepared) => + Effect.try({ + try: (): AgentInput => ({ + type: 'prompt', + content: materializer.toContentBlocks(prepared), + }), + catch: (error) => caughtEngineFailure(error), + }), + ), + ); + } + /** Replace the session's adapter under the same LinkCode id with one `start`ed on other provider * history: a fresh session (new root lineage), a fork at a checkpoint, or an inactive lineage's * own history resumed. The source adapter is stopped first — one live adapter per session, and diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index ebcf49251..09ab8980a 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -48,6 +48,7 @@ export class SessionOrchestrator { private readonly turns: ConversationTurnService, private readonly journals: ConversationLiveJournals, private readonly browserTools?: BrowserToolsetFactory, + private readonly onRunEnded?: (sessionId: SessionId, runId: RunId) => void, ) { this.events = new SessionEventProcessor( transport, @@ -130,11 +131,12 @@ export class SessionOrchestrator { sessionId: SessionId, input: AgentInput, prepared?: PersistedTurnIntent, + adapterInput?: AgentInput, ): Effect.Effect { return Effect.suspend(() => { const session = this.requireSession(sessionId); return session.run( - Effect.suspend(() => this.inputs.send(sessionId, session, input, prepared)), + Effect.suspend(() => this.inputs.send(sessionId, session, input, prepared, adapterInput)), ); }); } @@ -460,6 +462,7 @@ export class SessionOrchestrator { Effect.ensuring( Effect.suspend(() => { if (!this.remove(sessionId, session)) return Effect.void; + this.onRunEnded?.(sessionId, session.runId); if (releaseSession) this.onStopped(sessionId); // Teardown mid-turn kills the turn without a stop frame; settle it here. this.turns.settleStatus(sessionId, session.runId, 'stopped'); @@ -498,6 +501,7 @@ export class SessionOrchestrator { Effect.ensuring( Effect.suspend(() => { if (!this.remove(sessionId, session)) return Effect.void; + this.onRunEnded?.(sessionId, session.runId); // Release what a partial start may have reserved — notably the simulator MCP endpoint // token minted while resolving start options. Normal teardown does this via `onStopped`; // a discarded failed start must too, or that token leaks until daemon shutdown. diff --git a/packages/host/engine/src/session/session-input-dispatcher.ts b/packages/host/engine/src/session/session-input-dispatcher.ts index 1075303a9..2d220cffb 100644 --- a/packages/host/engine/src/session/session-input-dispatcher.ts +++ b/packages/host/engine/src/session/session-input-dispatcher.ts @@ -1,7 +1,13 @@ +import { UnsupportedAttachmentError } from '@linkcode/agent-adapter'; import type { AgentInput, SessionId } from '@linkcode/schema'; -import { agentCommandMatches, userRowMessageId } from '@linkcode/schema'; +import { + agentCommandMatches, + effectiveAttachmentCapability, + userRowMessageId, +} from '@linkcode/schema'; import { Cause, Effect, Exit } from 'effect'; import { nullthrow } from 'foxts/guard'; +import { assertInlineAttachmentsSupported } from '../attachment/admit'; import type { ConversationTurnService, PersistedTurnIntent } from '../conversation/turn-service'; import { mintOperationId, promptBlocksFromContent } from '../conversation/turn-service'; import { causeToRequestFailure, OperationError, RequestError } from '../failure'; @@ -28,6 +34,7 @@ export class SessionInputDispatcher { session: LiveSession, input: AgentInput, prepared?: PersistedTurnIntent, + adapterOverride?: AgentInput, ): Effect.Effect { const startsTurn = input.type === 'prompt' || input.type === 'command' || input.type === 'shell-command'; @@ -77,20 +84,29 @@ export class SessionInputDispatcher { events.rejectInput(sessionId, session, error.message); return yield* Effect.fail(error); } - let adapterInput: AgentInput = input; + let adapterInput: AgentInput = adapterOverride ?? input; if (input.type === 'prompt') { yield* Effect.try({ - try: () => assertAttachmentContentAllowed(input.content), - catch: (e) => e, + try() { + assertAttachmentContentAllowed(input.content); + assertInlineAttachmentsSupported( + input.content, + effectiveAttachmentCapability(records.get(sessionId)?.kind ?? session.adapter.kind), + ); + }, + catch(error) { + return error; + }, }); + const promptForAdapter = adapterInput.type === 'prompt' ? adapterInput : input; adapterInput = yield* resources.readySourceLocators(sessionId).pipe( Effect.map((locators) => locators.length === 0 - ? input + ? promptForAdapter : { - ...input, + ...promptForAdapter, content: [ - ...input.content, + ...promptForAdapter.content, { type: 'text' as const, text: `${RESOURCE_CONTEXT_SENTINEL}\n${locators.join('\n')}`, @@ -164,13 +180,19 @@ export class SessionInputDispatcher { yield* Effect.tryPromise({ try: () => session.adapter.send(adapterInput), catch: (cause) => - new OperationError({ - subsystem: 'agent', - operation: 'session.input', - publicMessage: 'Agent input was rejected', - cause, - ...(startsTurn && { reportedInConversation: true }), - }), + cause instanceof UnsupportedAttachmentError + ? new RequestError({ + code: 'unsupported_attachment', + message: cause.message, + reportedInConversation: true, + }) + : new OperationError({ + subsystem: 'agent', + operation: 'session.input', + publicMessage: 'Agent input was rejected', + cause, + ...(startsTurn && { reportedInConversation: true }), + }), }).pipe( Effect.tapError((error) => Effect.sync(() => { @@ -182,7 +204,13 @@ export class SessionInputDispatcher { ); } if (echoMessageId !== undefined) session.untrackPrompt(echoMessageId); - if (startsTurn) events.rejectInput(sessionId, session, error.publicMessage); + if (startsTurn) { + events.rejectInput( + sessionId, + session, + error instanceof RequestError ? error.message : error.publicMessage, + ); + } }), ), ); From 5a1178fe93df32a452f5a50cbbac66d20be5db35 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 4 Sep 2026 03:33:52 +0800 Subject: [PATCH 06/10] fix(engine): refuse unsupported resource_link before persist --- .../src/__tests__/attachment-admit.test.ts | 16 +++++ .../engine-attachment-submit.test.ts | 64 +++++++++++++++++++ .../__tests__/engine-session-input.test.ts | 26 ++++++++ packages/host/engine/src/attachment/admit.ts | 9 ++- .../engine/src/attachment/materializer.ts | 26 ++++++-- packages/host/engine/src/engine.ts | 3 +- .../engine/src/session/lifecycle-service.ts | 3 +- 7 files changed, 140 insertions(+), 7 deletions(-) diff --git a/packages/host/engine/src/__tests__/attachment-admit.test.ts b/packages/host/engine/src/__tests__/attachment-admit.test.ts index a6944fefe..e6b265e31 100644 --- a/packages/host/engine/src/__tests__/attachment-admit.test.ts +++ b/packages/host/engine/src/__tests__/attachment-admit.test.ts @@ -136,4 +136,20 @@ describe('assertInlineAttachmentsSupported', () => { } expect.fail('expected a typed refusal'); }); + + it('refuses a resource_link when the harness did not declare readonly_file', () => { + try { + assertInlineAttachmentsSupported( + [{ type: 'resource_link', uri: 'attachment:att-1', name: 'shot.png' }], + effectiveAttachmentCapability('claude-code'), + ); + } catch (error) { + expect(error).toMatchObject({ + code: 'unsupported_attachment', + message: 'This harness does not accept file attachments', + }); + return; + } + expect.fail('expected a typed refusal'); + }); }); diff --git a/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts b/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts index 515bc2d7a..a22cc931f 100644 --- a/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts +++ b/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { asHistoryId } from '@linkcode/agent-adapter'; import type { WirePayload } from '@linkcode/schema'; import { AttachmentIdSchema, @@ -222,4 +223,67 @@ describe('turn.submit attachment admit and materialize', () => { ]); expect(JSON.stringify(row.event.content)).not.toContain(PNG_1X1.toString('base64')); }); + + it('refuses a rewrite that round-trips the projected resource_link before persist', async () => { + const h = await started(); + const attachmentId = await readyPng(h); + await h.inject({ + kind: 'turn.submit', + clientReqId: 's-ok', + sessionId: h.sessionId, + operationId: OperationIdSchema.parse('op-ok'), + input: { + type: 'prompt', + blocks: [ + { type: 'text', text: 'look' }, + { type: 'attachment_ref', attachmentId }, + ], + }, + }); + await vi.waitFor(() => { + expect(h.sent).toContainEqual( + expect.objectContaining({ kind: 'turn.submitted', replyTo: 's-ok' }), + ); + }); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await h.inject({ kind: 'conversation.read', clientReqId: 'rr', sessionId: h.sessionId }); + const read = h.sent.find( + (payload) => payload.kind === 'conversation.read.result' && payload.replyTo === 'rr', + ); + if (read?.kind !== 'conversation.read.result') throw new Error('no conversation.read.result'); + const row = read.events.find((item) => 'event' in item && item.event.type === 'user-message'); + if ( + row === undefined || + !('event' in row) || + row.event.type !== 'user-message' || + row.event.branchCursor === undefined + ) { + throw new Error('no user row with a branch cursor'); + } + const turnsBefore = await h.conversationStore.listTurns(h.sessionId); + + await h.inject({ + kind: 'history.branch', + clientReqId: 'rewrite', + sourceSessionId: h.sessionId, + sourceMessageId: row.event.messageId, + branchCursor: row.event.branchCursor, + content: [ + { type: 'text', text: 'look again' }, + { + type: 'resource_link', + uri: attachmentUri(attachmentId), + name: 'shot.png', + }, + ], + }); + + expect(failure(h.sent, 'rewrite')).toMatchObject({ + code: 'unsupported_attachment', + message: 'This harness does not accept file attachments', + }); + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(turnsBefore.length); + expect(h.adapter.sentInputs).toHaveLength(1); + }); }); diff --git a/packages/host/engine/src/__tests__/engine-session-input.test.ts b/packages/host/engine/src/__tests__/engine-session-input.test.ts index 56bde1489..25aaa345b 100644 --- a/packages/host/engine/src/__tests__/engine-session-input.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-input.test.ts @@ -166,6 +166,32 @@ describe('engine session input', () => { code: 'unsupported_attachment', message: 'Prompt attachments are not supported by this harness', }); + expect(h.adapter.sentInputs).toEqual([]); + }); + + it('refuses a resource_link prompt before persist', async () => { + const h = await startedHarness(); + + await h.inject({ + kind: 'agent.input', + clientReqId: 'input', + sessionId: h.sessionId, + input: { + type: 'prompt', + content: [ + { type: 'text', text: 'look' }, + { type: 'resource_link', uri: 'attachment:att-1', name: 'shot.png' }, + ], + }, + }); + + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'input', + code: 'unsupported_attachment', + message: 'This harness does not accept file attachments', + }); + expect(h.adapter.sentInputs).toEqual([]); }); it('echoes command and shell inputs as the text the user typed', async () => { diff --git a/packages/host/engine/src/attachment/admit.ts b/packages/host/engine/src/attachment/admit.ts index 82d120bc4..d44ea5526 100644 --- a/packages/host/engine/src/attachment/admit.ts +++ b/packages/host/engine/src/attachment/admit.ts @@ -132,7 +132,14 @@ export function assertInlineAttachmentsSupported( ): void { for (let i = 0, len = content.length; i < len; i++) { const block = content[i]; - if (block.type !== 'image' && block.type !== 'audio' && block.type !== 'resource') continue; + if ( + block.type !== 'image' && + block.type !== 'audio' && + block.type !== 'resource' && + block.type !== 'resource_link' + ) { + continue; + } if (!capability) { throw new RequestError({ code: 'unsupported_attachment', diff --git a/packages/host/engine/src/attachment/materializer.ts b/packages/host/engine/src/attachment/materializer.ts index 1e9c6d412..702ddc10b 100644 --- a/packages/host/engine/src/attachment/materializer.ts +++ b/packages/host/engine/src/attachment/materializer.ts @@ -57,7 +57,21 @@ export class PromptMaterializer { return Effect.gen(function* () { const ids = uniqueAttachmentIds(attachmentIdsFromBlocks(prompt.blocks)); const stored = yield* load(ids); - admitPromptAttachments(prompt.blocks, stored, capability); + yield* Effect.try({ + try() { + admitPromptAttachments(prompt.blocks, stored, capability); + }, + catch(error) { + return error instanceof RequestError + ? error + : new OperationError({ + subsystem: 'store', + operation: 'attachments.admit', + publicMessage: 'Attachment validation failed', + cause: error, + }); + }, + }); const byId = new Map(stored.map((attachment) => [attachment.attachmentId, attachment])); const blocks: PreparedPrompt['blocks'] = []; for (let i = 0, len = prompt.blocks.length; i < len; i++) { @@ -104,17 +118,21 @@ export class PromptMaterializer { cleanupRun(sessionId: SessionId, runId: RunId): Promise { const dir = this.runDir(sessionId, runId); if (dir === undefined) return Promise.resolve(); - return rm(dir, { recursive: true, force: true }); + return this.io.run(() => rm(dir, { recursive: true, force: true })); } cleanupSession(sessionId: SessionId): Promise { const session = pathSegment(sessionId); if (session === undefined) return Promise.resolve(); - return rm(join(this.stateDir, 'materialized', session), { recursive: true, force: true }); + return this.io.run(() => + rm(join(this.stateDir, 'materialized', session), { recursive: true, force: true }), + ); } bootSweep(): Promise { - return rm(join(this.stateDir, 'materialized'), { recursive: true, force: true }); + return this.io.run(() => + rm(join(this.stateDir, 'materialized'), { recursive: true, force: true }), + ); } private runDir(sessionId: SessionId, runId: RunId): string | undefined { diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index b2974c22b..6f66ab7b3 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -9,6 +9,7 @@ import type { Transport, Unsubscribe } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import type { Scope } from 'effect'; import { Cause, Effect, FiberSet } from 'effect'; +import { noop } from 'foxts/noop'; import { CustomMcpServerService } from './agent/custom-mcp-service'; import { adoptDetectedLogins } from './agent/detected-logins'; import { AgentLoginService } from './agent/login-service'; @@ -219,7 +220,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( ? () => new BrowserReplHost((op, args) => browserBroker.dispatch(op, args)) : undefined, (sessionId, runId) => { - void materializer.cleanupRun(sessionId, runId); + void materializer.cleanupRun(sessionId, runId).catch(noop); }, ); simulators?.setSessionValidator((id) => sessions.has(id)); diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 02ed29541..1e721f248 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -20,6 +20,7 @@ import type { import { effectiveAttachmentCapability } from '@linkcode/schema'; import { Effect, Exit, Semaphore } from 'effect'; import { nullthrow } from 'foxts/guard'; +import { noop } from 'foxts/noop'; import { admitPromptAttachments, assertInlineAttachmentsSupported, @@ -159,7 +160,7 @@ export class SessionLifecycleService { yield* sessions.delete(sessionId); // Best-effort: a missed directory is removed at the next boot sweep. Do not await the // unlink on the delete reply — `session.delete` of `..` must not block or traverse. - void materializer.cleanupSession(sessionId); + void materializer.cleanupSession(sessionId).catch(noop); yield* worktrees.cleanupDeletedSession(sessionId); if (worktree && !worktrees.hasPath(worktree.worktreePath)) { const workspace = workspaces.findByCwd(worktree.worktreePath); From d35e62b04edd4e406d4aee70b22a4a0a42e78f65 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 4 Sep 2026 13:56:41 +0800 Subject: [PATCH 07/10] fix(schema): keep the attachment representation list open on the wire A closed enum inside capabilities-update makes the whole agent.event frame invalid-payload for a peer that has not learned a newer representation, dropping the session's capability stream. Kinds were already open; representations now match, and the host intersection drops what it cannot materialize. --- .../__tests__/attachment-capability.test.ts | 27 +++++++++++++++++++ .../schema/src/model/agent/input.ts | 4 +-- .../foundation/schema/src/model/attachment.ts | 13 ++++++--- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts b/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts index 05d1a15fe..d9df55f27 100644 --- a/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts +++ b/packages/foundation/schema/src/model/__tests__/attachment-capability.test.ts @@ -50,6 +50,33 @@ describe('AgentCapabilities.attachments', () => { }); }); +describe('AttachmentCapability forward compatibility', () => { + it('parses a representation this build does not know so the frame still validates', () => { + const parsed = AttachmentCapabilitySchema.safeParse({ + kinds: imageCapability.kinds, + representations: ['inline_image', 'extracted_text'], + }); + expect(parsed.success).toBe(true); + }); + + it('drops the unknown representation at the host intersection', () => { + const effective = intersectAttachmentCapability({ + kinds: imageCapability.kinds, + representations: ['inline_image', 'extracted_text'], + }); + expect(effective?.representations).toEqual(['inline_image']); + }); + + it('treats a capability of only unknown representations as no support', () => { + expect( + intersectAttachmentCapability({ + kinds: imageCapability.kinds, + representations: ['extracted_text'], + }), + ).toBeUndefined(); + }); +}); + describe('intersectAttachmentCapability', () => { it('returns undefined when the adapter declared nothing', () => { expect(intersectAttachmentCapability(undefined)).toBeUndefined(); diff --git a/packages/foundation/schema/src/model/agent/input.ts b/packages/foundation/schema/src/model/agent/input.ts index 98c9ae30e..da9475aee 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -166,7 +166,7 @@ export type AgentCapabilities = z.infer; /** What `onPrompt` consumes today: image ContentBlocks inlined to the SDK. grok-build declares * nothing — its prompt is a CLI argument. */ -const INLINE_IMAGE_ATTACHMENT_CAPABILITY = { +const INLINE_IMAGE_ATTACHMENT_CAPABILITY: AttachmentCapability = { kinds: { image: { mimeTypes: [...SUPPORTED_ATTACHMENT_IMAGE_MIME_TYPES], @@ -175,7 +175,7 @@ const INLINE_IMAGE_ATTACHMENT_CAPABILITY = { }, }, representations: ['inline_image'], -} as const satisfies AttachmentCapability; +}; /** Stable pre-session input capabilities. Live clients still trust each session's * `capabilities-update`; this complete matrix lets drafts and adapters share one source of truth diff --git a/packages/foundation/schema/src/model/attachment.ts b/packages/foundation/schema/src/model/attachment.ts index ea0a49b0b..884bf8883 100644 --- a/packages/foundation/schema/src/model/attachment.ts +++ b/packages/foundation/schema/src/model/attachment.ts @@ -95,8 +95,13 @@ export const AttachmentKindLimitsSchema = z.object({ export type AttachmentKindLimits = z.infer; /** How the engine hands bytes to a harness. `extracted_text` is later. */ -export const AttachmentRepresentationSchema = z.enum(['inline_image', 'readonly_file']); -export type AttachmentRepresentation = z.infer; +export const KNOWN_ATTACHMENT_REPRESENTATIONS = ['inline_image', 'readonly_file'] as const; +export type AttachmentRepresentation = (typeof KNOWN_ATTACHMENT_REPRESENTATIONS)[number]; + +/** Open on the wire like {@link AttachmentKindSchema}: this rides `capabilities-update`, so a newer + * peer's representation must not fail the whole frame. The host intersection drops what it cannot + * materialize. */ +export const AttachmentRepresentationSchema = z.string().min(1).max(32); export const AttachmentCapabilitySchema = z.object({ kinds: z.object({ @@ -119,7 +124,7 @@ export const HOST_ATTACHMENT_LIMITS: AttachmentCapability = { maxCount: DEFAULT_ATTACHMENT_IMAGE_MAX_COUNT, }, }, - representations: ['inline_image', 'readonly_file'], + representations: [...KNOWN_ATTACHMENT_REPRESENTATIONS], }; function intersectKindLimits( @@ -146,7 +151,7 @@ export function intersectAttachmentCapability( host: AttachmentCapability = HOST_ATTACHMENT_LIMITS, ): AttachmentCapability | undefined { if (declared === undefined) return undefined; - const representations: AttachmentRepresentation[] = []; + const representations: string[] = []; for (let i = 0, len = declared.representations.length; i < len; i++) { const representation = declared.representations[i]; if (host.representations.includes(representation)) representations.push(representation); From e7a3f0563f2279eebb5c236b20fe0abff99f617c Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 4 Sep 2026 13:58:38 +0800 Subject: [PATCH 08/10] fix(engine): charge every attachment ref occurrence against the prompt caps Admit deduped ids before accounting while the materializer converts one block per ref, so a repeated ref escaped the 12 MiB aggregate: 500 refs to one 8 MiB image admitted at '8 MiB, 1 image' and materialized 4 GiB. The caps now bound what actually leaves the store. A projected attachment: link is also refused by name instead of as a file attachment. --- .../src/__tests__/attachment-admit.test.ts | 37 ++++++++++++++++++- packages/host/engine/src/attachment/admit.ts | 14 ++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/host/engine/src/__tests__/attachment-admit.test.ts b/packages/host/engine/src/__tests__/attachment-admit.test.ts index e6b265e31..4d452160f 100644 --- a/packages/host/engine/src/__tests__/attachment-admit.test.ts +++ b/packages/host/engine/src/__tests__/attachment-admit.test.ts @@ -5,6 +5,8 @@ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_TOTAL_BYTES, } from '@linkcode/schema'; +import { createFixedArray } from 'foxts/create-fixed-array'; +import { nullthrow } from 'foxts/guard'; import { describe, expect, it } from 'vitest'; import { admitPromptAttachments, assertInlineAttachmentsSupported } from '../attachment/admit'; import type { StoredAttachment } from '../attachment/attachment-store'; @@ -120,6 +122,39 @@ describe('admitPromptAttachments', () => { } expect.fail('expected a typed refusal'); }); + + it('charges a repeated ref its bytes again so the aggregate cap bounds materialization', () => { + const oversized = Math.ceil(MAX_ATTACHMENT_TOTAL_BYTES / 3); + const blocks = [ + { type: 'attachment_ref' as const, attachmentId: ATT_1 }, + { type: 'attachment_ref' as const, attachmentId: ATT_1 }, + { type: 'attachment_ref' as const, attachmentId: ATT_1 }, + { type: 'attachment_ref' as const, attachmentId: ATT_1 }, + ]; + try { + admitPromptAttachments(blocks, [stored({ sizeBytes: oversized })], capability); + } catch (error) { + expect(error).toBeInstanceOf(RequestError); + expect(error).toMatchObject({ code: 'limit_exceeded' }); + return; + } + expect.fail('expected the repeated ref to exhaust the prompt aggregate cap'); + }); + + it('counts every occurrence against maxCount, not the unique id set', () => { + const capped = nullthrow(capability?.kinds.image?.maxCount, 'image maxCount'); + const blocks = createFixedArray(capped + 1).map(() => ({ + type: 'attachment_ref' as const, + attachmentId: ATT_1, + })); + try { + admitPromptAttachments(blocks, [stored({ sizeBytes: 1 })], capability); + } catch (error) { + expect(error).toMatchObject({ code: 'limit_exceeded', message: 'Too many attachments' }); + return; + } + expect.fail('expected the repeated ref to exhaust maxCount'); + }); }); describe('assertInlineAttachmentsSupported', () => { @@ -146,7 +181,7 @@ describe('assertInlineAttachmentsSupported', () => { } catch (error) { expect(error).toMatchObject({ code: 'unsupported_attachment', - message: 'This harness does not accept file attachments', + message: 'Editing a prompt attachment is not supported yet', }); return; } diff --git a/packages/host/engine/src/attachment/admit.ts b/packages/host/engine/src/attachment/admit.ts index d44ea5526..0f715d09b 100644 --- a/packages/host/engine/src/attachment/admit.ts +++ b/packages/host/engine/src/attachment/admit.ts @@ -4,7 +4,7 @@ import type { ContentBlock, PromptBlock, } from '@linkcode/schema'; -import { MAX_ATTACHMENT_TOTAL_BYTES } from '@linkcode/schema'; +import { attachmentIdFromUri, MAX_ATTACHMENT_TOTAL_BYTES } from '@linkcode/schema'; import { RequestError } from '../failure'; import type { StoredAttachment } from './attachment-store'; @@ -54,7 +54,9 @@ export function admitPromptAttachments( stored: readonly StoredAttachment[], capability: AttachmentCapability | undefined, ): void { - const ids = uniqueAttachmentIds(attachmentIdsFromBlocks(blocks)); + // Accounting walks every occurrence, not the unique set: the materializer converts one block per + // ref, so a repeated id costs its bytes again and the caps must bound what actually leaves the store. + const ids = attachmentIdsFromBlocks(blocks); if (ids.length === 0) return; if (!capability) { throw new RequestError({ @@ -162,6 +164,14 @@ export function assertInlineAttachmentsSupported( } continue; } + // A projected `attachment:` link is a stored attachment coming back in, not a file the harness + // was asked to read: refusing it as a "file attachment" would misname an image the harness takes. + if (block.type === 'resource_link' && attachmentIdFromUri(block.uri) !== undefined) { + throw new RequestError({ + code: 'unsupported_attachment', + message: 'Editing a prompt attachment is not supported yet', + }); + } if (!capability.representations.includes('readonly_file')) { throw new RequestError({ code: 'unsupported_attachment', From 602e66de0526404703b1bc00a33b206e4b9fcf7c Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 4 Sep 2026 13:58:52 +0800 Subject: [PATCH 09/10] fix(engine): materialize a repeated ref once and log cleanup failures A prompt may name one attachment twice in a run, and the destination is content-addressed, so an existing hardlink is already the same bytes; EEXIST was failing the turn as a filesystem error. Cleanup failures no longer vanish into a bare noop. --- .../src/__tests__/prompt-materializer.test.ts | 37 +++++++++++++++++++ .../engine/src/attachment/materializer.ts | 6 ++- packages/host/engine/src/engine.ts | 5 ++- .../engine/src/session/lifecycle-service.ts | 5 ++- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/host/engine/src/__tests__/prompt-materializer.test.ts b/packages/host/engine/src/__tests__/prompt-materializer.test.ts index 9365af6b2..505c873d3 100644 --- a/packages/host/engine/src/__tests__/prompt-materializer.test.ts +++ b/packages/host/engine/src/__tests__/prompt-materializer.test.ts @@ -159,6 +159,43 @@ describe('PromptMaterializer', () => { await expect(stat(againFile.path)).rejects.toMatchObject({ code: 'ENOENT' }); }); + it('materializes a repeated readonly_file ref twice in one run', async () => { + const { materializer, prompt, store } = await fixture(); + const stored = await store.getAttachment(AttachmentIdSchema.parse('att-1')); + if (!stored) throw new Error('fixture attachment missing'); + const fileCapability: AttachmentCapability = { + kinds: { + file: { mimeTypes: ['image/png'], maxBytes: MAX_ATTACHMENT_BYTES, maxCount: 4 }, + }, + representations: ['readonly_file'], + }; + await store.commitAttachment({ + blob: { blobId: stored.blobId, sizeBytes: stored.sizeBytes, createdAt: 1 }, + attachment: { ...stored, kind: 'file' }, + }); + const repeated: PromptRecord = { + ...prompt, + blocks: [ + { type: 'attachment_ref', attachmentId: stored.attachmentId }, + { type: 'attachment_ref', attachmentId: stored.attachmentId }, + ], + }; + const prepared = await Effect.runPromise( + materializer.prepare( + SessionIdSchema.parse('sess-dup'), + RunIdSchema.parse('run-dup'), + repeated, + fileCapability, + ), + ); + const [first, second] = prepared.blocks; + if (first.type !== 'readonly_file' || second.type !== 'readonly_file') { + throw new Error('expected both refs to materialize'); + } + expect(second.path).toBe(first.path); + expect((await stat(first.path)).mode & 0o222).toBe(0); + }); + it('does not traverse out of the materialized directory on cleanup', async () => { const { materializer, root } = await fixture(); const retained = join(root, 'retained.txt'); diff --git a/packages/host/engine/src/attachment/materializer.ts b/packages/host/engine/src/attachment/materializer.ts index 702ddc10b..cfda3385d 100644 --- a/packages/host/engine/src/attachment/materializer.ts +++ b/packages/host/engine/src/attachment/materializer.ts @@ -237,7 +237,11 @@ export class PromptMaterializer { try() { return io.run(async () => { await mkdir(destDir, { recursive: true }); - await link(source, dest); + // The destination is content-addressed, so an existing link is already the same bytes — + // a prompt may reference one attachment more than once within a run. + await link(source, dest).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'EEXIST') throw error; + }); await chmod(dest, 0o444); return { type: 'readonly_file' as const, diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index 6f66ab7b3..18230da04 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -9,7 +9,6 @@ import type { Transport, Unsubscribe } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import type { Scope } from 'effect'; import { Cause, Effect, FiberSet } from 'effect'; -import { noop } from 'foxts/noop'; import { CustomMcpServerService } from './agent/custom-mcp-service'; import { adoptDetectedLogins } from './agent/detected-logins'; import { AgentLoginService } from './agent/login-service'; @@ -220,7 +219,9 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( ? () => new BrowserReplHost((op, args) => browserBroker.dispatch(op, args)) : undefined, (sessionId, runId) => { - void materializer.cleanupRun(sessionId, runId).catch(noop); + void materializer.cleanupRun(sessionId, runId).catch((error: unknown) => { + Effect.runFork(Effect.logWarning('Failed to clean up materialized attachments', error)); + }); }, ); simulators?.setSessionValidator((id) => sessions.has(id)); diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 1e721f248..8b3af5d33 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -20,7 +20,6 @@ import type { import { effectiveAttachmentCapability } from '@linkcode/schema'; import { Effect, Exit, Semaphore } from 'effect'; import { nullthrow } from 'foxts/guard'; -import { noop } from 'foxts/noop'; import { admitPromptAttachments, assertInlineAttachmentsSupported, @@ -160,7 +159,9 @@ export class SessionLifecycleService { yield* sessions.delete(sessionId); // Best-effort: a missed directory is removed at the next boot sweep. Do not await the // unlink on the delete reply — `session.delete` of `..` must not block or traverse. - void materializer.cleanupSession(sessionId).catch(noop); + void materializer.cleanupSession(sessionId).catch((error: unknown) => { + Effect.runFork(Effect.logWarning('Failed to clean up materialized attachments', error)); + }); yield* worktrees.cleanupDeletedSession(sessionId); if (worktree && !worktrees.hasPath(worktree.worktreePath)) { const workspace = workspaces.findByCwd(worktree.worktreePath); From 255e72dd65c32f64e5ad3b29f38e44a1a0473605 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 4 Sep 2026 13:59:06 +0800 Subject: [PATCH 10/10] fix(engine): project the attachment kind off the resource_link title Both shipped resource_link renderers prefer title over name, so carrying the kind there labelled every attachment chip 'image' instead of its filename. The projection assertions also sat behind an early return that let them skip silently. --- .../engine-attachment-submit.test.ts | 11 ++++++---- .../__tests__/engine-session-input.test.ts | 22 +++++++++++++++++++ .../engine/src/conversation/turn-service.ts | 4 +++- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts b/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts index a22cc931f..356db7a2d 100644 --- a/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts +++ b/packages/host/engine/src/__tests__/engine-attachment-submit.test.ts @@ -194,8 +194,11 @@ describe('turn.submit attachment admit and materialize', () => { expect(turns).toHaveLength(1); const turnInput = nullthrow(turns[0], 'expected a persisted turn').input; expect(turnInput.type).toBe('prompt'); - if (turnInput.type !== 'prompt' || turnInput.promptId === null) return; - const prompt = await h.conversationStore.getPrompt(turnInput.promptId); + const promptId = nullthrow( + turnInput.type === 'prompt' ? turnInput.promptId : null, + 'a prompt turn must persist a promptId', + ); + const prompt = await h.conversationStore.getPrompt(promptId); expect(prompt?.blocks).toEqual([ { type: 'text', text: 'look' }, { type: 'attachment_ref', attachmentId }, @@ -218,7 +221,7 @@ describe('turn.submit attachment admit and materialize', () => { name: 'shot.png', mimeType: 'image/png', size: PNG_1X1.byteLength, - title: 'image', + description: 'image', }, ]); expect(JSON.stringify(row.event.content)).not.toContain(PNG_1X1.toString('base64')); @@ -281,7 +284,7 @@ describe('turn.submit attachment admit and materialize', () => { expect(failure(h.sent, 'rewrite')).toMatchObject({ code: 'unsupported_attachment', - message: 'This harness does not accept file attachments', + message: 'Editing a prompt attachment is not supported yet', }); expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(turnsBefore.length); expect(h.adapter.sentInputs).toHaveLength(1); diff --git a/packages/host/engine/src/__tests__/engine-session-input.test.ts b/packages/host/engine/src/__tests__/engine-session-input.test.ts index 25aaa345b..738d1b32b 100644 --- a/packages/host/engine/src/__tests__/engine-session-input.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-input.test.ts @@ -185,6 +185,28 @@ describe('engine session input', () => { }, }); + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'input', + code: 'unsupported_attachment', + message: 'Editing a prompt attachment is not supported yet', + }); + expect(h.adapter.sentInputs).toEqual([]); + }); + + it('still names a non-attachment resource_link a file refusal', async () => { + const h = await startedHarness(); + + await h.inject({ + kind: 'agent.input', + clientReqId: 'input', + sessionId: h.sessionId, + input: { + type: 'prompt', + content: [{ type: 'resource_link', uri: 'file:///etc/hosts', name: 'hosts' }], + }, + }); + expect(h.sent).toContainEqual({ kind: 'request.failed', replyTo: 'input', diff --git a/packages/host/engine/src/conversation/turn-service.ts b/packages/host/engine/src/conversation/turn-service.ts index 49f83c5a2..7a37cab62 100644 --- a/packages/host/engine/src/conversation/turn-service.ts +++ b/packages/host/engine/src/conversation/turn-service.ts @@ -190,10 +190,12 @@ export class ConversationTurnService { type: 'resource_link', uri: attachmentUri(block.attachmentId), name: attachment?.name ?? block.attachmentId, + // `kind` rides `description`, never `title`: renderers prefer `title` over `name`, + // so putting it there labels every attachment chip "image" instead of its filename. ...(attachment !== undefined && { mimeType: attachment.mimeType, size: attachment.sizeBytes, - title: attachment.kind, + description: attachment.kind, }), }); }