Skip to content

Commit 2b338a2

Browse files
fix(cursor): require every documented subagent envelope field and validate author.email with the pinned format
Addresses the second Codex review round on #375: - subagentStart: parent_conversation_id, tool_call_id, subagent_model, and is_parallel_worker are required (only git_branch is documented optional) in both the route envelope validator and the generated wrapper validator. - subagentStop: task, description, summary, duration_ms, message_count, tool_call_count, modified_files, and agent_transcript_path (string|null) are required to match the documented input, which marks no field optional. - Simulation encoders carry neutral values for the mandatory fields canonical input lacks so hook simulation still passes the tightened validators. - author.email goes through the pinned schema's format: email checker so values like dev@example..com fail closed as cursor.manifest.author.email.invalid.
1 parent 16b9da3 commit 2b338a2

6 files changed

Lines changed: 112 additions & 28 deletions

File tree

‎packages/agent-bundle/src/adapters/cursor.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,13 @@ const validateMcp = validator.compile(mcpSchema);
111111
const validateHooks = validator.compile(hooksSchema);
112112
const validateMarketplace = validator.compile(marketplaceSchema);
113113
/**
114-
* The exact `format: "uri"` check the pinned plugin schema applies to
115-
* `homepage`/`repository`, so metadata is validated as the string that will
116-
* be emitted rather than as the normalized form `new URL()` would accept.
114+
* The exact `format: "uri"` / `format: "email"` checks the pinned plugin
115+
* schema applies to `homepage`/`repository` and `author.email`, so metadata is
116+
* validated as the string that will be emitted rather than through a looser
117+
* local approximation (`new URL()` normalizes; a hand regex admits `a@b..c`).
117118
*/
118119
const validateSchemaUri = validator.compile({ type: 'string', format: 'uri' });
120+
const validateSchemaEmail = validator.compile({ type: 'string', format: 'email' });
119121

120122
/** The pinned Cursor document validators, shared with the unified bundle adapter. */
121123
export const cursorPluginValidator = validatePlugin;
@@ -310,7 +312,7 @@ const isAbsoluteUrl = (value: unknown): value is string => {
310312
};
311313

312314
const isEmail = (value: unknown): value is string =>
313-
isNonemptyString(value) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value);
315+
isNonemptyString(value) && validateSchemaEmail(value) === true;
314316

