papercliped 2.4.2 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +7 -0
- package/dist/oauth/paperclip-login.js +41 -0
- package/dist/oauth/pg-store.js +19 -6
- package/dist/oauth/provider.js +150 -17
- package/dist/oauth/store.js +26 -0
- package/dist/web/routes.js +1 -1
- package/docs/LAUNCH-KIT.md +133 -0
- package/docs/supabase-schema.sql +18 -1
- package/migrations/008_key_rotation.sql +15 -0
- package/package.json +1 -1
- package/site/docs/chatgpt.md +11 -0
- package/site/docs/faq.md +2 -2
- package/site/docs/getting-started.md +12 -5
- package/site/docs/other-ai-apps.md +23 -1
- package/site/docs/security.md +1 -0
- package/site/docs/troubleshooting.md +1 -1
- package/site/docs/what-is-papercliped.md +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## v2.5.1 — Paperclip keys renew themselves
|
|
6
|
+
|
|
7
|
+
- **Connections no longer break after 30 days.** Newer Paperclip versions expire the key Papercliped gets when you approve the sign-in after 30 days. Papercliped now renews it about a week before: it asks your Paperclip for a fresh key (named "Papercliped (your username)", same 30-day expiry), stores it encrypted and revokes the old one. Keys of people who rarely use their connection are renewed by the periodic sweep. If a renewal fails, the current key keeps working and it is tried again later. Older Paperclip versions, whose keys don't expire, work as before. Self-hosters on Postgres: run the database migration before starting this version (`npm run migrate`, or `BRIDGE_AUTO_MIGRATE=1`); keys stored before it are assumed to expire 30 days after they were connected, and the first renewal check reads the real date from Paperclip.
|
|
8
|
+
- **Community cards.** Ready-to-post images for the launch and the new r/OpenSourcedd community, light and dark, 1600×900, in `web/public/brand/community/` (rebuilt by `web/scripts/community-cards.mjs`). Download them from papercliped.co/brand, and the README now opens with the launch card.
|
|
9
|
+
|
|
10
|
+
## v2.5.0 — icon pack, launch-ready docs and tester feedback
|
|
11
|
+
|
|
12
|
+
- **Icon pack.** papercliped.co/brand has a one-click ZIP with every Papercliped icon in light and dark: the app icon from 16 to 1024 px, a favicon, profile pictures safe for circle crops (Discord, X, GitHub, npm, MCP directories), an Android/installable-app icon, an X header, a 16:9 banner and link-preview images. They're built from the real mascot and the site's own components by `web/scripts/icon-pack.mjs`, so they always match. The website now also has a classic favicon, an Apple touch icon and a web app manifest.
|
|
13
|
+
- **Docs ready for launch.** Getting started and *Any AI app* now cover Codex and Claude Code (plugin or MCP server). ChatGPT setup leads with the MCP connector in developer mode, with GPT Actions as the alternative. The glossary explains the control room, sessions and access modes. The FAQ explains which access mode a blocked pause or wake needs. Stale links to the old manage page now point to the control room.
|
|
14
|
+
- **Tester feedback form.** GitHub → New issue → *Tester feedback* asks testers which app and host they used, where they got stuck and what they asked. A launch kit for maintainers (`docs/LAUNCH-KIT.md`) covers the pre-launch checklist, a founding-tester program, where to promote, ready-to-paste posts and the demo script.
|
|
15
|
+
|
|
5
16
|
## v2.4.2 — Show the real reason a plugin action failed
|
|
6
17
|
|
|
7
18
|
- **Fixed: the Papercliped page inside Paperclip now shows the real reason a sign-in or save failed**, such as "That username and secret key don't match." Paperclip reports every plugin failure as HTTP 502, and a proxy in front of Paperclip (Cloudflare, for one) replaces that with its own "Bad gateway" page, so the page showed only "Request failed: 502". The plugin now returns failures as ordinary answers and the page turns them back into messages.
|
package/README.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Papercliped
|
|
2
2
|
|
|
3
|
+
<a href="https://papercliped.co">
|
|
4
|
+
<picture>
|
|
5
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/OpenSourcx/papercliped/main/web/public/brand/community/papercliped-launch-dark.png">
|
|
6
|
+
<img alt="Papercliped: run your Paperclip from any AI app. Claude, ChatGPT, Codex or any MCP app. Connector: https://mcp.papercliped.co/mcp" src="https://raw.githubusercontent.com/OpenSourcx/papercliped/main/web/public/brand/community/papercliped-launch-light.png">
|
|
7
|
+
</picture>
|
|
8
|
+
</a>
|
|
9
|
+
|
|
3
10
|
**Run your [Paperclip](https://github.com/paperclipai/paperclip) from any AI app.** Papercliped connects Claude, ChatGPT, Codex and any MCP app to your Paperclip, the open-source orchestrator for companies of AI agents. Check on agents, pause or wake them, assign issues, decide approvals and get status, cost and activity reports, without opening the dashboard.
|
|
4
11
|
|
|
5
12
|
[](https://github.com/OpenSourcx/papercliped/actions/workflows/ci.yml)
|
|
@@ -3,6 +3,15 @@ const APPROVAL_PATH = /^\/cli-auth\/[0-9a-f-]{36}\?token=[A-Za-z0-9_\-.~%]{8,300
|
|
|
3
3
|
/** Paperclip runs npm install server-side, which can take a while. */
|
|
4
4
|
const PLUGIN_INSTALL_TIMEOUT_MS = 120_000;
|
|
5
5
|
const TOKENISH = /^[\x21-\x7e]{8,600}$/; // printable ASCII, no whitespace
|
|
6
|
+
/** How long Paperclip keeps a board key it issued without an explicit expiry (its BOARD_API_KEY_TTL_MS). */
|
|
7
|
+
export const PAPERCLIP_KEY_TTL_MS = 30 * 24 * 3600 * 1000;
|
|
8
|
+
/** null = never expires; undefined = not said. Anything unreadable counts as not said. */
|
|
9
|
+
function expiryOf(v) {
|
|
10
|
+
if (v === null)
|
|
11
|
+
return null;
|
|
12
|
+
const t = typeof v === "string" ? Date.parse(v) : NaN;
|
|
13
|
+
return Number.isFinite(t) ? t : undefined;
|
|
14
|
+
}
|
|
6
15
|
export class LoginError extends Error {
|
|
7
16
|
status;
|
|
8
17
|
constructor(message, status) {
|
|
@@ -118,6 +127,38 @@ export class PaperclipLogin {
|
|
|
118
127
|
throw new LoginError("Paperclip installed something other than the Papercliped plugin");
|
|
119
128
|
return "installed";
|
|
120
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* The calling key's id and expiry, from Paperclip's own key listing. `unsupported` = this Paperclip has no key
|
|
132
|
+
* listing (versions from before board keys expired). expiresAt: null = never expires, undefined = not listed.
|
|
133
|
+
*/
|
|
134
|
+
async keyInfo(token) {
|
|
135
|
+
const auth = { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } };
|
|
136
|
+
const me = await json(await this.t.fetch(`${this.t.apiUrl}/cli-auth/me`, { ...auth, ...this.sig() }));
|
|
137
|
+
const keyId = typeof me?.keyId === "string" && UUID.test(me.keyId) ? me.keyId : null;
|
|
138
|
+
const res = await this.t.fetch(`${this.t.apiUrl}/board-api-keys`, { ...auth, ...this.sig() });
|
|
139
|
+
if (res.status === 404)
|
|
140
|
+
return { state: "unsupported" };
|
|
141
|
+
const list = await json(res);
|
|
142
|
+
if (!Array.isArray(list))
|
|
143
|
+
throw new LoginError("Paperclip returned an unexpected key list");
|
|
144
|
+
const mine = keyId ? list.find((k) => k && typeof k === "object" && k.id === keyId) : undefined;
|
|
145
|
+
return { state: "ok", keyId, expiresAt: mine ? expiryOf(mine.expiresAt) : undefined };
|
|
146
|
+
}
|
|
147
|
+
/** A new board key for the same Paperclip user, with Paperclip's default expiry. `unsupported` = no key API there. */
|
|
148
|
+
async createKey(token, name) {
|
|
149
|
+
const res = await this.t.fetch(`${this.t.apiUrl}/board-api-keys`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
152
|
+
body: JSON.stringify({ name: name.slice(0, 120) }),
|
|
153
|
+
...this.sig(),
|
|
154
|
+
});
|
|
155
|
+
if (res.status === 404)
|
|
156
|
+
return { state: "unsupported" };
|
|
157
|
+
const k = await json(res);
|
|
158
|
+
if (typeof k?.token !== "string" || !TOKENISH.test(k.token))
|
|
159
|
+
throw new LoginError("Paperclip returned an invalid credential");
|
|
160
|
+
return { state: "created", keyId: typeof k.id === "string" && UUID.test(k.id) ? k.id : null, token: k.token, expiresAt: expiryOf(k.expiresAt) };
|
|
161
|
+
}
|
|
121
162
|
/** Best effort: invalidate the board key we were issued when a grant is revoked. */
|
|
122
163
|
async revoke(token) {
|
|
123
164
|
try {
|
package/dist/oauth/pg-store.js
CHANGED
|
@@ -23,6 +23,7 @@ const toGrant = (r) => ({
|
|
|
23
23
|
resource: r.resource,
|
|
24
24
|
instanceUrl: r.instance_url,
|
|
25
25
|
sealedCredential: r.sealed_credential,
|
|
26
|
+
credentialExpiresAt: r.credential_expires_at == null ? null : num(r.credential_expires_at),
|
|
26
27
|
accountId: r.account_id ?? null,
|
|
27
28
|
username: r.username ?? null,
|
|
28
29
|
createdAt: num(r.created_at),
|
|
@@ -69,9 +70,9 @@ export class PgStore {
|
|
|
69
70
|
order by last_used_at desc offset $2))`, [t - CLIENT_IDLE_MS, MAX_CLIENTS]);
|
|
70
71
|
}
|
|
71
72
|
async putGrant(g) {
|
|
72
|
-
await this.q(`insert into bridge.grants (id, client_id, client_name, user_id, scopes, resource, instance_url, sealed_credential, created_at, last_used_at, revoked, account_id, username)
|
|
73
|
-
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
|
74
|
-
on conflict (id) do update set sealed_credential = excluded.sealed_credential, scopes = excluded.scopes, revoked = excluded.revoked, last_used_at = excluded.last_used_at`, [g.id, g.clientId, g.clientName, g.userId, g.scopes, g.resource, g.instanceUrl, g.sealedCredential, g.createdAt, g.lastUsedAt, g.revoked, g.accountId ?? null, g.username ?? null]);
|
|
73
|
+
await this.q(`insert into bridge.grants (id, client_id, client_name, user_id, scopes, resource, instance_url, sealed_credential, created_at, last_used_at, revoked, account_id, username, credential_expires_at)
|
|
74
|
+
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
|
75
|
+
on conflict (id) do update set sealed_credential = excluded.sealed_credential, credential_expires_at = excluded.credential_expires_at, scopes = excluded.scopes, revoked = excluded.revoked, last_used_at = excluded.last_used_at`, [g.id, g.clientId, g.clientName, g.userId, g.scopes, g.resource, g.instanceUrl, g.sealedCredential, g.createdAt, g.lastUsedAt, g.revoked, g.accountId ?? null, g.username ?? null, g.credentialExpiresAt ?? null]);
|
|
75
76
|
}
|
|
76
77
|
async getGrant(id) {
|
|
77
78
|
const r = await this.q("select * from bridge.grants where id = $1", [id]);
|
|
@@ -178,7 +179,7 @@ export class PgStore {
|
|
|
178
179
|
return { id: x.id, username: x.username, usernameKey: x.username_key, secretHash: x.secret_hash, createdAt: num(x.created_at), lastLoginAt: x.last_login_at == null ? null : num(x.last_login_at), disabled: x.disabled, anonymous: !!x.anonymous, alias: x.alias ?? null, beta: !!x.beta, policy: x.policy ?? null };
|
|
179
180
|
}
|
|
180
181
|
static toLink(x) {
|
|
181
|
-
return { accountId: x.account_id, instanceUrl: x.instance_url, paperclipUserId: x.paperclip_user_id, sealedCredential: x.sealed_credential, createdAt: num(x.created_at), connectedAt: num(x.connected_at), lastUsedAt: num(x.last_used_at), instanceLabel: x.instance_label ?? null };
|
|
182
|
+
return { accountId: x.account_id, instanceUrl: x.instance_url, paperclipUserId: x.paperclip_user_id, sealedCredential: x.sealed_credential, credentialExpiresAt: x.credential_expires_at == null ? null : num(x.credential_expires_at), createdAt: num(x.created_at), connectedAt: num(x.connected_at), lastUsedAt: num(x.last_used_at), instanceLabel: x.instance_label ?? null };
|
|
182
183
|
}
|
|
183
184
|
async createAccount(a) {
|
|
184
185
|
try {
|
|
@@ -283,8 +284,8 @@ export class PgStore {
|
|
|
283
284
|
}
|
|
284
285
|
async putLink(l) {
|
|
285
286
|
try {
|
|
286
|
-
await this.q(`insert into bridge.account_links (account_id, instance_url, paperclip_user_id, sealed_credential, created_at, connected_at, last_used_at, instance_label) values ($1,$2,$3,$4,$5,$6,$7,$8)
|
|
287
|
-
on conflict (account_id) do update set instance_url = excluded.instance_url, paperclip_user_id = excluded.paperclip_user_id, sealed_credential = excluded.sealed_credential, connected_at = excluded.connected_at, last_used_at = excluded.last_used_at, instance_label = excluded.instance_label`, [l.accountId, l.instanceUrl, l.paperclipUserId, l.sealedCredential, l.createdAt, l.connectedAt, l.lastUsedAt, l.instanceLabel ?? null]);
|
|
287
|
+
await this.q(`insert into bridge.account_links (account_id, instance_url, paperclip_user_id, sealed_credential, created_at, connected_at, last_used_at, instance_label, credential_expires_at) values ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
288
|
+
on conflict (account_id) do update set instance_url = excluded.instance_url, paperclip_user_id = excluded.paperclip_user_id, sealed_credential = excluded.sealed_credential, credential_expires_at = excluded.credential_expires_at, connected_at = excluded.connected_at, last_used_at = excluded.last_used_at, instance_label = excluded.instance_label`, [l.accountId, l.instanceUrl, l.paperclipUserId, l.sealedCredential, l.createdAt, l.connectedAt, l.lastUsedAt, l.instanceLabel ?? null, l.credentialExpiresAt ?? null]);
|
|
288
289
|
return true;
|
|
289
290
|
}
|
|
290
291
|
catch (e) {
|
|
@@ -312,6 +313,18 @@ export class PgStore {
|
|
|
312
313
|
async listIdleLinks(cutoff) {
|
|
313
314
|
return (await this.q("select * from bridge.account_links where sealed_credential is not null and last_used_at < $1", [cutoff])).rows.map(PgStore.toLink);
|
|
314
315
|
}
|
|
316
|
+
async listExpiringLinks(before) {
|
|
317
|
+
return (await this.q("select * from bridge.account_links where sealed_credential is not null and credential_expires_at < $1", [before])).rows.map(PgStore.toLink);
|
|
318
|
+
}
|
|
319
|
+
async listExpiringGrants(before) {
|
|
320
|
+
return (await this.q("select * from bridge.grants where not revoked and sealed_credential is not null and credential_expires_at < $1", [before])).rows.map(toGrant);
|
|
321
|
+
}
|
|
322
|
+
async swapCredential(h, expect, next, expiresAt) {
|
|
323
|
+
const sql = h.kind === "link"
|
|
324
|
+
? "update bridge.account_links set sealed_credential = $3, credential_expires_at = $4 where account_id = $1 and sealed_credential = $2"
|
|
325
|
+
: "update bridge.grants set sealed_credential = $3, credential_expires_at = $4 where id = $1 and sealed_credential = $2 and not revoked";
|
|
326
|
+
return ((await this.q(sql, [h.id, expect, next, expiresAt])).rowCount ?? 0) === 1;
|
|
327
|
+
}
|
|
315
328
|
async revokeAccountGrants(accountId) {
|
|
316
329
|
const c = await this.pool.connect();
|
|
317
330
|
try {
|
package/dist/oauth/provider.js
CHANGED
|
@@ -7,11 +7,11 @@ import { hostAllowed } from "../config.js";
|
|
|
7
7
|
import { UnsafeUrlError, createSafeFetch, parseInstanceUrl } from "../net/safe-fetch.js";
|
|
8
8
|
import { Keyring, isValidCodeChallenge, randomToken, safeEqual, sha256Hex, verifyPkce } from "./crypto.js";
|
|
9
9
|
import { approvePage, choosePage, consentPage, errorPage, instancePage, scopePage, secretPage, usernamePage, welcomePage } from "./pages.js";
|
|
10
|
-
import { LoginError, PaperclipLogin } from "./paperclip-login.js";
|
|
10
|
+
import { LoginError, PAPERCLIP_KEY_TTL_MS, PaperclipLogin } from "./paperclip-login.js";
|
|
11
11
|
import { parseAccountPolicy, parseSessionPolicy, readAccountPolicy, readSessionPolicy, surfaceOfTool } from "../access/policy.js";
|
|
12
12
|
import { tools as ALL_TOOLS } from "../tools.js";
|
|
13
13
|
import { VERSION } from "../version.js";
|
|
14
|
-
import { MemoryStore } from "./store.js";
|
|
14
|
+
import { MemoryStore, credentialExpiry } from "./store.js";
|
|
15
15
|
import { SCOPES, grantableScopes, isScope, maxRank, normalizeScope, normalizeScopes, scopeAllows, scopesUpTo } from "./scopes.js";
|
|
16
16
|
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
17
17
|
const PENDING_TTL_MS = 10 * 60_000;
|
|
@@ -28,6 +28,12 @@ const PLUGIN_KEY = "papercliped.remote-control";
|
|
|
28
28
|
const PLUGIN_INSTALL_MS = 120_000;
|
|
29
29
|
const MAX_FORM_BYTES = 16 * 1024;
|
|
30
30
|
const DAY_MS = 24 * 3600 * 1000;
|
|
31
|
+
/** Renew a stored Paperclip key this long before Paperclip expires it. */
|
|
32
|
+
const ROTATE_BEFORE_MS = 7 * DAY_MS;
|
|
33
|
+
/** After a rotation attempt, wait this long before trying that key again (shared across processes). */
|
|
34
|
+
const ROTATE_RETRY_MS = 10 * 60_000;
|
|
35
|
+
/** Revoke the replaced key only after this, so calls already under way with it (here or in another process) finish. */
|
|
36
|
+
const ROTATE_GRACE_MS = 60_000;
|
|
31
37
|
class OAuthError extends Error {
|
|
32
38
|
code;
|
|
33
39
|
status;
|
|
@@ -75,6 +81,10 @@ export class OAuthProvider {
|
|
|
75
81
|
background = new Set();
|
|
76
82
|
multi;
|
|
77
83
|
accounts;
|
|
84
|
+
/** Key rotations under way in this process, by holder: callers wait for one instead of starting another. */
|
|
85
|
+
rotations = new Map();
|
|
86
|
+
/** Holder → when this process may try a failed rotation again. */
|
|
87
|
+
rotateRetryAt = new Map();
|
|
78
88
|
constructor(deps) {
|
|
79
89
|
this.deps = deps;
|
|
80
90
|
this.now = deps.now ?? Date.now;
|
|
@@ -158,7 +168,8 @@ export class OAuthProvider {
|
|
|
158
168
|
/** The Paperclip client for a grant. In account mode the credential and instance come from the account's link. */
|
|
159
169
|
async resolveClient(grant) {
|
|
160
170
|
if (!this.accounts || !grant.accountId) {
|
|
161
|
-
const
|
|
171
|
+
const sealed = grant.sealedCredential ? await this.currentKey(this.heldOnGrant(grant, grant.sealedCredential)) : null;
|
|
172
|
+
const apiKey = sealed ? this.keyring.unseal(sealed) : null;
|
|
162
173
|
if (!this.multi)
|
|
163
174
|
return { client: new PaperclipClient({ ...this.deps.config, apiKey }) };
|
|
164
175
|
if (!grant.instanceUrl)
|
|
@@ -171,7 +182,7 @@ export class OAuthProvider {
|
|
|
171
182
|
throw new ReconnectRequired("This account's Paperclip connection has expired; reconnect to continue.");
|
|
172
183
|
const origin = this.checkedInstance(link.instanceUrl).origin; // re-validated on every use (policy can tighten)
|
|
173
184
|
await this.store.touchLink(grant.accountId);
|
|
174
|
-
const apiKey = this.keyring.unseal(link.sealedCredential);
|
|
185
|
+
const apiKey = this.keyring.unseal(await this.currentKey(this.heldOnLink(link, link.sealedCredential)));
|
|
175
186
|
return { client: new PaperclipClient({ ...this.deps.config, apiUrl: `${origin}/api`, apiKey, companyId: null }, this.safeFetch), instanceHost: link.instanceLabel ?? new URL(origin).host };
|
|
176
187
|
}
|
|
177
188
|
/** Convenience for callers that only need the client. */
|
|
@@ -201,22 +212,23 @@ export class OAuthProvider {
|
|
|
201
212
|
/* instance no longer allowed / key gone: nothing more we can do */
|
|
202
213
|
}
|
|
203
214
|
}
|
|
204
|
-
/** Revoke idle grants and idle account connections, delete their stored credentials, prune telemetry. Run periodically. */
|
|
215
|
+
/** Revoke idle grants and idle account connections, delete their stored credentials, renew Paperclip keys close to expiry, prune telemetry. Run periodically. */
|
|
205
216
|
async sweep() {
|
|
206
217
|
const days = this.deps.oauth.idleRevokeDays;
|
|
207
218
|
await this.store.prune();
|
|
208
|
-
if (!days)
|
|
209
|
-
return 0;
|
|
210
|
-
const cutoff = this.now() - days * DAY_MS;
|
|
211
219
|
let n = 0;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
await this.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
220
|
+
if (days) {
|
|
221
|
+
const cutoff = this.now() - days * DAY_MS;
|
|
222
|
+
const idle = await this.store.listIdleGrants(cutoff);
|
|
223
|
+
for (const g of idle)
|
|
224
|
+
await this.revokeGrant(g.id);
|
|
225
|
+
n += idle.length;
|
|
226
|
+
for (const l of await this.store.listIdleLinks(cutoff)) {
|
|
227
|
+
await this.dropConnection(l, "idle");
|
|
228
|
+
n += 1;
|
|
229
|
+
}
|
|
219
230
|
}
|
|
231
|
+
await this.rotateExpiring();
|
|
220
232
|
return n;
|
|
221
233
|
}
|
|
222
234
|
/** Forget an account's stored Paperclip credential (the username stays) and tell its Paperclip to revoke the key. */
|
|
@@ -237,6 +249,125 @@ export class OAuthProvider {
|
|
|
237
249
|
/* best effort */
|
|
238
250
|
}
|
|
239
251
|
}
|
|
252
|
+
// ───────────── Paperclip key rotation ─────────────
|
|
253
|
+
// Paperclip expires the board keys it issues (30 days by default). Each stored key is renewed about a week before
|
|
254
|
+
// that: the old key asks Paperclip for a new one, the new one is swapped in only if the old one is still stored
|
|
255
|
+
// (compare-and-swap), and only then is the old one revoked. Any failure leaves the working key in place.
|
|
256
|
+
heldOnLink(l, sealed) {
|
|
257
|
+
return { kind: "link", id: l.accountId, instanceUrl: l.instanceUrl, sealed, expiresAt: credentialExpiry(l.credentialExpiresAt, l.connectedAt) };
|
|
258
|
+
}
|
|
259
|
+
heldOnGrant(g, sealed) {
|
|
260
|
+
return { kind: "grant", id: g.id, instanceUrl: g.instanceUrl, sealed, expiresAt: credentialExpiry(g.credentialExpiresAt, g.createdAt) };
|
|
261
|
+
}
|
|
262
|
+
/** The sealed key to call Paperclip with: the stored one, or its replacement when it was due and got renewed. */
|
|
263
|
+
async currentKey(k) {
|
|
264
|
+
if (k.expiresAt === null || k.expiresAt - this.now() > ROTATE_BEFORE_MS)
|
|
265
|
+
return k.sealed;
|
|
266
|
+
const slot = `${k.kind}:${k.id}`;
|
|
267
|
+
let run = this.rotations.get(slot);
|
|
268
|
+
if (!run) {
|
|
269
|
+
if ((this.rotateRetryAt.get(slot) ?? 0) > this.now())
|
|
270
|
+
return k.sealed;
|
|
271
|
+
run = this.rotate(k, slot).finally(() => this.rotations.delete(slot));
|
|
272
|
+
this.rotations.set(slot, run);
|
|
273
|
+
}
|
|
274
|
+
return (await run) ?? k.sealed;
|
|
275
|
+
}
|
|
276
|
+
/** Renew every stored key that expires within the rotation window, so keys of people who rarely call get renewed too. */
|
|
277
|
+
async rotateExpiring() {
|
|
278
|
+
const before = this.now() + ROTATE_BEFORE_MS;
|
|
279
|
+
for (const l of await this.store.listExpiringLinks(before))
|
|
280
|
+
if (l.sealedCredential)
|
|
281
|
+
await this.currentKey(this.heldOnLink(l, l.sealedCredential));
|
|
282
|
+
for (const g of await this.store.listExpiringGrants(before))
|
|
283
|
+
if (g.sealedCredential)
|
|
284
|
+
await this.currentKey(this.heldOnGrant(g, g.sealedCredential));
|
|
285
|
+
}
|
|
286
|
+
/** One rotation. Resolves to the new sealed key, or null to keep using the stored one. Never throws. */
|
|
287
|
+
async rotate(k, slot) {
|
|
288
|
+
let outcome = null;
|
|
289
|
+
let who = null;
|
|
290
|
+
try {
|
|
291
|
+
// One rotation per key across every process: whoever takes the claim does it; the others keep the old key, which still works.
|
|
292
|
+
if (!(await this.store.hit(`rotate:${slot}`, 1, ROTATE_RETRY_MS))) {
|
|
293
|
+
this.rotateRetryAt.set(slot, this.now() + ROTATE_RETRY_MS);
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
const token = this.keyring.unseal(k.sealed);
|
|
297
|
+
if (k.kind === "grant" && token === this.deps.config.apiKey) {
|
|
298
|
+
// The operator's own PAPERCLIP_API_KEY (bridge admin token sign-in): not ours to replace.
|
|
299
|
+
await this.store.swapCredential(k, k.sealed, k.sealed, null);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
let name = "Papercliped";
|
|
303
|
+
if (k.kind === "link") {
|
|
304
|
+
const a = await this.store.getAccount(k.id);
|
|
305
|
+
if (a)
|
|
306
|
+
name = `Papercliped (${a.username})`;
|
|
307
|
+
who = a ? { accountId: a.id, username: displayName(a) } : { accountId: k.id, username: "" };
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
const g = await this.store.getGrant(k.id);
|
|
311
|
+
if (g)
|
|
312
|
+
name = `Papercliped (${g.username ?? g.clientName})`;
|
|
313
|
+
who = g ? { accountId: g.accountId ?? null, username: g.username ?? null } : null;
|
|
314
|
+
}
|
|
315
|
+
const login = this.loginFor(k.instanceUrl);
|
|
316
|
+
outcome = "failed";
|
|
317
|
+
const info = await login.keyInfo(token);
|
|
318
|
+
if (info.state === "unsupported" || info.expiresAt === null) {
|
|
319
|
+
// Older Paperclip, or a key that never expires: nothing to renew, now or later.
|
|
320
|
+
outcome = null;
|
|
321
|
+
await this.store.swapCredential(k, k.sealed, k.sealed, null);
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
if (info.expiresAt !== undefined && info.expiresAt - this.now() > ROTATE_BEFORE_MS) {
|
|
325
|
+
outcome = null; // the stored expiry was an estimate; Paperclip's own is later
|
|
326
|
+
await this.store.swapCredential(k, k.sealed, k.sealed, info.expiresAt);
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const made = await login.createKey(token, name);
|
|
330
|
+
if (made.state === "unsupported") {
|
|
331
|
+
outcome = null;
|
|
332
|
+
await this.store.swapCredential(k, k.sealed, k.sealed, null);
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
const next = this.keyring.seal(made.token);
|
|
336
|
+
if (!(await this.store.swapCredential(k, k.sealed, next, made.expiresAt === undefined ? this.now() + PAPERCLIP_KEY_TTL_MS : made.expiresAt))) {
|
|
337
|
+
// Reconnected, rotated elsewhere or disconnected meanwhile: what is stored now wins; drop the key we just made.
|
|
338
|
+
outcome = null;
|
|
339
|
+
await login.revoke(made.token);
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
outcome = "rotated";
|
|
343
|
+
this.revokeReplaced(login, token);
|
|
344
|
+
return next;
|
|
345
|
+
}
|
|
346
|
+
catch (e) {
|
|
347
|
+
outcome = "failed";
|
|
348
|
+
this.rotateRetryAt.set(slot, this.now() + ROTATE_RETRY_MS);
|
|
349
|
+
// Paperclip no longer accepts this key (expired or revoked): it can never be renewed, so stop trying. Using it
|
|
350
|
+
// still gets the reconnect hint, and reconnecting stores a fresh key with a fresh expiry.
|
|
351
|
+
if (e instanceof LoginError && e.status === 401)
|
|
352
|
+
await this.store.swapCredential(k, k.sealed, k.sealed, null).catch(() => false);
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
if (outcome)
|
|
357
|
+
await this.event("key", who, outcome);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/** Revoke a key that rotation replaced, after a grace period for calls still using it. Best effort: it expires on its own anyway. */
|
|
361
|
+
revokeReplaced(login, token) {
|
|
362
|
+
const grace = this.deps.rotationGraceMs ?? ROTATE_GRACE_MS;
|
|
363
|
+
if (grace <= 0) {
|
|
364
|
+
const run = login.revoke(token);
|
|
365
|
+
const tracked = run.finally(() => this.background.delete(tracked));
|
|
366
|
+
this.background.add(tracked);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
setTimeout(() => void login.revoke(token), grace).unref();
|
|
370
|
+
}
|
|
240
371
|
// ───────────── account management (the Paperclip plugin's connection manager) ─────────────
|
|
241
372
|
// Everything here acts on ONE account and is reached only through ManageRoutes, with a plugin token or the secret key.
|
|
242
373
|
/** Username + secret key, throttled exactly like the sign-in page (shared counters, so there is no cheaper way to guess). */
|
|
@@ -918,7 +1049,7 @@ export class OAuthProvider {
|
|
|
918
1049
|
const t = this.now();
|
|
919
1050
|
const acct = await this.store.getAccount(accountId);
|
|
920
1051
|
const instanceLabel = acct ? this.instanceLabelFor(acct, instanceUrl, old) : new URL(instanceUrl).host;
|
|
921
|
-
const ok = await this.store.putLink({ accountId, instanceUrl, paperclipUserId, sealedCredential: this.keyring.seal(credential), createdAt: old?.createdAt ?? t, connectedAt: t, lastUsedAt: t, instanceLabel });
|
|
1052
|
+
const ok = await this.store.putLink({ accountId, instanceUrl, paperclipUserId, sealedCredential: this.keyring.seal(credential), credentialExpiresAt: t + PAPERCLIP_KEY_TTL_MS, createdAt: old?.createdAt ?? t, connectedAt: t, lastUsedAt: t, instanceLabel });
|
|
922
1053
|
if (ok && old?.sealedCredential) {
|
|
923
1054
|
let same = false;
|
|
924
1055
|
try {
|
|
@@ -1163,7 +1294,7 @@ export class OAuthProvider {
|
|
|
1163
1294
|
}
|
|
1164
1295
|
if (!created)
|
|
1165
1296
|
return this.renderStage(res, rid, p, "Could not reserve an anonymous name right now. Please try again.", { value: typed });
|
|
1166
|
-
const linked = await this.store.putLink({ accountId: account.id, instanceUrl: p.instanceUrl, paperclipUserId: p.paperclipUserId, sealedCredential: this.keyring.seal(ch.boardApiToken), createdAt: t, connectedAt: t, lastUsedAt: t, instanceLabel: this.instanceLabelFor(account, p.instanceUrl) });
|
|
1297
|
+
const linked = await this.store.putLink({ accountId: account.id, instanceUrl: p.instanceUrl, paperclipUserId: p.paperclipUserId, sealedCredential: this.keyring.seal(ch.boardApiToken), credentialExpiresAt: t + PAPERCLIP_KEY_TTL_MS, createdAt: t, connectedAt: t, lastUsedAt: t, instanceLabel: this.instanceLabelFor(account, p.instanceUrl) });
|
|
1167
1298
|
if (!linked) {
|
|
1168
1299
|
await this.store.deleteAccount(account.id); // lost a race for this Paperclip identity
|
|
1169
1300
|
p.stage = "choose";
|
|
@@ -1291,6 +1422,8 @@ export class OAuthProvider {
|
|
|
1291
1422
|
resource: this.resource,
|
|
1292
1423
|
instanceUrl: entry.instanceUrl,
|
|
1293
1424
|
sealedCredential: entry.sealedCredential,
|
|
1425
|
+
// A key from an approved sign-in expires with Paperclip's default; the operator's own PAPERCLIP_API_KEY is never rotated.
|
|
1426
|
+
credentialExpiresAt: entry.sealedCredential && this.keyring.unseal(entry.sealedCredential) !== this.deps.config.apiKey ? t + PAPERCLIP_KEY_TTL_MS : null,
|
|
1294
1427
|
accountId: entry.accountId ?? null,
|
|
1295
1428
|
username: entry.username ?? null,
|
|
1296
1429
|
createdAt: t,
|
package/dist/oauth/store.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { AliasTakenError } from "../accounts/types.js";
|
|
4
|
+
import { PAPERCLIP_KEY_TTL_MS } from "./paperclip-login.js";
|
|
4
5
|
import { HIST_EDGES, isFault } from "../telemetry/types.js";
|
|
6
|
+
/** A stored credential's expiry; old rows without one are assumed to carry Paperclip's default TTL from `issuedAt`. */
|
|
7
|
+
export function credentialExpiry(expiresAt, issuedAt) {
|
|
8
|
+
return expiresAt === undefined ? issuedAt + PAPERCLIP_KEY_TTL_MS : expiresAt;
|
|
9
|
+
}
|
|
5
10
|
const MAX_USER_EVENTS = 20_000;
|
|
6
11
|
const MAX_CLIENTS = 1000;
|
|
7
12
|
const CLIENT_IDLE_MS = 7 * 24 * 3600 * 1000;
|
|
@@ -134,6 +139,21 @@ export class MemoryStore {
|
|
|
134
139
|
async listIdleGrants(cutoff) {
|
|
135
140
|
return Object.values(this.d.grants).filter((g) => !g.revoked && g.lastUsedAt < cutoff);
|
|
136
141
|
}
|
|
142
|
+
async listExpiringGrants(before) {
|
|
143
|
+
return Object.values(this.d.grants).filter((g) => {
|
|
144
|
+
const exp = g.sealedCredential && !g.revoked ? credentialExpiry(g.credentialExpiresAt, g.createdAt) : null;
|
|
145
|
+
return exp !== null && exp < before;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async swapCredential(h, expect, next, expiresAt) {
|
|
149
|
+
const row = h.kind === "link" ? this.lnks[h.id] : this.d.grants[h.id];
|
|
150
|
+
if (!row || row.sealedCredential !== expect || (h.kind === "grant" && row.revoked))
|
|
151
|
+
return false;
|
|
152
|
+
row.sealedCredential = next;
|
|
153
|
+
row.credentialExpiresAt = expiresAt;
|
|
154
|
+
this.save();
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
137
157
|
async putAccess(hash, r) {
|
|
138
158
|
this.d.access[hash] = r;
|
|
139
159
|
this.save();
|
|
@@ -358,6 +378,12 @@ export class MemoryStore {
|
|
|
358
378
|
async listIdleLinks(cutoff) {
|
|
359
379
|
return Object.values(this.lnks).filter((l) => l.sealedCredential && l.lastUsedAt < cutoff);
|
|
360
380
|
}
|
|
381
|
+
async listExpiringLinks(before) {
|
|
382
|
+
return Object.values(this.lnks).filter((l) => {
|
|
383
|
+
const exp = l.sealedCredential ? credentialExpiry(l.credentialExpiresAt, l.connectedAt) : null;
|
|
384
|
+
return exp !== null && exp < before;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
361
387
|
async revokeAccountGrants(accountId) {
|
|
362
388
|
let n = 0;
|
|
363
389
|
for (const g of Object.values(this.d.grants)) {
|
package/dist/web/routes.js
CHANGED
|
@@ -7,7 +7,7 @@ const PROTOCOL = /^\/(mcp|actions\/|openapi\.json$|register$|authorize|token$|re
|
|
|
7
7
|
const TYPES = {
|
|
8
8
|
".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json",
|
|
9
9
|
".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".xml": "application/xml; charset=utf-8",
|
|
10
|
-
".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8", ".webmanifest": "application/manifest+json",
|
|
10
|
+
".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8", ".webmanifest": "application/manifest+json", ".zip": "application/zip",
|
|
11
11
|
};
|
|
12
12
|
const HEADERS = { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin", "X-Frame-Options": "DENY" };
|
|
13
13
|
/**
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Launch kit
|
|
2
|
+
|
|
3
|
+
Everything for the public launch: what must be true first, how to get the first testers, where to post, and copy you can paste. Assets are in the [icon pack](../web/public/brand/papercliped-icons.zip) (also at papercliped.co/brand). For deploying the service, see [LAUNCH.md](LAUNCH.md).
|
|
4
|
+
|
|
5
|
+
## 1. Before launch day
|
|
6
|
+
|
|
7
|
+
| | Check | How |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| ☐ | The latest release is live | `https://mcp.papercliped.co/api/public/v1/info` shows the version in `package.json`; papercliped.co/prompts and docs.papercliped.co/hosting load. If not, Render → **Manual Deploy → Deploy latest commit**. |
|
|
10
|
+
| ☐ | Connections survive past 30 days | Paperclip expires approved keys after 30 days; the bridge renews each key a week before (v2.5.1). Run the `008_key_rotation.sql` migration (`npm run migrate`) on the production database when deploying it. |
|
|
11
|
+
| ☐ | A full run-through on a fresh Paperclip | Follow [Host your Paperclip](https://docs.papercliped.co/hosting) start to finish on one guide (Railway or a VPS), connect Claude **and** ChatGPT, run five prompts from the gallery, flip the access switch in the control room. Fix anything that trips you up. |
|
|
12
|
+
| ☐ | Discord and X exist | Create them with the icon pack (below), then set `VITE_DISCORD_URL` / `VITE_X_URL` on Render and rebuild. Set up Discord per [COMMUNITY.md](COMMUNITY.md), plus a `#testers` channel. |
|
|
13
|
+
| ☐ | GitHub is launch-ready | Turn on Discussions, set the repository social preview to `social-light-1200x630.png`, pin a "Start here" discussion, add topics: `mcp`, `paperclip`, `ai-agents`, `claude`, `chatgpt`, `codex`, `mcp-server`. |
|
|
14
|
+
| ☐ | A 60-second demo video | Script in section 6. Every post below uses it. |
|
|
15
|
+
| ☐ | Baseline numbers | Note users and connections on papercliped.co/status, so you can tell which post worked. |
|
|
16
|
+
|
|
17
|
+
### Which icon goes where
|
|
18
|
+
|
|
19
|
+
| Place | File |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Discord server icon, X / GitHub / npm profile picture, directory listings | `avatar-light-1024.png` (`avatar-dark-*` on dark themes) |
|
|
22
|
+
| X header | `x-header-light-1500x500.png` |
|
|
23
|
+
| Discord server banner, YouTube thumbnail base, Product Hunt gallery | `banner-light-960x540.png` |
|
|
24
|
+
| GitHub social preview, link previews | `social-light-1200x630.png` |
|
|
25
|
+
| App icon where a rounded tile fits (Product Hunt logo, docs, slides) | `icon-light-512.png` or `icon-light.svg` |
|
|
26
|
+
|
|
27
|
+
## 2. The first testers ("founding operators")
|
|
28
|
+
|
|
29
|
+
Aim for **20–50 people who already run Paperclip** before the big public posts. They find the setup problems, and their quotes become your social proof.
|
|
30
|
+
|
|
31
|
+
**Where to find them**
|
|
32
|
+
1. **Paperclip's Discord** (linked from Paperclip's README). Ask a moderator first where community projects may be shared; then post the tester call below.
|
|
33
|
+
2. **Replies on X** to people posting about Paperclip: a short, personal reply with the demo, not a link dump.
|
|
34
|
+
3. **Your own network** and anyone who has starred or forked Paperclip and posts about AI agents.
|
|
35
|
+
|
|
36
|
+
**What they get**
|
|
37
|
+
- A **Founding operator** role in Discord and a `#testers` channel with direct access to the maintainers.
|
|
38
|
+
- Their name in the release notes and a thanks section in the README (only if they want it).
|
|
39
|
+
- Fixes within days: every reported setup problem gets a patch release.
|
|
40
|
+
|
|
41
|
+
**What they do (20 minutes)**
|
|
42
|
+
1. Pick a hosting guide and get their Paperclip public (or use the one they have).
|
|
43
|
+
2. Connect one AI app with **Full control**.
|
|
44
|
+
3. Run five prompts from the gallery: *Morning catch-up*, *Give an agent a task*, *Wake and wait*, *What's waiting* (approvals), *Weekly review*.
|
|
45
|
+
4. Open the control room in Paperclip and switch to **Agent only**, then ask the AI to pause an agent (it should refuse and say why).
|
|
46
|
+
5. Fill in the **Tester feedback** form: GitHub → Issues → New issue → *Tester feedback*.
|
|
47
|
+
|
|
48
|
+
**Tester call (paste in Discord / DMs)**
|
|
49
|
+
> Looking for 20 Paperclip operators to try **Papercliped** before launch: free and open source, it lets you run your Paperclip from Claude, ChatGPT or Codex by just asking ("catch me up", "give the Web Engineer this task and wake them", "approve the hire"). Takes about 20 minutes. You get a Founding operator role, direct line to us, and fixes within days. Interested? Reply or DM me. 🧷
|
|
50
|
+
|
|
51
|
+
## 3. Where to promote, in order
|
|
52
|
+
|
|
53
|
+
| # | Channel | When | Notes |
|
|
54
|
+
| --- | --- | --- | --- |
|
|
55
|
+
| 1 | Paperclip Discord | Testers week, then launch day | The highest-intent audience there is. Follow their rules. |
|
|
56
|
+
| 2 | X | Launch day, then daily | Thread + demo. Tag Paperclip's account. One gallery prompt a day afterwards, with a short clip. |
|
|
57
|
+
| 3 | MCP directories | Launch week | Section 5. Permanent traffic from people searching for connectors. |
|
|
58
|
+
| 4 | Anthropic's connector directory and Claude Code plugin listings | Launch week | Puts Papercliped inside Claude's own UI. |
|
|
59
|
+
| 5 | Show HN | A weekday, 8–10am US Eastern | Stay and answer every comment for the first 3 hours. |
|
|
60
|
+
| 6 | Reddit | Spread over a week | r/ClaudeAI, r/ChatGPT, r/selfhosted (lead with the hosting guides), r/AI_Agents, r/mcp. Different text per sub; read each sub's self-promotion rules first. |
|
|
61
|
+
| 7 | dev.to / Hashnode | Launch week | Cross-post one hosting guide each ("Host Paperclip on Railway in 15 minutes") with a canonical link to the docs. These rank in search for a long time. |
|
|
62
|
+
| 8 | Product Hunt | 2–4 weeks after launch | Once Discord is active and you have tester quotes. |
|
|
63
|
+
| 9 | Paperclip's docs or README | After launch | Ask the Paperclip maintainers to list Papercliped as a community integration. |
|
|
64
|
+
|
|
65
|
+
## 4. Copy
|
|
66
|
+
|
|
67
|
+
**One-liner:** Run your Paperclip from Claude, ChatGPT or Codex: just ask.
|
|
68
|
+
|
|
69
|
+
**Short description (directories, 160 chars):** Free, open-source MCP connector for Paperclip. Manage agents, issues, approvals, budgets and reports from Claude, ChatGPT, Codex or any MCP app.
|
|
70
|
+
|
|
71
|
+
**Long description**
|
|
72
|
+
> Papercliped connects Claude, ChatGPT, Codex and any MCP app to your Paperclip, the open-source orchestrator for companies of AI agents. Instead of clicking through the dashboard, ask: "Catch me up", "Give the Web Engineer this task and wake them", "Approve the hire", "Weekly cost report". You approve every connection inside your own Paperclip, choose Read only or Full control, and decide in the control room whether AI apps control Paperclip directly, work through agents, or both — per agent if you like. 30 tools, ready-made reports, 40+ prompts to copy, and step-by-step guides to host Paperclip at home, on a VPS, Railway, Render, Fly.io or Coolify. Free and MIT licensed.
|
|
73
|
+
|
|
74
|
+
**X thread**
|
|
75
|
+
1. Running a company of AI agents on Paperclip? Now you can run it from Claude, ChatGPT or Codex. Just ask. 🧷 Papercliped is free and open source. [demo video]
|
|
76
|
+
2. "Catch me up on my Paperclip." "Give the Web Engineer this task and wake them." "Approve the hire." "Weekly cost report." 40+ ready-to-copy prompts → papercliped.co/prompts
|
|
77
|
+
3. You stay in control: approve every connection inside your own Paperclip, pick Read only or Full control, and set per-agent rules in the control room.
|
|
78
|
+
4. No public Paperclip yet? Step-by-step guides for your own computer (free tunnel), a VPS, Railway, Render, Fly.io and Coolify → docs.papercliped.co/hosting
|
|
79
|
+
5. Connect: paste https://mcp.papercliped.co/mcp into your AI app. Code: github.com/OpenSourcx/papercliped ⭐
|
|
80
|
+
|
|
81
|
+
**Show HN**
|
|
82
|
+
- Title: `Show HN: Papercliped – run your AI agent company from Claude or ChatGPT (open source)`
|
|
83
|
+
- URL: the GitHub repository.
|
|
84
|
+
- First comment:
|
|
85
|
+
> Hi HN! Paperclip is an open-source orchestrator where you run a "company" of AI agents (CEO, engineers, budgets, approvals). I kept opening its dashboard to do small things, so I built Papercliped: an MCP server that lets Claude, ChatGPT, Codex or any MCP client operate it in plain language.
|
|
86
|
+
>
|
|
87
|
+
> How it works: you add one connector URL, approve the sign-in inside your own Paperclip (no passwords pass through), and pick Read only or Full control. A control room inside Paperclip decides per agent whether AI apps may control it directly, only hand it work, or not see it at all. Every call is checked and audit-logged; Paperclip keys are encrypted at rest.
|
|
88
|
+
>
|
|
89
|
+
> It's MIT licensed, hosted for free, and you can self-host the bridge or run it locally with `npx papercliped`. I'd love feedback on the permission model and on what you'd want to ask your agents.
|
|
90
|
+
|
|
91
|
+
**Reddit (r/selfhosted angle)**
|
|
92
|
+
> **I wrote step-by-step guides for hosting Paperclip (AI agent orchestrator) on a VPS, Railway, Render, Fly.io, Coolify or at home with a tunnel** — official Docker image, persistent disk, first-admin setup, backups. They end with connecting it to Claude/ChatGPT through Papercliped, the open-source MCP bridge I maintain, but the hosting part stands on its own: docs.papercliped.co/hosting. Corrections welcome.
|
|
93
|
+
|
|
94
|
+
## 5. Directories
|
|
95
|
+
|
|
96
|
+
Submit the short and long descriptions above, the avatar icon, the banner, the connector URL `https://mcp.papercliped.co/mcp` (Streamable HTTP, OAuth) and the repository URL. Check each site's current submission steps.
|
|
97
|
+
|
|
98
|
+
- **Official MCP Registry** (registry.modelcontextprotocol.io): published with its `mcp-publisher` tool from a `server.json`; lists remote servers and npm packages (`papercliped`).
|
|
99
|
+
- **Smithery**, **Glama**, **PulseMCP**, **mcp.so**: submit forms or GitHub-based listings.
|
|
100
|
+
- **awesome-mcp-servers** lists on GitHub: a one-line pull request under the right category.
|
|
101
|
+
- **Anthropic's connector directory** and the **Claude Code plugin** listings: the plugin marketplace is already in this repository (`.claude-plugin/marketplace.json`).
|
|
102
|
+
- **Codex plugin** listings, if a public directory exists; the marketplace is in `.agents/plugins/marketplace.json`.
|
|
103
|
+
|
|
104
|
+
## 6. Demo video (60 seconds)
|
|
105
|
+
|
|
106
|
+
Screen-record at 1080p, split screen: AI app on the left, Paperclip dashboard on the right. No music needed; captions on.
|
|
107
|
+
|
|
108
|
+
1. (0–5 s) Title card: the banner image. Caption: *Run your Paperclip from any AI app.*
|
|
109
|
+
2. (5–20 s) Type *"Catch me up on my Paperclip."* Show the answer.
|
|
110
|
+
3. (20–40 s) *"Give the Web Engineer an issue to fix the signup page, wake them, and tell me when they're done."* Show the issue appear and the agent start in Paperclip.
|
|
111
|
+
4. (40–50 s) *"Approve the hire request."* The approval flips in the dashboard.
|
|
112
|
+
5. (50–60 s) Control room: flip the switch to **Agent only**. End card: `papercliped.co · free and open source`.
|
|
113
|
+
|
|
114
|
+
Record a vertical 30-second cut of steps 2–4 for X and short-video platforms.
|
|
115
|
+
|
|
116
|
+
## 7. Launch day
|
|
117
|
+
|
|
118
|
+
| Time (US Eastern) | Do |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| Day before | Final run-through; status page green; release notes up; demo uploaded. |
|
|
121
|
+
| 8:00 | Show HN post + first comment. |
|
|
122
|
+
| 8:15 | X thread; post in Paperclip's Discord; announce in your own Discord. |
|
|
123
|
+
| 8:30–12:00 | Answer every comment and question. Log every setup problem as an issue. |
|
|
124
|
+
| 12:00 | First Reddit post. |
|
|
125
|
+
| Evening | Patch release for anything found today. Thank testers publicly. |
|
|
126
|
+
| Next 7 days | One Reddit sub per day, one gallery prompt per day on X, directories submitted, one dev.to guide. |
|
|
127
|
+
|
|
128
|
+
## 8. Measure
|
|
129
|
+
|
|
130
|
+
- **papercliped.co/status**: users, connections, sign-in success and errors. Check daily for two weeks and note which post ran the day before.
|
|
131
|
+
- **GitHub**: stars, traffic (Insights → Traffic → referring sites), issues labelled `feedback`.
|
|
132
|
+
- **npm**: weekly downloads of `papercliped` and `papercliped-paperclip-plugin`.
|
|
133
|
+
- **Sign-in failures** on the status page are setup problems: each one is a docs fix waiting to happen.
|
package/docs/supabase-schema.sql
CHANGED
|
@@ -285,6 +285,23 @@ alter table bridge.grants add column if not exists policy jsonb;
|
|
|
285
285
|
-- Audit rows now also record calls the policy refused (error_class = policy_blocked).
|
|
286
286
|
comment on column bridge.audit_events.error_class is 'invalid_input | insufficient_scope | read_only | policy_blocked | upstream_4xx | upstream_5xx | upstream_unreachable | rate_limited | unauthorized | internal';
|
|
287
287
|
|
|
288
|
+
-- ───── 008_key_rotation.sql ─────
|
|
289
|
+
-- Paperclip key rotation: Paperclip expires the board keys it issues (30 days by default), so the bridge renews
|
|
290
|
+
-- each stored key about a week before it lapses. credential_expires_at is when Paperclip expires the stored key
|
|
291
|
+
-- (ms epoch); null = it never expires (older Paperclip, or a key Paperclip did not issue us), so it is never rotated.
|
|
292
|
+
alter table bridge.account_links add column if not exists credential_expires_at bigint;
|
|
293
|
+
alter table bridge.grants add column if not exists credential_expires_at bigint;
|
|
294
|
+
|
|
295
|
+
-- Keys stored before this migration came from an approved sign-in: assume Paperclip's default 30 days from then.
|
|
296
|
+
-- The first rotation check reads the real expiry from Paperclip and corrects it (or clears it if the key never expires).
|
|
297
|
+
update bridge.account_links set credential_expires_at = connected_at + 2592000000
|
|
298
|
+
where sealed_credential is not null and credential_expires_at is null;
|
|
299
|
+
update bridge.grants set credential_expires_at = created_at + 2592000000
|
|
300
|
+
where sealed_credential is not null and not revoked and credential_expires_at is null;
|
|
301
|
+
|
|
302
|
+
create index if not exists account_links_expiry_idx on bridge.account_links (credential_expires_at) where sealed_credential is not null;
|
|
303
|
+
create index if not exists grants_expiry_idx on bridge.grants (credential_expires_at) where sealed_credential is not null and not revoked;
|
|
304
|
+
|
|
288
305
|
-- ───── migration bookkeeping ─────
|
|
289
306
|
create table if not exists bridge.schema_migrations (version text primary key, applied_at timestamptz not null default now());
|
|
290
307
|
alter table bridge.schema_migrations enable row level security;
|
|
@@ -294,6 +311,6 @@ do $$ declare r text; begin
|
|
|
294
311
|
if exists (select 1 from pg_roles where rolname = r) then execute format('revoke all on bridge.schema_migrations from %I', r); end if;
|
|
295
312
|
end loop;
|
|
296
313
|
end $$;
|
|
297
|
-
insert into bridge.schema_migrations (version) values ('001_init.sql'), ('002_audit.sql'), ('003_accounts.sql'), ('004_privacy_panel.sql'), ('005_beta.sql'), ('006_two_levels.sql'), ('007_control_room.sql') on conflict (version) do nothing;
|
|
314
|
+
insert into bridge.schema_migrations (version) values ('001_init.sql'), ('002_audit.sql'), ('003_accounts.sql'), ('004_privacy_panel.sql'), ('005_beta.sql'), ('006_two_levels.sql'), ('007_control_room.sql'), ('008_key_rotation.sql') on conflict (version) do nothing;
|
|
298
315
|
|
|
299
316
|
commit;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- Paperclip key rotation: Paperclip expires the board keys it issues (30 days by default), so the bridge renews
|
|
2
|
+
-- each stored key about a week before it lapses. credential_expires_at is when Paperclip expires the stored key
|
|
3
|
+
-- (ms epoch); null = it never expires (older Paperclip, or a key Paperclip did not issue us), so it is never rotated.
|
|
4
|
+
alter table bridge.account_links add column if not exists credential_expires_at bigint;
|
|
5
|
+
alter table bridge.grants add column if not exists credential_expires_at bigint;
|
|
6
|
+
|
|
7
|
+
-- Keys stored before this migration came from an approved sign-in: assume Paperclip's default 30 days from then.
|
|
8
|
+
-- The first rotation check reads the real expiry from Paperclip and corrects it (or clears it if the key never expires).
|
|
9
|
+
update bridge.account_links set credential_expires_at = connected_at + 2592000000
|
|
10
|
+
where sealed_credential is not null and credential_expires_at is null;
|
|
11
|
+
update bridge.grants set credential_expires_at = created_at + 2592000000
|
|
12
|
+
where sealed_credential is not null and not revoked and credential_expires_at is null;
|
|
13
|
+
|
|
14
|
+
create index if not exists account_links_expiry_idx on bridge.account_links (credential_expires_at) where sealed_credential is not null;
|
|
15
|
+
create index if not exists grants_expiry_idx on bridge.grants (credential_expires_at) where sealed_credential is not null and not revoked;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "papercliped",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "Papercliped — connect Claude, ChatGPT and any MCP app to your Paperclip: control agents, sync, report. MCP server + hosted multi-tenant OAuth bridge + public stats API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/site/docs/chatgpt.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# ChatGPT setup
|
|
2
2
|
|
|
3
|
+
There are two ways. The MCP connector is quicker and gives ChatGPT every tool; GPT Actions works on plans without developer mode.
|
|
4
|
+
|
|
5
|
+
## Option 1: MCP connector (recommended)
|
|
6
|
+
1. In ChatGPT open **Settings → Apps → Advanced settings** and turn on **Developer mode** (your plan and workspace must allow it).
|
|
7
|
+
2. Choose **Create app** (or **Add connector**), name it Papercliped, and use the address `{{MCP}}/mcp` with **OAuth** sign-in.
|
|
8
|
+
3. ChatGPT opens the Papercliped sign-in: choose **Connect your Paperclip**, approve in your Paperclip, and pick **Read only** or **Full control**.
|
|
9
|
+
4. In a new chat, turn the Papercliped app on from the tools menu and ask *"Catch me up on my Paperclip."*
|
|
10
|
+
|
|
11
|
+
ChatGPT renames these menus often. If yours look different, search ChatGPT's help for "developer mode" or "custom connectors".
|
|
12
|
+
|
|
13
|
+
## Option 2: a custom GPT with Actions
|
|
3
14
|
Requires a ChatGPT plan that can create GPTs with Actions.
|
|
4
15
|
|
|
5
16
|
1. Create a GPT, then **Configure → Create new action**.
|
package/site/docs/faq.md
CHANGED
|
@@ -25,7 +25,7 @@ Newer Paperclip versions include an experimental, off-by-default MCP endpoint fo
|
|
|
25
25
|
Yes: run it locally with `npx papercliped@latest`. The hosted bridge needs a public https address; see [Host your Paperclip](/docs/hosting).
|
|
26
26
|
|
|
27
27
|
## Why does pausing an agent fail with 403?
|
|
28
|
-
Pause, resume, wake and approve need a
|
|
28
|
+
Pause, resume, wake and approve need a connection at **Full control**, and an [access mode](/docs/access-modes) that allows them: pausing and approvals are *direct control* (blocked in Agent only), waking is *work through agents* (blocked in API only). A blocked call names the setting to change; check it in the [control room](/docs/control-room).
|
|
29
29
|
|
|
30
30
|
## Why was a call blocked, or a tool missing?
|
|
31
31
|
Your own settings. Open **Papercliped** in Paperclip: the switch (API only, Full, Agent only), an agent's setting, or the session's tool and agent lists decide what an AI app may do, and **Activity** shows each blocked call and the reason. See [API only, Full or Agent only](/docs/access-modes).
|
|
@@ -34,7 +34,7 @@ Your own settings. Open **Papercliped** in Paperclip: the switch (API only, Full
|
|
|
34
34
|
Yes. In the [control room](/docs/control-room), open **Agents** and set it to **Off**: AI apps can't see it, touch it, or see what's assigned to it.
|
|
35
35
|
|
|
36
36
|
## How do I stop it?
|
|
37
|
-
Disconnect the
|
|
37
|
+
Disconnect the session in the [control room](/docs/control-room), revoke the key in your Paperclip, or delete your account. It stops at once.
|
|
38
38
|
|
|
39
39
|
## I lost my secret key.
|
|
40
40
|
Choose **Connect your Paperclip** when signing in and approve in Paperclip: you keep your account and can make a new key.
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# Getting started
|
|
2
2
|
|
|
3
|
-
Papercliped connects Claude
|
|
3
|
+
Papercliped connects Claude, ChatGPT, Codex and any MCP app to **your** Paperclip, so you can run it by asking instead of clicking through the dashboard. You keep control: you choose how much the AI may do, and you can cut access at any time.
|
|
4
4
|
|
|
5
5
|
## What you need
|
|
6
6
|
- A Paperclip that is reachable over the internet at an `https://` address (for example `https://workforce.example.com`). Private, local and `http://` addresses are refused on purpose. No public address yet? See [Host your Paperclip](/docs/hosting).
|
|
7
|
-
-
|
|
7
|
+
- An AI app that can add a remote MCP server: Claude (web, desktop or mobile), Claude Code, ChatGPT, Codex, or [another app](/docs/other-ai-apps).
|
|
8
8
|
|
|
9
9
|
## Connect from Claude
|
|
10
10
|
1. In Claude open **Settings → Connectors → Add custom connector**.
|
|
11
11
|
2. Use the address `{{MCP}}/mcp` and click **Connect**.
|
|
12
12
|
3. On the Papercliped page choose **Connect your Paperclip**, enter your Paperclip's address, and approve the request in your Paperclip.
|
|
13
13
|
4. Pick a **username** (see below). Papercliped then shows your **secret key once**. Save it in a password manager.
|
|
14
|
-
5. Choose how much to allow (see [Permissions](/docs/permissions)) and click **Allow**.
|
|
15
|
-
6. Ask Claude something like "
|
|
14
|
+
5. Choose how much to allow (see [Permissions](/docs/permissions)) and click **Allow**. Leave **Also install the Papercliped plugin** ticked to get the [control room](/docs/control-room) inside your Paperclip.
|
|
15
|
+
6. Ask Claude something like *"Catch me up on my Paperclip."* Then try more from the [prompt gallery](/docs/prompts).
|
|
16
16
|
|
|
17
17
|
## Claude Code
|
|
18
18
|
```
|
|
@@ -20,6 +20,13 @@ claude mcp add --transport http papercliped {{MCP}}/mcp
|
|
|
20
20
|
```
|
|
21
21
|
Or install the plugin: `/plugin marketplace add OpenSourcx/papercliped`, then `/plugin install papercliped@papercliped`.
|
|
22
22
|
|
|
23
|
+
## Codex
|
|
24
|
+
```
|
|
25
|
+
codex plugin marketplace add OpenSourcx/papercliped
|
|
26
|
+
codex plugin add papercliped@papercliped
|
|
27
|
+
```
|
|
28
|
+
Or just the MCP server: `codex mcp add paperclip --url {{MCP}}/mcp`.
|
|
29
|
+
|
|
23
30
|
## Run it yourself (npm)
|
|
24
31
|
```
|
|
25
32
|
npx papercliped@latest
|
|
@@ -33,7 +40,7 @@ Any app that supports MCP can use Papercliped. See [Any AI app (MCP)](/docs/othe
|
|
|
33
40
|
The Papercliped plugin for Paperclip is where you manage everything: the [control room](/docs/control-room) for connected apps, what each may do and which agents it applies to, plus privacy and your account. See [Paperclip plugin](/docs/paperclip-plugin).
|
|
34
41
|
|
|
35
42
|
## ChatGPT
|
|
36
|
-
Papercliped
|
|
43
|
+
Add Papercliped as an MCP app in developer mode, or build a custom GPT with Actions. See [ChatGPT setup](/docs/chatgpt).
|
|
37
44
|
|
|
38
45
|
See [Create your account](/docs/signup) for every screen in detail.
|
|
39
46
|
|
|
@@ -11,6 +11,28 @@ The app opens the Papercliped sign-in page. Choose **Connect your Paperclip** (o
|
|
|
11
11
|
|
|
12
12
|
Where the setting lives differs per app (often "Connectors", "MCP servers" or "Tools"). Check your app's own docs for the menu.
|
|
13
13
|
|
|
14
|
+
## Claude Code
|
|
15
|
+
Install the plugin (adds the tools, a Paperclip skill and the `/paperclip-status`, `/paperclip-sync` and `/paperclip-report` commands):
|
|
16
|
+
```
|
|
17
|
+
/plugin marketplace add OpenSourcx/papercliped
|
|
18
|
+
/plugin install papercliped@papercliped
|
|
19
|
+
```
|
|
20
|
+
Or add just the MCP server, then run `/mcp` to sign in:
|
|
21
|
+
```
|
|
22
|
+
claude mcp add --transport http paperclip {{MCP}}/mcp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Codex
|
|
26
|
+
Install the plugin:
|
|
27
|
+
```
|
|
28
|
+
codex plugin marketplace add OpenSourcx/papercliped
|
|
29
|
+
codex plugin add papercliped@papercliped
|
|
30
|
+
```
|
|
31
|
+
Or add just the MCP server (Codex starts the sign-in for you):
|
|
32
|
+
```
|
|
33
|
+
codex mcp add paperclip --url {{MCP}}/mcp
|
|
34
|
+
```
|
|
35
|
+
|
|
14
36
|
## Apps that only run local MCP servers
|
|
15
37
|
Run Papercliped on your own computer instead. It talks straight to your Paperclip, with no account needed:
|
|
16
38
|
```
|
|
@@ -25,4 +47,4 @@ Self-host the bridge with a static key (`BRIDGE_TOKEN`) and send it as `Authoriz
|
|
|
25
47
|
Use the custom MCP connector where your plan offers it, or GPT Actions with `{{MCP}}/openapi.json`. See [ChatGPT setup](/docs/chatgpt).
|
|
26
48
|
|
|
27
49
|
## What every app gets
|
|
28
|
-
The same tools everywhere: list and control agents, issues, goals and approvals, sync what changed, and reports on status, costs, agent performance and activity. What an app may change depends on the level you chose
|
|
50
|
+
The same tools everywhere: list and control agents, issues, goals and approvals, sync what changed, and reports on status, costs, agent performance and activity. What an app may change depends on the level you chose and your [access mode](/docs/access-modes); change either at any time in the [control room](/docs/control-room) inside your Paperclip. Not sure what to ask? Try the [prompt gallery](/docs/prompts).
|
package/site/docs/security.md
CHANGED
|
@@ -14,6 +14,7 @@ What Papercliped does to protect you, and what it cannot.
|
|
|
14
14
|
- **OAuth 2.1 with PKCE**, short-lived access tokens, rotating refresh tokens with reuse detection.
|
|
15
15
|
- **Rate limits** on sign-in, secret-key attempts and tool calls.
|
|
16
16
|
- **A separate operator dashboard** (an access-controlled service, not part of the open-source bridge) reads the database through a read-only role that can see only safe views: never your key, secret hash or an anonymous account's real name.
|
|
17
|
+
- **Keys are renewed, not made permanent.** Paperclip expires the keys it issues (30 days on newer versions). About a week before that, Papercliped asks Paperclip for a fresh key with the same expiry, stores it encrypted, and revokes the old one. It never asks for a key that doesn't expire. In Paperclip the key is named "Papercliped (your username)". If renewal fails, the current key keeps working and Papercliped tries again later.
|
|
17
18
|
- Revocation is immediate on disconnect, and idle keys are removed after 30 days.
|
|
18
19
|
|
|
19
20
|
## What it cannot do
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**"This hostname is not allowed for this Paperclip instance."** Paperclip doesn't recognise the address. Set `PAPERCLIP_DEPLOYMENT_EXPOSURE=public` and `PAPERCLIP_PUBLIC_URL` to exactly the address you use, then restart Paperclip. See [What every option needs](/docs/hosting#what-every-option-needs).
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**"The Paperclip credential behind this connection was rejected."** Newer Paperclip versions expire keys after 30 days. Papercliped renews yours automatically about a week before that, so this should not happen on its own. It does if the key was revoked in Paperclip, the renewal kept failing until the key expired (for example, Paperclip was unreachable that whole week), or your Paperclip account lost access. Disconnect and connect again from your AI app, then approve in Paperclip.
|
|
8
8
|
|
|
9
9
|
**Agents stop mid-task now and then.** Something restarted Paperclip: an update, a redeploy, or a host that sleeps. Runs in progress end on every restart. Use an always-on host and update when agents are idle.
|
|
10
10
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# What is Papercliped?
|
|
2
2
|
|
|
3
|
-
Papercliped connects AI apps (Claude, ChatGPT and anything that speaks MCP) to **your** Paperclip, so you can run your AI-agent company by asking: "What are my agents doing?", "Pause the one that's over budget", "Give me this week's costs."
|
|
3
|
+
Papercliped connects AI apps (Claude, ChatGPT, Codex and anything that speaks MCP) to **your** Paperclip, so you can run your AI-agent company by asking: "What are my agents doing?", "Pause the one that's over budget", "Give me this week's costs."
|
|
4
4
|
|
|
5
5
|
Paperclip is an open-source orchestrator for companies of AI agents. Papercliped is an independent, open-source project (MIT); it isn't made by the Paperclip team.
|
|
6
6
|
|
|
@@ -34,4 +34,6 @@ See the [tool reference](/docs/tools) for all of them.
|
|
|
34
34
|
- **Account**: your Papercliped username and secret key, used to sign in again and manage connections.
|
|
35
35
|
- **Secret key**: a key starting with `pcs_`, shown once; Papercliped keeps only a one-way hash.
|
|
36
36
|
- **Board token**: a Paperclip credential with operator rights. Papercliped stores it encrypted and only uses it within your level.
|
|
37
|
-
- **
|
|
37
|
+
- **Control room**: the Papercliped page inside your Paperclip (from the [Paperclip plugin](/docs/paperclip-plugin)), where you manage sessions, access modes, agents and your account. See [The control room](/docs/control-room).
|
|
38
|
+
- **Session**: one connected AI app. You can name it, change its level, and limit it to some tools or agents.
|
|
39
|
+
- **Access mode**: API only, Full or Agent only: whether AI apps control Paperclip directly, work through agents, or both. See [Access modes](/docs/access-modes).
|