diff --git a/.changeset/fix-dev-host-lifecycle.md b/.changeset/fix-dev-host-lifecycle.md new file mode 100644 index 000000000..93d617b42 --- /dev/null +++ b/.changeset/fix-dev-host-lifecycle.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Make `dev --install-host` restart idempotently from a stable project path, remove its receipt-owned host registration on exit, report dangling Claude or Codex marketplace sources as `AB7333` in Doctor, and let `uninstall --force` remove them. (#676) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 50bb4773d..c4da1e039 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -38,7 +38,7 @@ even when no error diagnostic was reported. | `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no consumer-runtime evidence requires; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7103`: the development package build; see below). | -| `AB7300`–`AB7332` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), the operator `.env` layer of an installed pack (`AB7331`; see below), and retained pre-#640 state (`AB7332`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | +| `AB7300`–`AB7333` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), the operator `.env` layer of an installed pack (`AB7331`; see below), retained pre-#640 state (`AB7332`; see below), and dangling receipt-owned marketplaces (`AB7333`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | | `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | | `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). | | `AB8215`–`AB8218` | Workbench read-only host discovery route (`/api/discovery`): `AB8215` invalid path, `AB8216` query string or non-`GET` method (400/405), `AB8217` report over the 16 MiB response limit (413), `AB8218` discovery not available (503). | @@ -1360,6 +1360,12 @@ authority. | --- | --- | --- | | `AB7332` | info | `/state` still exists while the installed artifact resolves framework state elsewhere. Move any state that must be retained, or use `uninstall --purge-data --confirm-purge` to remove both roots. | +## Read-only Doctor marketplace sources (`AB7333`) + +| Code | Severity | Trigger | +| --- | --- | --- | +| `AB7333` | error | A Claude or Codex marketplace recorded as Agent Bundle-owned by an install receipt points at a source directory that no longer exists. Run `agent-bundle uninstall --from --force`, or remove the named marketplace with the host CLI. | + ## Read-only runtime identity introspection (`AB7317`–`AB7318`) | Code | Severity | Trigger | diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index 59323f1f4..5c2731660 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -26,6 +26,10 @@ import { type InstallHost, type InstallResult, } from '../install/install.ts'; +import { + uninstallBundle as defaultUninstallBundle, + type UninstallBundleOptions, +} from '../install/uninstall.ts'; import { devProxyServerCommand } from './dev-proxy-command.ts'; import { subscribeToEpochAdoption, @@ -49,6 +53,7 @@ export interface DevHostInstallManagerOptions { readonly hosts: readonly InstallHost[]; readonly installBundle?: (options: InstallBundleOptions) => Promise; readonly projectRoot: string; + readonly uninstallBundle?: (options: UninstallBundleOptions) => Promise; /** The dev server's session runtime; absent, each program runs on its own `platformLayer`. */ readonly platformRuntime?: DevPlatformRuntime; } @@ -156,6 +161,25 @@ const prepareDevBundle = async ( } }; +const stableDevBundle = (projectRoot: string, host: InstallHost): string => + join(projectRoot, '.agent-bundle', 'dev', host); + +const ensureStableDevBundle = async (preparedRoot: string, stableRoot: string): Promise => { + const temporary = `${stableRoot}.stage-${process.pid}-${crypto.randomUUID()}`; + const previous = `${stableRoot}.previous-${process.pid}-${crypto.randomUUID()}`; + try { + await cp(preparedRoot, temporary, { errorOnExist: true, force: false, recursive: true, verbatimSymlinks: true }); + if (await pathExists(stableRoot)) await rename(stableRoot, previous); + await rename(temporary, stableRoot); + } catch (error) { + if (!await pathExists(stableRoot) && await pathExists(previous)) await rename(previous, stableRoot); + throw error; + } finally { + await rm(temporary, { force: true, recursive: true }); + await rm(previous, { force: true, recursive: true }); + } +}; + const installedDestination = ( result: InstallResult, home: string | undefined, @@ -314,6 +338,7 @@ export class DevHostInstallManager { readonly #installed = new Map(); readonly #projectRoot: string; readonly #run: PlatformRun; + readonly #uninstallBundle: (options: UninstallBundleOptions) => Promise; #closed = false; #pending: Promise = Promise.resolve(); #subscription: ProjectEventSubscription | undefined; @@ -328,6 +353,7 @@ export class DevHostInstallManager { this.#installBundle = options.installBundle ?? defaultInstallBundle; this.#projectRoot = resolve(options.projectRoot); this.#run = platformRunOf(options.platformRuntime); + this.#uninstallBundle = options.uninstallBundle ?? defaultUninstallBundle; } attached(host: InstallHost): Readonly<{ readonly destination: string; readonly epochId: string }> | undefined { @@ -407,19 +433,41 @@ export class DevHostInstallManager { this.#subscription?.unsubscribe(); this.#subscription = undefined; await this.#pending; + const failures: unknown[] = []; + for (const host of this.#hosts) { + if (host === 'cursor' || !this.#installed.has(host)) continue; + const root = stableDevBundle(this.#projectRoot, host); + try { + await this.#uninstallBundle({ + environment: this.#environment, + force: true, + from: root, + ...(this.#home === undefined ? {} : { home: this.#home }), + host, + scope: 'user', + }); + await rm(root, { force: true, recursive: true }); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) throw new AggregateError(failures, 'Failed to remove development host installs.'); } async #syncHost(epochRoot: string, epochId: string, host: InstallHost): Promise { // Every selected host installs from the composite epoch root (#555). const prepared = await prepareDevBundle(epochRoot, host, epochId, this.#projectRoot, this.#run); try { + const source = host === 'cursor' ? prepared.root : stableDevBundle(this.#projectRoot, host); let installed = this.#installed.get(host); if (installed === undefined) { + if (host !== 'cursor') await ensureStableDevBundle(prepared.root, source); const result = await this.#installBundle({ environment: this.#environment, - from: prepared.root, + from: source, ...(this.#home === undefined ? {} : { home: this.#home }), host, + ...(host === 'cursor' ? {} : { replace: true }), scope: 'user', }); installed = { diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index de4af1f73..3c63ee167 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -36,8 +36,10 @@ import { OPERATOR_ENV_FILE_NAMES, parseOperatorEnv } from '../launch-env.ts'; import { claudePluginRowErrors, parsePublicHostInventory, + parsePublicHostMarketplaces, publicHostCacheRoot, publicHostRoot, + readCodexMarketplaceSource, treeHash, type InstallHost, type PublicHostInstalledEntry, @@ -1225,6 +1227,48 @@ const readPublicHostListing = async ( return { status: 'available', stdout: result.stdout }; }; +const danglingMarketplaceDiagnostics = async ( + host: Exclude, + run: DoctorCommandRunner, + cwd: string, + hostRoot: string, + receipts: readonly DoctorReceiptFinding[], +): Promise => { + const owned = new Set(receipts.flatMap((receipt) => receipt.registrations + .filter((registration) => registration.kind === `${host}-marketplace`) + .map((registration) => registration.name) + .filter((name): name is string => name !== undefined))); + const result = await run(Object.freeze({ + args: Object.freeze(['plugin', 'marketplace', 'list', '--json']), + cwd, + executable: host, + })).catch(() => undefined); + const listed = result?.exitCode === 0 && result.termination === undefined + ? parsePublicHostMarketplaces(host, result.stdout) + : undefined; + const rows = listed ?? (host === 'codex' + ? (await Promise.all([...owned].map(async (name) => { + const root = await readCodexMarketplaceSource(hostRoot, name); + return root === undefined ? undefined : Object.freeze({ name, root }); + }))).filter((row) => row !== undefined) + : undefined); + if (rows === undefined) return Object.freeze([]); + const diagnostics: Diagnostic[] = []; + for (const row of rows) { + if (!owned.has(row.name)) continue; + const root = row.root; + if (root === undefined || await exists(root)) continue; + diagnostics.push(diagnostic( + 'AB7333', + `${host} marketplace ${JSON.stringify(row.name)} is owned by an Agent Bundle receipt but its source directory ${JSON.stringify(root)} no longer exists.`, + `Run \`agent-bundle uninstall ${host} --from --force\` to remove the stale registration, or run \`${host} plugin marketplace remove ${row.name}\`.`, + 'error', + host, + )); + } + return freezeDiagnostics(diagnostics); +}; + const readWebSurface = async (from: string | undefined): Promise => { if (from === undefined) return undefined; const read = await readArtifactManifest(from); @@ -2731,6 +2775,20 @@ const doctorHost = async ( async (receipt) => receiptRegistrationState(host, receipt, await listingFor(receipt)), ); diagnostics.push(...receipts.diagnostics); + if ( + host !== 'cursor' && + probed.probe.status === 'available' && + receipts.receipts.some((receipt) => + receipt.registrations.some((registration) => registration.kind === `${host}-marketplace`)) + ) { + diagnostics.push(...await danglingMarketplaceDiagnostics( + host, + run, + listingCwd, + publicHostRoot(host, environment, home), + receipts.receipts, + )); + } let bundle: DoctorHostReport['bundle']; if (options.from !== undefined) { try { diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index e31c646fc..dbe6b9e2f 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -410,6 +410,80 @@ export const publicHostProjectRoot = ( identity: PluginIdentity, ): string | undefined => host === 'claude' && scope !== 'user' ? identity.bundleRoot : undefined; +export interface PublicHostMarketplaceEntry { + readonly name: string; + readonly root?: string; +} + +export const parsePublicHostMarketplaces = ( + host: Exclude, + stdout: string, +): readonly PublicHostMarketplaceEntry[] | undefined => { + let document: unknown; + try { + document = JSON.parse(stdout) as unknown; + } catch { + return undefined; + } + const rows = host === 'claude' + ? document + : isRecord(document) ? document['marketplaces'] : undefined; + if (!Array.isArray(rows)) return undefined; + const marketplaces: PublicHostMarketplaceEntry[] = []; + for (const row of rows) { + if (!isRecord(row) || typeof row['name'] !== 'string') continue; + const root = typeof row['root'] === 'string' + ? row['root'] + : typeof row['path'] === 'string' ? row['path'] : undefined; + marketplaces.push(Object.freeze({ + name: row['name'], + ...(root === undefined ? {} : { root }), + })); + } + return Object.freeze(marketplaces); +}; + +export const readCodexMarketplaceSource = async ( + codexRoot: string, + marketplace: string, +): Promise => { + let config: string; + try { + config = await readFile(join(codexRoot, 'config.toml'), 'utf8'); + } catch { + return undefined; + } + const headers = new Set([ + `[marketplaces.${marketplace}]`, + `[marketplaces.${JSON.stringify(marketplace)}]`, + ]); + let selected = false; + let local = false; + let source: string | undefined; + for (const line of config.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (trimmed.startsWith('[')) { + if (selected) break; + selected = headers.has(trimmed); + continue; + } + if (!selected) continue; + if (/^source_type\s*=\s*"local"\s*(?:#.*)?$/u.test(trimmed)) { + local = true; + continue; + } + const encoded = /^source\s*=\s*("(?:[^"\\]|\\.)*")\s*(?:#.*)?$/u.exec(trimmed)?.[1]; + if (encoded === undefined) continue; + try { + const value = JSON.parse(encoded) as unknown; + source = typeof value === 'string' ? value : undefined; + } catch { + return undefined; + } + } + return local ? source : undefined; +}; + /** ` plugin marketplace list --json`: whether a marketplace of this name is configured; `unknown` when unusable. */ export const readPublicHostMarketplaceState = async ( runner: InstallCommandRunner, @@ -425,17 +499,9 @@ export const readPublicHostMarketplaceState = async ( } catch { return 'unknown'; } - let document: unknown; - try { - document = JSON.parse(stdout) as unknown; - } catch { - return 'unknown'; - } - const rows = host === 'claude' - ? document - : typeof document === 'object' && document !== null ? (document as { readonly marketplaces?: unknown }).marketplaces : undefined; - if (!Array.isArray(rows)) return 'unknown'; - return rows.some((row) => typeof row === 'object' && row !== null && (row as { readonly name?: unknown }).name === marketplace) + const rows = parsePublicHostMarketplaces(host, stdout); + if (rows === undefined) return 'unknown'; + return rows.some((row) => row.name === marketplace) ? 'present' : 'absent'; }; diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 6b3727973..7b476a124 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -21,6 +21,8 @@ import { publicHostRegistrations, publicHostRoot, publicHostUninstallArguments, + parsePublicHostMarketplaces, + readCodexMarketplaceSource, readInstalledManifest, readPublicHostInventory, readPublicHostMarketplaceState, @@ -1280,14 +1282,28 @@ const uninstallPublicCli = async ( version: identity.version, } as const; const inventory = await readPublicHostInventory(runner, identity, host, scope, environment, home); - if (inventory.status === 'unavailable') { + const marketplaceOwned = receipt !== undefined && + receipt.registrations.some((registration) => registration.kind === `${host}-marketplace`); + let forcedDanglingMarketplace = false; + if (inventory.status === 'unavailable' && force && marketplaceOwned) { + try { + const result = await runner.run(host, ['plugin', 'marketplace', 'list', '--json'], { cwd: identity.bundleRoot }); + const registered = result.code === 0 ? parsePublicHostMarketplaces(host, result.stdout) : undefined; + const root = registered?.find((entry) => entry.name === marketplace)?.root ?? + (host === 'codex' ? await readCodexMarketplaceSource(hostRoot, marketplace) : undefined); + forcedDanglingMarketplace = root !== undefined && !await exists(root); + } catch { + forcedDanglingMarketplace = false; + } + } + if (inventory.status === 'unavailable' && !forcedDanglingMarketplace) { throw failure( 'AB7004', `Cannot uninstall ${id} from ${host} safely: \`${host} plugin list --json\` was unusable (${inventory.detail}).`, host, ); } - const entry = inventory.entries[0]; + const entry = inventory.status === 'available' ? inventory.entries[0] : undefined; if (entry === undefined && receipt === undefined) { return Object.freeze({ ...base, @@ -1358,12 +1374,14 @@ const uninstallPublicCli = async ( // The marketplace is Agent Bundle's to remove only when a receipt records that an install registered it. // Without that record (no receipt, or the marketplace pre-existed the install) it is retained and said so. const marketplaceRegistration = defaults.find((registration) => registration.kind === `${host}-marketplace`); - const marketplaceOwned = receipt !== undefined && - receipt.registrations.some((registration) => registration.kind === `${host}-marketplace`); - const marketplaceState = await readPublicHostMarketplaceState(runner, identity, host, marketplace); + const marketplaceState = forcedDanglingMarketplace + ? 'present' + : await readPublicHostMarketplaceState(runner, identity, host, marketplace); // Dependents decide both whether the marketplace goes and whether Claude's scope-less durable state may be // purged, so they are read whenever either decision is live — and always before any mutation. - const dependents = marketplaceState !== 'absent' || (host === 'claude' && policy === 'purge') + const dependents = forcedDanglingMarketplace + ? Object.freeze({ others: Object.freeze([]), receipts: Object.freeze([]), sameOtherScopes: Object.freeze([]) }) + : marketplaceState !== 'absent' || (host === 'claude' && policy === 'purge') ? await marketplaceDependents(runner, identity, host, marketplace, id, scope, publicHostProjectRoot(host, scope, identity), hostRoot, receiptPath) : Object.freeze({ others: Object.freeze([]), receipts: Object.freeze([]), sameOtherScopes: Object.freeze([]) }); const dependentNames = dependents === 'unknown' ? 'unknown' : [...dependents.others, ...dependents.sameOtherScopes]; @@ -1402,8 +1420,8 @@ const uninstallPublicCli = async ( if (pluginRegistration !== undefined) { registrations.push(Object.freeze({ ...pluginRegistration, - action: entry === undefined ? 'already-absent' : planned ? 'planned' : 'removed', - detail: entry === undefined + action: entry === undefined && !forcedDanglingMarketplace ? 'already-absent' : planned ? 'planned' : 'removed', + detail: entry === undefined && !forcedDanglingMarketplace ? `${host} no longer lists ${id}${host === 'claude' ? ` at scope ${scope}` : ''}.` : `\`${host} ${publicHostUninstallArguments(host, id, scope).join(' ')}\``, })); @@ -1468,7 +1486,7 @@ const uninstallPublicCli = async ( state: 'planned', }); } - if (entry !== undefined) { + if (entry !== undefined || forcedDanglingMarketplace) { await runHostCommand(runner, identity, host, publicHostUninstallArguments(host, id, scope), 'removal'); } if (marketplaceState !== 'absent' && !retainMarketplace) { diff --git a/packages/agent-bundle/tests/dev-host-install.test.ts b/packages/agent-bundle/tests/dev-host-install.test.ts index 4666b162f..919d86a78 100644 --- a/packages/agent-bundle/tests/dev-host-install.test.ts +++ b/packages/agent-bundle/tests/dev-host-install.test.ts @@ -16,6 +16,7 @@ import { EpochStore } from '../src/dev/epoch-store.ts'; import { ProjectService } from '../src/dev/project-service.ts'; import type { ArtifactEpoch, FailedBuildAttempt } from '../src/dev/types.ts'; import type { InstallBundleOptions, InstallResult } from '../src/install/install.ts'; +import type { UninstallBundleOptions, UninstallResult } from '../src/install/uninstall.ts'; import { buildHostInstallFixture, disposeHostInstallFixture, @@ -122,7 +123,7 @@ it('defines the stage-1 proxy command shape with an absolute framework CLI entry }); }); -it('installs a marked Cursor dev variant and atomically re-points top-level directories on epoch swap', async () => { +it('installs a marked public-host dev variant from a stable source and removes it on teardown and restart', async () => { const root = await createRoot(); const home = join(root, 'home'); const projectRoot = join(root, 'project'); @@ -146,20 +147,44 @@ it('installs a marked Cursor dev variant and atomically re-points top-level dire ['epoch-3', thirdEpochRoot], ]); const installs: InstallBundleOptions[] = []; + const installedEpochs: string[] = []; + const uninstalls: UninstallBundleOptions[] = []; const syncEvents: unknown[] = []; const installBundle = async (options: InstallBundleOptions): Promise => { installs.push(options); + installedEpochs.push(JSON.parse(await readFile(join(options.from, DEV_INSTALL_MARKER), 'utf8')).epochId as string); await mkdir(join(destination, '..'), { recursive: true }); await cp(options.from, destination, { recursive: true }); return { bundleRoot: options.from, destination, - host: 'cursor', + host: 'codex', plugin: 'dev-proof', state: 'installed', version: '1.0.0', }; }; + const uninstallBundle = async (options: UninstallBundleOptions): Promise => { + uninstalls.push(options); + await rm(destination, { force: true, recursive: true }); + return { + bundleRoot: options.from, + data: { detail: 'test', outcome: 'kept', paths: [], policy: 'keep' }, + destination, + forced: options.force === true, + host: 'codex', + marketplace: 'dev-proof-marketplace', + mode: 'host-cli', + plugin: 'dev-proof', + receipt: { path: join(destination, '.agent-bundle-install.json'), status: 'consumed' }, + registrations: [], + removed: { directories: [destination], files: [] }, + retained: [], + scope: 'user', + state: 'uninstalled', + version: '1.0.0', + }; + }; const eventHub = new ProjectEventHub(); eventHub.subscribe((event) => { if (event.type === 'dev.host.sync') syncEvents.push(event.payload); @@ -174,9 +199,10 @@ it('installs a marked Cursor dev variant and atomically re-points top-level dire }, eventHub, home, - hosts: ['cursor'], + hosts: ['codex'], installBundle, projectRoot, + uninstallBundle, }); manager.start(); @@ -189,18 +215,17 @@ it('installs a marked Cursor dev variant and atomically re-points top-level dire expect(syncEvents.at(-1)).toMatchObject({ epochId: 'epoch-1', state: 'succeeded' }); expect(installs).toHaveLength(1); + expect(installs[0]).toMatchObject({ + from: join(projectRoot, '.agent-bundle', 'dev', 'codex'), + replace: true, + }); expect(JSON.parse(await readFile(join(destination, DEV_INSTALL_MARKER), 'utf8'))).toMatchObject({ epochId: 'epoch-1', - host: 'cursor', + host: 'codex', projectRoot, schemaVersion: 1, }); const mcpBefore = await readFile(join(destination, '.cursor-plugin', 'mcp.json'), 'utf8'); - expect(JSON.parse(mcpBefore)).toEqual({ - mcpServers: { - probe: await devProxyServerCommand(projectRoot, 'probe', 'cursor'), - }, - }); expect((await lstat(join(destination, 'skills'))).isSymbolicLink()).toBe(true); expect((await lstat(join(destination, 'hooks'))).isSymbolicLink()).toBe(true); @@ -242,7 +267,47 @@ it('installs a marked Cursor dev variant and atomically re-points top-level dire expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toBe('third skill\n'); await manager.close(); - await expect(readFile(join(destination, DEV_INSTALL_MARKER), 'utf8')).resolves.toContain('"epochId":"epoch-3"'); + expect(uninstalls).toEqual([expect.objectContaining({ + force: true, + from: join(projectRoot, '.agent-bundle', 'dev', 'codex'), + host: 'codex', + scope: 'user', + })]); + await expect(readFile(join(destination, DEV_INSTALL_MARKER), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readFile(join(projectRoot, '.agent-bundle', 'dev', 'codex', DEV_INSTALL_MARKER), 'utf8')) + .rejects.toMatchObject({ code: 'ENOENT' }); + + const staleSource = join(projectRoot, '.agent-bundle', 'dev', 'codex'); + await cp(firstEpochRoot, staleSource, { recursive: true }); + await writeFile( + join(staleSource, DEV_INSTALL_MARKER), + JSON.stringify({ epochId: 'stale', host: 'codex', projectRoot, schemaVersion: 1 }), + 'utf8', + ); + const restarted = new DevHostInstallManager({ + epochStore: { + acquireEpochReference: async () => ({ + close: async () => undefined, + epoch: epoch(projectRoot, 'epoch-3'), + root: thirdEpochRoot, + }), + }, + eventHub, + home, + hosts: ['codex'], + installBundle, + projectRoot, + uninstallBundle, + }); + restarted.sync('epoch-3'); + await restarted.settled(); + await restarted.close(); + expect(installs).toHaveLength(2); + expect(new Set(installs.map((install) => install.from))).toEqual(new Set([ + join(projectRoot, '.agent-bundle', 'dev', 'codex'), + ])); + expect(installedEpochs).toEqual(['epoch-1', 'epoch-3']); + expect(uninstalls).toHaveLength(2); }); it('publishes a diagnostic event and preserves the installed generation when re-sync fails', async () => { @@ -427,7 +492,7 @@ codexIt( ? 'installs a Codex dev variant and re-syncs its host-owned cache without another CLI call' : 'installs a Codex dev variant and re-syncs its host-owned cache [missing evidence: codex binary unavailable on PATH]', async () => { - await expect(runDevHostInstallProof(builtFixture(), 'codex', { environment: process.env })).resolves.toEqual({ + expect(await runDevHostInstallProof(builtFixture(), 'codex', { environment: process.env })).toEqual({ hookChanged: true, host: 'codex', marker: expect.objectContaining({ epochId: 'epoch-2', host: 'codex', schemaVersion: 1 }), diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 487102168..2a1d0a571 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -2336,6 +2336,96 @@ it('inventories store receipts, diagnoses orphaned ones (AB7328), and reports pr } }); +it('reports a receipt-owned Codex marketplace whose source directory is gone', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(join(fixture.root, 'source'), 'codex'); + const codexHome = join(fixture.root, 'codex'); + await installBundle({ + commandRunner: { + run: async (_command, args) => ({ + code: 0, + stderr: '', + stdout: args.join(' ') === 'plugin list --json' + ? JSON.stringify({ installed: [] }) + : args.join(' ') === 'plugin marketplace list --json' + ? JSON.stringify({ marketplaces: [] }) + : '', + }), + }, + environment: { CODEX_HOME: codexHome }, + from: bundle, + home: fixture.home, + host: 'codex', + }); + await writeFile( + join(codexHome, 'config.toml'), + `[marketplaces.doctor-fixture-marketplace]\nsource_type = "local"\nsource = ${JSON.stringify(bundle)}\n`, + 'utf8', + ); + await rm(bundle, { force: true, recursive: true }); + + const report = await runDoctor({ + commandRunner: async (request) => { + const command = request.args.join(' '); + if (command === '--version') return commandResult({ stdout: 'codex 0.147.0' }); + if (command === 'plugin marketplace list --json') { + return commandResult({ exitCode: 1, stderr: 'failed to load marketplace(s)' }); + } + return commandResult({ exitCode: 1, stderr: 'configured marketplace source is missing' }); + }, + endpointDirectory: fixture.endpointDirectory, + environment: { CODEX_HOME: codexHome }, + home: fixture.home, + hosts: ['codex'], + }); + + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7333', + message: expect.stringContaining(bundle), + severity: 'error', + target: 'codex', + }), + ])); + + const repairBundle = await createBundle(join(fixture.root, 'repair'), 'codex'); + const commands: string[] = []; + await expect(uninstallBundle({ + commandRunner: { + run: async (_command, args) => { + const command = args.join(' '); + commands.push(command); + if (command === 'plugin list --json') { + return { code: 1, stderr: 'configured marketplace source is missing', stdout: '' }; + } + if (command === 'plugin marketplace list --json') { + return { + code: 1, + stderr: 'failed to load marketplace(s)', + stdout: '', + }; + } + return { code: 0, stderr: '', stdout: '' }; + }, + }, + environment: { CODEX_HOME: codexHome }, + force: true, + from: repairBundle, + home: fixture.home, + host: 'codex', + })).resolves.toMatchObject({ state: 'uninstalled' }); + expect(commands).toEqual([ + 'plugin list --json', + 'plugin marketplace list --json', + 'plugin remove doctor-fixture@doctor-fixture-marketplace', + 'plugin marketplace remove doctor-fixture-marketplace', + ]); + } finally { + await fixture.cleanup(); + } +}); + it('cross-checks Claude project-scope receipts from their recorded project root, not the doctor cwd', async () => { const fixture = await temporaryDoctor(); try { diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 6a3b8b4fd..e26fe4bf0 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -42,6 +42,7 @@ import { startDevServer } from '../../src/dev/workbench-server.ts'; import { runDoctor, type DoctorCommandRunner } from '../../src/install/doctor.ts'; import { installBundle, type InstallHost } from '../../src/install/install.ts'; import { manifestInventory, readInstallReceipt } from '../../src/install/receipt.ts'; +import { uninstallBundle } from '../../src/install/uninstall.ts'; import { normalClaudeSettingsAndPluginsUnchanged, packedNativeEnvironment, @@ -721,8 +722,23 @@ export const runDevHostInstallProof = async ( }); const roots = new Map([['epoch-1', fixture.artifactRoot], ['epoch-2', epoch2Root]]); const eventHub = new ProjectEventHub(); + const syncFailures: unknown[] = []; + eventHub.subscribe((event) => { + if (event.type === 'dev.host.sync' && event.payload.state === 'failed') syncFailures.push(event.payload); + }); let hostCommandCalls = 0; - const manager = new DevHostInstallManager({ + const commandRunner = { + run: async (command: string, args: readonly string[], commandOptions: { readonly cwd: string }) => { + hostCommandCalls += 1; + const result = await run(command, args, { + cwd: commandOptions.cwd, + environment, + timeout: 180_000, + }); + return { code: result.exitCode, stderr: result.stderr, stdout: result.stdout }; + }, + }; + const createManager = (): DevHostInstallManager => new DevHostInstallManager({ environment, epochStore: { acquireEpochReference: async (epochId) => { @@ -734,22 +750,35 @@ export const runDevHostInstallProof = async ( eventHub, home, hosts: [host], - installBundle: async (installOptions) => installBundle({ - ...installOptions, - commandRunner: { - run: async (command, args, commandOptions) => { - hostCommandCalls += 1; - const result = await run(command, args, { - cwd: commandOptions.cwd, - environment, - timeout: 180_000, - }); - return { code: result.exitCode, stderr: result.stderr, stdout: result.stdout }; - }, - }, - }), + installBundle: async (installOptions) => installBundle({ ...installOptions, commandRunner }), projectRoot: fixture.root, + uninstallBundle: async (uninstallOptions) => uninstallBundle({ ...uninstallOptions, commandRunner }), }); + const manager = createManager(); + let restarted: DevHostInstallManager | undefined; + const assertHostHealthyAfterTeardown = async (): Promise => { + if (host === 'cursor') return; + const listing = await run(host, ['plugin', 'list', '--json'], { + cwd: fixture.root, + environment, + timeout: 30_000, + }); + assertProof(listing.exitCode === 0, `${host} plugin listing failed after dev teardown: ${commandDetail(listing)}`); + const marketplaces = await run(host, ['plugin', 'marketplace', 'list', '--json'], { + cwd: fixture.root, + environment, + timeout: 30_000, + }); + assertProof(marketplaces.exitCode === 0, `${host} marketplace listing failed after dev teardown: ${commandDetail(marketplaces)}`); + const document = parseJson(marketplaces.stdout, `${host} marketplace listing`); + const rows = host === 'claude' ? document : record(document)?.marketplaces; + assertProof(Array.isArray(rows), `${host} marketplace listing did not contain an array.`); + for (const row of rows) { + const fields = record(row); + const root = fields?.root ?? fields?.path; + if (typeof root === 'string') await access(root); + } + }; const marketplaceRoot = host === 'claude' ? claudeConfig : codexHome; const destination = host === 'cursor' ? join(home, '.cursor', 'plugins', 'local', plugin) @@ -804,6 +833,20 @@ export const runDevHostInstallProof = async ( await readFile(join(destination, DEV_INSTALL_MARKER), 'utf8'), `${host} dev marker`, ); + await manager.close(); + await assertHostHealthyAfterTeardown(); + + if (host !== 'cursor') { + restarted = createManager(); + restarted.sync(first.id); + await restarted.settled(); + assertProof( + restarted.attached(host)?.epochId === first.id, + `${host} dev install did not attach after restart: ${JSON.stringify(syncFailures.at(-1))}`, + ); + await restarted.close(); + await assertHostHealthyAfterTeardown(); + } return Object.freeze({ hookChanged: true, host, @@ -817,6 +860,7 @@ export const runDevHostInstallProof = async ( status: 'passed', }); } finally { + await restarted?.close(); await manager.close(); await rm(root, { force: true, recursive: true }); } diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index e3b3d72e6..92eeeead1 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -427,8 +427,8 @@ the selected hosts: npx agent-bundle dev --install-host cursor --install-host claude ``` -The first successful epoch uses the ordinary host installer, so Claude and Codex register the -plugin normally and read its files from their host-owned +The first successful epoch uses the ordinary host installer. Claude and Codex register from the +stable, receipt-owned `/.agent-bundle/dev/` source and read the installed files from their host-owned `plugins/cache///` directory; Cursor reads `~/.cursor/plugins/local/`. The installed root carries an `.agent-bundle-dev.json` with schema version `1`, the project root, the host, and the installed epoch. @@ -452,9 +452,10 @@ back to the prior generation and emits an `AB7202` diagnostic on `dev.host.sync` emits no `artifact.available` at all, so the last-good install is untouched. Re-sync writes the host cache directly and does not invoke the Claude or Codex CLI again. -Stopping the dev server leaves the marked development install in place. Hooks and Skills remain -on disk, while the stable proxy command fails closed until that project's dev server is running -again. +Stopping the dev server unregisters and removes its Claude and Codex development installs. A +later run registers the same stable source path again, so restart does not leave duplicate +marketplaces. If a receipt-owned marketplace source is removed without teardown, Doctor reports +`AB7333`; `uninstall --from --force` removes the dangling registration. ## Live host MCP proxy diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index 31857fe95..43b94d2fa 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -361,7 +361,8 @@ export const serve = async (): Promise => { npx agent-bundle dev --install-host cursor --install-host claude ``` -第一个成功的 epoch 使用普通的宿主安装器,因此 Claude 与 Codex 会正常注册插件,并从宿主自有的 +第一个成功的 epoch 使用普通的宿主安装器。Claude 与 Codex 从稳定、由收据归属的 +`/.agent-bundle/dev/` 源注册插件,并从宿主自有的 `plugins/cache///` 目录读取它的文件;Cursor 读取 `~/.cursor/plugins/local/`。被安装的根目录带有一份 `.agent-bundle-dev.json`,其中记录 schema 版本 `1`、项目根目录、宿主以及已安装的 epoch。 @@ -382,8 +383,9 @@ agent-bundle dev proxy --root --server --target --from --force` 会移除这条悬空注册。 ## 实时宿主 MCP 代理