diff --git a/.changeset/cursor-workspace-open-observation.md b/.changeset/cursor-workspace-open-observation.md new file mode 100644 index 000000000..154beb177 --- /dev/null +++ b/.changeset/cursor-workspace-open-observation.md @@ -0,0 +1,8 @@ +--- +"agent-bundle": minor +--- + +Support Cursor's canonical `workspace/open` event route as a fire-and-forget +observation. The generated wrapper accepts Cursor's sessionless `workspaceOpen` +envelope and emits no output; the optional native `pluginPaths` return channel +is deliberately not modeled. diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 4152be68b..bb12eba03 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -235,11 +235,13 @@ Host/Origin allowlists mitigate DNS rebinding and cross-origin requests, but the | `stop` | Supported | `Stop` | `Stop` | | `agent/start` | `subagentStart` | `SubagentStart` | `SubagentStart` | | `agent/stop` | `subagentStop` | `SubagentStop` | `SubagentStop` | -| `workspace/open` | Unavailable | Unavailable | Unavailable | +| `workspace/open` | Supported (observe-only; native `pluginPaths` return not modeled) | Unavailable | Unavailable | -Cursor's native `workspaceOpen` is sessionless: it fires without session or -conversation fields and its response returns `pluginPaths`. The generated -session-scoped wrapper and output vocabulary cannot express that envelope. +Cursor's native `workspaceOpen` is sessionless and its optional `pluginPaths` +return is deliberately not modeled. Event routes observe the documented +workspace envelope and emit no native output; plain `hooks.events.workspaceOpen` +handlers remain unavailable because their session-scoped vocabulary cannot +express it. `agent/start` is context-injection-only on Claude Code and Codex and cannot block subagent creation. Their `agent/stop` routes can continue the subagent with the native `decision: "block"` plus `reason` contract. Codex diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index 61ec38368..c116cba78 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -29,11 +29,7 @@ "stop": { "nativeEvent": "stop", "state": "supported" }, "tool/after": { "nativeEvent": "postToolUse", "state": "supported" }, "tool/before": { "nativeEvent": "preToolUse", "state": "supported" }, - "workspace/open": { - "nativeEvent": "workspaceOpen", - "reason": "Cursor's workspaceOpen fires outside an agent session with no session or conversation fields, and its response must return pluginPaths; the generated session-scoped wrapper vocabulary cannot express that envelope.", - "state": "unavailable" - } + "workspace/open": { "nativeEvent": "workspaceOpen", "state": "supported" } }, "matchers": { "agent": "^Task$", @@ -83,7 +79,7 @@ "workspaceRoot": "${workspaceFolder}" }, "provenance": { - "observedAt": "2026-08-31", + "observedAt": "2026-09-02", "cursorServerBuild": "9746bf00534f29fc29f1deb9ddfb5448f7905eb0", "evidence": [ "Adapter target is the full Cursor Plugin contract, not the root-manifest portable Agent Plugin contract.", @@ -93,7 +89,8 @@ "Local-plugin symlinks are realpath checked and rejected when their targets escape ~/.cursor/plugins/local.", "2026-09-01: cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines the commands component pointer; https://cursor.com/docs documents agent chat commands as plain Markdown prompt files in commands/ named by filename.", "2026-08-31: cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines the rules component pointer; https://cursor.com/docs/plugins documents the rules component.", - "The pinned Cursor hooks schema admits subagentStart, subagentStop, and workspaceOpen as first-class hook arrays; the documented native workspaceOpen contract is sessionless and returns pluginPaths, which the generated wrapper contract does not model (observed 2026-09-01, https://cursor.com/docs/agent/hooks).", + "2026-09-02: https://cursor.com/docs/hooks#workspaceopen documents workspaceOpen input as sessionless ({ hook_event_name, cursor_version, workspace_roots, user_email }; conversation_id/generation_id/model/session_id/transcript_path omitted) and its output pluginPaths as string[] (optional), so an empty response is legal; the generated event-route wrapper validates that envelope and projects the observation-only canonical workspace/open family to no output — the native pluginPaths return channel is deliberately not modeled and never emitted.", + "2026-09-02: live native capture on Cursor CLI (cursor-agent 2026.08.31-4057e58, trusted project hooks, non-interactive -p run): workspaceOpen fired on workspace open with exactly the documented sessionless envelope (hook_event_name, cursor_version, workspace_roots, user_email; no conversation_id/generation_id/model/session_id/transcript_path) while the same run's sessionStart carried session and conversation ids; the capture hook exited 0 with no stdout and the workspace open plus agent session proceeded, proving the pluginPaths-omitted empty response is legal on the real binary.", "2026-09-01: https://cursor.com/docs/plugins and https://prod.cursor.com/docs/reference/plugins document agents as a full Cursor Plugin component alongside rules and commands; #100 stage 2 defers the agents component per the G5 narrowing in #107, so no agents capability row is published until a later stage admits it." ] } diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 1c6eee9f3..5c9af0e1d 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -154,7 +154,14 @@ export const createNativeEventStarter = ( stop_hook_active: false, }); case 'workspace/open': - return deepFreeze(base); + return deepFreeze(target === 'cursor' + ? { + cursor_version: 'lifecycle-replay', + hook_event_name: nativeEvent, + user_email: null, + workspace_roots: ['/tmp'], + } + : base); default: { const exhaustive: never = canonicalEvent; return exhaustive; diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index d215b503c..d16c9ff5e 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -55,6 +55,24 @@ export const validateNativeEventEnvelope = ( return nativeEventError(`native hook_event_name must equal ${nativeEvent}`); } if (target === 'cursor') { + if (canonicalEvent === 'workspace/open') { + if ( + !Array.isArray(native.workspace_roots) + || native.workspace_roots.length === 0 + || !native.workspace_roots.every((root) => typeof root === 'string' && root.trim() !== '') + ) { + return nativeEventError('native workspace_roots must be a nonempty array of nonempty strings'); + } + requireNativeString(native, 'cursor_version'); + if ( + Object.hasOwn(native, 'user_email') + && native.user_email !== null + && typeof native.user_email !== 'string' + ) { + return nativeEventError('native user_email must be a string or null'); + } + return native; + } if (typeof native.session_id !== 'string' && typeof native.conversation_id !== 'string') { return nativeEventError('native session_id or conversation_id must be a string'); } @@ -278,5 +296,16 @@ export const projectEventDocument = ( }, }); } + if (event === 'workspace/open') { + if (parsedValue?.outcome === 'deny' || parsedValue?.updatedInput !== undefined) { + throw new TypeError('workspace/open is observation-only on every supported host and cannot deny or replace native input.'); + } + if (additionalContext !== undefined) { + throw new TypeError( + 'Cursor\'s workspaceOpen has no context/output channel; the native pluginPaths return channel is deliberately not modeled.', + ); + } + return undefined; + } return undefined; }; diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index e84461bc9..7d229ffa9 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -419,8 +419,8 @@ it('reports the evidence-backed G10 event family matrix without inferred support }); } expect(registry.get('cursor').capabilities['event:workspace/open']).toMatchObject({ - reason: expect.stringContaining('pluginPaths'), - state: 'unavailable', + evidence: { observedVersion: '2026-08-28', target: 'cursor' }, + state: 'supported', }); for (const target of ['claude', 'codex'] as const) { for (const capability of allNativeHosts) { @@ -441,7 +441,7 @@ it('reports the evidence-backed G10 event family matrix without inferred support }); } expect(registry.get('plugin').capabilities['event:workspace/open']).toMatchObject({ - reason: expect.stringContaining('pluginPaths'), + reason: expect.not.stringContaining('pluginPaths'), state: 'unavailable', }); expect(registry.get('plugin').capabilities['event:workspace/open']).toMatchObject({ diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index c9f91c43d..b6ebf4a92 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -194,6 +194,7 @@ it('records observed capability versions and rehashes schema snapshots against p } if (target === 'cursor') { + expect(sha256Hex(capability)).toBe('46f47e4daa8d072b8319440f912a72ea488b22893803d89382cec18998fb13eb'); const pluginSchema = JSON.parse(await readFile( new URL('../src/adapters/schemas/cursor/plugin.schema.json', import.meta.url), 'utf8', diff --git a/packages/agent-bundle/tests/event-project.test.ts b/packages/agent-bundle/tests/event-project.test.ts index 6d2ac50f8..d8e34f619 100644 --- a/packages/agent-bundle/tests/event-project.test.ts +++ b/packages/agent-bundle/tests/event-project.test.ts @@ -46,3 +46,38 @@ it('validates native event envelopes with the generated wrapper error contract', expect(() => validateNativeEventEnvelope([], options)) .toThrow('Agent Bundle event route error: stdin JSON value must be an object'); }); + +it('validates Cursor workspaceOpen without inventing an agent session', () => { + const options = { + canonicalEvent: 'workspace/open' as const, + nativeEvent: 'workspaceOpen', + target: 'cursor', + }; + const documented = { + cursor_version: '1.7.2', + hook_event_name: 'workspaceOpen', + user_email: null, + workspace_roots: ['/abs/path'], + }; + + expect(validateNativeEventEnvelope(documented, options)).toBe(documented); + for (const workspaceRoots of [undefined, [], [''], ['/abs/path', 7]]) { + expect(() => validateNativeEventEnvelope({ + ...documented, + workspace_roots: workspaceRoots, + }, options)).toThrow(/native workspace_roots must be a nonempty array of nonempty strings/u); + } + expect(() => validateNativeEventEnvelope({ ...documented, cursor_version: '' }, options)) + .toThrow(/native cursor_version must be a nonempty string/u); + expect(() => validateNativeEventEnvelope({ ...documented, user_email: 7 }, options)) + .toThrow(/native user_email must be a string or null/u); + + expect(() => validateNativeEventEnvelope({ + ...documented, + hook_event_name: 'sessionStart', + }, { + canonicalEvent: 'session/start', + nativeEvent: 'sessionStart', + target: 'cursor', + })).toThrow(/native session_id or conversation_id must be a string/u); +}); diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 4b3eefc79..e717b14f8 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1034,6 +1034,88 @@ it('runs the embedded Codex and Claude native codecs through their published wra } }, 15_000); +it('runs the Cursor workspace/open lifecycle starter through a generated wrapper with empty stdout', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-cursor-workspace-open-')); + const sourceRoot = join(root, 'src', 'events', 'workspace'); + const packageRoot = join(root, 'node_modules', 'agent-bundle'); + const wrapper = join(root, 'event-route-workspace-open.mjs'); + const base = hookModel(root); + const model: NormalizedPlugin = { + ...base, + hooks: [{ + event: 'workspaceOpen', + eventRoute: { event: 'workspace/open', fallback: 'none', runtime: 'standalone' }, + id: 'hook:event-route:workspace-open', + name: 'event-route-workspace-open', + provenance: { kind: 'conventional', sourcePath: join(sourceRoot, 'open.mjs') }, + source: join(sourceRoot, 'open.mjs'), + targets: ['cursor'], + tools: [], + }], + targets: [{ + id: 'target:cursor', + name: 'cursor', + provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') }, + }], + }; + + try { + await Promise.all([ + mkdir(sourceRoot, { recursive: true }), + mkdir(packageRoot, { recursive: true }), + ]); + await Promise.all([ + writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'), + writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile(join(packageRoot, 'package.json'), JSON.stringify({ + exports: { + './event-ipc': './event-ipc.mjs', + './event-project': './event-project.mjs', + }, + type: 'module', + })), + writeFile( + join(packageRoot, 'event-ipc.mjs'), + `export * from ${JSON.stringify(pathToFileURL(join(process.cwd(), 'packages/agent-bundle/dist/event-ipc.js')).href)};\n`, + ), + writeFile( + join(packageRoot, 'event-project.mjs'), + `export * from ${JSON.stringify(pathToFileURL(join(process.cwd(), 'packages/agent-bundle/dist/event-project.js')).href)};\n`, + ), + writeFile(join(sourceRoot, 'open.mjs'), [ + `import { Agent } from ${JSON.stringify(pathToFileURL(join(process.cwd(), 'packages/rsc-runtime/dist/index.js')).href)};`, + `import { createElement } from ${JSON.stringify(pathToFileURL(join(process.cwd(), 'packages/agent-bundle/node_modules/react/index.js')).href)};`, + '', + 'export default async function WorkspaceOpen() {', + ' return createElement(Agent.Result);', + '}', + '', + ].join('\n')), + ]); + const targetRegistry = createDefaultRegistry(); + const plan = targetRegistry.get('cursor').plan(model); + const generated = plan.hookEntries?.find((entry) => entry.relativePath === 'hooks/event-route-workspace-open.mjs'); + const starter = targetRegistry.hookContract('cursor')?.nativeEventStarter?.('workspace/open'); + + expect(plan.diagnostics).toEqual([]); + expect(generated).toBeDefined(); + await writeFile(wrapper, generated!.virtualSource); + expect(starter).toEqual({ + cursor_version: 'lifecycle-replay', + hook_event_name: 'workspaceOpen', + user_email: null, + workspace_roots: ['/tmp'], + }); + await expect(runNativeHook(wrapper, starter!)).resolves.toEqual({ + code: 0, + stderr: '', + stdout: '', + }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 15_000); + it('round-trips Claude and Codex subagent fields through published wrappers', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-subagent-hook-codecs-')); const sourceRoot = join(root, 'src', 'hooks'); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 61662bed4..6f30b8ec6 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -610,6 +610,48 @@ it('diagnoses a plain Cursor workspaceOpen hook instead of lowering a session-sc expect(plan.entries.some((entry) => entry.relativePath === 'hooks/hooks.json')).toBe(false); }); +it('plans a Cursor workspace/open event route without enabling the plain hook vocabulary', () => { + const model: NormalizedPlugin = { + ...plugin, + hooks: [{ + event: 'workspaceOpen', + eventRoute: { event: 'workspace/open', fallback: 'none', runtime: 'shared' }, + id: 'hook:event-route:workspace-open', + name: 'event-route-workspace-open', + provenance: { kind: 'conventional', sourcePath: '/workspace/src/events/workspace/open.tsx' }, + source: '/workspace/src/events/workspace/open.tsx', + targets: ['cursor'], + tools: [], + }], + marketplace: undefined, + mcpServers: [], + skills: [], + targets: [{ + id: 'target:cursor', + name: 'cursor', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + }], + }; + const plan = createDefaultRegistry().get('cursor').plan(model); + const hooks = plan.entries.find((entry) => entry.relativePath === 'hooks/hooks.json'); + + expect(plan.diagnostics).toEqual([]); + expect(hooks?.kind).toBe('write'); + expect(JSON.parse(hooks?.kind === 'write' ? hooks.content : '{}')).toEqual({ + hooks: { + workspaceOpen: [{ + command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/event-route-workspace-open.mjs"', + }], + }, + version: 1, + }); + expect(plan.hookEntries).toContainEqual(expect.objectContaining({ + nativeEvent: 'workspaceOpen', + relativePath: 'hooks/event-route-workspace-open.mjs', + target: 'cursor', + })); +}); + it('anchors compiled Claude MCP entries with absolute arguments, plugin-root cwd, and the env anchor', () => { const compiled = { ...plugin, diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index ccc72aa95..e52584ef0 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -797,7 +797,7 @@ it('discovers only the seven v1 event families and validates their component con expect(graph.diagnostics[1]?.sourcePath).toBe(join(root, 'src/events/tool/before.tsx')); }); -it('fails unavailable event routes before packaging for every selected target', async () => { +it('fails unavailable event routes before packaging while admitting supported targets', async () => { const eventSource = 'export default async function WorkspaceOpen() { return undefined; }\n'; const configSource = [ 'export default {', @@ -819,7 +819,7 @@ it('fails unavailable event routes before packaging for every selected target', code: 'AB4824', target: 'claude', })); - expect(unrestricted.diagnostics).toContainEqual(expect.objectContaining({ + expect(unrestricted.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'AB4824', target: 'cursor', })); @@ -835,11 +835,8 @@ it('fails unavailable event routes before packaging for every selected target', }); const restricted = await inspect({ root: restrictedRoot }); - expect(restricted.state).toBe('invalid'); - expect(restricted.diagnostics).toContainEqual(expect.objectContaining({ - code: 'AB4824', - target: 'cursor', - })); + expect(restricted.state).toBe('ready'); + expect(restricted.diagnostics).toEqual([]); }); it('rejects malformed event route targets with AB4825', async () => { diff --git a/packages/agent-bundle/tests/route-unit/event-project.test.ts b/packages/agent-bundle/tests/route-unit/event-project.test.ts index 0b26c9a58..a88199f0a 100644 --- a/packages/agent-bundle/tests/route-unit/event-project.test.ts +++ b/packages/agent-bundle/tests/route-unit/event-project.test.ts @@ -1,4 +1,4 @@ -import { Agent } from '@agent-bundle/runtime'; +import { Agent, type JsonValue } from '@agent-bundle/runtime'; import { expect, it } from '@rstest/core'; import { createElement, Suspense } from 'react'; @@ -147,3 +147,50 @@ it('projects subagent-stop continuation only through supported host contracts', expect(() => projectEventDocument(feedback.document, 'agent/stop', 'plugin', 'SubagentStop')) .toThrow(/must resolve the invoking host/u); }); + +it('projects workspace/open as a fire-and-forget observation only', async () => { + const props = createCanonicalEventProps( + 'workspace/open', + { + cursor_version: '1.7.2', + hook_event_name: 'workspaceOpen', + user_email: null, + workspace_roots: ['/workspace'], + }, + 'cursor', + 'workspaceOpen', + '2026-08-28', + new AbortController().signal, + ); + const routeInput = { + input: { canonical: props.canonical, native: props.native }, + kind: 'event-route', + routeId: 'event:workspace/open', + } as const; + const observation = await renderRoute({ + default: async () => createElement(Agent.Result), + }, routeInput); + expect(projectEventDocument(observation.document, 'workspace/open', 'cursor', 'workspaceOpen')).toBeUndefined(); + + const rejectedValues: readonly JsonValue[] = [ + { outcome: 'deny', reason: 'Do not open.' }, + { updatedInput: { workspace_roots: ['/replacement'] } }, + ]; + for (const value of rejectedValues) { + const rejected = await renderRoute({ + default: async () => createElement(Agent.Result, { value }), + }, routeInput); + expect(() => projectEventDocument(rejected.document, 'workspace/open', 'cursor', 'workspaceOpen')) + .toThrow(/workspace\/open is observation-only on every supported host/u); + } + + const context = await renderRoute({ + default: async () => createElement( + Agent.Result, + null, + createElement(Agent.Context, null, 'Inject this context.'), + ), + }, routeInput); + expect(() => projectEventDocument(context.document, 'workspace/open', 'cursor', 'workspaceOpen')) + .toThrow(/Cursor's workspaceOpen has no context\/output channel.*pluginPaths.*deliberately not modeled/u); +}); diff --git a/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts b/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts index 78a3418ad..be4a9dd74 100644 --- a/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts +++ b/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts @@ -122,3 +122,51 @@ it('replays Claude and Codex PostToolUse through decode, route execution, render }); } }); + +it('replays the Cursor workspaceOpen starter as an observation with no native response', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-lifecycle-replay-workspace-open-')); + roots.push(root); + await symlink(join(process.cwd(), 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ name: 'workspace-open-fixture', type: 'module' })), + writeProjectFile(root, 'src/events/workspace/open.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + 'export default async function WorkspaceOpen() {', + ' return createElement(Agent.Result);', + '}', + '', + ].join('\n')), + ]); + const graph = await compileRouteGraph(root, { targets: ['cursor'] } as never); + const service = new LifecycleReplayService({ + prepared: () => ({ graph, targets: ['cursor'] }), + }); + const lifecycle = service.list().lifecycles.find((candidate) => candidate.routeId === 'event:workspace/open'); + const target = lifecycle?.targets.find((candidate) => candidate.target === 'cursor'); + + expect(target?.fixture?.native).toEqual({ + cursor_version: 'lifecycle-replay', + hook_event_name: 'workspaceOpen', + user_email: null, + workspace_roots: ['/tmp'], + }); + const replay = await service.replay({ + binding: { + manifestDigest: graph.digest, + routeId: 'event:workspace/open', + target: 'cursor', + }, + native: target!.fixture!.native, + source: 'fixture', + }); + expect('diagnostics' in replay).toBe(false); + expect(replay).toMatchObject({ + canonical: { + event: 'workspace/open', + provenance: { host: 'cursor', nativeEvent: 'workspaceOpen' }, + }, + nativeInput: target?.fixture?.native, + }); + expect((replay as LifecycleReplay).nativeResponse).toBeUndefined(); +});