Skip to content

Commit c2e69f2

Browse files
fix dev host install lifecycle (#677)
1 parent a1cd6ac commit c2e69f2

11 files changed

Lines changed: 459 additions & 56 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
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)

‎docs/diagnostics.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ even when no error diagnostic was reported.
3838
| `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). |
3939
| `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). |
4040
| `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7103`: the development package build; see below). |
41-
| `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"). |
41+
| `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"). |
4242
| `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. |
4343
| `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). |
4444
| `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.
13601360
| --- | --- | --- |
13611361
| `AB7332` | info | `<plugin root>/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. |
13621362

1363+
## Read-only Doctor marketplace sources (`AB7333`)
1364+
1365+
| Code | Severity | Trigger |
1366+
| --- | --- | --- |
1367+
| `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 <host> --from <bundle-dir> --force`, or remove the named marketplace with the host CLI. |
1368+
13631369
## Read-only runtime identity introspection (`AB7317`–`AB7318`)
13641370

13651371
| Code | Severity | Trigger |

‎packages/agent-bundle/src/dev/host-install-manager.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import {
2626
type InstallHost,
2727
type InstallResult,
2828
} from '../install/install.ts';
29+
import {
30+
uninstallBundle as defaultUninstallBundle,
31+
type UninstallBundleOptions,
32+
} from '../install/uninstall.ts';
2933
import { devProxyServerCommand } from './dev-proxy-command.ts';
3034
import {
3135
subscribeToEpochAdoption,
@@ -49,6 +53,7 @@ export interface DevHostInstallManagerOptions {
4953
readonly hosts: readonly InstallHost[];
5054
readonly installBundle?: (options: InstallBundleOptions) => Promise<InstallResult>;
5155
readonly projectRoot: string;
56+
readonly uninstallBundle?: (options: UninstallBundleOptions) => Promise<unknown>;
5257
/** The dev server's session runtime; absent, each program runs on its own `platformLayer`. */
5358
readonly platformRuntime?: DevPlatformRuntime;
5459
}
@@ -156,6 +161,25 @@ const prepareDevBundle = async (
156161
}
157162
};
158163

