mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Local adapters (claude_local, codex_local) run agent heartbeats as
child processes, with a short-lived run JWT injected as
`PAPERCLIP_API_KEY` at spawn time
> - That JWT is minted exactly once, when the adapter spawns the process
— its TTL must therefore cover the entire wall-clock life of the run,
not just a prompt startup
> - On laptops the gap between spawn and first real execution can be
huge: a timer heartbeat scheduled while the lid is closed fires during a
~2s macOS dark wake, the machine re-sleeps immediately, and the frozen
child only executes during a later, longer wake — over an hour of
wall-clock delay in observed runs
> - The server's default TTL was 1h, so those sessions started with an
already-expired `PAPERCLIP_API_KEY` and every control-plane call 401'd;
the agent had to recover by manually minting a fresh key
> - The 1h default was also a spec drift: the CLI `env` command
(`DEFAULT_AGENT_JWT_TTL_SECONDS`) and the agent-authentication design
doc both document 172800s (48h)
> - This pull request realigns the server default to 48h and documents
the host-suspension constraint at the mint site and in the regression
test
> - The benefit is that lid-closed/suspended-host heartbeat runs come up
with a valid credential, and the three places that state the default now
agree
## Linked Issues or Issue Description
No public GitHub issue exists for this; per the bug-report template:
- **What happened:** A timer-driven heartbeat run on a MacBook (lid
closed, on battery) was invoked during a ~2s dark wake. The adapter
spawned the CLI and logged init within 2s, then the host re-slept and
the session sat frozen for ~64 minutes until a longer dark wake let it
execute. By then the injected run JWT (1h TTL, minted at spawn) had
expired, so every API call from the agent returned 401 and the run could
only recover via a manually minted key. A second agent's run the same
night showed the identical signature (output timestamps exactly matching
`pmset -g log` dark-wake windows).
- **Expected behavior:** A run that starts late because the host was
suspended should still have a valid `PAPERCLIP_API_KEY` when it finally
executes.
- **Steps to reproduce:** Run Paperclip on a laptop with a
`claude_local` agent on a timer heartbeat; close the lid on battery
overnight; observe a run invoked during a dark wake whose session
executes >1h later with an expired token (compare run-log timestamps to
`pmset -g log` sleep/wake entries).
- **Version/commit:** current `master` (14f20be9); local trusted
deployment mode.
Related context: #5864 introduced per-company signing keys in this same
module (no TTL changes).
## What Changed
- `server/src/agent-auth-jwt.ts`: default `ttlSeconds` for local agent
run JWTs raised from `60 * 60` (1h) to `60 * 60 * 48` (48h), matching
`DEFAULT_AGENT_JWT_TTL_SECONDS` in `cli/src/commands/env.ts` and
`doc/plans/2026-02-18-agent-authentication-implementation.md`; comment
documents why the TTL must cover host-suspension gaps
- `server/src/agent-auth-jwt.ts`: stale "~1h by default" reference in
the legacy-fallback guidance updated to 48h
- `server/src/__tests__/agent-auth-jwt.test.ts`: default-TTL regression
test updated to assert 48h and explain the constraint
- `PAPERCLIP_AGENT_JWT_TTL_SECONDS` remains the explicit override knob;
operators who set it see no behavior change
## Verification
- `cd server && pnpm vitest run src/__tests__/agent-auth-jwt.test.ts
src/__tests__/agent-auth-middleware.test.ts` — 24/24 pass locally
- Review that the three default sources now agree:
`server/src/agent-auth-jwt.ts` (`60 * 60 * 48`),
`cli/src/commands/env.ts` (`DEFAULT_AGENT_JWT_TTL_SECONDS = "172800"`),
design doc (`default: 172800`)
- Manual: on a laptop, set no TTL env, trigger a heartbeat, `echo
$PAPERCLIP_API_KEY` inside the run and decode the JWT — `exp - iat` is
172800
## Risks
- Longer-lived bearer tokens widen the leak window if a run token is
exfiltrated. Mitigations already in place: tokens are
per-company/per-instance signed (#5864), bound to a `run_id`, and never
persisted server-side. Operators wanting shorter tokens keep the
`PAPERCLIP_AGENT_JWT_TTL_SECONDS` override.
- The legacy master-secret fallback window guidance ("disable ~one TTL
after deploy") lengthens accordingly; the comment now states 48h
explicitly.
- Follow-up ideas intentionally out of scope: rejecting run JWTs whose
run has terminated (server-side revocation check), and holding a power
assertion (`caffeinate`-style) for the duration of local adapter runs so
dark-wake-spawned runs keep the host awake.
## Model Used
- Claude (Anthropic) — Fable 5, model ID `claude-fable-5`, via Claude
Code 2.1.x under Paperclip's `claude_local` adapter; extended thinking
and full tool use (shell, file edits, test execution) enabled
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: Paperclip <noreply@paperclip.ing>
320 lines
14 KiB
TypeScript
320 lines
14 KiB
TypeScript
import { createHmac } from "node:crypto";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createLocalAgentJwt, verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
|
|
|
describe("agent local JWT", () => {
|
|
const secretEnv = "PAPERCLIP_AGENT_JWT_SECRET";
|
|
const betterAuthSecretEnv = "BETTER_AUTH_SECRET";
|
|
const ttlEnv = "PAPERCLIP_AGENT_JWT_TTL_SECONDS";
|
|
const issuerEnv = "PAPERCLIP_AGENT_JWT_ISSUER";
|
|
const audienceEnv = "PAPERCLIP_AGENT_JWT_AUDIENCE";
|
|
const disableLegacyFallbackEnv = "PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK";
|
|
const instanceIdEnv = "PAPERCLIP_INSTANCE_ID";
|
|
|
|
const originalEnv = {
|
|
secret: process.env[secretEnv],
|
|
betterAuthSecret: process.env[betterAuthSecretEnv],
|
|
ttl: process.env[ttlEnv],
|
|
issuer: process.env[issuerEnv],
|
|
audience: process.env[audienceEnv],
|
|
disableLegacyFallback: process.env[disableLegacyFallbackEnv],
|
|
instanceId: process.env[instanceIdEnv],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
process.env[secretEnv] = "test-secret";
|
|
delete process.env[betterAuthSecretEnv];
|
|
process.env[ttlEnv] = "3600";
|
|
delete process.env[issuerEnv];
|
|
delete process.env[audienceEnv];
|
|
delete process.env[disableLegacyFallbackEnv];
|
|
delete process.env[instanceIdEnv];
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
if (originalEnv.secret === undefined) delete process.env[secretEnv];
|
|
else process.env[secretEnv] = originalEnv.secret;
|
|
if (originalEnv.betterAuthSecret === undefined) delete process.env[betterAuthSecretEnv];
|
|
else process.env[betterAuthSecretEnv] = originalEnv.betterAuthSecret;
|
|
if (originalEnv.ttl === undefined) delete process.env[ttlEnv];
|
|
else process.env[ttlEnv] = originalEnv.ttl;
|
|
if (originalEnv.issuer === undefined) delete process.env[issuerEnv];
|
|
else process.env[issuerEnv] = originalEnv.issuer;
|
|
if (originalEnv.audience === undefined) delete process.env[audienceEnv];
|
|
else process.env[audienceEnv] = originalEnv.audience;
|
|
if (originalEnv.disableLegacyFallback === undefined) delete process.env[disableLegacyFallbackEnv];
|
|
else process.env[disableLegacyFallbackEnv] = originalEnv.disableLegacyFallback;
|
|
if (originalEnv.instanceId === undefined) delete process.env[instanceIdEnv];
|
|
else process.env[instanceIdEnv] = originalEnv.instanceId;
|
|
});
|
|
|
|
it("creates and verifies a token", () => {
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1", "user-1");
|
|
expect(typeof token).toBe("string");
|
|
|
|
const claims = verifyLocalAgentJwt(token!);
|
|
expect(claims).toMatchObject({
|
|
sub: "agent-1",
|
|
company_id: "company-1",
|
|
adapter_type: "claude_local",
|
|
run_id: "run-1",
|
|
responsible_user_id: "user-1",
|
|
iss: "paperclip",
|
|
aud: "paperclip-api",
|
|
});
|
|
});
|
|
|
|
it("round-trips a skill_test run scope", () => {
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
const issueId = "11111111-1111-4111-8111-111111111111";
|
|
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1", "user-1", {
|
|
kind: "skill_test",
|
|
issueId,
|
|
});
|
|
|
|
const claims = verifyLocalAgentJwt(token!);
|
|
expect(claims?.key_scope).toEqual({ kind: "skill_test", issueId });
|
|
});
|
|
|
|
it("returns null when secret is missing", () => {
|
|
process.env[secretEnv] = "";
|
|
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
|
expect(token).toBeNull();
|
|
expect(verifyLocalAgentJwt("abc.def.ghi")).toBeNull();
|
|
});
|
|
|
|
it("falls back to BETTER_AUTH_SECRET when PAPERCLIP_AGENT_JWT_SECRET is absent", () => {
|
|
delete process.env[secretEnv];
|
|
process.env[betterAuthSecretEnv] = "fallback-secret";
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
|
expect(typeof token).toBe("string");
|
|
|
|
const claims = verifyLocalAgentJwt(token!);
|
|
expect(claims).toMatchObject({
|
|
sub: "agent-1",
|
|
company_id: "company-1",
|
|
adapter_type: "claude_local",
|
|
run_id: "run-1",
|
|
});
|
|
});
|
|
|
|
it("rejects expired tokens", () => {
|
|
process.env[ttlEnv] = "1";
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
|
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:05.000Z"));
|
|
expect(verifyLocalAgentJwt(token!)).toBeNull();
|
|
});
|
|
|
|
it("rejects issuer/audience mismatch", () => {
|
|
process.env[issuerEnv] = "custom-issuer";
|
|
process.env[audienceEnv] = "custom-audience";
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
const token = createLocalAgentJwt("agent-1", "company-1", "codex_local", "run-1");
|
|
|
|
process.env[issuerEnv] = "paperclip";
|
|
process.env[audienceEnv] = "paperclip-api";
|
|
expect(verifyLocalAgentJwt(token!)).toBeNull();
|
|
});
|
|
|
|
it("does not verify a token across companies (per-company isolation)", () => {
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
const tokenA = createLocalAgentJwt("agent-1", "company-A", "claude_local", "run-1");
|
|
expect(tokenA).not.toBeNull();
|
|
|
|
// A token whose body claims company-A must verify successfully under its
|
|
// own company-A derived key.
|
|
expect(verifyLocalAgentJwt(tokenA!)?.company_id).toBe("company-A");
|
|
|
|
// Tamper: forge a token by copying tokenA's header+signature and swapping
|
|
// the claim's company_id to company-B. The signature was bound to the
|
|
// company-A derived key over the original claims; once we re-encode with a
|
|
// different company_id (or rebind to company-B's key) verification must
|
|
// fail because the signature is over the original signing input.
|
|
const [headerB64, claimsB64, signature] = tokenA!.split(".");
|
|
const claims = JSON.parse(Buffer.from(claimsB64, "base64url").toString("utf8"));
|
|
claims.company_id = "company-B";
|
|
const tamperedClaimsB64 = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
|
|
const tampered = `${headerB64}.${tamperedClaimsB64}.${signature}`;
|
|
expect(verifyLocalAgentJwt(tampered)).toBeNull();
|
|
});
|
|
|
|
it("accepts legacy tokens signed with the master secret (backward compat)", () => {
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
const masterSecret = process.env[secretEnv]!;
|
|
|
|
// Hand-craft a token signed directly with the master secret, simulating a
|
|
// JWT issued before per-company derivation existed.
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const header = { alg: "HS256", typ: "JWT" };
|
|
const claims = {
|
|
sub: "agent-legacy",
|
|
company_id: "company-legacy",
|
|
adapter_type: "claude_local",
|
|
run_id: "run-legacy",
|
|
iat: now,
|
|
exp: now + 3600,
|
|
iss: "paperclip",
|
|
aud: "paperclip-api",
|
|
};
|
|
const headerB64 = Buffer.from(JSON.stringify(header), "utf8").toString("base64url");
|
|
const claimsB64 = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
|
|
const signingInput = `${headerB64}.${claimsB64}`;
|
|
const legacySig = createHmac("sha256", masterSecret).update(signingInput).digest("base64url");
|
|
const legacyToken = `${signingInput}.${legacySig}`;
|
|
|
|
const verified = verifyLocalAgentJwt(legacyToken);
|
|
expect(verified).toMatchObject({
|
|
sub: "agent-legacy",
|
|
company_id: "company-legacy",
|
|
adapter_type: "claude_local",
|
|
run_id: "run-legacy",
|
|
});
|
|
});
|
|
|
|
// --- Instance isolation (PAP-12899) ---------------------------------------
|
|
// A worktree/fork control-plane instance runs under a distinct
|
|
// PAPERCLIP_INSTANCE_ID but deliberately shares PAPERCLIP_AGENT_JWT_SECRET
|
|
// with its source instance (provisioning copies the secret). Before this
|
|
// change, a fork-minted run JWT validated successfully against the live plane
|
|
// (reads worked; writes then failed on missing heartbeat_runs FK rows). These
|
|
// tests pin the boundary that keeps fork tokens out of the live plane.
|
|
|
|
it("stamps the minting instance id into the token claims", () => {
|
|
process.env[instanceIdEnv] = "default";
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
|
const claims = verifyLocalAgentJwt(token!);
|
|
expect(claims?.instance_id).toBe("default");
|
|
});
|
|
|
|
it("rejects a fork/worktree-minted token on the live control plane", () => {
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
|
|
// Mint on a worktree/fork instance (distinct instance id, SAME secret).
|
|
process.env[instanceIdEnv] = "pap-12899-worktree";
|
|
const forkToken = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
|
expect(forkToken).not.toBeNull();
|
|
// Sanity: it verifies on the instance that minted it.
|
|
expect(verifyLocalAgentJwt(forkToken!)?.company_id).toBe("company-1");
|
|
|
|
// Now switch to the live control plane (same shared secret, "default"
|
|
// instance) and confirm the fork token no longer authenticates — neither
|
|
// its instance-scoped signature nor the legacy master-secret fallback
|
|
// matches, so reads and writes are both refused.
|
|
process.env[instanceIdEnv] = "default";
|
|
expect(verifyLocalAgentJwt(forkToken!)).toBeNull();
|
|
});
|
|
|
|
it("keeps live-plane heartbeat tokens authenticating across mint/verify", () => {
|
|
process.env[instanceIdEnv] = "default";
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1", "user-1");
|
|
const claims = verifyLocalAgentJwt(token!);
|
|
expect(claims).toMatchObject({
|
|
sub: "agent-1",
|
|
company_id: "company-1",
|
|
run_id: "run-1",
|
|
instance_id: "default",
|
|
});
|
|
});
|
|
|
|
it("rejects a token whose instance_id claim is forged to match the live plane", () => {
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
|
|
// Mint a fork token, then tamper the instance_id claim to impersonate the
|
|
// live "default" instance. The signature was bound to the fork instance's
|
|
// derived key, so re-encoding the claim cannot make it validate on the
|
|
// live plane — the claim check is defense-in-depth, the key is the boundary.
|
|
process.env[instanceIdEnv] = "pap-12899-worktree";
|
|
const forkToken = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
|
const [headerB64, claimsB64, signature] = forkToken!.split(".");
|
|
const claims = JSON.parse(Buffer.from(claimsB64, "base64url").toString("utf8"));
|
|
claims.instance_id = "default";
|
|
const tamperedClaimsB64 = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
|
|
const tampered = `${headerB64}.${tamperedClaimsB64}.${signature}`;
|
|
|
|
process.env[instanceIdEnv] = "default";
|
|
expect(verifyLocalAgentJwt(tampered)).toBeNull();
|
|
});
|
|
|
|
it("still rejects the master-secret legacy fallback once it is disabled (full instance isolation)", () => {
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
process.env[disableLegacyFallbackEnv] = "true";
|
|
process.env[instanceIdEnv] = "default";
|
|
// The legacy fallback signs with the raw shared secret and is therefore
|
|
// instance-agnostic; disabling it closes that residual cross-instance hole.
|
|
const legacyToken = craftLegacyMasterSecretToken(process.env[secretEnv]!, "company-1");
|
|
expect(verifyLocalAgentJwt(legacyToken)).toBeNull();
|
|
});
|
|
|
|
it("defaults TTL to 48h when PAPERCLIP_AGENT_JWT_TTL_SECONDS is unset", () => {
|
|
// Must match DEFAULT_AGENT_JWT_TTL_SECONDS in cli/src/commands/env.ts. Run
|
|
// tokens are minted once at adapter spawn, and a suspended host (laptop lid
|
|
// closed) can delay first execution past a short TTL, making the injected
|
|
// PAPERCLIP_API_KEY dead on arrival.
|
|
delete process.env[ttlEnv];
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
|
const claims = verifyLocalAgentJwt(token!);
|
|
expect(claims).not.toBeNull();
|
|
expect(claims!.exp - claims!.iat).toBe(60 * 60 * 48);
|
|
});
|
|
|
|
// Helper: hand-craft a token signed with the raw master secret (legacy path).
|
|
function craftLegacyMasterSecretToken(masterSecret: string, companyId: string) {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const header = { alg: "HS256", typ: "JWT" };
|
|
const claims = {
|
|
sub: "agent-legacy",
|
|
company_id: companyId,
|
|
adapter_type: "claude_local",
|
|
run_id: "run-legacy",
|
|
iat: now,
|
|
exp: now + 3600,
|
|
iss: "paperclip",
|
|
aud: "paperclip-api",
|
|
};
|
|
const headerB64 = Buffer.from(JSON.stringify(header), "utf8").toString("base64url");
|
|
const claimsB64 = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
|
|
const signingInput = `${headerB64}.${claimsB64}`;
|
|
const legacySig = createHmac("sha256", masterSecret).update(signingInput).digest("base64url");
|
|
return `${signingInput}.${legacySig}`;
|
|
}
|
|
|
|
it("accepts master-secret-signed tokens when PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK is unset", () => {
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
delete process.env[disableLegacyFallbackEnv];
|
|
const legacyToken = craftLegacyMasterSecretToken(process.env[secretEnv]!, "company-legacy");
|
|
const verified = verifyLocalAgentJwt(legacyToken);
|
|
expect(verified).not.toBeNull();
|
|
expect(verified!.company_id).toBe("company-legacy");
|
|
});
|
|
|
|
it("rejects master-secret-signed tokens when PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK is enabled", () => {
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
process.env[disableLegacyFallbackEnv] = "true";
|
|
const legacyToken = craftLegacyMasterSecretToken(process.env[secretEnv]!, "company-legacy");
|
|
expect(verifyLocalAgentJwt(legacyToken)).toBeNull();
|
|
});
|
|
|
|
it("still verifies per-company-signed tokens when PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK is enabled", () => {
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
process.env[disableLegacyFallbackEnv] = "true";
|
|
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
|
expect(token).not.toBeNull();
|
|
const verified = verifyLocalAgentJwt(token!);
|
|
expect(verified).toMatchObject({
|
|
sub: "agent-1",
|
|
company_id: "company-1",
|
|
adapter_type: "claude_local",
|
|
run_id: "run-1",
|
|
});
|
|
});
|
|
});
|