From ac5851f0aa6f04c0c8a63cb8f8e28d5db0c2972a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 04:37:31 +0000 Subject: [PATCH 1/3] refactor(install): run the local Cursor installer as an Effect program with a typed DiagnosticError channel installCursor is an Effect.fnUntraced generator that lifts only its leaf I/O and fails on DiagnosticError; the staging-parent cleanup is an explicit exit sequence (capture the apply Exit, remove the stage, unwrap) instead of try/finally. readIdentity and the public-CLI installers stay lifted as units because their raw-failure passthrough is a pinned contract. --- .changeset/effect-install-cursor-program.md | 5 + packages/agent-bundle/src/install/install.ts | 152 ++++++++++++------- packages/agent-bundle/tests/install.test.ts | 67 ++++++++ 3 files changed, 169 insertions(+), 55 deletions(-) create mode 100644 .changeset/effect-install-cursor-program.md diff --git a/.changeset/effect-install-cursor-program.md b/.changeset/effect-install-cursor-program.md new file mode 100644 index 000000000..c45a99361 --- /dev/null +++ b/.changeset/effect-install-cursor-program.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Report every local Cursor install failure as an `AB7004` diagnostic for the `cursor` host: a Cursor home that exists but cannot be inspected (for example an unreadable `~/.cursor`) now surfaces as `AB7004` with `target: 'cursor'` like every other Cursor install failure, instead of a bare error. Successful installs, `AB7002`/`AB7003`/`AB7005` refusals, and the Claude/Codex installers are unchanged. (#PR) diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 8836604d0..75948cc9a 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -9,7 +9,7 @@ import { DiagnosticError } from '../core/diagnostics.ts'; import { errorMessage, isErrno } from '../core/errors.ts'; import { exists } from '../core/paths.ts'; import { runPromise } from '../effect/boundary.ts'; -import { liftPromise } from '../effect/lift.ts'; +import { liftPromise, type LiftedRejection } from '../effect/lift.ts'; import { claudePluginRowErrors } from '../host-contracts/claude-plugin-validation.ts'; import { stageCursorMarketplace } from './cursor-marketplace.ts'; import { @@ -854,16 +854,58 @@ const installCursorMarketplace = async ( } }; -const installCursor = async ( +/** + * The Cursor installers' failure contract: a `DiagnosticError` they raised + * passes through, anything else a leaf helper threw becomes `AB7004` for the + * host. The public-CLI installers deliberately do *not* share it — a raw leaf + * failure (an unwritable receipt store, say) re-raises verbatim after their + * host-verb rollback, and `tests/install.test.ts` pins that. + */ +const installFailure = (host: InstallHost) => (error: unknown): DiagnosticError => + error instanceof DiagnosticError ? error : failure('AB7004', errorMessage(error), host); + +/** + * Stage, apply, and always remove the staging parent. The removal must + * *propagate* — a failed cleanup replaces the apply outcome, exactly as the + * former `try`/`finally` did — so this is an explicit exit sequence, not a + * scope finalizer. The FileSystem lane owns turning the staging parent into a + * scoped temporary directory. + */ +const withStagedArtifact = ( + stage: () => Promise>>, + apply: (staged: Awaited>) => Promise, +): Effect.Effect => + Effect.gen(function*() { + const staged = yield* liftPromise(stage); + const applied = yield* Effect.exit(liftPromise(() => apply(staged))); + yield* liftPromise(() => rm(staged.parent, { force: true, recursive: true })); + return yield* applied; + }); + +/** + * The local Cursor install as an Effect program: only the leaf I/O is lifted + * (root resolution, inventories, `exists`, `mkdir`, staging, receipts), the + * decision logic stays synchronous inside the fiber, and every failure leaves + * on the typed channel as a `DiagnosticError`. + */ +const installCursor = Effect.fnUntraced(function*( options: InstallBundleOptions, identity: PluginIdentity, scope: InstallScope, -): Promise => { +): Effect.fn.Return { if (scope !== 'user') { - throw failure('AB7003', `Cursor plugin installation supports only user scope, not ${scope}.`, 'cursor'); + return yield* Effect.fail( + failure('AB7003', `Cursor plugin installation supports only user scope, not ${scope}.`, 'cursor'), + ); } - if (options.mode === 'marketplace') return installCursorMarketplace(options, identity); - const cursorRoot = await resolveCursorRoot(options); + if (options.mode === 'marketplace') { + return yield* liftPromise(() => installCursorMarketplace(options, identity)).pipe( + Effect.mapError(installFailure('cursor')), + ); + } + const cursorRoot = yield* liftPromise(() => resolveCursorRoot(options)).pipe( + Effect.mapError(installFailure('cursor')), + ); const installRoot = join(cursorRoot, 'plugins', 'local'); const destination = join(installRoot, identity.plugin); const base = { @@ -875,15 +917,15 @@ const installCursor = async ( receipt: join(destination, installReceiptFile), version: identity.version, } as const; - try { - const artifact = await treeInventory(identity.bundleRoot); + const program = Effect.gen(function*() { + const artifact = yield* liftPromise(() => treeInventory(identity.bundleRoot)); // The receipt records which host directories this installer created on the way to the plugin root // (a fresh Cursor home has no `plugins/local`), so uninstall can prune exactly those and no more. const hostDirectories: string[] = []; for (const relativePath of ['plugins', 'plugins/local']) { - if (!await exists(join(cursorRoot, relativePath))) hostDirectories.push(relativePath); + if (!(yield* liftPromise(() => exists(join(cursorRoot, relativePath))))) hostDirectories.push(relativePath); } - await mkdir(installRoot, { recursive: true }); + yield* liftPromise(() => mkdir(installRoot, { recursive: true })); const receipt: InstallReceiptIdentity = { host: 'cursor', hostDirectories, @@ -893,94 +935,93 @@ const installCursor = async ( scope: 'user', version: identity.version, }; - if (!await exists(destination)) { - const staged = await stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt, stageRoot: installRoot }); - try { - await rename(staged.root, destination); - } finally { - await rm(staged.parent, { force: true, recursive: true }); - } - return { ...base, contentHash: artifact.hash, state: 'installed' }; + if (!(yield* liftPromise(() => exists(destination)))) { + yield* withStagedArtifact( + () => stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt, stageRoot: installRoot }), + (staged) => rename(staged.root, destination), + ); + return { ...base, contentHash: artifact.hash, state: 'installed' } as const; } if (resolve(identity.bundleRoot) === destination) { - return { ...base, contentHash: artifact.hash, state: 'already-installed' }; + return { ...base, contentHash: artifact.hash, state: 'already-installed' } as const; } - const compared = await compareInstalledTree({ + const installedManifest = yield* liftPromise(() => readInstalledManifest(destination)); + const compared = yield* liftPromise(() => compareInstalledTree({ artifact, destination, - installedManifest: await readInstalledManifest(destination), + installedManifest, plugin: identity.plugin, version: identity.version, - }); + })); // `uninstall --keep-data` leaves a shell holding only state/ (plus, normally, a remnant receipt that owns no // files): a reinstall fills it back in around the preserved durable state instead of refusing it as foreign // (nothing in it is anyone's plugin content) and reports an install, not a replacement. const remnant = compared.ownership === 'receipt' && compared.receipt !== undefined ? isRemnantReceipt(compared.receipt) - : compared.ownership === 'foreign' && await isRuntimeStateRemnant(destination); + : compared.ownership === 'foreign' && (yield* liftPromise(() => isRuntimeStateRemnant(destination))); const comparison: InstalledTreeComparison = remnant && compared.ownership === 'foreign' ? { ...compared, ownership: 'legacy', status: 'stale' } : compared; if (comparison.status === 'current') { if (comparison.ownership === 'legacy' && options.replace === true) { // Adoption created nothing: the legacy copy's directories are not the installer's to prune. - await writeInstallReceipt(destination, createInstallReceipt({ + yield* liftPromise(() => writeInstallReceipt(destination, createInstallReceipt({ ...receipt, directories: [], hostDirectories: [], inventory: artifact, - })); - return { ...base, contentHash: artifact.hash, state: 'adopted' }; + }))); + return { ...base, contentHash: artifact.hash, state: 'adopted' } as const; } // A receipt-managed identical copy whose receipt predates format/2 is upgraded in place: the // lifecycle fields are synthesized exactly as the reader migrates them, and nothing else changes. if (comparison.ownership === 'receipt' && comparison.receipt?.migratedFrom !== undefined) { - await writeInstallReceipt(destination, createInstallReceipt({ + const previous = comparison.receipt; + yield* liftPromise(() => writeInstallReceipt(destination, createInstallReceipt({ ...receipt, - directories: comparison.receipt.directories, - hostDirectories: comparison.receipt.hostDirectories, - installedAt: comparison.receipt.installedAt, + directories: previous.directories, + hostDirectories: previous.hostDirectories, + installedAt: previous.installedAt, inventory: artifact, updatedAt: new Date().toISOString(), - })); + }))); } - return { ...base, contentHash: artifact.hash, state: 'already-installed' }; + return { ...base, contentHash: artifact.hash, state: 'already-installed' } as const; } const replaceable = (comparison.status === 'stale' && comparison.ownership === 'receipt') || remnant ? true : comparison.status !== 'foreign' && options.replace === true; if (!replaceable) { - throw failure('AB7005', collisionMessage(destination, identity, comparison), 'cursor'); + return yield* Effect.fail(failure('AB7005', collisionMessage(destination, identity, comparison), 'cursor')); } // Replacing an existing copy created no host directories; the previous receipt's carry over. const replacement: InstallReceiptIdentity = { ...receipt, hostDirectories: comparison.receipt?.hostDirectories ?? [] }; - const staged = await stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt: replacement, stageRoot: installRoot }); - try { - await replaceInstalledTree({ comparison, destination, receipt: replacement, staged }); - } finally { - await rm(staged.parent, { force: true, recursive: true }); - } + yield* withStagedArtifact( + () => stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt: replacement, stageRoot: installRoot }), + (staged) => replaceInstalledTree({ comparison, destination, receipt: replacement, staged }), + ); // Filling a state-only shell is a fresh install of plugin content, not a replacement of any. - if (remnant) return { ...base, contentHash: artifact.hash, state: 'installed' }; + if (remnant) return { ...base, contentHash: artifact.hash, state: 'installed' } as const; return { ...base, contentHash: artifact.hash, previousContentHash: comparison.installedContentHash, state: 'replaced', - }; - } catch (error) { - if (error instanceof DiagnosticError) throw error; - throw failure( - 'AB7004', - errorMessage(error), - 'cursor', - ); - } -}; + } as const; + }); + return yield* program.pipe(Effect.mapError(installFailure('cursor'))); +}); +/** + * The install program. The Cursor branch is Effect-native with a + * `DiagnosticError` channel; `readIdentity` and the public-CLI installers are + * lifted as units whose raw leaf failures cross the boundary verbatim (their + * pinned contract — the CLI entry maps those to `AB7004` itself), so the + * program's channel is the union of both. + */ const installProgram = Effect.fnUntraced(function*( options: InstallBundleOptions, -): Effect.fn.Return { +): Effect.fn.Return { const scope = options.scope ?? 'user'; if (options.mode !== undefined && options.host !== 'cursor') { return yield* Effect.fail(failure( @@ -992,11 +1033,12 @@ const installProgram = Effect.fnUntraced(function*( const identity = yield* liftPromise(() => readIdentity(options.from, options.host)); switch (options.host) { case 'claude': - return yield* liftPromise(() => installPublicCli(options, identity, 'claude', scope)); - case 'codex': - return yield* liftPromise(() => installPublicCli(options, identity, 'codex', scope)); + case 'codex': { + const host = options.host; + return yield* liftPromise(() => installPublicCli(options, identity, host, scope)); + } case 'cursor': - return yield* liftPromise(() => installCursor(options, identity, scope)); + return yield* installCursor(options, identity, scope); default: { const exhaustive: never = options.host; return yield* Effect.fail(failure('AB7000', `Unsupported install host ${String(exhaustive)}.`, options.host)); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 9b37ab802..eebc1dd14 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -843,6 +843,73 @@ it('fails closed when Cursor is not detected in the selected home', async () => } }); +it('reports a Cursor home it cannot inspect as AB7004 for the cursor host, like every other Cursor install failure', async () => { + if (process.getuid?.() === 0) return; // root ignores directory modes; the lstat cannot be made to fail here. + const fixture = await createHostBundle('cursor'); + const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); + const home = join(parent, 'home'); + await mkdir(join(home, '.cursor'), { recursive: true }); + await chmod(home, 0o000); + try { + const error = await installBundle({ + from: fixture.from, + home, + host: 'cursor', + scope: 'user', + }).catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ + code: 'AB7004', + message: expect.stringContaining('EACCES'), + severity: 'error', + target: 'cursor', + }]); + } finally { + await chmod(home, 0o755); + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(parent, { force: true, recursive: true }), + ]); + } +}); + +it('removes the staging parent after a failed replacement and re-raises the refusal as AB7004', async () => { + const fixture = await createHostBundle('cursor'); + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); + const installRoot = join(home, '.cursor', 'plugins', 'local'); + await mkdir(installRoot, { recursive: true }); + try { + const first = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect(first.state).toBe('installed'); + const destination = first.destination; + if (destination === undefined) throw new Error('Expected a local Cursor install destination.'); + // The rebuilt artifact ships a new file exactly where the installed copy holds an unowned one, so + // the swap is staged in full and then refused by replaceInstalledTree. + await mkdir(join(fixture.bundleRoot, 'skills', 'new'), { recursive: true }); + await writeFile(join(fixture.bundleRoot, 'skills', 'new', 'SKILL.md'), '# new\n'); + await mkdir(join(destination, 'skills', 'new'), { recursive: true }); + await writeFile(join(destination, 'skills', 'new', 'SKILL.md'), '# operator-owned\n'); + const error = await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' }) + .catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ + code: 'AB7004', + message: expect.stringContaining('Refusing to overwrite unowned files'), + target: 'cursor', + }]); + // The refusal happened after staging: the staging parent is gone and the installed copy is untouched. + expect(await readdir(installRoot)).toEqual([destination.slice(installRoot.length + 1)]); + await expect(readFile(join(destination, 'skills', 'new', 'SKILL.md'), 'utf8')).resolves.toBe('# operator-owned\n'); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + it('refreshes a receipt whose inventory drifted even when the owned bytes hash equal, and restructures owned paths', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); From 99915e488a610a4a20b02b4f27a6bf4b238a4330 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 04:37:41 +0000 Subject: [PATCH 2/3] chore(changeset): reference #522 --- .changeset/effect-install-cursor-program.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/effect-install-cursor-program.md b/.changeset/effect-install-cursor-program.md index c45a99361..0d3df142f 100644 --- a/.changeset/effect-install-cursor-program.md +++ b/.changeset/effect-install-cursor-program.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Report every local Cursor install failure as an `AB7004` diagnostic for the `cursor` host: a Cursor home that exists but cannot be inspected (for example an unreadable `~/.cursor`) now surfaces as `AB7004` with `target: 'cursor'` like every other Cursor install failure, instead of a bare error. Successful installs, `AB7002`/`AB7003`/`AB7005` refusals, and the Claude/Codex installers are unchanged. (#PR) +Report every local Cursor install failure as an `AB7004` diagnostic for the `cursor` host: a Cursor home that exists but cannot be inspected (for example an unreadable `~/.cursor`) now surfaces as `AB7004` with `target: 'cursor'` like every other Cursor install failure, instead of a bare error. Successful installs, `AB7002`/`AB7003`/`AB7005` refusals, and the Claude/Codex installers are unchanged. (#522) From 66f962a7f535d10bf7dd06bdad1034141db8e52e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 04:57:00 +0000 Subject: [PATCH 3/3] docs(diagnostics): record the local Cursor install AB7004 contract; skip the unreadable-home test on Windows (#522) --- docs/diagnostics.md | 9 ++++++++- packages/agent-bundle/tests/install.test.ts | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 353136545..0c1246e03 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -901,7 +901,14 @@ reads as absent, and a receipt that is not a regular file (a symbolic link, a FIFO) is refused outright (`AB7004`) before it is read. The same rules apply to the artifact itself: a file whose path could not round-trip through a receipt (a backslash in a POSIX name, reserved characters, a trailing dot or space) is -refused (`AB7004`) before anything is staged. +refused (`AB7004`) before anything is staged. Every other failure of a local +Cursor install — a `~/.cursor` that exists but cannot be inspected, an +inventory, staging, or receipt write that fails — is reported the same way, as +`AB7004` with `target: cursor` and the underlying message; a missing +`~/.cursor` is `AB7002`. The staging directory is removed before the failure is +reported, so a refused or failed replacement never leaves a +`plugins/local/..stage-*` directory behind, and the installed copy is +untouched. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index eebc1dd14..4168ff41a 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -844,7 +844,9 @@ it('fails closed when Cursor is not detected in the selected home', async () => }); it('reports a Cursor home it cannot inspect as AB7004 for the cursor host, like every other Cursor install failure', async () => { - if (process.getuid?.() === 0) return; // root ignores directory modes; the lstat cannot be made to fail here. + // POSIX directory modes drive the failure: root ignores them, and Windows has no `getuid` and does not + // make a `0o000` directory untraversable, so neither can make the lstat fail here. + if (process.platform === 'win32' || process.getuid?.() === 0) return; const fixture = await createHostBundle('cursor'); const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); const home = join(parent, 'home');