Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-dev-host-lifecycle.md
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 7 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -1360,6 +1360,12 @@ authority.
| --- | --- | --- |
| `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. |

## 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 <host> --from <bundle-dir> --force`, or remove the named marketplace with the host CLI. |

## Read-only runtime identity introspection (`AB7317`–`AB7318`)

| Code | Severity | Trigger |
Expand Down
50 changes: 49 additions & 1 deletion packages/agent-bundle/src/dev/host-install-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -49,6 +53,7 @@ export interface DevHostInstallManagerOptions {
readonly hosts: readonly InstallHost[];
readonly installBundle?: (options: InstallBundleOptions) => Promise<InstallResult>;
readonly projectRoot: string;
readonly uninstallBundle?: (options: UninstallBundleOptions) => Promise<unknown>;
/** The dev server's session runtime; absent, each program runs on its own `platformLayer`. */
readonly platformRuntime?: DevPlatformRuntime;
}
Expand Down Expand Up @@ -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<void> => {
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,
Expand Down Expand Up @@ -314,6 +338,7 @@ export class DevHostInstallManager {
readonly #installed = new Map<InstallHost, InstalledDevHost>();
readonly #projectRoot: string;
readonly #run: PlatformRun;
readonly #uninstallBundle: (options: UninstallBundleOptions) => Promise<unknown>;
#closed = false;
#pending: Promise<void> = Promise.resolve();
#subscription: ProjectEventSubscription | undefined;
Expand All @@ -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 {
Expand Down Expand Up @@ -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<void> {
// 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 = {
Expand Down
58 changes: 58 additions & 0 deletions packages/agent-bundle/src/install/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1225,6 +1227,48 @@ const readPublicHostListing = async (
return { status: 'available', stdout: result.stdout };
};

const danglingMarketplaceDiagnostics = async (
host: Exclude<DoctorHost, 'cursor'>,
run: DoctorCommandRunner,
cwd: string,
hostRoot: string,
receipts: readonly DoctorReceiptFinding[],
): Promise<readonly Diagnostic[]> => {
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 <bundle-dir> --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<DoctorWebSurface | undefined> => {
if (from === undefined) return undefined;
const read = await readArtifactManifest(from);
Expand Down Expand Up @@ -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 {
Expand Down
88 changes: 77 additions & 11 deletions packages/agent-bundle/src/install/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InstallHost, 'cursor'>,
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<string | undefined> => {
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;
};

/** `<host> plugin marketplace list --json`: whether a marketplace of this name is configured; `unknown` when unusable. */
export const readPublicHostMarketplaceState = async (
runner: InstallCommandRunner,
Expand All @@ -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';
};
Expand Down
Loading
Loading