From f7dfce682891a407fd8f75578640b41b9ca3c17f Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Tue, 18 Aug 2026 23:33:00 +0200 Subject: [PATCH] fix(paperbell): re-handshake with the host on every ready event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PaperBell 0.4.7 invalidates any PPBClient handle from a previous host load: every request* on it resolves to null and its config push is gone. Our ready listener guarded on `!this.client`, so once the host updated we kept holding that dead handle — language following and account status silently stopped working until the user disabled and re-enabled this plugin by hand. attach() now runs on every paperbell:ready, not just the first: it releases the old handle, registers again, re-reads capabilities and re-subscribes. The last known config survives a reconnect (and a failed re-register), so the UI never flips back to the fallback language; on a reconnect only, we refetch the config when listGrants() says the scope is already granted, which prompts for nothing. A first connect stays scope-free by design. Also re-vendors the contract against 0.4.7: PPB_SCHEMA_VERSION 1 -> 2, so the "host schema is newer than vendored" warning stops firing spuriously, with type names aligned to the host's published contract appendix. Nothing else in the migration applies to us: we never reached past `.api` into the host's internals, never read plugin.settings, and no feature builds a URL from llm.baseUrl yet. Our settings card in PaperBell now appears only once a scope is granted — accepted deliberately, documented under "Deferred consent". Refs: MIGRATION-0.4.7.md in PaperBell-Org/Obsidian-PaperBell-Plugin Co-Authored-By: Claude Opus 5 --- MAINTAINING.md | 40 ++++--- docs/PAPERBELL_INTEGRATION.md | 68 +++++++++--- src/paperbell/client.ts | 140 +++++++++++++++++++----- src/paperbell/shared-config.ts | 136 +++++++++++++++++------ src/paperbell/store.ts | 7 +- test/paperbell/client.test.ts | 191 +++++++++++++++++++++++++++++++-- test/paperbell/fixtures.ts | 111 +++++++++++++------ 7 files changed, 565 insertions(+), 128 deletions(-) diff --git a/MAINTAINING.md b/MAINTAINING.md index d20905b..1f5552b 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -42,7 +42,10 @@ an **optional** dependency: the plugin works standalone, and lights up host-back config, account, AI via `requestCompletion`) only when PaperBell is present. - The shared contract lives at `src/paperbell/shared-config.ts`, a **vendored copy** of PaperBell's - `paperbell-shared-config.ts` (zero-dependency by design). + `paperbell-shared-config.ts` (zero-dependency by design). The host's plugin repo publishes + binaries and docs, not sources, so the thing you actually vendor from is **"附录 A —— 完整契约声明" + in its [README-ZH](https://github.com/PaperBell-Org/Obsidian-PaperBell-Plugin/blob/main/README-ZH.md)**. + Our type names match that appendix verbatim so each re-vendor is a readable diff. - It is pinned to `PPB_SCHEMA_VERSION`. When PaperBell bumps its schema, **re-vendor** the file and update the compatibility check. - ⚠️ **Re-vendoring overwrites our proposal block.** A straight copy from upstream — for *any* @@ -54,25 +57,34 @@ config, account, AI via `requestCompletion`) only when PaperBell is present. as such in the file header and written up in [docs/PROPOSAL_PROJECTS_SCOPE.md](./docs/PROPOSAL_PROJECTS_SCOPE.md). Its client methods are declared **optional** and every caller checks `capabilities` *and* `typeof method === "function"`, - so it stays inert against every host that exists today. `PPB_SCHEMA_VERSION` stays at `1` while it - is a proposal — bumping it unilaterally would silence the newer-schema warning for a real upstream - v2. When the host ships it, re-vendor as usual and delete the proposal marker. + so it stays inert against every host that exists today. `PPB_SCHEMA_VERSION` tracks the **host's** + number and nothing else — never bump it for a proposal, or the newer-schema warning goes quiet for + a real upstream bump. When the host ships it, re-vendor as usual and delete the proposal marker. -### Contract conformance (verified against PaperBell 0.4.4) +### Contract conformance (verified against PaperBell 0.4.7) The real host's `install()` does exactly what our client assumes: `this.plugin.api = api` (so `app.plugins.plugins["paperbell"].api` works), `window.registerPPBplugin = api.registerPPBplugin`, then `workspace.trigger("paperbell:ready", api)`. Its `getPluginInfo()` -returns `schemaVersion: 1` and +returns `schemaVersion: 2` and `capabilities: ["account","config","plugin-info","llm-invoke","llm-credentials","activation","download-ticket"]` — matching our vendored `PPB_SCHEMA_VERSION` and feature gating. We depend ONLY on this handshake contract, never on PaperBell's main features (which change independently). -The host has since grown three backward-compatible scopes/methods on top of the original four — -`requestLLMCredentials` (`llm-credentials`), `requestActivationInfo` (`activation`), and -`requestProtectedDownloadTicket` (`download-ticket`) — plus the `providerId` / `providerName` / -`hasApiKey` fields on the public LLM config and a host-internal `paperbell:plugins-changed` event. -`schemaVersion` stayed `1` (additions only), so no compatibility break; the vendored contract and the -thin `PaperBellClient` wrappers are synced to this surface. +What 0.4.7 changed, and how we answer it (upstream's own +[MIGRATION-0.4.7.md](https://github.com/PaperBell-Org/Obsidian-PaperBell-Plugin/blob/main/MIGRATION-0.4.7.md)): + +| Host change | Us | +| --- | --- | +| `app.plugins.plugins["paperbell"]` now exposes only `api` + `settings`; every internal object is private | Nothing to do — we only ever read `.api`, and never `plugin.settings` | +| `settings.pluginGrants` is no longer a writable array (it was a real privilege-escalation hole) | Nothing to do — we never forged grants; consent has always been the normal flow | +| A handle from a **previous** host load goes inert: every `request*` returns `null`, no throw | **Fixed here.** `client.ts` re-handshakes on every `paperbell:ready` instead of guarding against a second connect | +| `llm.baseUrl` / `llm.model` are now *effective* values (no trailing slash, defaults filled in) | Nothing to do — no feature builds a URL from them yet. When one does, still strip trailing slashes defensively | +| Only plugins **holding at least one scope** get a card in PaperBell's settings | Accepted deliberately — see "Deferred consent" in [docs/PAPERBELL_INTEGRATION.md](./docs/PAPERBELL_INTEGRATION.md) | + +Schema `1` → `2` narrowed the *broadcast* payload (`paperbell:config-changed`) to a public +language/profile layer; the per-client `onConfigChange` push we actually consume still carries the +restricted config, so the vendored types and the thin `PaperBellClient` wrappers stay as they were, +plus the optional `profile` / `cimpoFolders` / completion-quota fields 0.4.7 added. ### Verifying the handshake live @@ -89,6 +101,10 @@ needed. To verify against the **real** host in Obsidian: 4. Check: our settings tab shows a "PaperBell" section reading *Connected*; the console logs `[PaperOut] Connected to PaperBell host.`; the "Connect/Refresh" button pulls account/config (PaperBell prompts for consent the first time); disabling our plugin calls `unregister()`. +5. **Reload recovery** (the 0.4.7 case): with both plugins running, disable and re-enable *PaperBell* + only. The console must log `[PaperOut] Reconnected to PaperBell host after it reloaded.`, our + settings section must still read *Connected*, and switching PaperBell's language must still + re-render our UI — all without touching our plugin. An **automated, decoupled** guard also runs in the test suite: `test/paperbell/host-conformance.test.ts` statically checks that any bundle present at that path still exposes the handshake surface (events, diff --git a/docs/PAPERBELL_INTEGRATION.md b/docs/PAPERBELL_INTEGRATION.md index 5d69ce9..16ff00d 100644 --- a/docs/PAPERBELL_INTEGRATION.md +++ b/docs/PAPERBELL_INTEGRATION.md @@ -11,17 +11,45 @@ safe. The contract itself is a **vendored copy** of PaperBell's IPC surface; see ## The handshake -The host plugin (`app.plugins.plugins["paperbell"]`) exposes an `api` object and, on load, -fires a one-shot `paperbell:ready` workspace event carrying that API. Load order between -the two plugins is unspecified, so our client (`src/paperbell/client.ts`) covers both cases: +The host plugin (`app.plugins.plugins["paperbell"]`) exposes an `api` object and, on **every** +load, fires a `paperbell:ready` workspace event carrying that API. Load order between the two +plugins is unspecified, so our client (`src/paperbell/client.ts`) covers both cases: 1. **Probe** for `app.plugins.plugins["paperbell"].api` immediately, and -2. **Listen once** for `paperbell:ready` in case the host loads after us. +2. **Stay subscribed** to `paperbell:ready` for as long as we are loaded. -Whichever fires first wins; a guard prevents double-registration. Registration calls -`host.registerPPBplugin({ id, name, description, icon, onOpen })`. Our `onOpen` deep-links -back to this plugin's own settings tab, so PaperBell's settings can show a "PaperOut" entry -card that opens our settings. On plugin unload we call `unregister()` and reset our store. +Registration calls `host.registerPPBplugin({ id, name, description, icon, onOpen })`. Our +`onOpen` deep-links back to this plugin's own settings tab, so PaperBell's settings can show a +"PaperOut" entry card that opens our settings. On plugin unload we call `unregister()` and +reset our store. + +### Why the listener is permanent + +The handle `registerPPBplugin()` returns is bound to *that* load of the host. When PaperBell +updates — or is disabled and re-enabled — the handle survives as an object but goes inert: +since host 0.4.7 every `request*` on it resolves to `null` (deliberately, instead of throwing +something cryptic) and its `onConfigChange` push is gone. + +So `attach()` runs on each ready event, not just the first: it releases the old handle +(`unsubscribe` + `unregister`, both wrapped — they reach into a host that may already be gone), +registers again, re-reads capabilities, and re-subscribes. Two details make the recovery +invisible to the user: + +- the last known config is **kept** — across the reconnect, and even if re-registering fails — + so the UI doesn't flip back to the fallback language for the split second before a fresh one + arrives. Only unloading our plugin clears it; and +- **on a reconnect only**, if `listGrants()` says the user already granted `config`, we refetch + it. The grant outlived the reload, so that costs no consent prompt, and it is how we notice a + language the host changed while our handle was dead. A first connect stays scope-free — see + *Deferred consent* under **Scopes** below. + +Note there is no "same host object, skip the handshake" shortcut. Whether a reloaded host hands +back a fresh `api` is its business, and guessing wrong would leave us on a dead handle forever — +the exact bug this replaced. A redundant re-register costs one `unregister()` and one +`registerPPBplugin()`; we never hold two at once. + +Guarding against a "double connect" here instead — the shape this code had before host 0.4.7 — +is what makes a plugin silently stop following the host after the first PaperBell update. If no host is found, the client simply stays disconnected and every method below returns `null` — the rest of the plugin never notices. @@ -42,10 +70,19 @@ the user the first time it touches a scope; approval is remembered, denial retur | `download-ticket` | a ticket for a protected download | Wired, not yet used by a feature | | `projects` | the host's project list, for linking an output to its project | **Proposed** — consumed by the new-paper modal, but no shipped host implements it | +### Deferred consent (and what it costs) + We deliberately request **no** scopes at startup — that would trigger a consent prompt on every launch. Only `plugin-info` (which needs no consent) is read eagerly to learn the host's `capabilities`; everything else is requested lazily on an explicit user action. +Since host 0.4.7 a plugin's card only appears in PaperBell's own settings page once it +**holds at least one scope** — registering is no longer enough. Combined with the above, +that means a user who has never pressed **Connect / Refresh** (and never used a host-backed +feature) won't see PaperOut listed there. We accept that: a consent dialog on every launch is +a worse trade than a card that appears the moment the integration is actually used. Once any +scope is granted the card shows up and stays, reload after reload. + ## What the integration does today ### Follow the host's language @@ -120,7 +157,8 @@ host team in [PROPOSAL_PROJECTS_SCOPE.md](./PROPOSAL_PROJECTS_SCOPE.md). - No host → client stays disconnected; `connected` is `false`, `config` is `null`, `capabilities` is `[]`. - Every `fetch*` / `request*` helper on `PaperBellClient` returns `null` when disconnected, - so callers can treat "host absent" and "scope denied" identically. + so callers can treat "host absent" and "scope denied" identically — and, for the window + between a host reload and the ready event that heals it, "host restarting" too. - Language falls back to Obsidian's UI language; account UI shows "not connected". - Nothing about compiling, scaffolding, or Pandoc export depends on the host. @@ -132,8 +170,12 @@ schema version than we vendored, the client logs a warning (it does not break). host bumps its schema, re-vendor this file and reconcile the check — the procedure and a decoupled conformance test are described in [MAINTAINING.md](../MAINTAINING.md). +It currently tracks host **0.4.7** (`PPB_SCHEMA_VERSION = 2`). The v1 → v2 bump narrowed the +*broadcast* `paperbell:config-changed` payload to a public language/profile layer; the directed +`onConfigChange` push we consume still carries the restricted config, so nothing we read moved. + One block of that file is **ours, not upstream's**: the proposed `projects` scope, flagged -in the file header. `PPB_SCHEMA_VERSION` stays at `1` while it is a proposal — raising it -unilaterally would silence the "host schema is newer than vendored" warning for a real -upstream v2. Feature detection never reads the schema version anyway; it reads -capabilities and checks the method exists. +in the file header. `PPB_SCHEMA_VERSION` tracks the host's number and nothing else — raising +it for a proposal would silence the "host schema is newer than vendored" warning for a real +upstream bump. Feature detection never reads the schema version anyway; it reads capabilities +and checks the method exists. diff --git a/src/paperbell/client.ts b/src/paperbell/client.ts index 64b2e92..6075baf 100644 --- a/src/paperbell/client.ts +++ b/src/paperbell/client.ts @@ -8,23 +8,27 @@ import { type PPBClient as PPBClientHandle, type PPBCompletionParams, type PPBCompletionResult, - type PPBLLMCredentials, - type PPBActivationInfo, - type PPBDownloadTicket, - type PPBDownloadTicketParams, + type PaperBellLLMCredentials, + type PaperBellActivationInfo, + type PPBProtectedDownloadTicket, + type PPBProtectedDownloadParams, type PPBProject, type PPBProjectsQuery, type PaperBellAccountInfo, - type PaperBellSharedConfigPublic, + type PaperBellRestrictedConfig, type PPBScope, } from "./shared-config"; import { paperbell, DISCONNECTED } from "./store"; /** PaperBell host plugin id (the parent). */ const HOST_PLUGIN_ID = "paperbell"; -/** Our own id — MUST match manifest.json `id`. Used for registration and settings deep-link. */ -const THIS_PLUGIN_ID = "longform-paperbell"; -const THIS_PLUGIN_NAME = "PaperOut To-Authors"; +/** + * Our own id — MUST match manifest.json `id`. Used for registration, for matching our + * entry in the host's grant list, and for the settings deep-link. Exported so tests + * assert against this value rather than a copy of it. + */ +export const THIS_PLUGIN_ID = "longform-paperbell"; +export const THIS_PLUGIN_NAME = "PaperOut To-Authors"; /** * Optional bridge to the PaperBell host plugin. @@ -38,6 +42,9 @@ const THIS_PLUGIN_NAME = "PaperOut To-Authors"; * those trigger a host consent prompt, so they are requested lazily on user action * (settings button, AI command). Capabilities come from `getPluginInfo()`, which needs * no consent. + * + * The handshake is re-run on every host `ready` event, not just the first: a handle from + * a previous host load is inert. See `attach()`. */ export class PaperBellClient { private plugin: LongformPlugin; @@ -60,21 +67,26 @@ export class PaperBellClient { } /** - * Probe for the host now; if it isn't loaded yet, wait (once) for its ready event. - * The listener is registered via `plugin.registerEvent`, so it is cleaned up on unload. + * Probe for the host now, and stay subscribed to its ready event. + * + * The host broadcasts PPB_READY_EVENT on *every* load, so that listener does double + * duty: it covers the host-loads-after-us ordering, and it is also the only way we + * recover when the host reloads (an update, or a disable/enable). Dropping it after + * the first successful handshake would leave us holding a dead handle — every + * `request*` on it silently returns null — until the user restarted us by hand. + * + * Registered via `plugin.registerEvent`, so it is still cleaned up on unload. */ init(): void { const host = this.lookupHost(); if (host) { - this.onHostReady(host); + this.attach(host); } - // The host fires PPB_READY_EVENT once when it loads; this covers the - // host-loads-after-us ordering. Guard against a double connect. this.plugin.registerEvent( this.app.workspace.on(PPB_READY_EVENT as never, ((api: PPBHostApi) => { - if (!this.client && api) { - this.onHostReady(api); + if (api) { + this.attach(api); } }) as never) ); @@ -87,7 +99,20 @@ export class PaperBellClient { return api ?? null; } - private onHostReady(host: PPBHostApi): void { + /** + * (Re-)handshake with a host. Safe to call again at any time: the previous handle — + * which a host reload has already invalidated — is released first, so we never end up + * with two registrations or a stale config subscription. + * + * Deliberately no "same host, skip it" shortcut: whether a reloaded host hands back a + * fresh `api` object is its business, and guessing wrong there is unrecoverable — we + * would sit on a dead handle forever, which is the bug this method exists to fix. A + * redundant re-register costs one unregister and one register. + */ + private attach(host: PPBHostApi): void { + const reconnecting = this.client !== null; + this.releaseHandle(); + let handle: PPBClientHandle; try { handle = host.registerPPBplugin({ @@ -100,6 +125,14 @@ export class PaperBellClient { }); } catch (e) { console.error("[PaperOut] Failed to register with PaperBell host:", e); + // Disconnected, but the last config we were given is still the best answer we + // have for "what language does the host want?" — same reasoning as the reconnect + // path below. Only `destroy()` clears it outright. + paperbell.update((s) => ({ + ...s, + connected: false, + capabilities: DISCONNECTED.capabilities, + })); return; } this.client = handle; @@ -113,22 +146,57 @@ export class PaperBellClient { } this.capabilities = capabilities; - paperbell.set({ connected: true, config: null, capabilities }); - console.log("[PaperOut] Connected to PaperBell host."); + // Keep whatever config we already had: on a reconnect it is the last value the host + // gave us, and dropping it would flip the UI back to the fallback language for as + // long as it takes to fetch a fresh one. + paperbell.update((s) => ({ + ...s, + connected: true, + capabilities, + })); + console.log( + reconnecting + ? "[PaperOut] Reconnected to PaperBell host after it reloaded." + : "[PaperOut] Connected to PaperBell host." + ); - // Keep the public config fresh when the host pushes changes. Subscribing does - // not prompt for consent (it's a plain workspace event under the hood). + // Keep the config fresh when the host pushes changes. Subscribing does not prompt + // for consent. This is a directed push bound to `handle`, so it dies with it — + // hence the re-subscribe on every attach. this.unsubscribeConfig = handle.onConfigChange((config) => { this.checkSchema(config); paperbell.update((s) => ({ ...s, config })); }); + + // Only after a reconnect, and only if the user already granted `config`: the grant + // outlived the reload, so this prompts for nothing, and it is how we notice a + // language the host changed while our handle was dead. On a first connect we stay + // scope-free by design (see the class docstring). + if (reconnecting && this.hasGrant(host, "config")) { + this.fetchSharedConfig().catch((e) => { + console.warn("[PaperOut] Could not refresh PaperBell config:", e); + }); + } + } + + /** Whether the user has already granted us `scope`, per the host's grant list. */ + private hasGrant(host: PPBHostApi, scope: PPBScope): boolean { + try { + return (host.listGrants() ?? []).some( + (grant) => + grant.sourceId === THIS_PLUGIN_ID && grant.scopes.includes(scope) + ); + } catch (e) { + console.warn("[PaperOut] Could not read PaperBell grants:", e); + return false; + } } /** * Request the host's public shared config (scope: `config`). First call prompts the * user for consent. Returns null if denied or the host is absent. Updates the store. */ - async fetchSharedConfig(): Promise { + async fetchSharedConfig(): Promise { if (!this.client) return null; const config = await this.client.requestSharedConfig(); if (config) { @@ -162,12 +230,12 @@ export class PaperBellClient { * (which keeps the key inside the host); use this only when a feature must talk to * the provider directly. Never persist or log the returned key. */ - async requestLLMCredentials(): Promise { + async requestLLMCredentials(): Promise { return this.client ? this.client.requestLLMCredentials() : null; } /** Request the host's activation/license status (scope: `activation`). First call prompts for consent. */ - async requestActivationInfo(): Promise { + async requestActivationInfo(): Promise { return this.client ? this.client.requestActivationInfo() : null; } @@ -177,8 +245,8 @@ export class PaperBellClient { * Returns null when the host is absent or the scope is denied. */ async requestProtectedDownloadTicket( - params?: PPBDownloadTicketParams - ): Promise { + params?: PPBProtectedDownloadParams + ): Promise { return this.client ? this.client.requestProtectedDownloadTicket(params) : null; @@ -230,8 +298,27 @@ export class PaperBellClient { /** Tear down: unsubscribe, unregister from the host, reset the store. */ destroy(): void { + this.releaseHandle(); + paperbell.set({ ...DISCONNECTED }); + } + + /** + * Drop the current handle and its subscription, leaving the store alone. + * + * Both calls go into a host we do not control — and after a host reload they reach a + * handle whose owner is gone — so neither is allowed to abort the caller: `attach()` + * has to get to its fresh registration, and `destroy()` runs during plugin unload. + */ + private releaseHandle(): void { if (this.unsubscribeConfig) { - this.unsubscribeConfig(); + try { + this.unsubscribeConfig(); + } catch (e) { + console.warn( + "[PaperOut] Error unsubscribing from PaperBell config:", + e + ); + } this.unsubscribeConfig = null; } if (this.client) { @@ -243,7 +330,6 @@ export class PaperBellClient { this.client = null; } this.capabilities = []; - paperbell.set({ ...DISCONNECTED }); } private checkSchema(config: { schemaVersion: number }): void { diff --git a/src/paperbell/shared-config.ts b/src/paperbell/shared-config.ts index 458cf72..62b057c 100644 --- a/src/paperbell/shared-config.ts +++ b/src/paperbell/shared-config.ts @@ -9,18 +9,31 @@ * schema, re-vendor this file and reconcile the compatibility check in `client.ts`. * See MAINTAINING.md → "PaperBell relationship". * - * Last synced against PaperBell host build `paperbell` v0.4.4: `schemaVersion` is still 1, - * with backward-compatible additions — the `llm-credentials` / `activation` / `download-ticket` - * scopes and their `request*` methods, the `paperbell:plugins-changed` event, and the - * `providerId` / `providerName` / `hasApiKey` fields on the public LLM config. + * Last synced against PaperBell **0.4.7**, whose published contract is "附录 A" of the host's + * README-ZH (its plugin repo ships docs + release binaries, not sources — that appendix *is* + * the upstream file for vendoring purposes). Type names here match it verbatim so the next + * re-vendor is a readable diff. What 0.4.7 changed for us: + * - `schemaVersion` is **2** (v1 → v2 narrowed the broadcast payload from the full config + * to {@link PaperBellPublicConfig}; the per-client push still carries the restricted one); + * - `PaperBellSharedConfigPublic` was renamed {@link PaperBellRestrictedConfig}, with the + * old name kept upstream as a deprecated alias; + * - `profile` / `cimpoFolders` (optional) and the completion-result quota fields were added; + * - `llm.baseUrl` / `llm.model` are now *effective* values — `baseUrl` has no trailing slash + * and both fall back to the host's built-in defaults instead of echoing empty user input. + * Additions are not a schema bump upstream, so treat unknown optional fields as absent and + * never compare `schemaVersion` for equality. + * + * One deliberate deviation from the appendix: `PPBClient.requestSharedConfig()` is typed + * with the current name, `PaperBellRestrictedConfig`, where upstream's own appendix still + * writes the deprecated alias. Identical type, quieter deprecation. * * ⚠️ PROPOSAL — NOT YET UPSTREAM: the `projects` scope and everything it drags in * (`PPB_PROJECTS_CHANGED_EVENT`, `PPBProject`, `PPBProjectsQuery`, `PPBProjectsResult`, * `PPBClient.requestProjects` / `onProjectsChange`) are *our* proposal to the host, written - * up in docs/PROPOSAL_PROJECTS_SCOPE.md. No shipped host implements them yet, which is why - * the client methods are optional and every consumer gates on capability + `typeof` checks - * rather than on `PPB_SCHEMA_VERSION` — which stays at 1 until the host really bumps it, - * so the "host schema is newer than vendored" warning keeps working. + * up in docs/PROPOSAL_PROJECTS_SCOPE.md. 0.4.7 still does not implement it, which is why the + * client methods are optional and every consumer gates on capability + `typeof` checks rather + * than on `PPB_SCHEMA_VERSION` — that constant tracks the host's number and nothing else, so + * the "host schema is newer than vendored" warning keeps working. * * ── Original header ────────────────────────────────────────────────────────── * PaperBell 对外共享契约(消费方 / IPC 表面)。 @@ -30,18 +43,21 @@ */ /** 契约版本号,便于未来兼容判断。 */ -export const PPB_SCHEMA_VERSION = 1; +export const PPB_SCHEMA_VERSION = 2; /** * 宿主挂载完成后在 `app.workspace` 上 trigger 的事件名,载荷为 {@link PPBHostApi}。 - * 子插件与 PaperBell 的加载顺序不确定,推荐握手模式(事件只在宿主加载时触发一次, - * 后加载的一方必须先主动探测)。 + * 子插件与 PaperBell 的加载顺序不确定,推荐握手模式(先主动探测,探不到再等事件)。 + * + * 宿主**每次**装载都会广播它 —— 所以这条监听同时承担「首次握手」和「宿主重载后重新 + * 握手」两个职责,必须常驻:握手成功后摘掉监听,PaperBell 更新一次就再也连不回来。 */ export const PPB_READY_EVENT = "paperbell:ready"; /** - * 宿主核心配置(语言 / LLM / 账户)变更时在 `app.workspace` 上 trigger 的事件名, - * 载荷为 {@link PaperBellSharedConfigPublic}(去密钥)。 + * 宿主核心配置(语言 / 用户资料)变更时在 `app.workspace` 上 trigger 的事件名, + * 载荷为公开层的 {@link PaperBellPublicConfig} —— schema v2 起已由完整配置收窄至此。 + * 要拿到含 LLM / 账户的受限层,用 {@link PPBClient.onConfigChange}。 */ export const PPB_CONFIG_CHANGED_EVENT = "paperbell:config-changed"; @@ -57,17 +73,47 @@ export const PPB_PLUGINS_CHANGED_EVENT = "paperbell:plugins-changed"; */ export const PPB_PROJECTS_CHANGED_EVENT = "paperbell:projects-changed"; +/** 用户资料。宿主未填任何一项时整个 `profile` 字段缺席。 */ +export interface PaperBellUserProfile { + name?: string; + title?: string; + email?: string; + institution?: string; + avatar?: string; +} + +/** 宿主的 CIMPO 文件夹布局(可选)。 */ +export interface PaperBellCimpoFolders { + concepts: string; + inputs: string; + metadata: string; + projects: string; + outputs: string; +} + +/** + * 公开层广播载荷:{@link PPB_CONFIG_CHANGED_EVENT} 在 workspace 总线上携带的形态, + * 无需握手、无需 scope。schema v1 → v2 的破坏性变更就是把它从完整配置收窄到这里。 + * 注意与 {@link PPBClient.onConfigChange} 区分 —— 后者是宿主对已授权子插件的定向推送, + * 载荷是更宽的 {@link PaperBellRestrictedConfig}。 + */ +export interface PaperBellPublicConfig { + schemaVersion: number; + language: "en" | "zh"; + profile?: PaperBellUserProfile; +} + /** Cards Wrangler 期望从 PaperBell 主插件读到的共享配置(消费方契约)。 */ export interface PaperBellSharedConfig { schemaVersion: number; // 便于未来兼容判断 language: "en" | "zh"; // 统一 UI 语言,供子插件跟随 llm: { - providerId: string; // AI 提供方 id(如 "anthropic" / "openai" / 自定义提供方) - providerName: string; // 提供方展示名 + providerId?: string; // AI 提供方 id(如 "anthropic" / "openai" / 自定义提供方) + providerName?: string; // 提供方展示名 api: "anthropic" | "openai"; // 决定请求/响应形态(复用现有 ProviderApi) - baseUrl: string; // 调度网关基址 + baseUrl: string; // 调度网关基址(0.4.7 起为生效值:已去掉尾部斜杠,留空时回落到默认地址) apiKey: string; // 鉴权密钥 / 会话 token - model: string; // 默认模型 id + model: string; // 默认模型 id(0.4.7 起用户没选过时回落到内置默认模型) models?: { extract?: string; query?: string }; // 可选:按任务路由 }; account?: { @@ -76,6 +122,7 @@ export interface PaperBellSharedConfig { plan?: string; // free | pro | ... displayName?: string; }; + cimpoFolders?: PaperBellCimpoFolders; } /** @@ -93,7 +140,7 @@ export type PaperBellLLMConfigPublic = Omit< * 完整 LLM 凭据(**含 `apiKey`**),经 `requestLLMCredentials()`(scope: `llm-credentials`) * 取回。属敏感数据 —— 首次请求会弹同意框,子插件须自行妥善保管、避免落盘或日志外泄。 */ -export type PPBLLMCredentials = PaperBellSharedConfig["llm"]; +export type PaperBellLLMCredentials = PaperBellSharedConfig["llm"]; /** IPC 默认返回的账户信息(非敏感)。 */ export interface PaperBellAccountInfo { @@ -105,14 +152,19 @@ export interface PaperBellAccountInfo { isActive: boolean; } -/** 经 IPC 对外暴露的共享配置(去密钥)。 */ -export interface PaperBellSharedConfigPublic { +/** 经 IPC 对外暴露的共享配置(去密钥)。受限层,需 `config` scope。 */ +export interface PaperBellRestrictedConfig { schemaVersion: number; language: "en" | "zh"; llm: PaperBellLLMConfigPublic; account?: PaperBellAccountInfo; + profile?: PaperBellUserProfile; + cimpoFolders?: PaperBellCimpoFolders; } +/** @deprecated 上游 0.4.7 起改名为 {@link PaperBellRestrictedConfig};别名同样保留。 */ +export type PaperBellSharedConfigPublic = PaperBellRestrictedConfig; + /** 主插件自身信息(供子插件发现能力)。 */ export interface PaperBellPluginInfo { id: string; // 'paperbell' @@ -159,13 +211,17 @@ export interface PPBCompletionResult { model: string; /** ok=false 时的错误描述(不含密钥等敏感信息)。 */ error?: string; + /** ok=false 时的机读原因;目前只有免费档额度耗尽一种。 */ + errorCode?: "quota-exhausted"; + /** 免费档额度快照(宿主给出时)。`resetsAt` 为 epoch ms。 */ + quota?: { limit: number; remaining: number; resetsAt: number }; } /** * `activation`:宿主许可证 / 激活状态(经 `requestActivationInfo()` 取回)。 * 不含激活码本身 —— 仅暴露是否激活及其派生信息。 */ -export interface PPBActivationInfo { +export interface PaperBellActivationInfo { /** 许可证是否处于激活态。 */ isActive: boolean; /** 到期时间(epoch ms),不适用时省略。 */ @@ -176,7 +232,7 @@ export interface PPBActivationInfo { } /** `download-ticket`:请求受保护下载链接时的参数。 */ -export interface PPBDownloadTicketParams { +export interface PPBProtectedDownloadParams { /** 下载服务基址,缺省 `https://paperbell.cn`。 */ baseUrl?: string; /** 产品标识,缺省 `paperbell-core`。 */ @@ -187,8 +243,13 @@ export interface PPBDownloadTicketParams { * `download-ticket`:宿主凭激活码换取的受保护下载凭据。至少含一个可下载 `url`; * 其余字段随宿主/产品而定。 */ -export interface PPBDownloadTicket { +export interface PPBProtectedDownloadTicket { url: string; + filename?: string; + /** 链接有效期(秒)。 */ + expires_in?: number; + version?: string; + sha256?: string; [key: string]: unknown; } @@ -264,7 +325,7 @@ export interface PPBGrant { */ export interface PPBClient { requestAccountInfo(): Promise; - requestSharedConfig(): Promise; + requestSharedConfig(): Promise; requestPluginInfo(): Promise; /** * 请求宿主代发一次补全(scope: llm-invoke)。 @@ -277,22 +338,24 @@ export interface PPBClient { * 请求完整 LLM 凭据(**含 apiKey**;scope: llm-credentials)。 * 拒绝授权 / 宿主缺失返回 null。属敏感数据,请勿落盘或写日志。 */ - requestLLMCredentials(): Promise; + requestLLMCredentials(): Promise; /** 请求宿主许可证 / 激活状态(scope: activation)。拒绝授权 / 宿主缺失返回 null。 */ - requestActivationInfo(): Promise; + requestActivationInfo(): Promise; /** * 请求受保护下载链接(scope: download-ticket)。需宿主处于激活态; * 拒绝授权 / 宿主缺失返回 null,未激活或换取失败由宿主抛错。 */ requestProtectedDownloadTicket( - params?: PPBDownloadTicketParams, - ): Promise; + params?: PPBProtectedDownloadParams, + ): Promise; /** - * 订阅公开配置变更;返回取消订阅函数。 - * 底层即 workspace 事件 {@link PPB_CONFIG_CHANGED_EVENT}。 + * 订阅受限层配置变更;返回取消订阅函数。 + * + * 这是宿主对**本 client** 的定向推送,不是 workspace 总线 —— 因此它随 client 一起 + * 失效:宿主重载后必须在 {@link PPB_READY_EVENT} 上重新握手并重新订阅。 */ onConfigChange( - cb: (config: PaperBellSharedConfigPublic) => void, + cb: (config: PaperBellRestrictedConfig) => void, ): () => void; /** * 请求宿主的项目清单(scope: projects)。拒绝授权 / 宿主缺失返回 null。 @@ -324,3 +387,14 @@ export interface PPBHostApi { /** 撤销某来源的全部授权。 */ revokeGrant(sourceId: string): void; } + +/** + * 宿主同时把握手函数挂到 `window` 上(早于 `api` 存在,为 QuickAdd 一类用户保留)。 + * 我们不用这条路径 —— 走 `app.plugins.plugins["paperbell"].api` 才有加载顺序保证 —— + * 但它属于契约的一部分,保留声明以便与上游附录逐行对齐。 + */ +declare global { + interface Window { + registerPPBplugin?: RegisterPPBPlugin; + } +} diff --git a/src/paperbell/store.ts b/src/paperbell/store.ts index f2b084f..c6de3a5 100644 --- a/src/paperbell/store.ts +++ b/src/paperbell/store.ts @@ -1,6 +1,6 @@ import { writable } from "svelte/store"; -import type { PaperBellSharedConfigPublic, PPBScope } from "./shared-config"; +import type { PaperBellRestrictedConfig, PPBScope } from "./shared-config"; /** * Reactive state of our connection to the PaperBell host plugin. Populated by @@ -11,10 +11,11 @@ export interface PaperBellState { /** True once we have handshaked with (registered against) the PaperBell host. */ connected: boolean; /** - * Latest public (key-free) shared config. `null` until the user grants the + * Latest restricted (key-free) shared config. `null` until the user grants the * `config` scope (first `requestSharedConfig`) or the host pushes a change. + * Survives a host reload — see `client.ts`, `attach()`. */ - config: PaperBellSharedConfigPublic | null; + config: PaperBellRestrictedConfig | null; /** Host-advertised capabilities, from plugin-info. Used to gate features (e.g. `llm-invoke`). */ capabilities: PPBScope[]; } diff --git a/test/paperbell/client.test.ts b/test/paperbell/client.test.ts index 5362f51..404912c 100644 --- a/test/paperbell/client.test.ts +++ b/test/paperbell/client.test.ts @@ -5,16 +5,22 @@ import { PaperBellClient } from "src/paperbell/client"; import { paperbell } from "src/paperbell/store"; import { PPB_READY_EVENT } from "src/paperbell/shared-config"; import type { + PaperBellRestrictedConfig, PPBCompletionResult, PPBProject, } from "src/paperbell/shared-config"; import { MockPlugin, MockPaperBellHost, - makePublicConfig, + grantFor, + makeRestrictedConfig, type MockHostOptions, } from "./fixtures"; +/** Let the client's fire-and-forget config refresh settle. */ +const flush = (): Promise => + new Promise((resolve) => setTimeout(resolve, 0)); + function newClient(): { client: PaperBellClient; plugin: MockPlugin } { const plugin = new MockPlugin(); const client = new PaperBellClient(plugin as any); @@ -168,15 +174,20 @@ describe("PaperBellClient — handshake", () => { expect(get(paperbell).connected).toBe(true); }); - it("does not double-register if the ready event also fires", () => { + it("re-handshakes (rather than stacking) when the ready event also fires", () => { const { client, plugin } = newClient(); const host = new MockPaperBellHost(); plugin.app.installHost(host); client.init(); // connects via probe - plugin.app.workspace.trigger(PPB_READY_EVENT, host); // must be ignored + plugin.app.workspace.trigger(PPB_READY_EVENT, host); - expect(host.registeredSources).toHaveLength(1); + // The host only broadcasts on load, so a second event means it reloaded and our + // handle is dead. We drop it and register again — never hold two at once. + expect(host.registeredSources).toHaveLength(2); + expect(host.unregisterCalls).toBe(1); + expect(host.configSubscriberCount()).toBe(1); + expect(client.connected).toBe(true); }); it("stays disconnected (and does not throw) if the host rejects registration", () => { @@ -193,6 +204,172 @@ describe("PaperBellClient — handshake", () => { }); }); +describe("PaperBellClient — recovery after the host reloads", () => { + /** + * Connect, then put the client in the state a PaperBell update leaves it in: a live + * session (optionally having already pushed us `seedConfig`, as a real one would), + * then a reload that turns our handle into a zombie, then a fresh host installed but + * not yet announced. The test fires the ready event itself. + */ + function connectThenReloadHost({ + newHost: newHostOpts = {}, + seedConfig, + }: { + newHost?: MockHostOptions; + seedConfig?: PaperBellRestrictedConfig; + } = {}): { + client: PaperBellClient; + plugin: MockPlugin; + oldHost: MockPaperBellHost; + newHost: MockPaperBellHost; + } { + const { client, plugin } = newClient(); + const oldHost = new MockPaperBellHost(); + plugin.app.installHost(oldHost); + client.init(); + if (seedConfig) oldHost.emitConfigChange(seedConfig); + + oldHost.reload(); // the handle we hold is now a zombie + const newHost = new MockPaperBellHost(newHostOpts); + plugin.app.installHost(newHost); + return { client, plugin, oldHost, newHost }; + } + + it("registers against the new host and releases the dead handle", () => { + const { client, plugin, oldHost, newHost } = connectThenReloadHost(); + + plugin.app.workspace.trigger(PPB_READY_EVENT, newHost); + + expect(client.connected).toBe(true); + expect(newHost.registeredSources).toHaveLength(1); + expect(oldHost.unregisterCalls).toBe(1); + expect(get(paperbell).connected).toBe(true); + }); + + it("never holds two registrations, however often ready fires", () => { + // What the old "does not double-register" guard was really protecting. We no + // longer *skip* a repeat handshake (that is what left us on a dead handle), so + // the invariant moved: every registration but the current one is unregistered. + const { client, plugin } = newClient(); + const host = new MockPaperBellHost(); + plugin.app.installHost(host); + client.init(); + + for (let i = 0; i < 3; i++) { + plugin.app.workspace.trigger(PPB_READY_EVENT, host); + } + + expect(host.registeredSources).toHaveLength(4); + expect(host.unregisterCalls).toBe(3); + expect(host.configSubscriberCount()).toBe(1); + expect(client.connected).toBe(true); + }); + + it("resubscribes, so config pushes reach the store again", () => { + const { plugin, newHost } = connectThenReloadHost(); + + plugin.app.workspace.trigger(PPB_READY_EVENT, newHost); + expect(newHost.configSubscriberCount()).toBe(1); + + const cfg = makeRestrictedConfig({ language: "zh" }); + newHost.emitConfigChange(cfg); + expect(get(paperbell).config).toEqual(cfg); + }); + + it("keeps the last known config so the UI does not flip back mid-reload", () => { + const cfg = makeRestrictedConfig({ language: "zh" }); + const { plugin, newHost } = connectThenReloadHost({ seedConfig: cfg }); + expect(get(paperbell).config).toEqual(cfg); + + // The fresh host serves no config (nothing was granted), so anything still in the + // store after this can only be what we deliberately carried over. + plugin.app.workspace.trigger(PPB_READY_EVENT, newHost); + + expect(get(paperbell).config).toEqual(cfg); + }); + + it("keeps the last known config even if re-registering fails", () => { + const errSpy = vi + .spyOn(console, "error") + .mockImplementation((): void => undefined); + const cfg = makeRestrictedConfig({ language: "zh" }); + const { client, plugin, newHost } = connectThenReloadHost({ + newHost: { rejectRegistration: true }, + seedConfig: cfg, + }); + + plugin.app.workspace.trigger(PPB_READY_EVENT, newHost); + + // Disconnected, but not amnesiac: dropping the config here would cause exactly + // the language flip the reconnect path goes out of its way to avoid. + expect(client.connected).toBe(false); + expect(get(paperbell)).toEqual({ + connected: false, + config: cfg, + capabilities: [], + }); + errSpy.mockRestore(); + }); + + it("refetches the config when that scope is already granted", async () => { + const fresh = makeRestrictedConfig({ language: "zh" }); + const { plugin, newHost } = connectThenReloadHost({ + newHost: { sharedConfig: fresh, grants: [grantFor("config")] }, + }); + + plugin.app.workspace.trigger(PPB_READY_EVENT, newHost); + await flush(); + + // The grant survives the reload, so this costs no consent prompt — and without + // it we would keep serving whatever the host had before it restarted. + expect(get(paperbell).config).toEqual(fresh); + }); + + it("does not refetch when we hold no grant (that would prompt for consent)", async () => { + const { plugin, newHost } = connectThenReloadHost({ + newHost: { sharedConfig: makeRestrictedConfig() }, + }); + + plugin.app.workspace.trigger(PPB_READY_EVENT, newHost); + await flush(); + + expect(get(paperbell).config).toBeNull(); + }); + + it("requests nothing on a FIRST connect, grant or no grant", async () => { + // Startup stays scope-free: the refetch is a recovery step, not a launch step. + const { client, plugin } = newClient(); + plugin.app.installHost( + new MockPaperBellHost({ + sharedConfig: makeRestrictedConfig(), + grants: [grantFor("config")], + }) + ); + + client.init(); + await flush(); + + expect(client.connected).toBe(true); + expect(get(paperbell).config).toBeNull(); + }); + + it("connects anyway when the host's grant list blows up", () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation((): void => undefined); + const { client, plugin, newHost } = connectThenReloadHost({ + newHost: { grantsThrow: true, grants: [grantFor("config")] }, + }); + + expect(() => + plugin.app.workspace.trigger(PPB_READY_EVENT, newHost) + ).not.toThrow(); + expect(client.connected).toBe(true); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); + describe("PaperBellClient — config following", () => { it("updates the store when the host pushes a config change", () => { const { client, plugin } = newClient(); @@ -202,7 +379,7 @@ describe("PaperBellClient — config following", () => { expect(get(paperbell).config).toBeNull(); - const cfg = makePublicConfig({ language: "zh" }); + const cfg = makeRestrictedConfig({ language: "zh" }); host.emitConfigChange(cfg); expect(get(paperbell).config).toEqual(cfg); @@ -218,7 +395,7 @@ describe("PaperBellClient — config following", () => { plugin.app.installHost(host); client.init(); - host.emitConfigChange(makePublicConfig({ schemaVersion: 999 })); + host.emitConfigChange(makeRestrictedConfig({ schemaVersion: 999 })); expect(warnSpy).toHaveBeenCalled(); expect(String(warnSpy.mock.calls[0][0])).toContain("schemaVersion"); @@ -238,7 +415,7 @@ describe("PaperBellClient — config following", () => { describe("PaperBellClient — on-demand consented data", () => { it("fetchSharedConfig returns and stores the config", async () => { - const cfg = makePublicConfig(); + const cfg = makeRestrictedConfig(); const { client, plugin } = newClient(); plugin.app.installHost(new MockPaperBellHost({ sharedConfig: cfg })); client.init(); diff --git a/test/paperbell/fixtures.ts b/test/paperbell/fixtures.ts index d8de27e..2bf0a7c 100644 --- a/test/paperbell/fixtures.ts +++ b/test/paperbell/fixtures.ts @@ -12,21 +12,22 @@ import type { PaperBellAccountInfo, PaperBellPluginInfo, - PaperBellSharedConfigPublic, - PPBActivationInfo, + PaperBellRestrictedConfig, + PaperBellActivationInfo, PPBClient, PPBCompletionParams, PPBCompletionResult, - PPBDownloadTicket, - PPBDownloadTicketParams, + PPBProtectedDownloadTicket, + PPBProtectedDownloadParams, PPBGrant, PPBHostApi, - PPBLLMCredentials, + PaperBellLLMCredentials, PPBProjectsQuery, PPBProjectsResult, PPBRequestSource, PPBScope, } from "src/paperbell/shared-config"; +import { THIS_PLUGIN_ID, THIS_PLUGIN_NAME } from "src/paperbell/client"; /** * The full set of scopes the real host advertises today. @@ -101,17 +102,17 @@ export interface MockHostOptions { /** Capabilities advertised via `getPluginInfo()`. Defaults to all scopes. */ capabilities?: PPBScope[]; /** Value returned by `requestSharedConfig()`. Default null (as if consent denied). */ - sharedConfig?: PaperBellSharedConfigPublic | null; + sharedConfig?: PaperBellRestrictedConfig | null; /** Value returned by `requestAccountInfo()`. */ account?: PaperBellAccountInfo | null; /** Value returned by `requestCompletion()`. */ completion?: PPBCompletionResult | null; /** Value returned by `requestLLMCredentials()`. */ - llmCredentials?: PPBLLMCredentials | null; + llmCredentials?: PaperBellLLMCredentials | null; /** Value returned by `requestActivationInfo()`. */ - activation?: PPBActivationInfo | null; + activation?: PaperBellActivationInfo | null; /** Value returned by `requestProtectedDownloadTicket()`. */ - downloadTicket?: PPBDownloadTicket | null; + downloadTicket?: PPBProtectedDownloadTicket | null; /** If true, `registerPPBplugin` throws (host rejects the handshake). */ rejectRegistration?: boolean; /** @@ -122,6 +123,10 @@ export interface MockHostOptions { projects?: PPBProjectsResult | null; /** If true, `requestProjects()` rejects (host blew up mid-call). */ projectsThrow?: boolean; + /** Grants the user has already approved, as `listGrants()` reports them. */ + grants?: PPBGrant[]; + /** If true, `listGrants()` throws (host blew up). */ + grantsThrow?: boolean; } /** @@ -134,25 +139,29 @@ export class MockPaperBellHost implements PPBHostApi { lastCompletionParams: PPBCompletionParams | null = null; pluginInfo: PaperBellPluginInfo; - sharedConfig: PaperBellSharedConfigPublic | null; + sharedConfig: PaperBellRestrictedConfig | null; account: PaperBellAccountInfo | null; completion: PPBCompletionResult | null; - llmCredentials: PPBLLMCredentials | null; - activation: PPBActivationInfo | null; - downloadTicket: PPBDownloadTicket | null; - lastDownloadTicketParams: PPBDownloadTicketParams | undefined; + llmCredentials: PaperBellLLMCredentials | null; + activation: PaperBellActivationInfo | null; + downloadTicket: PPBProtectedDownloadTicket | null; + lastDownloadTicketParams: PPBProtectedDownloadParams | undefined; lastProjectsQuery: PPBProjectsQuery | undefined; private projects: PPBProjectsResult | null | undefined; private projectsThrow: boolean; + private grants: PPBGrant[]; + private grantsThrow: boolean; + /** Set by {@link reload}; makes every handle this host already issued inert. */ + private stale = false; private rejectRegistration: boolean; - private configSubscribers: Array<(c: PaperBellSharedConfigPublic) => void> = []; + private configSubscribers: Array<(c: PaperBellRestrictedConfig) => void> = []; constructor(opts: MockHostOptions = {}) { this.pluginInfo = { id: "paperbell", name: "PaperBell", version: "1.0.0", - schemaVersion: 1, + schemaVersion: 2, isActivated: true, capabilities: opts.capabilities ?? [...ALL_SCOPES], }; @@ -165,6 +174,8 @@ export class MockPaperBellHost implements PPBHostApi { this.rejectRegistration = opts.rejectRegistration ?? false; this.projects = opts.projects; this.projectsThrow = opts.projectsThrow ?? false; + this.grants = opts.grants ?? []; + this.grantsThrow = opts.grantsThrow ?? false; } /** Whether this mock pretends to be new enough to serve a project list. */ @@ -185,29 +196,33 @@ export class MockPaperBellHost implements PPBHostApi { requestProjects: async (params?: PPBProjectsQuery) => { this.lastProjectsQuery = params; if (this.projectsThrow) throw new Error("host exploded"); - return this.projects ?? null; + return this.stale ? null : this.projects ?? null; }, } : {}; + // Everything a handle can answer goes through here, so {@link reload} makes the + // WHOLE handle inert in one place — a real 0.4.7 host nulls every `request*`, and a + // mock that only nulled some of them would let a test pass against a zombie. + const live = (value: T): T | null => (this.stale ? null : value); return { ...projectsApi, - requestAccountInfo: async () => this.account, - requestSharedConfig: async () => this.sharedConfig, - requestPluginInfo: async () => this.pluginInfo, + requestAccountInfo: async () => live(this.account), + requestSharedConfig: async () => live(this.sharedConfig), + requestPluginInfo: async () => live(this.pluginInfo), requestCompletion: async (params: PPBCompletionParams) => { this.lastCompletionParams = params; - return this.completion; + return live(this.completion); }, - requestLLMCredentials: async () => this.llmCredentials, - requestActivationInfo: async () => this.activation, + requestLLMCredentials: async () => live(this.llmCredentials), + requestActivationInfo: async () => live(this.activation), requestProtectedDownloadTicket: async ( - params?: PPBDownloadTicketParams + params?: PPBProtectedDownloadParams ) => { this.lastDownloadTicketParams = params; - return this.downloadTicket; + return live(this.downloadTicket); }, - onConfigChange: (cb: (c: PaperBellSharedConfigPublic) => void) => { - this.configSubscribers.push(cb); + onConfigChange: (cb: (c: PaperBellRestrictedConfig) => void) => { + if (!this.stale) this.configSubscribers.push(cb); return () => { this.configSubscribers = this.configSubscribers.filter( (c) => c !== cb @@ -225,7 +240,8 @@ export class MockPaperBellHost implements PPBHostApi { } listGrants(): PPBGrant[] { - return []; + if (this.grantsThrow) throw new Error("host exploded"); + return this.grants; } revokeGrant(_sourceId: string): void { @@ -233,8 +249,19 @@ export class MockPaperBellHost implements PPBHostApi { } // ── test helpers ────────────────────────────────────────────────────────── - /** Simulate the host broadcasting a public-config change to subscribers. */ - emitConfigChange(config: PaperBellSharedConfigPublic): void { + /** + * Mimic PaperBell 0.4.7 being reloaded: handles it already handed out keep working + * as objects but go inert — every `request*` resolves to `null` (no throw) and the + * config push is gone. A test then installs a fresh host and fires the ready event, + * which is exactly what the real host does on each load. + */ + reload(): void { + this.stale = true; + this.configSubscribers = []; + } + + /** Simulate the host pushing a restricted-config change to its subscribers. */ + emitConfigChange(config: PaperBellRestrictedConfig): void { this.configSubscribers.slice().forEach((cb) => cb(config)); } @@ -244,12 +271,26 @@ export class MockPaperBellHost implements PPBHostApi { } } -/** A valid public shared config for tests, with an overridable schema version. */ -export function makePublicConfig( - overrides: Partial = {} -): PaperBellSharedConfigPublic { +/** + * A grant of `scopes` to our own plugin id, as the host would report it. Reuses the + * client's own constants: a copy that drifted would make `hasGrant` quietly return + * false and the test pass for the wrong reason. + */ +export function grantFor(...scopes: PPBScope[]): PPBGrant { + return { + sourceId: THIS_PLUGIN_ID, + sourceName: THIS_PLUGIN_NAME, + scopes, + grantedAt: 0, + }; +} + +/** A valid restricted shared config for tests, with an overridable schema version. */ +export function makeRestrictedConfig( + overrides: Partial = {} +): PaperBellRestrictedConfig { return { - schemaVersion: 1, + schemaVersion: 2, language: "en", llm: { providerId: "anthropic",