fix(server): align agent run JWT default TTL with documented 48h default (#10176)

## 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>
This commit is contained in:
Dylan Roy 2026-08-12 19:44:20 -04:00 committed by GitHub
parent b7b8fbf688
commit 6a546e8a9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 14 additions and 4 deletions

View File

@ -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).

View File

@ -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