Skip to content

Commit d3351d3

Browse files
committed
fix(workbench): gate dev-mock attachment reads on a session root and sniff its commits
1 parent b7b79e6 commit d3351d3

2 files changed

Lines changed: 152 additions & 9 deletions

File tree

‎packages/client/workbench/src/mock/dev-mock-host.ts‎

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
ATTACHMENT_UPLOAD_CHUNK_BYTES,
4949
AttachmentIdSchema,
5050
blobIdFromSha256,
51+
declaredMimeTypeMatches,
5152
managedAgentAssetId,
5253
managedAssetIdEquals,
5354
managedAssetKey,
@@ -271,6 +272,8 @@ export class DevMockHost {
271272
{ blobId: BlobId; sizeBytes: number; name: string }
272273
>();
273274
private readonly attachmentBegins = new Map<string, MockAttachmentBegin>();
275+
/** The daemon's `isReachable` roots: sessions whose prompt or resource names the attachment. */
276+
private readonly attachmentSessions = new Map<AttachmentId, Set<SessionId>>();
274277
private uploadSeq = 0;
275278
private attachmentSeq = 0;
276279

@@ -969,7 +972,16 @@ export class DevMockHost {
969972
this.resources.set(resourceId, processing);
970973
this.send({ kind: 'resource.changed', resource: processing });
971974
await wait(CONTROL_LATENCY_MS);
972-
const ready: SessionResource = { ...processing, status: 'ready', updatedAt: Date.now() };
975+
// Resource bytes land in the attachment store on the daemon, which is what roots them for
976+
// `attachment.read`; a resource with no attachment id would be unreadable through the wire.
977+
const attachmentId = await this.publishResourceAttachment(payload);
978+
this.rootAttachment(payload.sessionId, attachmentId);
979+
const ready: SessionResource = {
980+
...processing,
981+
status: 'ready',
982+
attachmentId,
983+
updatedAt: Date.now(),
984+
};
973985
this.resources.set(resourceId, ready);
974986
this.send({ kind: 'resource.changed', resource: ready });
975987
this.send({ kind: 'resource.uploaded', replyTo: payload.clientReqId, resource: ready });
@@ -1406,6 +1418,13 @@ export class DevMockHost {
14061418
this.sendFailure(p.clientReqId, 'Dev mock host does not support explicit-parent submits.');
14071419
return;
14081420
}
1421+
if (p.input.type === 'prompt') {
1422+
const blocks = p.input.blocks;
1423+
for (let i = 0, len = blocks.length; i < len; i++) {
1424+
const block = blocks[i];
1425+
if (block.type === 'attachment_ref') this.rootAttachment(p.sessionId, block.attachmentId);
1426+
}
1427+
}
14091428
const content = turnSubmitContent(p.input);
14101429
const turn = this.beginTurn(session, content, p.input.type === 'prompt' ? undefined : p.input);
14111430
this.send({ kind: 'turn.submitted', replyTo: p.clientReqId, turnId: turn.graph.turnId });
@@ -2019,6 +2038,15 @@ export class DevMockHost {
20192038
);
20202039
return;
20212040
}
2041+
// Engine order: size, then declared MIME vs bytes, then SHA-256 — a rejected commit must
2042+
// leave no blob behind.
2043+
const declaredMime = upload.mimeType ?? 'application/octet-stream';
2044+
if (!declaredMimeTypeMatches(declaredMime, upload.bytes.subarray(0, 16))) {
2045+
this.sendFailure(payload.clientReqId, `File contents are not ${declaredMime}`, {
2046+
code: 'invalid_request',
2047+
});
2048+
return;
2049+
}
20222050
if (upload.state === 'ready') {
20232051
const digest = await mockSha256Hex(upload.bytes);
20242052
if (digest !== upload.declaredSha256) {
@@ -2062,8 +2090,31 @@ export class DevMockHost {
20622090
this.sendSuccess(payload.clientReqId);
20632091
}
20642092

2093+
private async publishResourceAttachment(
2094+
payload: Extract<WirePayload, { kind: 'resource.source.upload' }>,
2095+
): Promise<AttachmentId> {
2096+
const bytes = mockBase64ToBytes(payload.data);
2097+
const digest = await mockSha256Hex(bytes);
2098+
this.attachmentBlobs.set(digest, bytes);
2099+
this.attachmentSeq += 1;
2100+
const attachmentId = AttachmentIdSchema.parse(`att-mock-${this.attachmentSeq}`);
2101+
this.attachmentRecords.set(attachmentId, {
2102+
blobId: blobIdFromSha256(digest),
2103+
sizeBytes: bytes.byteLength,
2104+
name: payload.name,
2105+
});
2106+
return attachmentId;
2107+
}
2108+
2109+
/** Root an attachment in a session, the way persisting a prompt or a resource does on the daemon. */
2110+
private rootAttachment(sessionId: SessionId, attachmentId: AttachmentId): void {
2111+
const rooted = this.attachmentSessions.get(attachmentId) ?? new Set<SessionId>();
2112+
rooted.add(sessionId);
2113+
this.attachmentSessions.set(attachmentId, rooted);
2114+
}
2115+
20652116
private readAttachment(payload: Extract<WirePayload, { kind: 'attachment.read' }>): void {
2066-
if (!this.sessions.has(payload.sessionId)) {
2117+
if (!this.attachmentSessions.get(payload.attachmentId)?.has(payload.sessionId)) {
20672118
this.sendFailure(payload.clientReqId, 'Attachment not found', { code: 'not_found' });
20682119
return;
20692120
}

‎packages/client/workbench/tests/integration/dev-mock-attachments.test.ts‎

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { LinkCodeClient } from '@linkcode/client-core';
2-
import { ATTACHMENT_UPLOAD_CHUNK_BYTES, AttachmentIdSchema } from '@linkcode/schema';
2+
import type { AttachmentId, SessionId } from '@linkcode/schema';
3+
import {
4+
ATTACHMENT_UPLOAD_CHUNK_BYTES,
5+
AttachmentIdSchema,
6+
OperationIdSchema,
7+
} from '@linkcode/schema';
8+
import type { Transport } from '@linkcode/transport';
9+
import { createWireMessage } from '@linkcode/transport';
10+
import { nullthrow } from 'foxts/guard';
311
import { describe, expect, it } from 'vitest';
412
import { createDevMockTransport } from '../../src/mock/dev-mock-transport';
513

@@ -9,26 +17,101 @@ async function connectedClient(): Promise<LinkCodeClient> {
917
return client;
1018
}
1119

20+
/** `turn.submit` has no client-core method yet (CODE-638), so the prompt-ref root is driven raw. */
21+
function submitPromptRef(
22+
transport: Transport,
23+
sessionId: SessionId,
24+
attachmentId: AttachmentId,
25+
): Promise<void> {
26+
return new Promise((resolve, reject) => {
27+
const clientReqId = 'creq-attachment-ref';
28+
const unsubscribe = transport.onMessage((message) => {
29+
const p = message.payload;
30+
if (!('replyTo' in p) || p.replyTo !== clientReqId) return;
31+
unsubscribe();
32+
if (p.kind === 'turn.submitted') resolve();
33+
else reject(new Error(p.kind === 'request.failed' ? p.message : `unexpected ${p.kind}`));
34+
});
35+
transport.send(
36+
createWireMessage({
37+
kind: 'turn.submit',
38+
clientReqId,
39+
sessionId,
40+
operationId: OperationIdSchema.parse('op-attachment-ref'),
41+
input: {
42+
type: 'prompt',
43+
blocks: [
44+
{ type: 'text', text: 'look at this' },
45+
{ type: 'attachment_ref', attachmentId },
46+
],
47+
},
48+
}),
49+
);
50+
});
51+
}
52+
1253
describe('dev mock attachment store', () => {
1354
it('round-trips a multi-chunk upload and dedupes the second copy', async () => {
1455
const client = await connectedClient();
15-
const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' });
56+
await client.startSession({ kind: 'codex', cwd: '/mock/repo' });
1657
const bytes = new Uint8Array(ATTACHMENT_UPLOAD_CHUNK_BYTES + 17);
1758
for (let i = 0; i < bytes.byteLength; i++) bytes[i] = i % 251;
1859

1960
const first = await client.putAttachment({ bytes, name: 'shot.bin', attachmentKind: 'file' });
20-
const read = await client.getAttachmentBytes(sessionId, first.attachmentId);
21-
expect(read.bytes).toEqual(bytes);
22-
expect(read.blobId).toBe(first.blobId);
23-
2461
// Same bytes, new record: the mock must answer `exists` and transfer nothing.
2562
const second = await client.putAttachment({ bytes, name: 'copy.bin', attachmentKind: 'file' });
2663
expect(second.blobId).toBe(first.blobId);
2764
expect(second.attachmentId).not.toBe(first.attachmentId);
2865
client.dispose();
2966
});
3067

31-
it('rejects an unknown attachment and a chunk at the wrong offset', async () => {
68+
it('reads an attachment only from a session that roots it', async () => {
69+
const client = await connectedClient();
70+
const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' });
71+
const bytes = new TextEncoder().encode('resource bytes');
72+
73+
// An upload alone is a draft lease — the daemon's isReachable roots nothing yet.
74+
const draft = await client.putAttachment({ bytes, name: 'draft.txt', attachmentKind: 'file' });
75+
await expect(client.getAttachmentBytes(sessionId, draft.attachmentId)).rejects.toThrow(
76+
'Attachment not found',
77+
);
78+
79+
// A session resource is a root, and carries the attachment its bytes landed in.
80+
const resource = await client.uploadSource(
81+
sessionId,
82+
'brief.txt',
83+
btoa('resource bytes'),
84+
'text/plain',
85+
);
86+
const attachmentId = nullthrow(resource.attachmentId, 'resource missing attachmentId');
87+
const read = await client.getAttachmentBytes(sessionId, attachmentId);
88+
expect(read.bytes).toEqual(bytes);
89+
90+
const otherSession = await client.startSession({ kind: 'codex', cwd: '/mock/other' });
91+
await expect(client.getAttachmentBytes(otherSession, attachmentId)).rejects.toThrow(
92+
'Attachment not found',
93+
);
94+
client.dispose();
95+
});
96+
97+
it('roots a draft attachment once a prompt of that session references it', async () => {
98+
const transport = createDevMockTransport();
99+
const client = new LinkCodeClient(transport);
100+
await client.connect();
101+
const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' });
102+
const bytes = new TextEncoder().encode('attached by prompt');
103+
const draft = await client.putAttachment({ bytes, name: 'note.txt', attachmentKind: 'file' });
104+
105+
await expect(client.getAttachmentBytes(sessionId, draft.attachmentId)).rejects.toThrow(
106+
'Attachment not found',
107+
);
108+
await submitPromptRef(transport, sessionId, draft.attachmentId);
109+
const read = await client.getAttachmentBytes(sessionId, draft.attachmentId);
110+
expect(read.bytes).toEqual(bytes);
111+
client.dispose();
112+
});
113+
114+
it('rejects an unknown attachment, a wrong offset, and bytes that are not the declared image', async () => {
32115
const client = await connectedClient();
33116
const sessionId = await client.startSession({ kind: 'codex', cwd: '/mock/repo' });
34117
await expect(
@@ -44,6 +127,15 @@ describe('dev mock attachment store', () => {
44127
await expect(client.sendAttachmentChunk(begun.uploadId, 8, 'YQ==')).rejects.toThrow(
45128
'Expected offset 0',
46129
);
130+
131+
await expect(
132+
client.putAttachment({
133+
bytes: new TextEncoder().encode('not a png'),
134+
name: 'fake.png',
135+
mimeType: 'image/png',
136+
attachmentKind: 'image',
137+
}),
138+
).rejects.toThrow('File contents are not image/png');
47139
client.dispose();
48140
});
49141
});

0 commit comments

Comments
 (0)