mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Plugins extend the application without adding each integration to
Core.
> - A downstream image needs a way to supply prebuilt plugins.
> - Some plugin UI must stay mounted as users move between pages.
> - This change adds an image catalog and a persistent application slot.
> - Operators can upgrade or remove these plugins through their image
and configuration.
## Linked Issues or Issue Description
**Subsystem affected**
Plugin packaging, activation and application UI.
**Problem or motivation**
The built-in plugin catalog is fixed in Core source. Downstream images
cannot add entries through an explicit catalog. Existing page slots also
cannot preserve a small application overlay across route changes.
**Proposed solution**
Read a bounded catalog of prebuilt plugins from the image. Verify its
files before importing manifests. Use the existing managed selection and
plugin lifecycle. Add an `appShellOverlay` slot with account and company
cleanup.
**Alternatives considered**
A downstream fork adds merge work. Script injection provides no plugin
lifecycle. A separate runtime download system adds a second distribution
channel.
**Roadmap alignment**
This extends the existing plugin system. Related PR #9006 covers runtime
install replication; this change covers immutable image contents. PR
#12555 covers CLI scaffolding. Neither provides this catalog or
application slot. The maintainer requested this work directly.
## What Changed
- Validate catalog identities, confined paths, package versions and
bundle hashes before importing code.
- Apply image selection to persisted plugin installs, including removal
and rollback. Adopt the verified image path from existing npm/local
installs and bind runtime worker/UI entrypoints to verified package
declarations.
- Mount application overlays in both UI shells. Preserve route state and
clear it on account, company and onboarding changes.
- Restrict service-worker offline storage/fallback to hashed public
assets in a separate cache namespace; exclude application HTML and
extension/API data, including after worker restart.
- Document the packaging contract, trust model and rollback
requirements.
## Verification
- Passed `pnpm -r typecheck`, `pnpm build`, and `pnpm
check:token-gates`. Affected server/UI typechecks and builds, plus token
gates, passed again after rebasing onto current master; the 124 focused
tests also passed after rebase.
- Latest focused verification: 124 tests in nine files passed for
catalog/reconciliation/loader, overlay lifecycle, Layout and
service-worker policy. The broader UI/shared/SDK run passed 7,204 tests
in 690 files with canonical `TMPDIR`.
- Real disposable Core/PostgreSQL: catalog install, selection removal,
0.1.0→0.1.1→0.1.0, same-version npm/legacy-path adoption, and
preservation of disabled status passed. Added permissions entered
`upgrade_pending`, withheld UI across restart, and activated only after
explicit operator enable.
- Real Chromium: desktop/mobile layout, route draft retention and
Escape/focus passed with mocked extension responses. A persistent
browser restart retained public hashed-asset offline fallback while
refusing seeded legacy/current private entries and legacy HTML.
- Full `pnpm test:run`: 12,539 passed; 17 failed across six existing
files, stopping later phases. macOS read-only directory renames fail in
runtime-skill-cache and company-skills-service; email tests require an
absent local AgentMail fixture. Native runner/comment-redaction passed
in isolation after temporary Rust setup; agent-conversations also passed
in isolation. No unrelated source was changed to hide failures.
- After rebase, two unchanged chat timing tests failed in CI and passed
locally in isolation. Their CI shard passed on its single retry. All
other current-head CI jobs passed on the initial run; review is 5/5 with
no unresolved threads.
- No live deployment or external plugin service was used.
## Risks
- Plugins are trusted code. The catalog detects packaging errors; it
does not authenticate an untrusted image builder.
- Invalid catalogs fail startup. Images must contain the catalog and
bundles together, with stable directories.
- A host older than this contract lacks the activation guard. Disable
added plugins and remove their configuration keys before reverting to
it.
- Offline navigation now returns 503 instead of replaying cached
application HTML. Only public build assets have offline fallback.
- Rolling back an unapproved permission change retains the approval
gate; review the current manifest and explicitly enable it. A reduced
permission set cannot establish prior approval or prior enabled status.
- Plugin data migrations need their own rollback policy. This change
retains installed records and does not reverse migrations.
## Model Used
- OpenAI GPT-6 (Codex), model ID `gpt-6`, with repository inspection,
code execution and browser verification. The runtime does not expose an
exact context-window size.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (relevant suites; broad
macOS server-run exceptions are documented above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (fresh run on 488b3754ae; chat
shard passed its single retry)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(fresh review on 488b3754ae)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
86 lines
3.7 KiB
JavaScript
86 lines
3.7 KiB
JavaScript
// The build id is stamped into this file at production build time (see
|
|
// stampServiceWorkerBuildId in vite.config.ts), so a deploy that changes only
|
|
// the app bundle still changes sw.js byte-for-byte. That is what makes the
|
|
// browser install a new worker, which — via skipWaiting + controllerchange —
|
|
// reloads parked tabs onto the fresh bundle. Left as the literal placeholder in
|
|
// dev, where HMR (not the worker) drives refreshes.
|
|
const BUILD_ID = "__PAPERCLIP_BUILD_ID__";
|
|
// Separate this allowlisted cache from older workers that cached arbitrary URLs.
|
|
const CACHE_NAME = `paperclip-public-assets-${BUILD_ID}`;
|
|
const privateRequests = new Set();
|
|
const privateCacheControl = /(?:^|,)\s*(?:no-store|private)(?:\s*(?:,|=)|\s*$)/i;
|
|
|
|
async function evictRequest(request) {
|
|
await Promise.all((await caches.keys()).map(async (key) => {
|
|
const cache = await caches.open(key);
|
|
await cache.delete(request, { ignoreVary: true });
|
|
}));
|
|
}
|
|
|
|
self.addEventListener("install", () => {
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
event.waitUntil(
|
|
caches.keys().then((keys) =>
|
|
Promise.all(keys.map((key) => caches.delete(key)))
|
|
)
|
|
);
|
|
self.clients.claim();
|
|
});
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
// Only immutable Vite build assets have a public offline-cache contract.
|
|
// Never infer that application/extension responses are public from absent
|
|
// headers, or from an in-memory classification lost when this worker restarts.
|
|
const publicAsset = url.origin === self.location.origin && !url.search &&
|
|
/^\/assets\/[^/]+-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9.]+$/.test(url.pathname);
|
|
|
|
// Explicitly private requests must bypass BOTH cache writes and offline
|
|
// fallback, including extension endpoints outside the host /api namespace.
|
|
if (request.method !== "GET" || url.pathname.startsWith("/api")) {
|
|
return;
|
|
}
|
|
if (request.cache === "no-store") {
|
|
privateRequests.add(request.url);
|
|
event.waitUntil(evictRequest(request).catch(() => {}));
|
|
return;
|
|
}
|
|
|
|
// Network-first; only public build assets can use an offline fallback.
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then(async (response) => {
|
|
const cacheControl = response.headers.get("cache-control") ?? "";
|
|
if (privateCacheControl.test(cacheControl)) {
|
|
// Revoke earlier cacheable responses too. Keep an in-memory denylist
|
|
// if storage is unavailable so offline fallback still fails closed.
|
|
privateRequests.add(request.url);
|
|
await evictRequest(request).catch(() => {});
|
|
} else if (response.ok && publicAsset && !privateRequests.has(request.url)) {
|
|
const clone = response.clone();
|
|
await caches.open(CACHE_NAME).then(async (cache) => {
|
|
await cache.put(request, clone);
|
|
// A concurrent response may have revoked this URL during put().
|
|
if (privateRequests.has(request.url)) await cache.delete(request, { ignoreVary: true });
|
|
}).catch(() => {});
|
|
}
|
|
return response;
|
|
})
|
|
.catch(async () => {
|
|
if (privateRequests.has(request.url)) return Response.error();
|
|
if (!publicAsset) return request.mode === "navigate" ? new Response("Offline", { status: 503 }) : Response.error();
|
|
// Restrict lookup to this policy's cache; old arbitrary-response caches
|
|
// must not become fallback candidates if activation cleanup fails.
|
|
try {
|
|
const cached = await (await caches.open(CACHE_NAME)).match(request);
|
|
if (cached && !privateCacheControl.test(cached.headers.get("cache-control") ?? "")) return cached;
|
|
} catch { /* Unavailable cache storage is an offline miss. */ }
|
|
return Response.error();
|
|
})
|
|
);
|
|
});
|