From 6a546e8a9a95033e3b63cc2d8c8be0788b99f99e Mon Sep 17 00:00:00 2001 From: Dylan Roy Date: Wed, 12 Aug 2026 19:44:20 -0400 Subject: [PATCH] fix(server): align agent run JWT default TTL with documented 48h default (#10176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- server/src/__tests__/agent-auth-jwt.test.ts | 8 ++++++-- server/src/agent-auth-jwt.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/agent-auth-jwt.test.ts b/server/src/__tests__/agent-auth-jwt.test.ts index 7d809ce3d3..9ad27d6e6b 100644 --- a/server/src/__tests__/agent-auth-jwt.test.ts +++ b/server/src/__tests__/agent-auth-jwt.test.ts @@ -253,13 +253,17 @@ describe("agent local JWT", () => { expect(verifyLocalAgentJwt(legacyToken)).toBeNull(); }); - it("defaults TTL to 1h when PAPERCLIP_AGENT_JWT_TTL_SECONDS is unset", () => { + 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); + expect(claims!.exp - claims!.iat).toBe(60 * 60 * 48); }); // Helper: hand-craft a token signed with the raw master secret (legacy path). diff --git a/server/src/agent-auth-jwt.ts b/server/src/agent-auth-jwt.ts index 140bcfafe4..a4f42b9d77 100644 --- a/server/src/agent-auth-jwt.ts +++ b/server/src/agent-auth-jwt.ts @@ -42,7 +42,13 @@ function jwtConfig() { return { secret, - ttlSeconds: parseNumber(process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS, 60 * 60), + // 48h default, matching DEFAULT_AGENT_JWT_TTL_SECONDS in cli/src/commands/env.ts + // and the agent-authentication design doc. Run tokens are minted once at + // adapter spawn and injected as env, so the TTL must cover the entire run — + // including host-suspension gaps: heartbeats scheduled while a laptop lid is + // closed fire during ~2s dark wakes, and the spawned session can then sit + // frozen for over an hour before it first executes. + ttlSeconds: parseNumber(process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS, 60 * 60 * 48), issuer: process.env.PAPERCLIP_AGENT_JWT_ISSUER ?? "paperclip", audience: process.env.PAPERCLIP_AGENT_JWT_AUDIENCE ?? "paperclip-api", // The control-plane instance this process belongs to. The live plane runs as @@ -183,7 +189,7 @@ export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null { // bounds the legacy window naturally). // // Operators should set `PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK=true` - // approximately one JWT TTL (~1h by default, see PAPERCLIP_AGENT_JWT_TTL_SECONDS) + // approximately one JWT TTL (~48h by default, see PAPERCLIP_AGENT_JWT_TTL_SECONDS) // after deploying per-company signing. Once set, the master-secret fallback // is disabled and only tokens validating under the per-instance/per-company // derived key are accepted — closing the window in which a leaked master