papercliped 2.5.0 → 2.5.2
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 +9 -0
- package/README.md +9 -1
- 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/docs/COMMUNITY.md +1 -1
- package/docs/LAUNCH-KIT.md +1 -1
- package/docs/supabase-schema.sql +18 -1
- package/migrations/008_key_rotation.sql +15 -0
- package/package.json +1 -1
- package/site/docs/security.md +1 -0
- package/site/docs/troubleshooting.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## v2.5.2 — join the OpenSourcedd community
|
|
6
|
+
|
|
7
|
+
- **Join the community.** Papercliped's community is now live as **OpenSourcedd**: the [Discord server](https://discord.gg/cAECU68jSA) and [r/OpenSourcedd](https://www.reddit.com/r/OpenSourcedd) on Reddit. Both are linked from the website footer and community page, GitHub's "New issue" page, the README and the contributing guide.
|
|
8
|
+
|
|
9
|
+
## v2.5.1 — Paperclip keys renew themselves
|
|
10
|
+
|
|
11
|
+
- **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.
|
|
12
|
+
- **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.
|
|
13
|
+
|
|
5
14
|
## v2.5.0 — icon pack, launch-ready docs and tester feedback
|
|
6
15
|
|
|
7
16
|
- **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.
|
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)
|
|
@@ -115,7 +122,8 @@ npm install && npm test
|
|
|
115
122
|
|
|
116
123
|
## Community and contributing
|
|
117
124
|
|
|
118
|
-
- Questions and
|
|
125
|
+
- Questions and chat: the OpenSourcedd [Discord](https://discord.gg/cAECU68jSA) and [GitHub Discussions](https://github.com/OpenSourcx/papercliped/discussions)
|
|
126
|
+
- Ideas, project shares and feedback: [r/OpenSourcedd](https://www.reddit.com/r/OpenSourcedd)
|
|
119
127
|
- Bugs and feature requests: [issues](https://github.com/OpenSourcx/papercliped/issues/new/choose)
|
|
120
128
|
- Contributing (setup, checks, the path from first PR to maintainer): [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
121
129
|
- Code of conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
|
@@ -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/docs/COMMUNITY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Community: Discord, Discussions and socials
|
|
2
2
|
|
|
3
|
-
A ready-to-follow setup for the
|
|
3
|
+
A ready-to-follow setup for the Discord, plus where everything else lives. Papercliped's community is **OpenSourcedd**: the Discord server (https://discord.gg/cAECU68jSA) and r/OpenSourcedd on Reddit, both set in `web/src/config/community.ts`. Other links show up on the website only once they're set (`VITE_X_URL`, `VITE_DISCUSSIONS_URL`, `VITE_FORUM_URL` at build time; `VITE_DISCORD_URL` / `VITE_REDDIT_URL` override the defaults). Nothing here needs a bot.
|
|
4
4
|
|
|
5
5
|
## 1. Create the server (about 20 minutes)
|
|
6
6
|
|
package/docs/LAUNCH-KIT.md
CHANGED
|
@@ -7,7 +7,7 @@ Everything for the public launch: what must be true first, how to get the first
|
|
|
7
7
|
| | Check | How |
|
|
8
8
|
| --- | --- | --- |
|
|
9
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;
|
|
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
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
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
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`. |
|
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.5.
|
|
3
|
+
"version": "2.5.2",
|
|
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/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
|
|