papercliped 2.6.1 → 2.7.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papercliped",
3
- "version": "2.6.1",
3
+ "version": "2.7.1",
4
4
  "description": "Run your Paperclip from Claude: control agents, sync with them and get reports, at the access level you choose.",
5
5
  "author": {
6
6
  "name": "KpnWorld"
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v2.7.1 — works with Paperclip 2026.1023.0
6
+
7
+ - **Works with Paperclip 2026.1023.0.** Checked against a real instance: sign-in, key renewal, every tool and the plugin install. "Wake agent now" now uses Paperclip's current wake route (the old one still works there, but is a legacy alias), and the Papercliped plugin is built against the 2026.1023.0 plugin SDK. A new test pins the list of Paperclip routes the tools call, so a Paperclip release that removes one fails CI instead of failing for you.
8
+ - **Docs:** which Paperclip versions are supported, and which Paperclip role you need (Viewer for Read only, Operator for Full control), in `docs/PAPERCLIP-COMPATIBILITY.md` and the permissions page.
9
+
10
+ ## v2.7.0 — Ed25519-signed Connect account claims
11
+
12
+ - **Connect claims can be signed with an Ed25519 key.** Set `CONNECT_CLAIM_ED25519_PRIVATE_KEY` (PKCS#8 PEM; `node scripts/gen-claim-key.mjs <file>` makes a pair) and optionally `CONNECT_CLAIM_KEY_ID`; Connect then verifies with the public key only and can no longer forge claims. The shared `CONNECT_CLAIM_SIGNING_KEY` still works for one more release. Nothing to do unless you run Connect claims; they stay off by default.
13
+
5
14
  ## v2.6.1 — docs: mention the upcoming managed address
6
15
 
7
16
  - **Docs:** the "At home, with a tunnel" guide now mentions that a managed address from Papercliped Connect (for ePaperclip on Proxmox) is being built and is not available yet. Nothing to do.
@@ -1,9 +1,14 @@
1
- import { createHash, createHmac, randomBytes } from "node:crypto";
1
+ import { createHash, createHmac, createPrivateKey, randomBytes, sign as edSign } from "node:crypto";
2
2
  /**
3
3
  * This service-only assertion proves ownership of one EXISTING Papercliped
4
4
  * account for a particular (already verified on Connect) OIDC identity.
5
5
  * Disabled unless CONNECT_CLAIM_SIGNING_KEY is configured explicitly.
6
6
  * It is NOT a login session, MCP grant, or Proxmox permission.
7
+ *
8
+ * Two signature formats (Connect accepts both during the rollout release):
9
+ * - EdDSA (Ed25519), header { alg: "EdDSA", v: 2, kid }: signed with a private key held only here; Connect verifies with a
10
+ * public key, so Connect can no longer forge claims. Preferred; used whenever an Ed25519 key is configured.
11
+ * - HS256, header { alg: "HS256", v: 1 }: the legacy shared secret.
7
12
  */
8
13
  export const CLAIM_TTL_SECONDS = 60;
9
14
  const ASSERTION_ISSUER = "https://papercliped.co";
@@ -23,21 +28,35 @@ export function claimIdentityBinding(issuer, subject) {
23
28
  throw new Error("Invalid verified identity");
24
29
  return createHash("sha256").update(JSON.stringify([issuer, subject]), "utf8").digest("base64url");
25
30
  }
26
- export function signConnectClaim({ accountId, issuer, subject, signingKey, nowMs = Date.now(), }) {
31
+ /** Parses CONNECT_CLAIM_ED25519_PRIVATE_KEY (PKCS#8 PEM; literal "\\n" sequences are accepted for single-line env vars). Throws unless it is Ed25519. */
32
+ export function parseClaimEdKey(pem, kid) {
33
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(kid))
34
+ throw new Error("Invalid claim key id");
35
+ const key = createPrivateKey(pem.replace(/\\n/g, "\n"));
36
+ if (key.asymmetricKeyType !== "ed25519")
37
+ throw new Error("Claim signing key must be Ed25519");
38
+ return { key, kid };
39
+ }
40
+ /** The configured Ed25519 key, or null when unset. A set-but-invalid key throws at startup instead of silently falling back to HS256. */
41
+ export function claimEdKeyFromEnv(env = process.env) {
42
+ const pem = env.CONNECT_CLAIM_ED25519_PRIVATE_KEY?.trim();
43
+ return pem ? parseClaimEdKey(pem, env.CONNECT_CLAIM_KEY_ID?.trim() || "pcd-claim-1") : null;
44
+ }
45
+ export function signConnectClaim({ accountId, issuer, subject, signingKey, edKey, nowMs = Date.now(), }) {
27
46
  if (!ACCOUNT_ID.test(accountId) || !validClaimIdentity(issuer, subject))
28
47
  throw new Error("Invalid claim subject");
29
- if (typeof signingKey !== "string" || Buffer.byteLength(signingKey, "utf8") < 32)
48
+ if (!edKey && (typeof signingKey !== "string" || Buffer.byteLength(signingKey, "utf8") < 32))
30
49
  throw new Error("Signing key not configured");
31
50
  if (!Number.isSafeInteger(nowMs) || nowMs < 0)
32
51
  throw new Error("Invalid time");
33
52
  const iat = Math.floor(nowMs / 1000);
34
- const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "PCD-ACCOUNT-ASSERTION", v: 1 })).toString("base64url");
53
+ const header = Buffer.from(JSON.stringify(edKey ? { alg: "EdDSA", typ: "PCD-ACCOUNT-ASSERTION", v: 2, kid: edKey.kid } : { alg: "HS256", typ: "PCD-ACCOUNT-ASSERTION", v: 1 })).toString("base64url");
35
54
  const payload = Buffer.from(JSON.stringify({
36
55
  iss: ASSERTION_ISSUER, aud: CLAIM_AUDIENCE, sub: accountId,
37
56
  jti: randomBytes(24).toString("base64url"), iat, exp: iat + CLAIM_TTL_SECONDS,
38
57
  binding: claimIdentityBinding(issuer, subject),
39
58
  })).toString("base64url");