315317
const isNonemptyStringArray = (value: unknown): value is readonly string[] =>
316318
Array.isArray(value) && value.every(isNonemptyString);
@@ -387,7 +389,7 @@ export const planCursorManifestMetadata = (
387389
diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.author.name.invalid`, 'Cursor author.name must be a nonempty string.'));
388390
}
389391
if (author['email'] !== undefined && !isEmail(author['email'])) {
390-
diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.author.email.invalid`, 'Cursor author.email must be a valid email address.'));
392+
diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.author.email.invalid`, "Cursor author.email must be an email address the pinned schema's email format admits."));
391393
}
392394
if (extra.length === 0 && isNonemptyString(author['name']) && (author['email'] === undefined || isEmail(author['email']))) {
393395
document['author'] = Object.freeze({

‎packages/agent-bundle/src/adapters/hook-contract.ts‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -534,21 +534,32 @@ export const encodeCursorPlaygroundInput = (
534534
// 2026-09-02): Cursor names the subagent fields subagent_* and reports the
535535
// completion summary and follow-up loop count instead of Claude's
536536
// last_assistant_message / stop_hook_active pair.
537+
// Every documented field except git_branch is required, so the simulated
538+
// envelope carries neutral values for the ones canonical input lacks.
537539
...(nativeEvent === 'subagentStart'
538540
? {
541+
is_parallel_worker: false,
542+
parent_conversation_id: input.sessionId,
539543
subagent_id: input.agentId,
544+
subagent_model: input.model ?? '',
540545
subagent_type: input.agentType,
541546
task: '',
542-
tool_call_id: input.toolUseId,
547+
tool_call_id: input.toolUseId ?? '',
543548
}
544549
: {}),
545550
...(nativeEvent === 'subagentStop'
546551
? {
547552
agent_transcript_path: input.agentTranscriptPath ?? null,
553+
description: '',
554+
duration_ms: 0,
548555
loop_count: input.stopHookActive === true ? 1 : 0,
556+
message_count: 0,
557+
modified_files: [],
549558
status: 'completed',
550559
subagent_type: input.agentType,
551-
summary: input.lastAssistantMessage,
560+
summary: input.lastAssistantMessage ?? '',
561+
task: '',
562+
tool_call_count: 0,
552563
}
553564
: {}),
554565
session_id: input.sessionId,
@@ -771,8 +782,8 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [
771782
' cwd: canonicalInput.cwd,',
772783
' hook_event_name: nativeEvent,',
773784
' ...(canonicalEvent === "stop" ? { loop_count: canonicalInput.stopHookActive === true ? 1 : 0, status: "completed" } : {}),',
774-
' ...(canonicalEvent === "agentStart" ? { subagent_id: canonicalInput.agentId, subagent_type: canonicalInput.agentType, task: "", tool_call_id: canonicalInput.toolUseId } : {}),',
775-
' ...(canonicalEvent === "agentStop" ? { agent_transcript_path: canonicalInput.agentTranscriptPath ?? null, loop_count: canonicalInput.stopHookActive === true ? 1 : 0, status: "completed", subagent_type: canonicalInput.agentType, summary: canonicalInput.lastAssistantMessage } : {}),',
785+
' ...(canonicalEvent === "agentStart" ? { is_parallel_worker: false, parent_conversation_id: canonicalInput.sessionId, subagent_id: canonicalInput.agentId, subagent_model: canonicalInput.model ?? "", subagent_type: canonicalInput.agentType, task: "", tool_call_id: canonicalInput.toolUseId ?? "" } : {}),',
786+
' ...(canonicalEvent === "agentStop" ? { agent_transcript_path: canonicalInput.agentTranscriptPath ?? null, description: "", duration_ms: 0, loop_count: canonicalInput.stopHookActive === true ? 1 : 0, message_count: 0, modified_files: [], status: "completed", subagent_type: canonicalInput.agentType, summary: canonicalInput.lastAssistantMessage ?? "", task: "", tool_call_count: 0 } : {}),',
776787
' session_id: canonicalInput.sessionId,',
777788
' ...(subagentEvent ? {} : { tool_input: canonicalInput.toolInput, tool_name: canonicalInput.toolName, tool_use_id: canonicalInput.toolUseId }),',
778789
' ...(canonicalEvent === "afterTool" && canonicalInput.toolResponse !== undefined ? { tool_output: JSON.stringify(canonicalInput.toolResponse) } : {}),',
@@ -833,15 +844,19 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [
833844
' return;',
834845
' }',
835846
' if (canonicalEvent === "agentStart") {',
836-
' requireString(input, "subagent_id");',
837-
' requireString(input, "subagent_type");',
838-
' requireString(input, "task");',
847+
' for (const field of ["subagent_id", "subagent_type", "task", "parent_conversation_id", "tool_call_id", "subagent_model"]) requireString(input, field);',
848+
' if (typeof input.is_parallel_worker !== "boolean") fail("native is_parallel_worker must be a boolean");',
849+
' if (input.git_branch !== undefined) requireString(input, "git_branch");',
839850
' return;',
840851
' }',
841852
' if (canonicalEvent === "agentStop") {',
842-
' requireString(input, "subagent_type");',
853+
' for (const field of ["subagent_type", "task", "description", "summary"]) requireString(input, field);',
843854
' if (!["completed", "error", "aborted"].includes(input.status)) fail("native subagentStop status is invalid");',
844-
' if (typeof input.loop_count !== "number") fail("native subagentStop loop_count must be a number");',
855+
' for (const field of ["duration_ms", "message_count", "tool_call_count", "loop_count"]) {',
856+
' if (typeof input[field] !== "number") fail(`native subagentStop ${field} must be a number`);',
857+
' }',
858+
' if (!Array.isArray(input.modified_files) || !input.modified_files.every((file) => typeof file === "string")) fail("native modified_files must be an array of strings");',
859+
' if (input.agent_transcript_path !== null && typeof input.agent_transcript_path !== "string") fail("native agent_transcript_path must be a string or null");',
845860
' return;',
846861
' }',
847862
' if (typeof input.loop_count !== "number") fail("native stop loop_count must be a number");',

‎packages/agent-bundle/src/events/projection.ts‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -185,37 +185,37 @@ export const validateNativeEventEnvelope = (
185185
}
186186
if (canonicalEvent === 'agent/start') {
187187
// https://cursor.com/docs/hooks#subagentstart (retrieved 2026-09-02).
188+
// Only git_branch is documented "(optional)"; every other field is required.
188189
requireNativeString(native, 'subagent_id');
189190
requireNativeString(native, 'subagent_type');
190191
requireNativeStringValue(native, 'task');
191-
for (const field of ['parent_conversation_id', 'tool_call_id', 'subagent_model', 'git_branch']) {
192-
if (Object.hasOwn(native, field)) requireNativeStringValue(native, field);
193-
}
194-
if (Object.hasOwn(native, 'is_parallel_worker')) requireNativeBoolean(native, 'is_parallel_worker');
192+
requireNativeString(native, 'parent_conversation_id');
193+
requireNativeString(native, 'tool_call_id');
194+
requireNativeStringValue(native, 'subagent_model');
195+
requireNativeBoolean(native, 'is_parallel_worker');
196+
if (Object.hasOwn(native, 'git_branch')) requireNativeStringValue(native, 'git_branch');
195197
}
196198
if (canonicalEvent === 'agent/stop') {
197199
// https://cursor.com/docs/hooks#subagentstop (retrieved 2026-09-02).
198200
requireNativeString(native, 'subagent_type');
199201
if (!['completed', 'error', 'aborted'].includes(String(native.status))) {
200202
return nativeEventError('native status is invalid');
201203
}
204+
// The documented subagentStop input marks no field optional;
205+
// agent_transcript_path is `string | null`.
202206
for (const field of ['task', 'description', 'summary']) {
203-
if (Object.hasOwn(native, field)) requireNativeStringValue(native, field);
207+
requireNativeStringValue(native, field);
204208
}
205209
for (const field of ['duration_ms', 'message_count', 'tool_call_count']) {
206-
if (Object.hasOwn(native, field)) requireNativeNumber(native, field);
210+
requireNativeNumber(native, field);
207211
}
208212
requireNativeNumber(native, 'loop_count');
209-
if (
210-
Object.hasOwn(native, 'modified_files')
211-
&& (!Array.isArray(native.modified_files) || !native.modified_files.every((file) => typeof file === 'string'))
212-
) {
213+
if (!Array.isArray(native.modified_files) || !native.modified_files.every((file) => typeof file === 'string')) {
213214
return nativeEventError('native modified_files must be an array of strings');
214215
}
215216
if (
216-
Object.hasOwn(native, 'agent_transcript_path')
217-
&& native.agent_transcript_path !== null
218-
&& typeof native.agent_transcript_path !== 'string'
217+
!Object.hasOwn(native, 'agent_transcript_path')
218+
|| (native.agent_transcript_path !== null && typeof native.agent_transcript_path !== 'string')
219219
) {
220220
return nativeEventError('native agent_transcript_path must be a string or null');
221221
}

‎packages/agent-bundle/tests/cursor-adapter.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,13 +276,22 @@ it('rejects cursor URLs that new URL() would normalize but the pinned uri format
276276
expect(manifest).not.toHaveProperty('homepage');
277277
expect(manifest).not.toHaveProperty('repository');
278278
}
279+
// author.email goes through the same pinned `format: "email"` checker; a
280+
// hand regex would admit the doubled dot and defer the failure to the
281+
// generic schema pass.
282+
const doubledDot = withCursorConfig({ author: { email: 'dev@example..com', name: 'Example' } });
283+
expect(cursorAdapter.plan(doubledDot).diagnostics.map((diagnostic) => diagnostic.code))
284+
.toEqual(['cursor.manifest.author.email.invalid']);
285+
expect(JSON.parse(writeContents(doubledDot)['.cursor-plugin/plugin.json']!)).not.toHaveProperty('author');
279286
const exact = withCursorConfig({
287+
author: { email: 'dev@example.com', name: 'Example' },
280288
homepage: 'https://example.test',
281289
repository: 'https://EXAMPLE.test/a%2Fb?ref=main#readme',
282290
});
283291
const plan = cursorAdapter.plan(exact);
284292
expect(plan.diagnostics).toEqual([]);
285293
const manifest = JSON.parse(writeContents(exact)['.cursor-plugin/plugin.json']!) as Record<string, unknown>;
294+
expect(manifest['author']).toEqual({ email: 'dev@example.com', name: 'Example' });
286295
expect(manifest['homepage']).toBe('https://example.test');
287296
expect(manifest['repository']).toBe('https://EXAMPLE.test/a%2Fb?ref=main#readme');
288297
});

‎packages/agent-bundle/tests/event-project.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,19 @@ it('validates the documented Cursor subagentStart and subagentStop envelopes fai
132132
.toThrow(/native subagent_id must be a nonempty string/u);
133133
expect(() => validateNativeEventEnvelope({ ...start, is_parallel_worker: 'no' }, startOptions))
134134
.toThrow(/native is_parallel_worker must be a boolean/u);
135+
// Only git_branch is documented "(optional)"; every other field must be present.
136+
const { git_branch: _gitBranch, ...withoutGitBranch } = start;
137+
expect(validateNativeEventEnvelope(withoutGitBranch, startOptions)).toBe(withoutGitBranch);
138+
for (const [field, message] of [
139+
['parent_conversation_id', 'native parent_conversation_id must be a nonempty string'],
140+
['tool_call_id', 'native tool_call_id must be a nonempty string'],
141+
['subagent_model', 'native subagent_model must be a string'],
142+
['is_parallel_worker', 'native is_parallel_worker must be a boolean'],
143+
['task', 'native task must be a string'],
144+
] as const) {
145+
const { [field]: _omitted, ...missing } = start;
146+
expect(() => validateNativeEventEnvelope(missing, startOptions)).toThrow(message);
147+
}
135148
// Claude's agent_id/agent_type spelling is not the Cursor envelope.
136149
expect(() => validateNativeEventEnvelope({
137150
agent_id: 'abc-123',
@@ -166,6 +179,20 @@ it('validates the documented Cursor subagentStart and subagentStop envelopes fai
166179
.toThrow(/native modified_files must be an array of strings/u);
167180
expect(() => validateNativeEventEnvelope({ ...stop, agent_transcript_path: 7 }, stopOptions))
168181
.toThrow(/native agent_transcript_path must be a string or null/u);
182+
// The documented subagentStop input marks no field optional.
183+
for (const [field, message] of [
184+
['task', 'native task must be a string'],
185+
['description', 'native description must be a string'],
186+
['summary', 'native summary must be a string'],
187+
['duration_ms', 'native duration_ms must be a number'],
188+
['message_count', 'native message_count must be a number'],
189+
['tool_call_count', 'native tool_call_count must be a number'],
190+
['modified_files', 'native modified_files must be an array of strings'],
191+
['agent_transcript_path', 'native agent_transcript_path must be a string or null'],
192+
] as const) {
193+
const { [field]: _omitted, ...missing } = stop;
194+
expect(() => validateNativeEventEnvelope(missing, stopOptions)).toThrow(message);
195+
}
169196
});
170197

171198
it('validates prompt/submit and session/end host envelopes fail closed', () => {

‎packages/agent-bundle/tests/hooks.test.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1323,10 +1323,41 @@ it('round-trips the documented Cursor subagent envelopes through published Curso
13231323
stderr: 'Agent Bundle hook error: native subagentStop status is invalid\n',
13241324
stdout: '',
13251325
});
1326+
// Every documented field except git_branch is mandatory: a malformed
1327+
// envelope must fail closed before the handler runs with undefined fields.
1328+
const { git_branch: _gitBranch, ...startWithoutGitBranch } = startInput;
1329+
await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), startWithoutGitBranch))
1330+
.resolves.toMatchObject({ code: 0, stderr: '' });
1331+
for (const [field, message] of [
1332+
['tool_call_id', 'native tool_call_id must be a string'],
1333+
['parent_conversation_id', 'native parent_conversation_id must be a string'],
1334+
['subagent_model', 'native subagent_model must be a string'],
1335+
['is_parallel_worker', 'native is_parallel_worker must be a boolean'],
1336+
] as const) {
1337+
const { [field]: _omitted, ...missing } = startInput;
1338+
await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), missing)).resolves.toEqual({
1339+
code: 1,
1340+
stderr: `Agent Bundle hook error: ${message}\n`,
1341+
stdout: '',
1342+
});
1343+
}
1344+
for (const [field, message] of [
1345+
['description', 'native description must be a string'],
1346+
['duration_ms', 'native subagentStop duration_ms must be a number'],
1347+
['modified_files', 'native modified_files must be an array of strings'],
1348+
['agent_transcript_path', 'native agent_transcript_path must be a string or null'],
1349+
] as const) {
1350+
const { [field]: _omitted, ...missing } = stopInput;
1351+
await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), missing)).resolves.toEqual({
1352+
code: 1,
1353+
stderr: `Agent Bundle hook error: ${message}\n`,
1354+
stdout: '',
1355+
});
1356+
}
13261357
} finally {
13271358
await rm(root, { force: true, recursive: true });
13281359
}
1329-
}, 15_000);
1360+
}, 30_000);
13301361

13311362
it('rejects malformed event-specific native input before calling generated Codex and Claude hooks', async () => {
13321363
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-hooks-native-input-'));

0 commit comments

Comments
 (0)