From d77e7ae1fde04abba4abb66ff9aa60aad62c378f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:35:16 +0000 Subject: [PATCH 1/3] fix(cloud-connection): an install-local uninstall withdraws the package from the running kernel The install-local DELETE removed the ledger entry and ran the protocol's uninstall cleanups, but left the package registered in the running kernel until the next restart. Every reader of "registered packages" kept counting it, and one of them re-created a ghost grant: another package's hot install announces metadata:reloaded, the declared-permission seeding re-runs over every registered package, and the uninstalled package's permission set came back as a package-managed row that outlived the restart as an orphan. The DELETE now withdraws the package through SchemaRegistry.uninstallPackage, the one verb the protocol's own uninstall uses, on the objectql engine's registry, right after the ledger removal and before the cleanups. A refused withdrawal is reported on the response as a failed outcome in `cleanups`, with the cause and remedy in the operator log. The response note no longer says the kernel cannot unregister in place. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-Authored-By: Claude --- .../src/marketplace-install-local-plugin.ts | 124 ++++++++++++++++-- 1 file changed, 111 insertions(+), 13 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index f9929f2f700..7667b862050 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -36,12 +36,13 @@ * outright (#8976). * * DELETE /api/v1/marketplace/install-local/:manifestId - * → removes the cached manifest, then runs the uninstall cleanups - * domain plugins registered with the protocol (#21490) — the - * package's permission sets and their grants go with it — and - * reports each outcome as `cleanups`. Kernel must be restarted to - * fully unload — `engine.registerApp` is additive only. We - * document this in the response message. + * → removes the cached manifest, withdraws the package from the + * running kernel through the registry's `uninstallPackage` — the + * verb the protocol's own uninstall uses — so its objects answer + * 404 at once (#21576), then runs the uninstall cleanups domain + * plugins registered with the protocol (#21490) — the package's + * permission sets and their grants go with it — and reports each + * outcome as `cleanups`, a refused withdrawal included. * * Persistence layout: * /.objectstack/installed-packages/.json @@ -120,6 +121,13 @@ type UninstallCleanupRunner = { /** The outcome name this door reports when the runner itself could not run. */ const UNINSTALL_CLEANUP_RUNNER = 'protocol.runUninstallCleanups'; +/** + * [#21576] The outcome name this door reports when the running kernel did not + * withdraw the uninstalled package — named for the registry verb, as the one + * above is named for the protocol's. + */ +const REGISTRY_WITHDRAWAL = 'registry.uninstallPackage'; + /** * [#8976] The capability every MUTATING install-local route demands. * @@ -1122,22 +1130,112 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } catch (err: any) { return c.json({ success: false, error: { code: 'MARKETPLACE_STORAGE_FAILED', message: err?.message ?? String(err) } }, 500); } - // [#21490] Only now — the ledger entry is gone, so the package will not - // come back at the next restart — revoke what its metadata granted. - // Never before: an uninstall whose ledger write failed above leaves the - // package installed, and it must keep its grants. - const cleanups = await this.runUninstallCleanups(ctx, manifestId, admission.userId); - ctx.logger?.info?.(`[MarketplaceInstallLocal] uninstalled ${manifestId} (cached manifest removed; ${cleanups.length} uninstall cleanup(s) ran; restart runtime to unload from running kernel)`); + // [#21576] Only now — the ledger entry is gone, so the package will not + // come back at the next restart — withdraw it from the running kernel, + // and then [#21490] revoke what its metadata granted. Never before: an + // uninstall whose ledger write failed above leaves the package + // installed, and it must stay registered and keep its grants. + // + // The withdrawal goes FIRST, with no `await` between it and the ledger + // removal, for the same reason `deletePackage` withdraws before it runs + // the cleanups: from the moment the uninstall is durable, nothing that + // reads "registered packages" can see this one again. The cleanups + // await the store row by row; were the package still registered while + // they ran, another request's hot install announcing + // `metadata:reloaded` in that window would re-project the very sets + // they had just selected and removed. The cleanups lose nothing by + // going second: `deletePackage` already runs every registered cleanup + // after its withdrawal, and the one registered today + // (`security.package-permissions`) selects by package id in the store, + // never through the registry. + const withdrawal = this.withdrawFromRunningKernel(ctx, manifestId); + const cleanups = [ + ...(withdrawal ? [withdrawal] : []), + ...await this.runUninstallCleanups(ctx, manifestId, admission.userId), + ]; + ctx.logger?.info?.( + `[MarketplaceInstallLocal] uninstalled ${manifestId} (cached manifest removed; ` + + (withdrawal + ? 'the running kernel did NOT withdraw it, so it stays loaded until the next restart; ' + : 'withdrawn from the running kernel; ') + + `${cleanups.length - (withdrawal ? 1 : 0)} uninstall cleanup(s) ran)`, + ); return c.json({ success: true, data: { manifestId, cleanups, - note: 'Cached manifest removed, and the uninstall cleanups this runtime\'s plugins registered ran — each one\'s outcome is in `cleanups`. The app remains loaded in the running kernel until the next restart (the kernel API does not support unregistering apps in-place).', + note: withdrawal + ? 'Cached manifest removed, and the uninstall cleanups this runtime\'s plugins registered ran — each one\'s outcome is in `cleanups`. The running kernel did not withdraw the package, so it stays loaded until the next restart; that is the `registry.uninstallPackage` entry in `cleanups`.' + : 'Cached manifest removed, the package withdrawn from the running kernel, and the uninstall cleanups this runtime\'s plugins registered ran — each one\'s outcome is in `cleanups`.', }, }, 200); }; + /** + * [#21576] Withdraw a package this door just removed from its ledger from + * the running kernel — through `SchemaRegistry.uninstallPackage`, the ONE + * verb the protocol's own uninstall (`deletePackage`) withdraws a package + * with, on the same registry: the `objectql` engine's, which is the engine + * the protocol is assembled over and the registry this door's install + * registers into (the `manifest` service's `registerApp`). + * + * Before this, the door left the package registered until the next + * restart, and every reader of "registered packages" kept answering as if + * it were installed. One of them re-created a ghost grant: another + * package's hot install announces `metadata:reloaded`, `plugin-security` + * re-runs its declared-permission seeding over every registered package, + * and the uninstalled package's permission set came back as a fresh + * `managed_by: package` row that outlived the restart as an orphan + * (ADR-0090: "No ghost grants"). Withdrawing the registration — not + * teaching that one reader to skip it — makes every such reader right. + * + * What the verb withdraws, all of it re-added by a later install of the + * same id (`registerApp`): the package's object contributions (so its + * objects answer 404 at once instead of after a restart), its namespace, + * every metadata item it shipped, its boot disable seed, and its package + * record. Its tables and rows are untouched. + * + * Never throws: the uninstall has already happened. Two answers: + * - `undefined` — withdrawn, or nothing to withdraw: no `objectql` + * engine, or a registry that does not hold the package (a cloud + * install whose hot-register failed, a rehydrate that failed); + * - one failed outcome named {@link REGISTRY_WITHDRAWAL}, when the + * registry refused (ADR-0029: another package extends an object this + * one owns) or could not be asked. It rides on `cleanups`, the way a + * failed cleanup does, and the operator log says what it costs and + * the remedy; the cause is logged, never put on the wire. + */ + private withdrawFromRunningKernel = ( + ctx: PluginContext, + manifestId: string, + ): UninstallCleanupOutcome | undefined => { + let ql: IObjectQLEngine | undefined; + try { ql = ctx.getService('objectql'); } catch { /* no data engine — nothing registered */ } + const registry = ql?.registry; + if (!registry) return undefined; + try { + if (registry.getPackage(manifestId) === undefined) return undefined; + registry.uninstallPackage(manifestId); + return undefined; + } catch (err: any) { + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] uninstalled ${manifestId}, but the running kernel did not withdraw it ` + + `(${err?.message ?? err}) — it stays registered until the next restart: its objects keep answering, ` + + 'and every reader of the registered packages still counts it, so a later hot install can re-project ' + + 'its permission sets. The outcome is on the response (`cleanups`). Remedy: remove what blocks the ' + + 'withdrawal (the cause in parentheses names it) and restart the runtime — the ledger entry is ' + + 'already gone, so the restart does not bring the package back.', + ); + return { + name: REGISTRY_WITHDRAWAL, + success: false, + removed: 0, + error: 'the running kernel did not withdraw the package — it stays loaded until the next restart', + }; + } + }; + /** * [#21490] Run the protocol's registered uninstall cleanups for a package * this door just removed from its ledger — ADR-0086 D3's data-plane From 4bf2540cf7d8a18b24c070c18cb7061c1cb5ab92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:36:59 +0000 Subject: [PATCH 2/3] test(cli): promote the re-seed window's two readings and pin the hot withdrawal and its reinstall control Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-Authored-By: Claude --- ...cal-uninstall-cleanups.integration.test.ts | 115 ++++++++++++++---- 1 file changed, 92 insertions(+), 23 deletions(-) diff --git a/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts b/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts index 72bd1cc16ef..35b9f6f6c50 100644 --- a/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts +++ b/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts @@ -25,14 +25,20 @@ * ## What each `it` reads * * One fixture, two orders of events, each probed through the data route a user - * and an admin use: the set by name, the user grant of it by set id, and — after - * the restart — the package's object. The grant is made through the data door - * before the uninstall, so "no binding" is read off a row that existed, not off - * an empty table. + * and an admin use: the set by name, the user grant of it by set id, and the + * package's object — right after the DELETE and after the restart. The grant is + * made through the data door before the uninstall, so "no binding" is read off a + * row that existed, not off an empty table. * * A third order of events measures the re-seed window — DELETE, then a hot - * install of ANOTHER package, then restart — and records, as `it.fails`, the - * defect it found there; the block above that `describe` says what it is. + * install of ANOTHER package, then restart. #21576: the DELETE now withdraws the + * package from the running kernel (`SchemaRegistry.uninstallPackage`, the verb + * the protocol's own uninstall uses), so nothing re-projects its set there; the + * block above that `describe` says what used to happen. + * + * A fourth order is the withdrawal's control: a hot reinstall of the SAME + * package in the same process, right after its DELETE, registers it again — + * whatever the withdrawal took, the install path puts back. * * ## Spawn shape * @@ -261,6 +267,10 @@ interface Run { uninstall?: Answer; /** Same process, right after the DELETE answered. */ after?: Grants; + /** The package's object right before the DELETE — it answered, so a later 404 is the DELETE's. */ + objectBefore?: Answer; + /** #21576: the package's object in the same process, right after the DELETE answered. */ + objectAfter?: Answer; /** A restart on the same home. */ restarted?: Grants; /** The package's object after the restart — the uninstall's own effect, as the control. */ @@ -269,6 +279,10 @@ interface Run { otherInstall?: { exit: number | null; output: string }; /** Re-seed order only: same process, right after that second install. */ afterOtherInstall?: Grants; + /** Reinstall order only: the same package installed again, in the same process, after its DELETE. */ + reinstall?: { exit: number | null; output: string }; + /** Reinstall order only: the object and the set right after that reinstall. */ + afterReinstall?: { object: Answer; sets: Answer }; } /** @@ -288,8 +302,10 @@ async function grantThenUninstall(live: LiveStart, session: Session, run: Run): permission_set_id: setId, }); run.before = await readGrants(live, session.token, setId); + run.objectBefore = await http(live, 'GET', `/api/v1/data/${TASK}`, session.token); run.uninstall = await http(live, 'DELETE', UNINSTALL, session.token); run.after = await readGrants(live, session.token, setId); + run.objectAfter = await http(live, 'GET', `/api/v1/data/${TASK}`, session.token); } async function readAfterRestart(live: LiveStart, run: Run): Promise { @@ -298,7 +314,7 @@ async function readAfterRestart(live: LiveStart, run: Run): Promise { run.object = await http(live, 'GET', `/api/v1/data/${TASK}`, token); } -const runs: Record<'hot' | 'restarted' | 'reseed', Run> = { hot: {}, restarted: {}, reseed: {} }; +const runs: Record<'hot' | 'restarted' | 'reseed' | 'reinstall', Run> = { hot: {}, restarted: {}, reseed: {}, reinstall: {} }; beforeAll(async () => { const root = mkdtempSync(join(tmpdir(), 'install-local-uninstall-')); @@ -342,8 +358,9 @@ beforeAll(async () => { await stopGroup(e.child); // ── order 3: hot install → DELETE → hot install of ANOTHER package → restart ── - // The DELETE does not withdraw the package from the running kernel, so it is - // still registered when the second install announces `metadata:reloaded`. + // The second install announces `metadata:reloaded` into the process the + // DELETE ran in, so whatever that process still counts as registered is + // re-seeded — the withdrawal is what keeps the uninstalled package out of it. const reseedDir = join(root, 'reseed'); mkdirSync(reseedDir, { recursive: true }); const reseedHome = join(reseedDir, 'home'); @@ -357,7 +374,23 @@ beforeAll(async () => { const g = await bootStart(reseedDir, reseedHome, port); await readAfterRestart(g, runs.reseed); await stopGroup(g.child); -}, 8 * BOOT_TIMEOUT_MS); + + // ── order 4: hot install → DELETE → hot reinstall of the SAME package ────── + // The withdrawal's control: whatever `uninstallPackage` took from the running + // kernel, the install path registers again, in the same process. + const reinstallDir = join(root, 'reinstall'); + mkdirSync(reinstallDir, { recursive: true }); + const h = await bootStart(reinstallDir, join(reinstallDir, 'home'), port); + const hSession = await authenticate(h); + runs.reinstall.install = await packageInstall(appDir, h); + await grantThenUninstall(h, hSession, runs.reinstall); + runs.reinstall.reinstall = await packageInstall(appDir, h); + runs.reinstall.afterReinstall = { + object: await http(h, 'GET', `/api/v1/data/${TASK}`, hSession.token), + sets: await http(h, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, hSession.token), + }; + await stopGroup(h.child); +}, 9 * BOOT_TIMEOUT_MS); afterAll(async () => { for (const child of groups) await stopGroup(child); @@ -392,6 +425,16 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean expect(rowsOf(run.after!.bindings), JSON.stringify(run.after!.bindings.body)).toEqual([]); }); + // #21576: the DELETE withdraws the package from the running kernel, so + // its object answers at once what it used to answer only after a + // restart — the same status and the same code. + it('right after the DELETE: the package object answers what it answers after a restart — no restart needed', () => { + const run = runs[name]; + expect(run.objectBefore?.status, JSON.stringify(run.objectBefore?.body)).toBe(200); + expect(run.objectAfter?.status, JSON.stringify(run.objectAfter?.body)).toBe(404); + expect(run.objectAfter?.body?.error?.code, JSON.stringify(run.objectAfter?.body)).toBe(run.object?.body?.error?.code); + }); + it('after a restart: still no set and no grant, and the package object is gone', () => { const run = runs[name]; expect(run.object?.status, JSON.stringify(run.object?.body)).toBe(404); @@ -403,19 +446,19 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean }); } - // ── The re-seed window: MEASURED RED, reported for filing, not fixed here ── + // ── The re-seed window (#21576) ───────────────────────────────────────── // - // This DELETE leaves the package registered in the running kernel until the - // next restart (the response's own note says so), and plugin-security's - // `metadata:reloaded` subscriber re-runs the declared-permission seeding over - // every package the kernel holds. So another package's hot install before - // that restart re-projects the uninstalled package's set as a fresh - // `managed_by: package` row, and the restart leaves it orphaned: the package - // is gone, its set is not. The grant does NOT come back — the cleanup deleted - // the binding and the seeding writes none — and that half is pinned plainly. + // This DELETE used to leave the package registered in the running kernel + // until the next restart, and plugin-security's `metadata:reloaded` + // subscriber re-runs the declared-permission seeding over every package the + // kernel holds. So another package's hot install before that restart + // re-projected the uninstalled package's set as a fresh `managed_by: package` + // row, and the restart left it orphaned: the package gone, its set not. The + // grant never came back — the cleanup deleted the binding and the seeding + // writes none. // - // The two set readings are `it.fails`: each turns red the day its half is - // fixed, which is the cue to promote it to a plain assertion. + // The DELETE now withdraws the package from the running kernel, so no reader + // of the registered packages — that seeding included — counts it again. describe('hot install → DELETE → hot install of another package → restart (the re-seed window)', () => { it('precondition: both installs landed, and the DELETE revoked the set and its grant', () => { const run = runs.reseed; @@ -439,14 +482,40 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean expect(run.object?.status, JSON.stringify(run.object?.body)).toBe(404); }); - it.fails('KNOWN-BROKEN: the other package\'s hot install re-projects the uninstalled package\'s set (promote to a plain assertion once fixed)', () => { + it('the other package\'s hot install does not re-project the uninstalled package\'s set', () => { const run = runs.reseed; expect(rowsOf(run.afterOtherInstall!.sets), JSON.stringify(run.afterOtherInstall!.sets.body)).toEqual([]); }); - it.fails('KNOWN-BROKEN: that re-projected set survives the restart as an orphan row (promote to a plain assertion once fixed)', () => { + it('after the restart there is no package-managed set for the uninstalled package — no orphan row', () => { const run = runs.reseed; expect(rowsOf(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toEqual([]); }); }); + + // ── The withdrawal's control (#21576) ─────────────────────────────────── + // + // `uninstallPackage` takes the package's objects, namespace, metadata items + // and record out of the running kernel. A hot reinstall of the same package + // in the same process puts every one of them back: the object answers again + // and the set is projected again — once, as the package's own. + describe('hot install → DELETE → hot reinstall of the same package (the withdrawal\'s control)', () => { + it('precondition: the install landed, and the DELETE took the object out of the running kernel', () => { + const run = runs.reinstall; + expect(run.install?.exit, run.install?.output).toBe(0); + expect(run.uninstall?.status, JSON.stringify(run.uninstall?.body)).toBe(200); + expect(run.objectBefore?.status, JSON.stringify(run.objectBefore?.body)).toBe(200); + expect(run.objectAfter?.status, JSON.stringify(run.objectAfter?.body)).toBe(404); + expect(rowsOf(run.after!.sets), JSON.stringify(run.after!.sets.body)).toEqual([]); + }); + + it('the reinstall registers the package again: its object answers, and its set is projected once', () => { + const run = runs.reinstall; + expect(run.reinstall?.exit, run.reinstall?.output).toBe(0); + expect(run.afterReinstall!.object.status, JSON.stringify(run.afterReinstall!.object.body)).toBe(200); + expect(run.afterReinstall!.sets.status).toBe(200); + expect(rowsOf(run.afterReinstall!.sets).map((r) => [r?.name, r?.managed_by, r?.package_id]), JSON.stringify(run.afterReinstall!.sets.body)) + .toEqual([[PERMISSION_SET, 'package', APP_ID]]); + }); + }); }); From 9f3e65608d0339dc964ea78094e61f00b6ae700c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:40:51 +0000 Subject: [PATCH 3/3] test(cloud-connection): pin the uninstall's withdrawal, its order and its refusals; changeset Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-Authored-By: Claude --- ...-local-uninstall-withdraws-registration.md | 12 + ...install-local-uninstall-withdrawal.test.ts | 246 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 .changeset/21576-install-local-uninstall-withdraws-registration.md create mode 100644 packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts diff --git a/.changeset/21576-install-local-uninstall-withdraws-registration.md b/.changeset/21576-install-local-uninstall-withdraws-registration.md new file mode 100644 index 00000000000..4342a9a9c8c --- /dev/null +++ b/.changeset/21576-install-local-uninstall-withdraws-registration.md @@ -0,0 +1,12 @@ +--- +"@objectstack/cloud-connection": patch +--- + +An install-local uninstall (`DELETE /api/v1/marketplace/install-local/:manifestId`) now withdraws the package from the running kernel + +Clause-②: no + +- The DELETE used to remove the ledger entry and run the uninstall cleanups, but it left the package registered in the running kernel until the next restart. So another package's hot install re-ran the declared-permission seeding over the uninstalled package too. Its permission set came back as a package-managed row, and that row survived the restart as an orphan that an administrator could grant. +- After the ledger entry is removed, the door now calls `SchemaRegistry.uninstallPackage`, the same verb the protocol's own uninstall uses, on the same registry. It does this before the cleanups run. The package's objects answer 404 straight away, not only after a restart, and no reader of the registered packages counts it again. A reinstall of the same package in the same process registers it again. +- If the registry refuses the withdrawal, for example because another package extends an object this package owns, the uninstall still succeeds and the cleanups still run. The refusal is reported as a failed `registry.uninstallPackage` entry in `cleanups`. The operator log carries the cause and the remedy. +- The response `note` no longer says the kernel cannot unregister a package in place. The request and response keys are unchanged. diff --git a/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts b/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts new file mode 100644 index 00000000000..dbab1c1dd09 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21576 — `DELETE /api/v1/marketplace/install-local/:manifestId` withdraws the + * package from the running kernel, through `SchemaRegistry.uninstallPackage` — + * the verb the protocol's own uninstall (`deletePackage`) uses — on the + * `objectql` engine's registry. + * + * The defect: the door removed the ledger entry and ran the uninstall cleanups, + * but left the package registered until the next restart. Another package's hot + * install then announced `metadata:reloaded`, plugin-security re-ran its + * declared-permission seeding over every registered package, and the + * uninstalled package's permission set came back as an orphan `managed_by: + * package` row (ADR-0090: "No ghost grants"). + * + * What this file pins about the plugin's half (end to end — the object's hot + * answer, the re-seed window, a same-process reinstall — lives in the CLI suite + * `package-install-local-uninstall-cleanups.integration.test.ts`): + * + * - the withdrawal names the MANIFEST id, runs once, after the ledger entry + * is gone and BEFORE the cleanups, and adds nothing to `cleanups` when it + * succeeds; + * - nothing is withdrawn when the uninstall did not happen: a refused caller, + * an id this door never installed (even one the registry holds), a ledger + * write that failed; + * - a refused withdrawal is reported on the response as a failed outcome, + * never swallowed, the cleanups still run, and the cause goes to the + * operator log only; + * - a registry that does not hold the package has nothing to withdraw. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +// The first load of the runtime's dist paid at module top, never inside a +// clocked `it` (`scripts/check-test-source-alias.mjs`): the plugin reaches the +// same module through a dynamic `import()` for its handler binder on rehydrate. +import '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { INSTALLER_USER_ID, installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +const APP_ID = 'com.example.tasksapp'; +/** A cloud install's ledger `packageId` — the catalog's id, NOT the manifest id the registry knows. */ +const CATALOG_ID = 'pkg_01tasksapp'; +/** A package the running registry holds that this door never installed — config-defined code. */ +const USER_CODE_ID = 'com.example.usercode'; +const CLEANUP_OUTCOMES = [{ name: 'security.package-permissions', success: true, removed: 3 }]; + +type Handler = (c: any) => Promise; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +function makeDeleteC(manifestId: string) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: `http://localhost:3000/api/v1/marketplace/install-local/${manifestId}`, + raw: new Request('http://localhost:3000/x'), + json: async () => ({}), + param: (name: string) => (name === 'manifestId' ? manifestId : undefined), + }, + json, + }; +} + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-withdraw-')); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); + +/** A ledger entry as a CLOUD install writes it: catalog `packageId`, manifest `manifestId`. */ +function seedLedger(): void { + new LocalManifestSource(dir).write({ + packageId: CATALOG_ID, + versionId: 'v1', + manifestId: APP_ID, + version: '0.1.0', + manifest: { id: APP_ID, name: 'Tasks App', objects: [] }, + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: INSTALLER_USER_ID, + withSampleData: false, + }); +} + +const ledgerFile = () => join(dir, `${APP_ID}.json`); + +/** + * Boot the plugin to `kernel:ready` and hand back its DELETE route, over an + * `objectql` engine whose registry holds `registered` and a `protocol` runner — + * both recording, in ONE ordered list, each call and whether the ledger file + * was still on disk at that moment. + */ +async function bootPlugin(opts: { + registered?: string[]; + refuseWithdrawal?: string; + auth?: unknown; +} = {}) { + const events: Array<{ step: string; id: string; ledgerOnDisk: boolean }> = []; + const packages = new Map((opts.registered ?? [APP_ID, USER_CODE_ID]).map((id) => [id, { manifest: { id } }])); + const registry = { + getAllPackages: () => [...packages.values()], + getPackage: (id: string) => packages.get(id), + uninstallPackage: vi.fn((id: string) => { + events.push({ step: 'withdraw', id, ledgerOnDisk: existsSync(ledgerFile()) }); + if (opts.refuseWithdrawal) throw new Error(opts.refuseWithdrawal); + return packages.delete(id); + }), + }; + const hooks = new Map(); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const rawApp = makeRawApp(); + const services: Record = { + manifest: { register: vi.fn() }, + auth: opts.auth ?? installerAuthService(), + objectql: withInstallerGrants({ syncSchemas: async () => undefined, registry }), + protocol: { + runUninstallCleanups: vi.fn(async (request: { packageId: string }) => { + events.push({ step: 'cleanups', id: request.packageId, ledgerOnDisk: existsSync(ledgerFile()) }); + return CLEANUP_OUTCOMES; + }), + }, + }; + const ctx: any = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx); + await hooks.get('kernel:ready')?.(); + const uninstall = async (manifestId = APP_ID) => + rawApp.routes.get('DELETE /api/v1/marketplace/install-local/:manifestId')!(makeDeleteC(manifestId)); + const warnings = () => logger.warn.mock.calls.map((c: any[]) => String(c[0])); + return { uninstall, events, packages, registry, warnings }; +} + +describe('#21576: an install-local uninstall withdraws the package from the running kernel', () => { + it('withdraws by the manifest id, once, after the ledger entry is gone and before the cleanups run', async () => { + seedLedger(); + const { uninstall, events, packages, warnings } = await bootPlugin(); + + const res = await uninstall(); + + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.success).toBe(true); + expect(events).toEqual([ + { step: 'withdraw', id: APP_ID, ledgerOnDisk: false }, + { step: 'cleanups', id: APP_ID, ledgerOnDisk: false }, + ]); + expect(packages.has(APP_ID)).toBe(false); + // Another package the registry holds is not this uninstall's to touch. + expect(packages.has(USER_CODE_ID)).toBe(true); + // A withdrawal that succeeded adds nothing to the outcomes. + expect(res.payload.data.cleanups).toEqual(CLEANUP_OUTCOMES); + expect(warnings()).toEqual([]); + }); + + it('a refused caller withdraws nothing', async () => { + seedLedger(); + const { uninstall, registry, packages } = await bootPlugin({ + auth: { api: { getSession: async () => null } }, + }); + + const res = await uninstall(); + + expect(res.status).toBe(401); + expect(registry.uninstallPackage).not.toHaveBeenCalled(); + expect(packages.has(APP_ID)).toBe(true); + expect(existsSync(ledgerFile())).toBe(true); + }); + + it('an id this door never installed withdraws nothing — even one the running registry holds', async () => { + seedLedger(); + const { uninstall, registry, packages } = await bootPlugin(); + + const res = await uninstall(USER_CODE_ID); + + expect(res.status).toBe(404); + expect(res.payload.error.code).toBe('RESOURCE_NOT_FOUND'); + expect(registry.uninstallPackage).not.toHaveBeenCalled(); + expect(packages.has(USER_CODE_ID)).toBe(true); + }); + + it('a ledger write that fails withdraws nothing — the package is still installed and stays registered', async () => { + seedLedger(); + vi.spyOn(LocalManifestSource.prototype, 'remove').mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + const { uninstall, events, packages } = await bootPlugin(); + + const res = await uninstall(); + + expect(res.status).toBe(500); + expect(res.payload.error.code).toBe('MARKETPLACE_STORAGE_FAILED'); + expect(events).toEqual([]); + expect(packages.has(APP_ID)).toBe(true); + }); + + it('a refused withdrawal is one failed outcome on the response, the cleanups still run, and the cause stays in the log', async () => { + seedLedger(); + const cause = 'Cannot uninstall package: an object it owns is extended by another package'; + const { uninstall, events, warnings } = await bootPlugin({ refuseWithdrawal: cause }); + + const res = await uninstall(); + + // The uninstall itself happened — the ledger entry is gone — so the + // request succeeded; what did not complete is reported, not swallowed. + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.success).toBe(true); + expect(existsSync(ledgerFile())).toBe(false); + expect(events.map((e) => e.step)).toEqual(['withdraw', 'cleanups']); + const [withdrawal, ...rest] = res.payload.data.cleanups; + expect(withdrawal).toMatchObject({ name: 'registry.uninstallPackage', success: false, removed: 0 }); + expect(typeof withdrawal.error).toBe('string'); + expect(rest).toEqual(CLEANUP_OUTCOMES); + // The thrown text goes to the operator log only, never onto the wire. + expect(JSON.stringify(res.payload)).not.toContain(cause); + expect(warnings().filter((w) => w.includes(cause) && w.includes(APP_ID))).toHaveLength(1); + }); + + it('a registry that does not hold the package has nothing to withdraw — no call, no failed outcome', async () => { + seedLedger(); + const { uninstall, registry, warnings } = await bootPlugin({ registered: [USER_CODE_ID] }); + + const res = await uninstall(); + + expect(res.status).toBe(200); + expect(registry.uninstallPackage).not.toHaveBeenCalled(); + expect(res.payload.data.cleanups).toEqual(CLEANUP_OUTCOMES); + expect(warnings()).toEqual([]); + }); +});