164+
const stableDevBundle = (projectRoot: string, host: InstallHost): string =>
165+
join(projectRoot, '.agent-bundle', 'dev', host);
166+
167+
const ensureStableDevBundle = async (preparedRoot: string, stableRoot: string): Promise<void> => {
168+
const temporary = `${stableRoot}.stage-${process.pid}-${crypto.randomUUID()}`;
169+
const previous = `${stableRoot}.previous-${process.pid}-${crypto.randomUUID()}`;
170+
try {
171+
await cp(preparedRoot, temporary, { errorOnExist: true, force: false, recursive: true, verbatimSymlinks: true });
172+
if (await pathExists(stableRoot)) await rename(stableRoot, previous);
173+
await rename(temporary, stableRoot);
174+
} catch (error) {
175+
if (!await pathExists(stableRoot) && await pathExists(previous)) await rename(previous, stableRoot);
176+
throw error;
177+
} finally {
178+
await rm(temporary, { force: true, recursive: true });
179+
await rm(previous, { force: true, recursive: true });
180+
}
181+
};
182+
159183
const installedDestination = (
160184
result: InstallResult,
161185
home: string | undefined,
@@ -314,6 +338,7 @@ export class DevHostInstallManager {
314338
readonly #installed = new Map<InstallHost, InstalledDevHost>();
315339
readonly #projectRoot: string;
316340
readonly #run: PlatformRun;
341+
readonly #uninstallBundle: (options: UninstallBundleOptions) => Promise<unknown>;
317342
#closed = false;
318343
#pending: Promise<void> = Promise.resolve();
319344
#subscription: ProjectEventSubscription | undefined;
@@ -328,6 +353,7 @@ export class DevHostInstallManager {
328353
this.#installBundle = options.installBundle ?? defaultInstallBundle;
329354
this.#projectRoot = resolve(options.projectRoot);
330355
this.#run = platformRunOf(options.platformRuntime);
356+
this.#uninstallBundle = options.uninstallBundle ?? defaultUninstallBundle;
331357
}
332358

333359
attached(host: InstallHost): Readonly<{ readonly destination: string; readonly epochId: string }> | undefined {
@@ -407,19 +433,41 @@ export class DevHostInstallManager {
407433
this.#subscription?.unsubscribe();
408434
this.#subscription = undefined;
409435
await this.#pending;
436+
const failures: unknown[] = [];
437+
for (const host of this.#hosts) {
438+
if (host === 'cursor' || !this.#installed.has(host)) continue;
439+
const root = stableDevBundle(this.#projectRoot, host);
440+
try {
441+
await this.#uninstallBundle({
442+
environment: this.#environment,
443+
force: true,
444+
from: root,
445+
...(this.#home === undefined ? {} : { home: this.#home }),
446+
host,
447+
scope: 'user',
448+
});
449+
await rm(root, { force: true, recursive: true });
450+
} catch (error) {
451+
failures.push(error);
452+
}
453+
}
454+
if (failures.length > 0) throw new AggregateError(failures, 'Failed to remove development host installs.');
410455
}
411456

412457
async #syncHost(epochRoot: string, epochId: string, host: InstallHost): Promise<void> {
413458
// Every selected host installs from the composite epoch root (#555).
414459
const prepared = await prepareDevBundle(epochRoot, host, epochId, this.#projectRoot, this.#run);
415460
try {
461+
const source = host === 'cursor' ? prepared.root : stableDevBundle(this.#projectRoot, host);
416462
let installed = this.#installed.get(host);
417463
if (installed === undefined) {
464+
if (host !== 'cursor') await ensureStableDevBundle(prepared.root, source);
418465
const result = await this.#installBundle({
419466
environment: this.#environment,
420-
from: prepared.root,
467+
from: source,
421468
...(this.#home === undefined ? {} : { home: this.#home }),
422469
host,
470+
...(host === 'cursor' ? {} : { replace: true }),
423471
scope: 'user',
424472
});
425473
installed = {

‎packages/agent-bundle/src/install/doctor.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ import { OPERATOR_ENV_FILE_NAMES, parseOperatorEnv } from '../launch-env.ts';
3636
import {
3737
claudePluginRowErrors,
3838
parsePublicHostInventory,
39+
parsePublicHostMarketplaces,
3940
publicHostCacheRoot,
4041
publicHostRoot,
42+
readCodexMarketplaceSource,
4143
treeHash,
4244
type InstallHost,
4345
type PublicHostInstalledEntry,
@@ -1225,6 +1227,48 @@ const readPublicHostListing = async (
12251227
return { status: 'available', stdout: result.stdout };
12261228
};
12271229

1230+
const danglingMarketplaceDiagnostics = async (
1231+
host: Exclude<DoctorHost, 'cursor'>,
1232+
run: DoctorCommandRunner,
1233+
cwd: string,
1234+
hostRoot: string,
1235+
receipts: readonly DoctorReceiptFinding[],
1236+
): Promise<readonly Diagnostic[]> => {
1237+
const owned = new Set(receipts.flatMap((receipt) => receipt.registrations
1238+
.filter((registration) => registration.kind === `${host}-marketplace`)
1239+
.map((registration) => registration.name)
1240+
.filter((name): name is string => name !== undefined)));
1241+
const result = await run(Object.freeze({
1242+
args: Object.freeze(['plugin', 'marketplace', 'list', '--json']),
1243+
cwd,
1244+
executable: host,
1245+
})).catch(() => undefined);
1246+
const listed = result?.exitCode === 0 && result.termination === undefined
1247+
? parsePublicHostMarketplaces(host, result.stdout)
1248+
: undefined;
1249+
const rows = listed ?? (host === 'codex'
1250+
? (await Promise.all([...owned].map(async (name) => {
1251+
const root = await readCodexMarketplaceSource(hostRoot, name);
1252+
return root === undefined ? undefined : Object.freeze({ name, root });
1253+
}))).filter((row) => row !== undefined)
1254+
: undefined);
1255+
if (rows === undefined) return Object.freeze([]);
1256+
const diagnostics: Diagnostic[] = [];
1257+
for (const row of rows) {
1258+
if (!owned.has(row.name)) continue;
1259+
const root = row.root;
1260+
if (root === undefined || await exists(root)) continue;
1261+
diagnostics.push(diagnostic(
1262+
'AB7333',
1263+
`${host} marketplace ${JSON.stringify(row.name)} is owned by an Agent Bundle receipt but its source directory ${JSON.stringify(root)} no longer exists.`,
1264+
`Run \`agent-bundle uninstall ${host} --from <bundle-dir> --force\` to remove the stale registration, or run \`${host} plugin marketplace remove ${row.name}\`.`,
1265+
'error',
1266+
host,
1267+
));
1268+
}
1269+
return freezeDiagnostics(diagnostics);
1270+
};
1271+
12281272
const readWebSurface = async (from: string | undefined): Promise<DoctorWebSurface | undefined> => {
12291273
if (from === undefined) return undefined;
12301274
const read = await readArtifactManifest(from);
@@ -2731,6 +2775,20 @@ const doctorHost = async (
27312775
async (receipt) => receiptRegistrationState(host, receipt, await listingFor(receipt)),
27322776
);
27332777
diagnostics.push(...receipts.diagnostics);
2778+
if (
2779+
host !== 'cursor' &&
2780+
probed.probe.status === 'available' &&
2781+
receipts.receipts.some((receipt) =>
2782+
receipt.registrations.some((registration) => registration.kind === `${host}-marketplace`))
2783+
) {
2784+
diagnostics.push(...await danglingMarketplaceDiagnostics(
2785+
host,
2786+
run,
2787+
listingCwd,
2788+
publicHostRoot(host, environment, home),
2789+
receipts.receipts,
2790+
));
2791+
}
27342792
let bundle: DoctorHostReport['bundle'];
27352793
if (options.from !== undefined) {
27362794
try {

‎packages/agent-bundle/src/install/install.ts‎

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,80 @@ export const publicHostProjectRoot = (
410410
identity: PluginIdentity,
411411
): string | undefined => host === 'claude' && scope !== 'user' ? identity.bundleRoot : undefined;
412412

413+
export interface PublicHostMarketplaceEntry {
414+
readonly name: string;
415+
readonly root?: string;
416+
}
417+
418+
export const parsePublicHostMarketplaces = (
419+
host: Exclude<InstallHost, 'cursor'>,
420+
stdout: string,
421+
): readonly PublicHostMarketplaceEntry[] | undefined => {
422+
let document: unknown;
423+
try {
424+
document = JSON.parse(stdout) as unknown;
425+
} catch {
426+
return undefined;
427+
}
428+
const rows = host === 'claude'
429+
? document
430+
: isRecord(document) ? document['marketplaces'] : undefined;
431+
if (!Array.isArray(rows)) return undefined;
432+
const marketplaces: PublicHostMarketplaceEntry[] = [];
433+
for (const row of rows) {
434+
if (!isRecord(row) || typeof row['name'] !== 'string') continue;
435+
const root = typeof row['root'] === 'string'
436+
? row['root']
437+
: typeof row['path'] === 'string' ? row['path'] : undefined;
438+
marketplaces.push(Object.freeze({
439+
name: row['name'],
440+
...(root === undefined ? {} : { root }),
441+
}));
442+
}
443+
return Object.freeze(marketplaces);
444+
};
445+
446+
export const readCodexMarketplaceSource = async (
447+
codexRoot: string,
448+
marketplace: string,
449+
): Promise<string | undefined> => {
450+
let config: string;
451+
try {
452+
config = await readFile(join(codexRoot, 'config.toml'), 'utf8');
453+
} catch {
454+
return undefined;
455+
}
456+
const headers = new Set([
457+
`[marketplaces.${marketplace}]`,
458+
`[marketplaces.${JSON.stringify(marketplace)}]`,
459+
]);
460+
let selected = false;
461+
let local = false;
462+
let source: string | undefined;
463+
for (const line of config.split(/\r?\n/u)) {
464+
const trimmed = line.trim();
465+
if (trimmed.startsWith('[')) {
466+
if (selected) break;
467+
selected = headers.has(trimmed);
468+
continue;
469+
}
470+
if (!selected) continue;
471+
if (/^source_type\s*=\s*"local"\s*(?:#.*)?$/u.test(trimmed)) {
472+
local = true;
473+
continue;
474+
}
475+
const encoded = /^source\s*=\s*("(?:[^"\\]|\\.)*")\s*(?:#.*)?$/u.exec(trimmed)?.[1];
476+
if (encoded === undefined) continue;
477+
try {
478+
const value = JSON.parse(encoded) as unknown;
479+
source = typeof value === 'string' ? value : undefined;
480+
} catch {
481+
return undefined;
482+
}
483+
}
484+
return local ? source : undefined;
485+
};
486+
413487
/** `<host> plugin marketplace list --json`: whether a marketplace of this name is configured; `unknown` when unusable. */
414488
export const readPublicHostMarketplaceState = async (
415489
runner: InstallCommandRunner,
@@ -425,17 +499,9 @@ export const readPublicHostMarketplaceState = async (
425499
} catch {
426500
return 'unknown';
427501
}
428-
let document: unknown;
429-
try {
430-
document = JSON.parse(stdout) as unknown;
431-
} catch {
432-
return 'unknown';
433-
}
434-
const rows = host === 'claude'
435-
? document
436-
: typeof document === 'object' && document !== null ? (document as { readonly marketplaces?: unknown }).marketplaces : undefined;
437-
if (!Array.isArray(rows)) return 'unknown';
438-
return rows.some((row) => typeof row === 'object' && row !== null && (row as { readonly name?: unknown }).name === marketplace)
502+
const rows = parsePublicHostMarketplaces(host, stdout);
503+
if (rows === undefined) return 'unknown';
504+
return rows.some((row) => row.name === marketplace)
439505
? 'present'
440506
: 'absent';
441507
};

0 commit comments

Comments
 (0)