Standalone Handshake V1 — witnessed, bounded A2A channel at /connect/mcp - #115
Merged
Merged
Conversation
…connect/mcp endpoint Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tocol validators to e2e anchored closure Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…+'1b', not repeat(32)) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cal digests, consent/authority payloads Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…uthority signature, manifest cross-check Checklist checks identity (policy-gated resolution), authority signature recovery against sessionKeyAddress, and manifest cross-consistency, with a canonical checklist digest over schema clockchain.standalone-handshake-checklist/v1. Centralize validTerms/validReadiness test fixtures in test/helpers/ so no test file imports another (avoids double execution under node --test); controller sign-off: test 2 exercises the resolver via a required_fresh policy. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…s, fail-closed admission Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…ning receipt, closure anchoring Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rver at /connect/mcp Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…handshake.json on the main HTTP server Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… llms.txt Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…chored revocation over HTTP Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…h consensus-derived times channel_open now anchors all three transitions before setStage/openChannel, so a transient anchor failure leaves the session in consented and the retry idempotent. openedAtMs derives from the open anchor's block time (ledger consensus time), and the CONSENT and OPEN transition records carry both parties' consent digests. Closure records are pinned in the store before anchoring (setPendingClosure/ takePendingClosure) so retries re-anchor the identical digest; anchoring fails hard when a reference already holds a different digest. Spec doc notes both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… tool layer Admission refusals surface the bare reason code; retryable names keep the temp-unavailable shape; everything else (including coordinator failures and unknown errors) returns the constant STANDALONE_HANDSHAKE_UNAVAILABLE — raw messages never reach unauthenticated clients. Failures also emit the v2-style structured log line (event/tool/errorName) before the error body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ping on every broker path Resolving a live handle now refreshes its expiry (TTL from last use, not from issue) so a handle survives channels longer than one fixed horizon while an idle handle still expires. Role-scoped results also strip initiatorAccess/ responderAccess, matching the v2 broker's defense-in-depth posture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd pin the boundary tests A non-string or empty channel_send body is now refused with MALFORMED (per the spec's reason-code list); only a well-formed string over the byte cap is TOO_LARGE. Adds boundary-accept tests (durationSeconds 60/86400, maxMessageBytes 1/16384 accept, 0 rejects), checklist fail-closed and digest-sensitivity tests (a throwing recoverAddress yields AUTHORITY_INVALID; a failing checklist digests differently), and a one-line spec note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-closure semantics The store gains sessionIds() (the single source of truth for auth scans), terminal-stage eviction with a terminalRetention cap (default TERMINAL_SESSION_RETENTION = 500; active sessions are never evicted), frozen scope/checklist copies from status(), and UNKNOWN_PARTY validation on closeChannel/revokeChannel. The coordinator drops its private cachedIds cache. Closure is now fail-safe end to end: channel_close/channel_revoke pre-touch the store and refuse an expired channel with EXPIRED before any ledger write, and the closure record is pinned peek-then-clear-late — setPendingClosure keeps a like-for-like pin (retry re-anchors the identical digest) and refuses a different outcome or role with CLOSURE_PENDING before anchoring; the pin clears only after the store mutation succeeds, so it survives anchor and mutation failures alike. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…o-end coverage top-ups The /connect/mcp catch now emits a structured warn event before the 503 so a lazy handler failure is visible in logs, not silently swallowed. The landing test asserts the zero-padded "08" module badge directly instead of the vacuous includes(MODULE_COUNT) check, and the e2e flow covers the opened channel's status stage, a second in-scope send at seq 2, and both-direction toRole read filtering. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…re rate limits, retryable accept, bounded store Adversarial audit of the v1 surface found five real defects; all are fixed with regression tests: - Rate limits collapsed to one global bucket: the listener binds ::, so the trusted proxy peer arrives IPv6-mapped (::ffff:172.30.0.3) and never string-equals the configured 172.30.0.3 — XFF was ignored and any client could exhaust the whole endpoint's budget. Normalize mapped addresses before comparing (standalone + v2 public servers). - Time judgments ran on the server wall clock: the runtime coordinator now reads Clockchain consensus time via clock-sdk (transient-refusal before first sync), and channel_open fails transiently on an unparseable anchor block time instead of silently falling back to Date.now. - A transient identity-resolution failure during accept bricked the session in readiness_pending forever: the claim is restored and the stage rolled back so the same invitation retries cleanly; a tampered envelope restores the claim too. - The discovery manifest hardcoded the production /connect/mcp endpoint and served it under /staging/*: the endpoint now derives from an env override or the request's forwarded host/prefix, and the Caddyfile gains the committed handle_path /staging/* route (previously an uncommitted on-box edit) with X-Forwarded-Prefix. - Unbounded memory: per-session message cap, idle TTL for non-terminal sessions, TTL for unclaimed invitations, terminal-session retention cap, and limiter-map sweeping. - Mixed-case (checksummed) hex is accepted and canonicalized to lowercase so digests and the recovered-address compare are casing-independent; the pinned registry address canonicalizes the same way. - channel_send's schema no longer pre-empts the spec'd reason codes: a non-string body reaches admission and surfaces MALFORMED. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…s sub-ms ClockchainClock.now().epochMs carries monotonic sub-millisecond noise, so closedAtMs/sentAtMs reached decimal-validated records as floats and the closure record refused with a validation error — channel_revoke failed opaque on the live staging flow. Floor at the runtime binding and at the two record use sites so any injected clock stays safe. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…e demo theater - GET /connect/verify?ledgerId&blockHeight — public, unauthenticated route that verifies an anchor against the immutable on-chain block (no API key on the upstream read). Receipts are only meaningful if anyone can check them; the node network is not browser-reachable, so the host fronts the same keyless check. Per-IP limited, positive results cached (immutable). - packages/web-demo: handshake theater demo — two agent personas with fresh ephemeral Sepolia keypairs sign EIP-191 locally and run the real flow against staging: invite, checklist, dual consent, three opening anchors, bounded exchange, anchored revocation. SSE streams every beat to a three-pane page (initiator | ledger witness | responder) with scripted fault injection (forged signature, mismatched manifest, pre-open send, out-of-scope kind, oversized body, replayed invitation, forged handle, send-after-revoke) so the fail-closed reason codes are part of the show. "Verify anchors keylessly" calls /connect/verify; past runs replay from their stored event logs. - mcp-server gains a narrow exports map so the demo imports the protocol records (canonical bytes must match the server's byte-for-byte). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Adds a second demo mode where the page visitor plays Agent B: an ephemeral Sepolia keypair is generated in the tab, authority + consent records are signed with the bundled viem IIFE (canonical sorted-key bytes identical to the coordinator's), and only signatures cross the wire via a transport-only tool proxy. The demo server hosts the Agent A initiator persona end-to-end. Also fixes proxied tool-call payloads to tolerate plain-text MCP error bodies, and pins esbuild for the bundle step. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Transitional change. The host now publishes its exact active terms in discovery, which the coordinator's exact parser previously rejected outright, and invite() minted sessions for arbitrary caller terms the host would never drive (deadlocking at awaiting_descriptor). Until the terms-bearing host is fully rolled out, discovery() accepts both the legacy shape without terms and the new shape with valid normalized terms. When host terms are present they are authoritative: a canonically different caller terms object fails before minting, state creation, or relay posting, and the published terms flow to metadata, statement digests, policy, the relay body, and the response. When absent, the legacy caller-terms path is preserved and one bounded structured telemetry event (event name + sessionId only, no terms or secrets) is emitted so rollout completion is observable. The post-claim storeInitial terms-rotation rejection is deliberately deferred until invitation-claim durability lands. Constraint: deployment skew — a strict terms requirement would break every invite while any pre-terms host session is still being served Constraint: public error surface stays generic — mismatch surfaces as HANDSHAKE_UNAVAILABLE, non-retryable Rejected: strict terms requirement now | requires verified host rollout first; this commit is the safe intermediate state Rejected: post-claim storeInitial terms check now | runs after the invitation is durably claimed and would burn it on rotation Confidence: high Scope-risk: narrow Directive: the legacy fallback and telemetry are transitional — remove both in the strict follow-up once claim durability and host rollout are verified Tested: node --test coordinator+public-server (12 pass); npm test -w @clockchain/mcp-server (406 pass) Not-tested: live relay; one flaky suite run observed once then passing on rerun
Invitation acceptance previously burned the claim before the responder was durably seated, so any failure after claim made the invitation unrecoverable. Store schema v2 now persists a durable claim record (phases claimed/initialized/posted/completed) keyed by HMAC(serverKey, clientKey) plus the responder mint fields, so the same invitation plus same client-held key resumes monotonically and returns byte-identical responder access through access expiry. Constraint: only the HMAC digest and key id persist; raw invitations, raw acceptance keys, and responder access tokens never reach disk. Constraint: caller entropy is required for durability so a second invitation holder cannot replay as the same claimant. Rejected: invitation-derived idempotency | any holder could recompute it. Rejected: persisting the minted responder access | reconstruction from persisted kid/jti/nbf/exp plus the retained signing key is equivalent and keeps tokens out of the store. Confidence: high Scope-risk: narrow Directive: accept() returns the claim record for the coordinator to drive through advanceClaim; coordinator phase recovery and relay idempotency are NOT yet wired. v1 stores migrate at load: unclaimed to claim:null, claimed to legacy_terminal which can never reconstruct access. A missing key still performs a legacy one-shot terminal claim. Tested: 20/20 invitation-store tests (v1 migration, key hygiene, restart reconstruction, signing/HMAC rotation, expiry bounds); full @clockchain/mcp-server suite 423/423; tsc build clean. Not-tested: coordinator accept-phase ordering, relay duplicate identity, and public-tool acceptanceIdempotencyKey surface.
A keyed accept now resumes monotonically instead of burning the invitation: begin-or-resume the durable claim, insert-or-validate responder state, advance initialized, post the claim receipt idempotently, advance posted, then advance completed after the response is built. A crash at any point retries with byte-identical responder access, the preserved relay identity, and one logical relay message. Constraint: storeInitial preserves an existing responder relay key and sender key, and rejects mismatched principal/session/role, terms, discovery, certificate, or relay identity rather than rotating. Constraint: refreshed discovery is re-fetched before state mutation; published terms must canonically match invitation metadata, while a legacy record without terms still passes with bounded event+session telemetry for mixed-version rollout recovery. Rejected: JSON.stringify body equality for relay dedup | key order can differ; canonical digests bound to session/role/senderKey/kind make exact duplicates a no-op and conflicting duplicates terminal. Rejected: relying on invitation-store serialization alone | the relay post window between begin and completion needs same-key in-process serialization keyed by durable claim identity. Confidence: high Scope-risk: narrow Directive: public-tool acceptanceIdempotencyKey schema, instructions, and runtime HMAC env wiring (AGENT_HANDSHAKE_ACCEPTANCE_HMAC_*) remain unwired; keyed accepts only reach the service when a caller injects the key through invoke. No cross-process file-store safety is claimed. Tested: focused invitation-store + coordinator 38/38; full @clockchain/mcp-server suite 433/433; tsc -b clean. Not-tested: public HTTP surface, env-configured HMAC keys, end-to-end restart against the real relay.
Responders can now pass an optional acceptanceIdempotencyKey to agent_handshake_accept_invitation so a retryable transport failure no longer burns the single-use invitation, while role-access handles stay opaque and live only through the signed access expiry. Constraint: an absent key preserves the legacy one-shot accept for older clients; the no-key path is transitional compatibility, not the durable retry path. Constraint: present keys must be a lowercase UUIDv4 or canonical base64url carrying at least 128 bits; malformed values fail at schema validation before reaching the coordinator. Constraint: opaque ccra_ handles live only until the signed access expMs, deduplicated by access digest so retries return the same handle. Rejected: a fixed wall-clock handle TTL | it could outlive the signed capability it hides. Confidence: high Scope-risk: narrow Directive: the raw key is validated but never persisted, returned, or logged; callers generate and retain it with client-native secure randomness. No new dependencies were added. Tested: public-server suite 7/7; full @clockchain/mcp-server 437/437; tsc -b clean; root npm test green. Not-tested: retry from a real remote MCP client across process restart; runtime HMAC env wiring lands in the follow-up commit.
Durable keyed accepts now survive HMAC rotation because runtime, compose, and SSM provisioning carry an active plus optional previous acceptance HMAC key ring that is distinct from role-access signing keys, so durable retries keep working through rotation without key aliasing between purposes. Constraint: AGENT_HANDSHAKE_ACCEPTANCE_HMAC_ACTIVE is required and AGENT_HANDSHAKE_ACCEPTANCE_HMAC_PREVIOUS is optional; both are strict canonical base64 of at least 32 bytes with a valid kid, and every kid and secret must be distinct across the access and HMAC rings. Constraint: only a genuine ParameterNotFound marks the previous key absent; any other SSM fetch corruption fails the deploy closed. Constraint: the previous acceptance HMAC key is retained until every responder-access expiry it keyed has passed, per the runbook rotation order. Rejected: reusing the role-access ring for idempotency HMACs | cross-purpose aliasing weakens both rotation boundaries. Confidence: high Scope-risk: moderate Directive: never rotate active and previous acceptance HMAC keys away in one deploy while referenced claims remain retryable. Tested: infra suite 34/34 including compose contract, deploy-assets, and unsafe-rotation rejection tests; @clockchain/mcp-server 437/437; root npm test green across all workspaces; tsc -b clean. Not-tested: production SSM parameter provisioning or a live deploy.
thetangstr
changed the base branch from
claude/hermes-default-report
to
main
September 17, 2026 02:40
The audited handshake source cannot honestly deploy under the published v2.1.3 pin: a fresh bundle from audited main differs from the released helper bytes, and the v2.1.3 tag must never move. Every runtime surface that names the helper release — pin validator, verified bootstrap, join-tool literal, signing payloads, and advertised server version — now derives from one declared V2_HELPER_VERSION, and the deploy wrapper's jq filter consumes one declared version and derived prefix instead of embedding both literals. The future manifest digest is not knowable pre-publication and stays runtime SSM data; only the shape and version gate live in code. Constraint: the v2.1.4 tag and GitHub release are deliberately unpublished; production SSM still pins v2.1.3, so deploying this before publication and the post-publication pin update would fail closed by design. Rejected: keeping a per-surface 2.1.4 literal | the last release migration drifted because version, prefix, bootstrap, and fixtures each carried their own copy. Rejected: hardcoding the future manifestDigest | the release workflow records SHA-256 of randomized init output, so it cannot be precomputed; the pin stays runtime data. Confidence: high Scope-risk: narrow — version-gate constants, deploy validation, and exact fixtures only; Caddy staging route and durable-accept work untouched. Directive: when bumping the helper release again, change V2_HELPER_VERSION in instructions.ts and v2_helper_version in compose-up.sh — infra/test/deploy-assets.test.mjs now fails if they drift. After publication, update the SSM release pin with the published manifestDigest before deploying. Tested: node --test infra/test/deploy-assets.test.mjs (28/28 incl. new drift test); npm test -w @clockchain/mcp-server (437/437); root npm run build and npm test (624 workspace + 35 infra); git diff --check clean. Not-tested: acceptance of the real v2.1.4 manifest bytes (asset does not exist yet); live deploy against the unpublished release; the coordinator join path's wrong-version rejection beyond the existing schema/literal gate.
Independent verification of the v2.1.4 gate found one narrow hole: the coordinator join path had no dedicated negative test, so a regression that re-accepted an old helperVersion would ship silently. The new coordinator test drives a real invited session and shows 2.1.3, 2.1.5, and empty versions are rejected before access authorization while 2.1.4 joins and produces the identity_claim signing request. The public-server test shows the wire-level zod literal rejects a stale helperVersion as an isError result with no structured payload, so the bad input never reaches the coordinator. Constraint: runtime behavior is frozen at the release candidate; only coverage was missing. Rejected: changing the join gate or schema | the existing literal and coordinator check already fail closed; the gap was proof, not behavior. Confidence: high Scope-risk: narrow — two test files, no runtime changes. Directive: helperVersion is a fail-closed pin gate, not a negotiation; future bumps must keep a stale-version rejection test current. Tested: node --test on the two touched files (26/26); npm test -w @clockchain/mcp-server within root run (438/438); root npm test (625 workspace + 35 infra, 0 fail); git diff --check clean. Not-tested: no new runtime path exists to test; the rejection evidence is the assertion itself.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
packages/mcp-server/src/standalone-handshake/: the pre-negotiation, mutually authenticated gateway for two previously unconnected agents — ERC-8004 identity + signed authority statements + deterministic capability-manifest checklist, signed consent over identical terms, then a bounded channel whose opening (three chained anchored transitions) and closure are ledger receipts. Implements the approved stakeholder spec (docs/superpowers/specs/2026-09-14-standalone-handshake-design.md, from the Standalone Handshake requirements doc)./connect/mcp(no API key — two strangers must be able to connect):handshake_invite,handshake_accept_invitation,handshake_status,consent_sign,channel_open,channel_send,channel_read,channel_status,channel_close,channel_revoke, with role-access handles (sliding TTL), per-IP rate limits, fail-closed admission (exact reason codes: NOT_OPEN / EXPIRED / REVOKED / SCOPE_VIOLATION / TOO_LARGE / UNKNOWN_PARTY), lazy consensus-anchored expiry, and immediate one-sided revocation.terms-readiness→consent→openas predecessor-chained transition records (the consent/open records carry both parties' consent digests); closures anchor a pinned closure record; open times derive from the anchor's consensus block time. Keylessly verifiable through the existing public verify path./connect/mcp+/.well-known/standalone-handshake.json; landing page section, module card, andllms.txtannounce the capability (consent covers communication only — never an agreement, never a transaction).agent-handshakeandhandshakemodules are untouched — Standalone Handshake is its own productization track per the spec.Test plan
cd packages/mcp-server && npm test) — includes protocol exact-key validation, the full state-machine edge table, expiry at exactlyexpiresAtMs, revocation mid-conversation, checklist cross-check matrix, fault-injection (transient anchor failure at open/closure leaves the session retryable and re-anchors idempotently), broker sliding-TTL, error mapping (reason codes surface, internals suppressed, structured failure logs), and an end-to-end two-party run over real HTTP from invitation to anchored revocation.git diffconfirms no changes undersrc/agent-handshake/orsrc/handshake/.🤖 Generated with Claude Code