40
59
  const signed = header + "." + payload;
41
- const mac = createHmac("sha256", signingKey).update(signed).digest("base64url");
42
- return { assertion: signed + "." + mac, expiresIn: CLAIM_TTL_SECONDS };
60
+ const sig = edKey ? edSign(null, Buffer.from(signed), edKey.key).toString("base64url") : createHmac("sha256", signingKey).update(signed).digest("base64url");
61
+ return { assertion: signed + "." + sig, expiresIn: CLAIM_TTL_SECONDS };
43
62
  }
package/dist/http.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { supabaseIdentityVerifier } from "./accounts/supabase-session.js";
3
+ import { claimEdKeyFromEnv } from "./accounts/connect-claim.js";
3
4
  import { readConfig, readHttpConfig } from "./config.js";
4
5
  import { migrate } from "./migrate.js";
5
6
  import { OAuthProvider } from "./oauth/provider.js";
@@ -76,7 +77,7 @@ async function main() {
76
77
  const web = webDir ? new WebAppRoutes({ dir: webDir, hosts, forumUrl: process.env.FORUM_URL?.trim() || null }) : undefined;
77
78
  if (web)
78
79
  console.error(`website: ${webDir}${process.env.PUBLIC_HOSTS ? ` (hosts: ${process.env.PUBLIC_HOSTS})` : ""}`);
79
- const manage = provider && http.oauth?.mode === "multi" ? new ManageRoutes(provider, { connectClaimSigningKey: process.env.CONNECT_CLAIM_SIGNING_KEY?.trim() || null, verifyIdentity: process.env.CONNECT_SUPABASE_PUBLISHABLE_KEY?.trim() ? supabaseIdentityVerifier(process.env.CONNECT_SUPABASE_PUBLISHABLE_KEY.trim()) : null }) : undefined;
80
+ const manage = provider && http.oauth?.mode === "multi" ? new ManageRoutes(provider, { connectClaimSigningKey: process.env.CONNECT_CLAIM_SIGNING_KEY?.trim() || null, connectClaimEdKey: claimEdKeyFromEnv(), verifyIdentity: process.env.CONNECT_SUPABASE_PUBLISHABLE_KEY?.trim() ? supabaseIdentityVerifier(process.env.CONNECT_SUPABASE_PUBLISHABLE_KEY.trim()) : null }) : undefined;
80
81
  const server = createHttpServer(config, http, { oauth: provider, recorder, site, manage, publicApi, web });
81
82
  server.listen(http.port, http.host, () => {
82
83
  const o = http.oauth;
@@ -74,9 +74,10 @@ export class ManageRoutes {
74
74
  // The returned 60-second token is bound to the browser identity separately
75
75
  // verified by Connect, and can be consumed only once there.
76
76
  if (route === "/connect-claim") {
77
- const key = this.options.connectClaimSigningKey;
77
+ const hmacKey = this.options.connectClaimSigningKey;
78
+ const edKey = this.options.connectClaimEdKey ?? null;
78
79
  const verify = this.options.verifyIdentity;
79
- if (!key || key.length < 32 || !verify)
80
+ if ((!edKey && (!hmacKey || hmacKey.length < 32)) || !verify)
80
81
  return this.json(res, 404, { error: "Not found" });
81
82
  if (m !== "POST")
82
83
  return this.json(res, 405, { error: "Method not allowed" }, { Allow: "POST" });
@@ -102,7 +103,7 @@ export class ManageRoutes {
102
103
  return this.json(res, 409, { error: "This account or sign-in is already linked to another" });
103
104
  const claim = signConnectClaim({
104
105
  accountId: login.account.id, issuer: who.issuer, subject: who.subject,
105
- signingKey: key, nowMs: now,
106
+ signingKey: hmacKey, edKey, nowMs: now,
106
107
  });
107
108
  return this.json(res, 200, claim);
108
109
  }
package/dist/tools.js CHANGED
@@ -74,7 +74,8 @@ export const tools = [
74
74
  description: "Manually trigger a heartbeat so the agent starts working immediately.",
75
75
  access: "write",
76
76
  schema: z.object({ agentId: id("Agent") }),
77
- run: (c, i) => c.post(`/agents/${i.agentId}/heartbeat/invoke`),
77
+ // /wakeup is Paperclip's current route (in every release since 2026.318); /heartbeat/invoke is its legacy alias. /wakeup needs a JSON body.
78
+ run: (c, i) => c.post(`/agents/${i.agentId}/wakeup`, { source: "on_demand", triggerDetail: "manual" }),
78
79
  }),
79
80
  tool({
80
81
  name: "paperclip_set_agent_budget",
@@ -0,0 +1,50 @@
1
+ # Paperclip compatibility
2
+
3
+ Papercliped talks to a Paperclip it does not ship, over Paperclip's REST API and its plugin system. This page says which Paperclip versions that has been checked against, how, and what to do when a new one comes out.
4
+
5
+ ## Supported versions
6
+
7
+ | Paperclip (`paperclipai`) | Status | Checked how |
8
+ | --- | --- | --- |
9
+ | **2026.1023.0** (latest stable at the time of writing) | Supported, certified | A real instance (Node 24.21, embedded Postgres) ran the login flow, key renewal, every route the tools call, the plugin install and the role checks below. `test/paperclip-contract.test.ts` pins its route list. |
10
+ | 2026.1005.0 | Supported, not run live | The same contract test passes against its OpenAPI document: every route the bridge calls exists there. |
11
+ | 2026.1012.0 | Expected to work, not checked | Between the two above. |
12
+ | 2026.318.0 to 2026.1001.0 | Best effort | `/agents/:id/wakeup` and `/board-api-keys` exist from 2026.318 on. Older keys never expire, so renewal is skipped. |
13
+ | Nightly, canary, beta | Not supported | They change without notice. |
14
+
15
+ Paperclip needs Node 24.11 or later (`engines` of the package). Papercliped itself does not run Paperclip, so this only matters for the person who hosts it.
16
+
17
+ ## What Papercliped calls
18
+
19
+ - **Login:** `POST /cli-auth/challenges`, the person approves in Paperclip's own page, `GET /cli-auth/challenges/:id`, then `GET /cli-auth/me`.
20
+ - **Key renewal:** `GET /board-api-keys` (to read this key's expiry), `POST /board-api-keys` with the old key (a new key for the same user, 30-day expiry), `POST /cli-auth/revoke-current` with the old key. Checked live on 2026.1023.0: the new key works, the old one answers 401 once revoked.
21
+ - **Tools:** the routes in `src/tools.ts`. `paperclip_wake_agent` uses `POST /agents/:id/wakeup` with `{ source: "on_demand", triggerDetail: "manual" }`. Paperclip also serves a legacy `POST /agents/:id/heartbeat/invoke` (empty body allowed), kept only for old callers; `/wakeup` rejects a request with no JSON body with a 400.
22
+ - **Plugin:** `GET /plugins`, `POST /plugins/install` (instance admins only, anyone else gets 403, reported as "denied"). The plugin is built against `@paperclipai/plugin-sdk` 2026.1023.0, plugin API version 1, and its manifest passes `pluginManifestV1Schema`; it was installed into a running 2026.1023.0 and reached `ready`.
23
+
24
+ ## Which Paperclip role the connected person needs
25
+
26
+ The key Papercliped holds is the Paperclip user's own board key: it can do what that user can do in their companies, nothing is narrowed on Paperclip's side. So the role is the person's company membership. Measured on 2026.1023.0 with one key per role:
27
+
28
+ | Tool | Viewer | Operator | Admin |
29
+ | --- | --- | --- | --- |
30
+ | List companies, agents, issues, costs, activity, approvals | yes | yes | yes |
31
+ | Create or update issues, comment, create goals | **no (403)** | yes | yes |
32
+ | Pause, resume, wake, set budget, terminate an agent | **no (403)** | yes | yes |
33
+ | Install the Papercliped plugin into Paperclip | no | no | no (instance admin only) |
34
+
35
+ - **Read only** access in Papercliped needs only **Viewer**.
36
+ - **Full control** needs **Operator**. Owner and Admin add nothing the tools use (they add inviting people, approving join requests and managing permissions), so use Operator as the least-privilege role.
37
+ - Since 2026.1023 an Operator holds 14 permission grants (it held 1): create and configure agents, create skills, manage environments, invite users, assign tasks, pipelines, and the tool grants. Papercliped's tools use none of the extra ones. If that is more than you want, narrow the member in Paperclip (`PATCH /companies/:id/members/:memberId/permissions`) or give the person Viewer and accept read only.
38
+ - The one-click plugin install needs an **instance admin**. Anyone else installs the plugin from Paperclip's own plugin page.
39
+
40
+ ## Key expiry
41
+
42
+ Paperclip's board keys expire after 30 days. Papercliped renews each key about a week before (see `docs/SECURITY.md` and the changelog for 2.5.1). Renewal uses the old key, so a key that has already expired (401) cannot be renewed: the person connects again. Keys on Paperclip versions that list no expiry are treated as expiring 30 days after they were issued until the first renewal check reads the real date.
43
+
44
+ ## When a new Paperclip comes out
45
+
46
+ 1. Start a throwaway instance of it (loopback, its own `PAPERCLIP_HOME`), sign up, claim the instance.
47
+ 2. `node scripts/paperclip-routes.mjs http://127.0.0.1:<port> <version>` rewrites `test/fixtures/paperclip-routes.json`.
48
+ 3. `npm test`. A failure in `test/paperclip-contract.test.ts` names the call that no longer exists.
49
+ 4. Bump `@paperclipai/plugin-sdk` in `plugin/package.json`, run the plugin's typecheck, tests and build, and install the built plugin into the instance.
50
+ 5. Update the table above and add a changelog entry.
@@ -6,6 +6,8 @@ The new `POST /api/manage/connect-claim` route is **disabled unless** the server
6
6
 
7
7
  The route also needs `CONNECT_SUPABASE_PUBLISHABLE_KEY` (the Paperclipped project's public key; without it the route stays 404). The caller sends `{username, secret, accessToken}`: the bridge asks Supabase Auth who the `accessToken` belongs to (confirmed email, not anonymous, not banned), so issuer and subject are never taken from the request. It then verifies the owner's existing username + secret key via the throttled `manageLogin` function, binds the identity to the account in `bridge.account_identities` (409 if the account or the identity is already bound to another), and returns a 60-second HMAC-SHA256 assertion with `iss=https://papercliped.co`, `aud=papercliped-connect`, canonical account `sub`, unique `jti`, and an SHA256 binding to the claimed OIDC issuer + subject. Connect must independently verify the authenticated OIDC principal, check exact binding and atomically consume the `jti` in `connect.account_assertion_replays`.
8
8
 
9
+ **Asymmetric signing (preferred).** With `CONNECT_CLAIM_ED25519_PRIVATE_KEY` set (Ed25519, PKCS#8 PEM, `\n` escapes allowed) the assertion is signed `EdDSA` with header `{alg:"EdDSA", typ:"PCD-ACCOUNT-ASSERTION", v:2, kid}` (`CONNECT_CLAIM_KEY_ID`, default `pcd-claim-1`); Connect verifies with the matching public JWK (`CLAIM_PUBLIC_JWKS`) and rotates keys by kid. If both keys are set the Ed25519 key is used. An invalid Ed25519 key stops startup. `CONNECT_CLAIM_SIGNING_KEY` (HS256, `v:1`) remains for one release and is then removed.
10
+
9
11
  **Security boundaries:** This is **not** an OIDC provider, browser session, Connect entitlement, MCP grant, or Proxmox access. The issuer does not itself verify third-party OIDC identities. The bridge now verifies the Supabase session itself; Connect must still verify binding and consume the `jti`. Do not enable this route until a proper account-claiming UX, CSRF defenses, secret rotation and privacy handling are reviewed. No code is deployed from this branch.
10
12
 
11
13
  This route does not modify stored accounts or grants. It never stores or returns the user's secret key. Existing plugin sign-in and MCP OAuth behavior are unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papercliped",
3
- "version": "2.6.1",
3
+ "version": "2.7.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",
@@ -22,6 +22,9 @@ These only ever narrow what a level allows; they never grant more than the level
22
22
  - Apps that still ask for the old "admin" level get Full control.
23
23
  - To change a level, use the [Paperclip plugin](/docs/paperclip-plugin), or disconnect the app and connect it again.
24
24
 
25
+ ## Which Paperclip role you need
26
+ Papercliped uses your own Paperclip account, so what it can do also depends on your role in the company. **Viewer** is enough for Read only. **Operator** is enough for Full control (create and update issues, pause, resume, wake and terminate agents, set budgets). Owner and Admin add nothing Papercliped uses. Installing the plugin with one click needs an instance admin; anyone else can install it from Paperclip's plugin page. Papercliped works with Paperclip 2026.1023.0, the latest stable release, and with the releases from 2026.1005.0 on that it has been checked against.
27
+
25
28
  ## Disconnecting
26
29
  - Remove the connector in Claude or ChatGPT.
27
30
  - To cut access immediately, revoke the key in your Paperclip (it is named like "… (via bridge) (board)").