diff --git a/infra/clockchain-mcp/RUNBOOK.md b/infra/clockchain-mcp/RUNBOOK.md index 4d97dd0..1f51c2a 100644 --- a/infra/clockchain-mcp/RUNBOOK.md +++ b/infra/clockchain-mcp/RUNBOOK.md @@ -44,9 +44,14 @@ stakeholder capabilities are never stored in SSM. 2. Install the matching Handshake commit in the host checkout, keep the checkout clean, load the active host-root private key from SSM, and record its public fingerprint in the release pin. -3. Install the matching MCP commit, rotate the active/previous role-access key - pair if required, then run `compose-up.sh`. It verifies the exact Handshake - SHA before Docker starts and atomically replaces the private host files. +3. Install the matching MCP commit and rotate the active/previous role-access + key pair if required. From that exact checkout, run + `sudo infra/scripts/install-clockchain-mcp-deploy-assets.sh`. The installer + first refreshes the out-of-checkout `compose-up.sh` and systemd unit, then + restarts the service. The refreshed wrapper verifies the exact Handshake SHA + before Docker starts and atomically replaces the private host files. Never + restart the service directly after changing the checkout: systemd deliberately + executes `/opt/clockchain-mcp/compose-up.sh`, not the copy inside the repo. 4. Deploy Research only after the production MCP manifest reports the same helper digest and host-root ring that Research pins. diff --git a/infra/clockchain-mcp/compose-up.sh b/infra/clockchain-mcp/compose-up.sh index 57f7117..da1ae29 100755 --- a/infra/clockchain-mcp/compose-up.sh +++ b/infra/clockchain-mcp/compose-up.sh @@ -148,7 +148,7 @@ validate_mcp_runtime_config() { validate_v2_server_config() { local release_filter access_filter active_kid previous_kid - release_filter='type == "object" and (keys | sort) == ["allowedAssetPrefix","hostRoots","manifestDigest","sourceCommit","version"] and .version == "2.1.0" and (.sourceCommit | test("^[0-9a-f]{40}$")) and (.manifestDigest | test("^[0-9a-f]{64}$")) and .allowedAssetPrefix == "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/" and (.hostRoots | type == "array" and length >= 1 and length <= 2 and all(.[]; type == "object" and (keys | sort) == ["fingerprint","kid"] and (.kid | test("^[a-z0-9][a-z0-9-]{0,63}$")) and (.fingerprint | test("^[0-9a-f]{64}$"))))' + release_filter='type == "object" and (keys | sort) == ["allowedAssetPrefix","hostRoots","manifestDigest","sourceCommit","version"] and .version == "2.1.2" and (.sourceCommit | test("^[0-9a-f]{40}$")) and (.manifestDigest | test("^[0-9a-f]{64}$")) and .allowedAssetPrefix == "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/" and (.hostRoots | type == "array" and length >= 1 and length <= 2 and all(.[]; type == "object" and (keys | sort) == ["fingerprint","kid"] and (.kid | test("^[a-z0-9][a-z0-9-]{0,63}$")) and (.fingerprint | test("^[0-9a-f]{64}$"))))' access_filter='type == "object" and (keys | sort) == ["kid","secretBase64"] and (.kid | test("^[a-z0-9][a-z0-9-]{0,63}$")) and (.secretBase64 | @base64d | length >= 32)' if ! jq -e "$release_filter" >/dev/null 2>&1 <<<"$AGENT_HANDSHAKE_RELEASE_PIN"; then diff --git a/infra/test/deploy-assets.test.mjs b/infra/test/deploy-assets.test.mjs index 740e0b6..7f73d21 100644 --- a/infra/test/deploy-assets.test.mjs +++ b/infra/test/deploy-assets.test.mjs @@ -39,7 +39,7 @@ const expectedEnv = { CLOCKCHAIN_API_KEY: "api-key-line-1\napi-key-line-2\n", MCP_AUTH_TOKENS: "token-a,token-b\n", MCP_TOKEN_SIGNING_SECRET: "signing-secret\nwith-newline\n", - AGENT_HANDSHAKE_RELEASE_PIN: '{"version":"2.1.0","sourceCommit":"0123456789abcdef0123456789abcdef01234567","manifestDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","allowedAssetPrefix":"https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/","hostRoots":[{"kid":"root-2026-08","fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}\n', + AGENT_HANDSHAKE_RELEASE_PIN: '{"version":"2.1.2","sourceCommit":"0123456789abcdef0123456789abcdef01234567","manifestDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","allowedAssetPrefix":"https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/","hostRoots":[{"kid":"root-2026-08","fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}\n', AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE: '{"kid":"role-active","secretBase64":"YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="}\n', AGENT_HANDSHAKE_ROLE_ACCESS_PREVIOUS: '{"kid":"role-previous","secretBase64":"YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI="}\n', }; @@ -187,7 +187,7 @@ case "$name" in /clockchain/mcp/CLOCKCHAIN_API_KEY) value=$'api-key-line-1\\napi-key-line-2\\n' ;; /clockchain/mcp/MCP_AUTH_TOKENS) value=$'token-a,token-b\\n' ;; /clockchain/mcp/MCP_TOKEN_SIGNING_SECRET) value=$'signing-secret\\nwith-newline\\n' ;; - /clockchain/mcp/AGENT_HANDSHAKE_RELEASE_PIN) value=$'{"version":"2.1.0","sourceCommit":"0123456789abcdef0123456789abcdef01234567","manifestDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","allowedAssetPrefix":"https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/","hostRoots":[{"kid":"root-2026-08","fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}\\n' ;; + /clockchain/mcp/AGENT_HANDSHAKE_RELEASE_PIN) value=$'{"version":"2.1.2","sourceCommit":"0123456789abcdef0123456789abcdef01234567","manifestDigest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","allowedAssetPrefix":"https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/","hostRoots":[{"kid":"root-2026-08","fingerprint":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}\\n' ;; /clockchain/mcp/AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE) value=$'{"kid":"role-active","secretBase64":"YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="}\\n' ;; /clockchain/mcp/AGENT_HANDSHAKE_ROLE_ACCESS_PREVIOUS) value=$'{"kid":"role-previous","secretBase64":"YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI="}\\n' ;; /clockchain/host/FUNDING_WALLET_JSON) value=$'{"wallet":"line-1\\\\nline-2"}\\n' ;; @@ -297,10 +297,10 @@ async function resolvedComposeConfig() { MCP_AUTH_TOKENS: "dummy-token", MCP_TOKEN_SIGNING_SECRET: "dummy-signing", AGENT_HANDSHAKE_RELEASE_PIN: JSON.stringify({ - version: "2.1.0", + version: "2.1.2", sourceCommit: expectedHandshakeSha, manifestDigest: "a".repeat(64), - allowedAssetPrefix: "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/", + allowedAssetPrefix: "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/", hostRoots: [{ kid: "root-2026-08", fingerprint: "b".repeat(64) }], }), AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE: "dummy-role-active", @@ -753,6 +753,20 @@ test("installer enables and restarts the systemd unit", async () => { assert.match(install, /systemctl restart clockchain-mcp\.service/); }); +test("release runbook reinstalls deploy assets before every MCP restart", async () => { + const runbook = await readFile(path.join(deployDir, "RUNBOOK.md"), "utf8"); + assert.match( + runbook, + /infra\/scripts\/install-clockchain-mcp-deploy-assets\.sh/, + "deploys must refresh the out-of-checkout systemd wrapper before restart", + ); + assert.doesNotMatch( + runbook, + /then run `compose-up\.sh`/, + "the copied wrapper must not be invoked without first reinstalling it", + ); +}); + test("provisioning IAM policy is limited to MCP and host SSM prefixes", async () => { const provision = await readFile(path.join(repoRoot, "infra", "scripts", "provision-clockchain-mcp-host.sh"), "utf8"); assert.match(provision, /parameter\/clockchain\/mcp\/\*/); diff --git a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts index 4c2ac85..9602ec5 100644 --- a/packages/mcp-server/src/agent-handshake/v2/coordinator.ts +++ b/packages/mcp-server/src/agent-handshake/v2/coordinator.ts @@ -11,6 +11,7 @@ import { recoverEip191Address, resolveOwnedAgentRegistration } from "../../hands import { authorizeV2RoleAccess, verifyV2RoleAccess, type V2AccessKey, type V2Role } from "./access.js"; import type { V2InvitationMetadata } from "./invitation-store.js"; import { createV2InvitationService, createV2InvitationStore } from "./invitation-store.js"; +import { readV2ReleasePin, verifiedV2HelperPrefix } from "./instructions.js"; import { normalizeV2Acceptance, normalizeV2Descriptor, @@ -49,7 +50,7 @@ type CoordinatorData = JsonObject & { sessionDigest?: string; transitions?: JsonObject[]; evidenceUploaded?: boolean; - certificateVerified?: boolean; + certificateAvailable?: boolean; relay?: { senderKey: string }; stage?: string; }; @@ -58,13 +59,19 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a- const SHA = /^[0-9a-f]{40}$/; const DIGEST = /^[0-9a-f]{64}$/; const DECIMAL = /^(?:0|[1-9][0-9]*)$/; -const ADDRESS = /^0x[0-9a-f]{40}$/; +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; const SIGNATURE = /^0x[0-9a-f]{130}$/; +const RETRY_AFTER_MS = 3000; +const NEXT_ACTION = "call_agent_handshake_next_with_unchanged_role_access"; export class V2CoordinatorError extends Error { constructor() { super("Agent handshake coordination failed safely."); this.name = "V2CoordinatorError"; } } +export class V2TransientCoordinatorError extends Error { + constructor() { super("Agent handshake coordination is waiting for durable infrastructure state."); this.name = "V2TransientCoordinatorError"; } +} function fail(): never { throw new V2CoordinatorError(); } +function transient(): never { throw new V2TransientCoordinatorError(); } function exact(value: unknown, keys: readonly string[]): JsonObject { if (value === null || typeof value !== "object" || Array.isArray(value)) fail(); @@ -100,7 +107,7 @@ function merge(current: HandshakeRecord | null, keyValue: HandshakeKey, patch: P return { ...(current ?? { ...keyValue, status: "active" }), data: { ...data(current), ...patch }, - status: patch.certificateVerified ? "complete" : "active", + status: "active", }; } @@ -134,7 +141,7 @@ function signRequest(current: CoordinatorData, role: V2Role, operation: string, const bytes = canonicalBytes(payload); return Object.freeze({ schema: "clockchain.agent-handshake-signing-request/v1", - helperVersion: "2.1.0", + helperVersion: "2.1.2", operation, role, sessionId: current.discovery.sessionId, @@ -165,6 +172,88 @@ function evidenceEnvelope(result: JsonObject, address: string, signatureHex: str }); } +function localStateDir(sessionId: string, role: V2Role): string { + if (!UUID.test(sessionId)) fail(); + return `$TMPDIR/.clockchain/handshakes/${sessionId}/${role}`; +} + +function helperStep(verifiedHelperPrefix: string, operation: string, sessionId: string, role: V2Role, payload?: JsonObject): JsonObject { + const stateDir = localStateDir(sessionId, role); + const argvAfterVerifiedPrefix = [operation, "--state-dir", stateDir]; + if (payload !== undefined) { + argvAfterVerifiedPrefix.push("--payload-base64url", Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")); + } + const shellCommandSuffix = argvAfterVerifiedPrefix + .map((value, index) => index === 2 ? `"${value}"` : value) + .join(" "); + return Object.freeze({ + operation, + argvAfterVerifiedPrefix: Object.freeze(argvAfterVerifiedPrefix), + shellCommand: `${verifiedHelperPrefix} ${shellCommandSuffix}`, + shellCommandSuffix, + }); +} + +function setupLocalAction(verifiedHelperPrefix: string, policy: JsonObject, sessionId: string, role: V2Role): JsonObject { + const stateDir = localStateDir(sessionId, role); + return Object.freeze({ + executor: "pinned_helper", + operations: Object.freeze(["init", "policy", "inspect"]), + payloadEncoding: "base64url_utf8_json", + policyPayload: policy, + stateDirectoryCommand: `mkdir -p -m 700 "${stateDir}"`, + helperSteps: Object.freeze([ + helperStep(verifiedHelperPrefix, "init", sessionId, role), + helperStep(verifiedHelperPrefix, "policy", sessionId, role, policy), + helperStep(verifiedHelperPrefix, "inspect", sessionId, role), + ]), + stateDir: "new_private_absolute_state_dir", + registrationGate: "do_not_register_until_agent_handshake_next_returns_erc8004_registration_after_join_and_funding", + afterSuccess: "call_agent_handshake_join_with_helper_output", + }); +} + +function signingLocalAction(verifiedHelperPrefix: string, signingRequest: JsonObject): JsonObject { + const role = signingRequest.role as V2Role; + const sessionId = signingRequest.sessionId as string; + return Object.freeze({ + executor: "pinned_helper", + operation: "sign", + payloadEncoding: "base64url_utf8_json", + payload: signingRequest, + helperStep: helperStep(verifiedHelperPrefix, "sign", sessionId, role, signingRequest), + stateDir: "reuse_exact_absolute_state_dir", + afterSuccess: "call_agent_handshake_submit_with_helper_output_and_unchanged_policy_digest", + }); +} + +function certificateLocalAction(verifiedHelperPrefix: string, input: { + certificate: JsonObject; + discovery: JsonObject; + role: V2Role; + sessionId: string; +}): JsonObject { + const payload = Object.freeze({ + schema: "clockchain.agent-handshake-certificate-verification/v1", + helperVersion: "2.1.2", + role: input.role, + sessionId: input.sessionId, + repositorySha: input.discovery.repositorySha, + sessionDeadlineMs: input.discovery.sessionDeadlineMs, + certificate: input.certificate, + externalBusinessActionPerformed: false, + }); + return Object.freeze({ + executor: "pinned_helper", + operation: "verify-certificate", + payloadEncoding: "base64url_utf8_json", + payload, + helperStep: helperStep(verifiedHelperPrefix, "verify-certificate", input.sessionId, input.role, payload), + stateDir: "reuse_exact_absolute_state_dir", + terminalProof: "use_verified_helper_output_only", + }); +} + function find(entries: readonly JsonObject[], kind: string, role?: string): JsonObject | undefined { return [...entries].reverse().find((entry) => entry?.kind === kind && (role === undefined || entry?.role === role)); } @@ -183,6 +272,7 @@ export function createV2Coordinator(options: { recoverEip191Address(input: { bytes: Buffer; signatureHex: string }): Promise; resolveRegistration(input: { address: string; fromBlock: string }): Promise; advanceTransitions(input: { descriptor: JsonObject; role: V2Role; existing: readonly JsonObject[] }): Promise; + verifiedHelperPrefix: string; }) { const store = options.stateStore ?? createHandshakeStateStore(); const now = options.now ?? Date.now; @@ -259,7 +349,7 @@ export function createV2Coordinator(options: { async invite(value: unknown): Promise { const terms = normalizeV2Terms(value) as JsonObject; const found = discovery(await options.relay.fetchDiscovery()); - if (now() >= Number(found.invitationExpiresAtMs)) fail(); + if (now() >= Number(found.invitationExpiresAtMs)) transient(); const metadata = metadataFrom(found, terms); const created = await options.invitationService.create({ sessionId: found.sessionId, @@ -268,8 +358,13 @@ export function createV2Coordinator(options: { expMs: found.sessionDeadlineMs, metadata, }); - await storeInitial(created.initiatorAccess, metadata, "initiator"); - return Object.freeze({ ...created, endpoint: "https://mcp.clockchain.network/handshake/mcp", sessionId: found.sessionId, invitationExpiresAtMs: found.invitationExpiresAtMs, sessionDeadlineMs: found.sessionDeadlineMs, terms }); + const keyValue = await storeInitial(created.initiatorAccess, metadata, "initiator"); + await post(keyValue, "agent_v2_invitation_created", { + createdAtMs: String(now()), + externalBusinessActionPerformed: false, + }); + const policy = localPolicy(terms, "initiator") as JsonObject; + return Object.freeze({ ...created, endpoint: "https://mcp.clockchain.network/handshake/mcp", sessionId: found.sessionId, invitationExpiresAtMs: found.invitationExpiresAtMs, sessionDeadlineMs: found.sessionDeadlineMs, terms, localPolicy: policy, localAction: setupLocalAction(options.verifiedHelperPrefix, policy, found.sessionId, "initiator") }); }, async acceptInvitation(invitation: string): Promise { @@ -280,31 +375,36 @@ export function createV2Coordinator(options: { claimedAtMs: accepted.claimedAtMs, externalBusinessActionPerformed: false, }); - return Object.freeze({ responderAccess: accepted.responderAccess, sessionId: (accepted.metadata.hostSessionKeyCertificate as JsonObject).certificate?.sessionId, terms: accepted.metadata.terms, sessionDeadlineMs: accepted.metadata.sessionDeadlineMs }); + const policy = localPolicy(accepted.metadata.terms as JsonObject, "responder") as JsonObject; + const sessionId = (accepted.metadata.hostSessionKeyCertificate as JsonObject).certificate?.sessionId as string; + return Object.freeze({ responderAccess: accepted.responderAccess, sessionId, terms: accepted.metadata.terms, sessionDeadlineMs: accepted.metadata.sessionDeadlineMs, localPolicy: policy, localAction: setupLocalAction(options.verifiedHelperPrefix, policy, sessionId, "responder") }); }, async join(input: { access: string; helperVersion: string; sessionKeyAddress: string; policyDigest: string }): Promise { - if (input.helperVersion !== "2.1.0" || !ADDRESS.test(input.sessionKeyAddress) || !DIGEST.test(input.policyDigest)) fail(); + if (input.helperVersion !== "2.1.2" || !ADDRESS.test(input.sessionKeyAddress) || !DIGEST.test(input.policyDigest)) fail(); + const sessionKeyAddress = input.sessionKeyAddress.toLowerCase(); const auth = await authorize(input.access, "agent_handshake_join"); const expectedPolicy = localPolicy(auth.current.terms, auth.verified.payload.role); if (v2CanonicalRecord(expectedPolicy).digest !== input.policyDigest) fail(); - if (auth.current.policyDigest && (auth.current.policyDigest !== input.policyDigest || auth.current.sessionKeyAddress !== input.sessionKeyAddress)) fail(); + if (auth.current.policyDigest && (auth.current.policyDigest !== input.policyDigest || auth.current.sessionKeyAddress !== sessionKeyAddress)) fail(); const claim = normalizeV2IdentityClaim({ schema: "clockchain.agent-handshake-identity-claim/v2", protocol: "clockchain.agent-handshake/v2", sessionId: auth.keyValue.session, repositorySha: auth.current.discovery.repositorySha, - role: auth.verified.payload.role, sessionKeyAddress: input.sessionKeyAddress, + role: auth.verified.payload.role, sessionKeyAddress, policyDigest: input.policyDigest, statementDigest: v2CanonicalRecord(auth.current.terms).digest, externalBusinessActionPerformed: false, }) as JsonObject; const updated = await store.update(auth.keyValue, (current) => merge(current, auth.keyValue, { - policyDigest: input.policyDigest, sessionKeyAddress: input.sessionKeyAddress, + policyDigest: input.policyDigest, sessionKeyAddress, pending: { operation: "identity_claim", payload: claim }, stage: "sign_identity", })); + const signingRequest = signRequest(data(updated), auth.verified.payload.role, "identity_claim", claim); return Object.freeze({ role: auth.verified.payload.role, sessionId: auth.keyValue.session, hostSessionKeyCertificate: auth.current.discovery.hostSessionKeyCertificate, repositorySha: auth.current.discovery.repositorySha, sessionDeadlineMs: auth.current.discovery.sessionDeadlineMs, - signingRequest: signRequest(data(updated), auth.verified.payload.role, "identity_claim", claim), + signingRequest, + localAction: signingLocalAction(options.verifiedHelperPrefix, signingRequest), }); }, @@ -318,25 +418,41 @@ export function createV2Coordinator(options: { let current = await refresh(auth.keyValue); const role = auth.verified.payload.role; if (!current.policyDigest || !current.sessionKeyAddress) fail(); - if (current.pending) return Object.freeze({ stage: current.stage, signingRequest: signRequest(current, role, current.pending.operation, current.pending.payload) }); + if (current.pending) { + const signingRequest = signRequest(current, role, current.pending.operation, current.pending.payload); + return Object.freeze({ stage: current.stage, signingRequest, localAction: signingLocalAction(options.verifiedHelperPrefix, signingRequest) }); + } const entries = (await options.relay.getMessages({ sessionId: auth.keyValue.session })).messages; if (!current.party) { if (current.terms.identityPolicy.erc8004 !== "not_required" && !funded(entries, role, current.sessionKeyAddress)) { - return Object.freeze({ needed: "funding_record", role, sessionId: auth.keyValue.session, stage: "awaiting_funding" }); + return Object.freeze({ needed: "funding_record", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_funding" }); } let registration = null; if (current.terms.identityPolicy.erc8004 !== "not_required") { registration = await options.resolveRegistration({ address: current.sessionKeyAddress, fromBlock: current.discovery.sessionOpenedBlock ?? "0" }); - if (!registration) return Object.freeze({ needed: "erc8004_registration", role, sessionId: auth.keyValue.session, stage: "awaiting_identity_registration", identityPolicy: current.terms.identityPolicy }); + if (!registration) return Object.freeze({ + needed: "erc8004_registration", + role, + sessionId: auth.keyValue.session, + stage: "awaiting_identity_registration", + identityPolicy: current.terms.identityPolicy, + localAction: Object.freeze({ + executor: "pinned_helper", + operation: "register", + stateDir: "reuse_exact_absolute_state_dir", + helperStep: helperStep(options.verifiedHelperPrefix, "register", auth.keyValue.session, role), + afterSuccess: NEXT_ACTION, + }), + }); if (current.terms.identityPolicy.erc8004 === "required_fresh" && BigInt(registration.registrationBlock) <= BigInt(current.discovery.sessionOpenedBlock ?? "0")) fail(); } const party = normalizeV2Party({ sessionKeyAddress: current.sessionKeyAddress, policyDigest: current.policyDigest, erc8004: registration }, current.terms.identityPolicy) as JsonObject; await post(auth.keyValue, "agent_v2_party_ready", party); const updated = await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { party, stage: "party_ready" })); - return Object.freeze({ needed: null, role, sessionId: auth.keyValue.session, stage: "party_ready", identity: party }); + return Object.freeze({ needed: null, role, sessionId: auth.keyValue.session, stage: "party_ready", identity: party, nextAction: NEXT_ACTION }); } current = await refresh(auth.keyValue); - if (!current.counterpart) return Object.freeze({ needed: "counterpart_identity", role, sessionId: auth.keyValue.session, stage: "awaiting_counterpart" }); + if (!current.counterpart) return Object.freeze({ needed: "counterpart_identity", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_counterpart" }); const parties = role === "initiator" ? { initiator: current.party, responder: current.counterpart } : { initiator: current.counterpart, responder: current.party }; if (role === "initiator" && !current.proposalEnvelope) { const issuedAtMs = String(now()); @@ -349,10 +465,11 @@ export function createV2Coordinator(options: { externalBusinessActionPerformed: false, }) as JsonObject; const updated = await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { pending: { operation: "proposal", payload: proposal }, stage: "sign_proposal" })); - return Object.freeze({ stage: "sign_proposal", signingRequest: signRequest(data(updated), role, "proposal", proposal) }); + const signingRequest = signRequest(data(updated), role, "proposal", proposal); + return Object.freeze({ stage: "sign_proposal", signingRequest, localAction: signingLocalAction(options.verifiedHelperPrefix, signingRequest) }); } if (role === "responder" && !current.acceptanceEnvelope) { - if (!current.proposalEnvelope?.payload) return Object.freeze({ needed: "proposal", role, sessionId: auth.keyValue.session, stage: "awaiting_proposal" }); + if (!current.proposalEnvelope?.payload) return Object.freeze({ needed: "proposal", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_proposal" }); const proposal = normalizeV2Proposal(current.proposalEnvelope.payload) as JsonObject; const acceptance = normalizeV2Acceptance({ schema: "clockchain.agent-handshake-acceptance/v2", protocol: "clockchain.agent-handshake/v2", @@ -363,18 +480,19 @@ export function createV2Coordinator(options: { externalBusinessActionPerformed: false, }) as JsonObject; const updated = await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { pending: { operation: "acceptance", payload: acceptance }, stage: "sign_acceptance" })); - return Object.freeze({ stage: "sign_acceptance", signingRequest: signRequest(data(updated), role, "acceptance", acceptance) }); + const signingRequest = signRequest(data(updated), role, "acceptance", acceptance); + return Object.freeze({ stage: "sign_acceptance", signingRequest, localAction: signingLocalAction(options.verifiedHelperPrefix, signingRequest) }); } current = await refresh(auth.keyValue); - if (!current.descriptorEnvelope?.descriptor || !current.sessionDigest) return Object.freeze({ needed: "descriptor", role, sessionId: auth.keyValue.session, stage: "awaiting_descriptor" }); + if (!current.descriptorEnvelope?.descriptor || !current.sessionDigest) return Object.freeze({ needed: "descriptor", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_descriptor" }); const descriptor = normalizeV2Descriptor(current.descriptorEnvelope.descriptor) as JsonObject; const transitions = await options.advanceTransitions({ descriptor, role, existing: current.transitions ?? [] }); if (transitions.length !== 3) { await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { transitions, stage: "awaiting_anchors" })); - return Object.freeze({ needed: "counterpart_transition", role, sessionId: auth.keyValue.session, stage: "awaiting_anchors" }); + return Object.freeze({ needed: "counterpart_transition", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_anchors" }); } if (role === "initiator") await post(auth.keyValue, "agent_v2_anchor_report", { transitions }); - if (current.evidenceUploaded) return Object.freeze({ needed: "certificate", role, sessionId: auth.keyValue.session, stage: "awaiting_certificate" }); + if (current.evidenceUploaded) return Object.freeze({ needed: "certificate", retryAfterMs: RETRY_AFTER_MS, role, sessionId: auth.keyValue.session, stage: "awaiting_certificate" }); const evidence = normalizeV2EvidenceResult({ externalBusinessActionPerformed: false, party: current.party, policyDigest: current.policyDigest, reference: current.terms.reference, repositorySha: current.discovery.repositorySha, role, @@ -383,7 +501,8 @@ export function createV2Coordinator(options: { transitionDigests: transitions.map((entry) => entry.digest), }, current.terms.identityPolicy) as JsonObject; const updated = await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { transitions, pending: { operation: "evidence", payload: evidence }, stage: "sign_evidence" })); - return Object.freeze({ stage: "sign_evidence", signingRequest: signRequest(data(updated), role, "evidence", evidence) }); + const signingRequest = signRequest(data(updated), role, "evidence", evidence); + return Object.freeze({ stage: "sign_evidence", signingRequest, localAction: signingLocalAction(options.verifiedHelperPrefix, signingRequest) }); }, async submit(input: { access: string; policyDigest: string; signatureHex: string }): Promise { @@ -430,8 +549,16 @@ export function createV2Coordinator(options: { result.policyDigests[auth.verified.payload.role] !== auth.current.policyDigest || result.parties[auth.verified.payload.role].sessionKeyAddress !== auth.current.sessionKeyAddress ) fail(); - await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { certificateVerified: true, stage: "certificate_available" })); - return Object.freeze({ certificate: envelope }); + await store.update(auth.keyValue, (value) => merge(value, auth.keyValue, { certificateAvailable: true, stage: "certificate_available" })); + return Object.freeze({ + certificate: envelope, + localAction: certificateLocalAction(options.verifiedHelperPrefix, { + certificate: envelope, + discovery: auth.current.discovery, + role: auth.verified.payload.role, + sessionId: auth.keyValue.session, + }), + }); }, async invoke(name: string, args: JsonObject): Promise { @@ -482,17 +609,27 @@ async function anchorV2(client: any, transition: JsonObject, canWrite: boolean): const ledgerId = String(record.ledgerId ?? ""); if (!UUID.test(ledgerId)) fail(); const ledger = await client.getLedgerEntry(ledgerId); + if ( + !ledger || typeof ledger !== "object" || ledger.blockHeight === undefined || ledger.blockHeight === null || + ledger.ledgerId === undefined || ledger.ledgerId === null || ledger.assetHash === undefined || + ledger.assetHash === null || ledger.assetReferenceId === undefined || ledger.assetReferenceId === null + ) transient(); const blockHeight = String(ledger.blockHeight ?? ""); if (!DECIMAL.test(blockHeight) || ledger.ledgerId !== ledgerId || ledger.assetHash !== digest || ledger.assetReferenceId !== reference) fail(); const chain = await client.getChainRecord(blockHeight, ledgerId); + if ( + !chain || typeof chain !== "object" || chain.blockHeight === undefined || chain.blockHeight === null || + chain.assetHash === undefined || chain.assetHash === null || + chain.assetReferenceId === undefined || chain.assetReferenceId === null + ) transient(); if (!chain || chain.assetHash !== digest || chain.assetReferenceId !== reference || String(chain.blockHeight) !== blockHeight) fail(); const block = await client.getBlock(blockHeight); const blockTimeRaw = String(block.blockTime ?? block.madMarzulloTime ?? ""); - if (!blockTimeRaw) fail(); + if (!blockTimeRaw) transient(); return Object.freeze({ blockTimeRaw, digest, message: transition, onChain: Object.freeze({ blockHeight, ledgerId }) }); } -async function advanceRuntimeV2(client: any, input: { descriptor: JsonObject; role: V2Role; existing: readonly JsonObject[] }): Promise { +export async function __advanceRuntimeV2(client: any, input: { descriptor: JsonObject; role: V2Role; existing: readonly JsonObject[] }): Promise { const descriptor = input.descriptor; const sessionDigest = v2CanonicalRecord(descriptor).digest; const base = { @@ -524,6 +661,7 @@ async function advanceRuntimeV2(client: any, input: { descriptor: JsonObject; ro } export function createRuntimeV2Coordinator(env: Record = process.env) { + const releasePin = readV2ReleasePin(env); const activeAccessKey = accessKeyFromEnvironment(env.AGENT_HANDSHAKE_ROLE_ACCESS_ACTIVE); const accessKeys = [activeAccessKey]; if (env.AGENT_HANDSHAKE_ROLE_ACCESS_PREVIOUS) accessKeys.push(accessKeyFromEnvironment(env.AGENT_HANDSHAKE_ROLE_ACCESS_PREVIOUS)); @@ -554,6 +692,7 @@ export function createRuntimeV2Coordinator(env: Record advanceRuntimeV2(clockchain, input), + advanceTransitions: (input) => __advanceRuntimeV2(clockchain, input), + verifiedHelperPrefix: verifiedV2HelperPrefix(releasePin), }); } diff --git a/packages/mcp-server/src/agent-handshake/v2/instructions.ts b/packages/mcp-server/src/agent-handshake/v2/instructions.ts index 3ed7e81..01606fc 100644 --- a/packages/mcp-server/src/agent-handshake/v2/instructions.ts +++ b/packages/mcp-server/src/agent-handshake/v2/instructions.ts @@ -1,5 +1,5 @@ export type V2ReleasePin = Readonly<{ - version: "2.1.0"; + version: "2.1.2"; sourceCommit: string; manifestDigest: string; allowedAssetPrefix: string; @@ -9,12 +9,12 @@ export type V2ReleasePin = Readonly<{ const SHA = /^[0-9a-f]{40}$/; const DIGEST = /^[0-9a-f]{64}$/; const KID = /^[a-z0-9][a-z0-9-]{0,63}$/; -const PREFIX = "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/"; +const PREFIX = "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/"; const HELPER_FILENAME = "clockchain-agent-handshake.cjs"; -export const V2_VERIFIED_HELPER_BOOTSTRAP = 'const fs=require("node:fs");const crypto=require("node:crypto");const Module=require("node:module");const argv=process.argv.slice(1);const expected=argv.shift();const manifestPath=argv.shift();const helperPath=argv.shift();const manifestBytes=fs.readFileSync(manifestPath);const manifestDigest=crypto.createHash("sha256").update(manifestBytes).digest("hex");if(manifestDigest!==expected)process.exit(86);const manifest=JSON.parse(manifestBytes);if(manifest.schema!=="clockchain.agent-handshake-release-manifest/v1"||manifest.version!=="2.1.0"||!Array.isArray(manifest.assets)||manifest.assets.length!==1)process.exit(86);const asset=manifest.assets[0];if(asset.filename!=="clockchain-agent-handshake.cjs"||asset.url!=="https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/clockchain-agent-handshake.cjs"||typeof asset.sha256!=="string"||!/^[0-9a-f]{64}$/.test(asset.sha256))process.exit(86);const helperBytes=fs.readFileSync(helperPath);const helperDigest=crypto.createHash("sha256").update(helperBytes).digest("hex");if(helperDigest!==asset.sha256)process.exit(86);process.argv=[process.execPath].concat(helperPath).concat(argv);const loaded=new Module(helperPath);loaded.filename=helperPath;loaded.paths=[];const compile=loaded._compile.bind(loaded);compile(...[helperBytes.toString("utf8")].concat(helperPath));'; +export const V2_VERIFIED_HELPER_BOOTSTRAP = 'const fs=require("node:fs");const crypto=require("node:crypto");const Module=require("node:module");const argv=process.argv.slice(1);const expected=argv.shift();const manifestPath=argv.shift();const helperPath=argv.shift();const manifestBytes=fs.readFileSync(manifestPath);const manifestDigest=crypto.createHash("sha256").update(manifestBytes).digest("hex");if(manifestDigest!==expected)process.exit(86);const manifest=JSON.parse(manifestBytes);if(manifest.schema!=="clockchain.agent-handshake-release-manifest/v1"||manifest.version!=="2.1.2"||!/^24\\./.test(manifest.nodeRuntime)||!/^24\\./.test(process.versions.node)||!Array.isArray(manifest.assets)||manifest.assets.length!==1)process.exit(86);const asset=manifest.assets[0];if(asset.filename!=="clockchain-agent-handshake.cjs"||asset.url!=="https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/clockchain-agent-handshake.cjs"||typeof asset.sha256!=="string"||!/^[0-9a-f]{64}$/.test(asset.sha256))process.exit(86);const helperBytes=fs.readFileSync(helperPath);const helperDigest=crypto.createHash("sha256").update(helperBytes).digest("hex");if(helperDigest!==asset.sha256)process.exit(86);process.argv=[process.execPath].concat(helperPath).concat(argv);const loaded=new Module(helperPath);loaded.filename=helperPath;loaded.paths=[];const compile=loaded._compile.bind(loaded);compile(...[helperBytes.toString("utf8")].concat(helperPath));'; -function verifiedBootstrapPrefix(pin: V2ReleasePin): string { +export function verifiedV2HelperPrefix(pin: V2ReleasePin): string { return `node --input-type=commonjs --eval '${V2_VERIFIED_HELPER_BOOTSTRAP}' ${pin.manifestDigest} ./manifest.json ./${HELPER_FILENAME}`; } @@ -23,7 +23,7 @@ export function validateV2ReleasePin(value: unknown): V2ReleasePin { const item = value as Record; if (Object.keys(item).sort().join(",") !== "allowedAssetPrefix,hostRoots,manifestDigest,sourceCommit,version") throw new Error("Agent handshake release pin is unavailable."); if ( - item.version !== "2.1.0" || !SHA.test(item.sourceCommit) || !DIGEST.test(item.manifestDigest) || + item.version !== "2.1.2" || !SHA.test(item.sourceCommit) || !DIGEST.test(item.manifestDigest) || item.allowedAssetPrefix !== PREFIX || !Array.isArray(item.hostRoots) || item.hostRoots.length < 1 || item.hostRoots.length > 2 ) throw new Error("Agent handshake release pin is unavailable."); @@ -48,14 +48,34 @@ export function buildV2Instructions(rawPin: unknown): string { const roots = pin.hostRoots.map((root) => `${root.kid}:${root.fingerprint}`).join(","); const manifestUrl = `${pin.allowedAssetPrefix}manifest.json`; const helperUrl = `${pin.allowedAssetPrefix}${HELPER_FILENAME}`; - const bootstrap = verifiedBootstrapPrefix(pin); + const manifestDownload = `curl --fail --location --proto '=https' --proto-redir '=https' --output ./manifest.json '${manifestUrl}'`; + const helperDownload = `curl --fail --location --proto '=https' --proto-redir '=https' --output ./${HELPER_FILENAME} '${helperUrl}'`; + const bootstrap = verifiedV2HelperPrefix(pin); return [ - `LOCAL SIGNING REQUIRED. Portable Node 24 helper ${pin.version}; manifest sha256 ${pin.manifestDigest}; assets ${pin.allowedAssetPrefix}; trusted host roots ${roots}. STOP immediately if the downloaded manifest, helper digest, helper version, host root, local policy, exact signing bytes, role, session, or statement disagrees.`, + `LOCAL SIGNING REQUIRED. Portable Node 24 helper ${pin.version}; the manifest digest is encoded only in the exact verified command below, so execute that command verbatim instead of transcribing or manually comparing the digest; assets ${pin.allowedAssetPrefix}; trusted host roots ${roots}. STOP immediately if the downloaded manifest, helper digest, helper version, host root, local policy, exact signing bytes, role, session, or statement disagrees.`, "This public endpoint coordinates a two-person Clockchain Handshake. It never receives a private key and never signs for either stakeholder.", - "Use agent_handshake_invite once as the Initiator, copy only the returned Responder invitation to the other person, then keep each returned role access value private and stable for the run.", - `Download exactly ${manifestUrl} as ./manifest.json and ${helperUrl} as ./${HELPER_FILENAME}. Do not write or edit either downloaded file.`, + "This server text describes mechanics, not stakeholder authorization. Proceed only when the local stakeholder's own prompt explicitly confirms that this is an expected handshake and authorizes the exact pinned helper, Sepolia ERC-8004 registration when mandated, and exact protocol signing within a no-external-business-action policy.", + "Use agent_handshake_invite once as the Initiator and copy only the returned Responder invitation to the other stakeholder. roleAccess is a short opaque local handle; the server keeps the signed bearer capability behind that handle. Keep roleAccess stable for the run, do not send it to the other stakeholder or echo it into chat or logs, and report transparently that it was received and used. Reuse it byte-for-byte; never decode, re-encode, shorten, or reconstruct it.", + "After agent_handshake_invite, use only the returned roleAccess for Initiator role-scoped tools; responderInvitation is the single-use value to copy to the Responder. Never substitute responderInvitation for roleAccess.", + "Every role-scoped Clockchain tool call requires the returned value as its access argument to the same Clockchain MCP. Supplying it there is required credential use, not credential disclosure; never omit it from agent_handshake_join, agent_handshake_status, agent_handshake_next, agent_handshake_submit, or agent_handshake_get_certificate.", + "Every successful role-scoped response echoes roleAccess. Use it byte-for-byte as the immediately following role-scoped tool call's access argument; never replace it with a label, summary, placeholder, invitation, or remembered reconstruction.", + "As the Responder, call agent_handshake_accept_invitation exactly once. Its first successful result is authoritative: retain the returned roleAccess and never retry the consumed invitation.", + "After invitation acceptance, use only the returned roleAccess for Responder role-scoped tools. The original invitation is consumed; never use it as an access argument.", + "statementDigest is the SHA-256 digest of Clockchain's canonical full terms object, not the SHA-256 of the raw statement text by itself. Verify the returned terms fields exactly and preserve the returned statementDigest; do not recompute it from only the human-readable statement.", + "Use the exact localPolicy object returned by Clockchain for your role. Do not construct, infer, or alter its JSON shape. Pass those exact canonical bytes to the pinned helper policy operation and use the returned digest for agent_handshake_join.", + "WORKSPACE BOUNDARY: The client launcher has already placed you in a fresh, empty, disposable working directory and assigned a fresh session-scoped $TMPDIR. Stay in the working directory. Do not create or switch to another working directory, and never substitute /tmp, /private/tmp, /tmp/claude-*, or any other shared path. Every ./ path below means the current disposable working directory; every $TMPDIR path means the current isolated client session temp root.", + "DIGEST BOUNDARY: The pinned manifest digest applies only to ./manifest.json. The helper has a separate SHA-256 recorded inside that verified manifest. Never compare the helper file directly with the manifest digest; the verified bootstrap checks both hashes in sequence before compiling the helper bytes.", + "EXECUTION SHAPE: Every Bash action below must be one standalone Bash tool call. Never combine Bash actions or add another command, newline, shell operator, cd, pwd, set, or shell wrapper. Follow the listed order exactly. Do not run a returned stateDirectoryCommand until after both pinned files have been downloaded and inspected.", + `Download the two pinned files by running each command as its own separate Bash tool call. Never prefix, wrap, or combine either command, and do not add shell separators. Manifest command: ${manifestDownload}`, + `After the manifest command completes, run this helper command as a new Bash tool call: ${helperDownload}. Inspect the downloaded manifest and helper source before execution. Do not modify either downloaded file and do not use WebFetch for this local download step.`, `For --version and every local helper operation, use this exact verified prefix and append only the requested helper arguments: ${bootstrap}. The bootstrap hashes the raw manifest against the pinned digest, hashes the helper against that verified manifest, and can compile only those verified bytes in memory. Never run the helper directly, invent bytes, or substitute a wallet, policy, session, or role.`, + "When a localAction includes stateDirectoryCommand, run that exact command once. It creates a private, session-scoped directory beneath this client's isolated $TMPDIR. Then run each helperStep.shellCommand verbatim and in returned order. The shellCommand already contains the verified prefix and exact payload: never concatenate it yourself, reconstruct it, edit it, or re-encode a returned payload. If an operation's shellCommand does not include --payload-base64url, do not add that flag or any payload.", + "Use only the exact session-scoped $TMPDIR path returned by Clockchain for every local helper operation in this handshake. Do not assign it to another shell variable, replace it with $HOME or $PWD, create another state directory, or reuse state from another session.", + "SEQUENCE GATE: After init, policy, and inspect succeed, call agent_handshake_join immediately with the helper output. Do not run register before join. Clockchain must first observe the joined address and fund that exact seat; only then may a later agent_handshake_next response return needed: erc8004_registration. Run register only in response to that explicit funded local action.", "The Initiator may mandate live ERC-8004 registration. Registration and EIP-191 signing happen locally; Clockchain only funds the exact public session-key address when fresh registration is required and verifies the public on-chain record.", + "When agent_handshake_next returns needed: erc8004_registration, run the pinned helper operation register with the same absolute state directory used for init and policy. After it succeeds, call agent_handshake_next again with the unchanged local role access. Do not keep polling instead of performing that returned local action.", + "Every needed or stage response is nonterminal. If it includes a localAction, perform it exactly; otherwise wait for retryAfterMs when returned, then call agent_handshake_next again with the unchanged local role access. A party_ready response includes the same explicit next action. Do not send a final response or exit until the final certificate is locally verified or Clockchain returns an explicit unrecoverable error. Never infer that the other stakeholder stopped from a waiting response.", + "HANDSHAKE_TEMPORARILY_UNAVAILABLE with retryable: true is not a terminal protocol rejection. Wait for retryAfterMs and retry the same tool with unchanged inputs. Stop on a terminal protocol rejection with retryable: false.", "No browser, repository clone, plugin, general Clockchain credential, payment, or external business action is part of this workflow. Codex and Claude Code use the same seven tools.", ].join("\n\n"); } @@ -77,7 +97,7 @@ export function buildV2Manifest(rawPin: unknown) { manifestUrl: `${pin.allowedAssetPrefix}manifest.json`, helperUrl: `${pin.allowedAssetPrefix}${HELPER_FILENAME}`, nodeRuntimeMajor: "24", - verifiedBootstrapPrefix: verifiedBootstrapPrefix(pin), + verifiedBootstrapPrefix: verifiedV2HelperPrefix(pin), }), hostRoots: pin.hostRoots, supportedClients: Object.freeze(["codex", "claude-code"]), diff --git a/packages/mcp-server/src/agent-handshake/v2/public-server.ts b/packages/mcp-server/src/agent-handshake/v2/public-server.ts index 94b430f..9030fef 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-server.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-server.ts @@ -1,3 +1,4 @@ +import { randomBytes } from "node:crypto"; import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -5,6 +6,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ import { buildV2Instructions, type V2ReleasePin } from "./instructions.js"; import { registerV2PublicTools, V2_PUBLIC_TOOL_NAMES, type V2PublicInvoke } from "./public-tools.js"; +import { V2RoleAccessError } from "./access.js"; export { V2_PUBLIC_TOOL_NAMES } from "./public-tools.js"; @@ -35,8 +37,76 @@ function limiter(limit: number, windowMs: number, now: () => number) { }; } +const ROLE_ACCESS_HANDLE = /^ccra_[A-Za-z0-9_-]{22}$/; +const ROLE_ACCESS_HANDLE_TTL_MS = 60 * 60_000; +const ROLE_ACCESS_HANDLE_LIMIT = 10_000; +const ROLE_SCOPED_TOOLS = new Set([ + "agent_handshake_join", + "agent_handshake_status", + "agent_handshake_next", + "agent_handshake_submit", + "agent_handshake_get_certificate", +]); + +function object(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new V2RoleAccessError(); + return value as Record; +} + +function createRoleAccessBroker(invoke: V2PublicInvoke, now: () => number): V2PublicInvoke { + const handles = new Map(); + + function prune(): void { + const current = now(); + for (const [handle, entry] of handles) { + if (current >= entry.expiresAt) handles.delete(handle); + } + } + + function issue(access: unknown): string { + if (typeof access !== "string" || access.length < 80 || access.length > 4096) throw new V2RoleAccessError(); + prune(); + if (handles.size >= ROLE_ACCESS_HANDLE_LIMIT) throw new V2RoleAccessError(); + let handle: string; + do { handle = `ccra_${randomBytes(16).toString("base64url")}`; } while (handles.has(handle)); + handles.set(handle, { access, expiresAt: now() + ROLE_ACCESS_HANDLE_TTL_MS }); + return handle; + } + + function resolve(value: unknown): { clientAccess: string; signedAccess: string } { + if (typeof value !== "string") throw new V2RoleAccessError(); + if (!ROLE_ACCESS_HANDLE.test(value)) return { clientAccess: "", signedAccess: value }; + prune(); + const entry = handles.get(value); + if (!entry) throw new V2RoleAccessError(); + return { clientAccess: value, signedAccess: entry.access }; + } + + return async (name, args) => { + if (ROLE_SCOPED_TOOLS.has(name)) { + const resolved = resolve(args.access); + const result = object(await invoke(name, { ...args, access: resolved.signedAccess })); + const roleAccess = resolved.clientAccess || issue(resolved.signedAccess); + const { initiatorAccess: _initiator, responderAccess: _responder, ...publicResult } = result; + return { ...publicResult, roleAccess }; + } + const result = object(await invoke(name, args)); + if (name === "agent_handshake_invite") { + const roleAccess = issue(result.initiatorAccess); + const { initiatorAccess: _initiator, responderAccess: _responder, ...publicResult } = result; + return { ...publicResult, roleAccess }; + } + if (name === "agent_handshake_accept_invitation") { + const roleAccess = issue(result.responderAccess); + const { initiatorAccess: _initiator, responderAccess: _responder, ...publicResult } = result; + return { ...publicResult, roleAccess }; + } + return result; + }; +} + export function buildV2PublicServer(options: { pin: V2ReleasePin; invoke: V2PublicInvoke }): McpServer { - const server = new McpServer({ name: "clockchain-agent-handshake", version: "2.1.0" }, { + const server = new McpServer({ name: "clockchain-agent-handshake", version: "2.1.2" }, { instructions: buildV2Instructions(options.pin), }); registerV2PublicTools(server, options.invoke); @@ -54,6 +124,7 @@ export function createV2PublicHttpHandler(options: { const now = options.now ?? Date.now; const allowInvite = limiter(options.invitePerHour ?? 5, 60 * 60_000, now); const allowCall = limiter(options.callsPerMinute ?? 120, 60_000, now); + const invoke = createRoleAccessBroker(options.invoke, now); return async (req: IncomingMessage, res: ServerResponse): Promise => { if ((req.url ?? "").split("?")[0] !== "/handshake/mcp") { res.writeHead(404, { "content-type": "application/json" }); @@ -70,7 +141,7 @@ export function createV2PublicHttpHandler(options: { pin: options.pin, invoke: async (name, args) => { if (name === "agent_handshake_invite" && !allowInvite(`invite:${ip}`)) throw new Error("rate_limited"); - return options.invoke(name, args); + return invoke(name, args); }, }); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); @@ -86,4 +157,3 @@ export function createV2PublicHttpHandler(options: { } }; } - diff --git a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts index 772c87e..d832f42 100644 --- a/packages/mcp-server/src/agent-handshake/v2/public-tools.ts +++ b/packages/mcp-server/src/agent-handshake/v2/public-tools.ts @@ -1,5 +1,10 @@ import { z } from "zod"; +import { + AGENT_HANDSHAKE_V2_CHAIN_ID, + AGENT_HANDSHAKE_V2_REGISTRY_ADDRESS, +} from "./protocol.js"; + export const V2_PUBLIC_TOOL_NAMES = Object.freeze([ "agent_handshake_invite", "agent_handshake_accept_invitation", @@ -13,12 +18,44 @@ export const V2_PUBLIC_TOOL_NAMES = Object.freeze([ export type V2PublicToolName = typeof V2_PUBLIC_TOOL_NAMES[number]; export type V2PublicInvoke = (name: V2PublicToolName, args: Record) => Promise; -const access = z.string().min(80).max(4096); -const identityPolicy = z.object({ - erc8004: z.enum(["required_fresh", "required_existing_or_fresh", "not_required"]), - chainId: z.string().nullable(), - registryAddress: z.string().nullable(), -}).strict(); +const access = z.string().min(27).max(4096); +const ROLE_SCOPED_TOOLS = new Set([ + "agent_handshake_join", + "agent_handshake_status", + "agent_handshake_next", + "agent_handshake_submit", + "agent_handshake_get_certificate", +]); +const TERMINAL_ERROR_NAMES = new Set([ + "AgentHandshakeV2ValidationError", + "V2CoordinatorError", + "V2InvitationError", + "V2RoleAccessError", +]); +const RETRYABLE_ERROR_NAMES = new Set([ + "HttpRequestError", + "RpcRequestError", + "TimeoutError", + "V2TransientCoordinatorError", +]); +const SAFE_ERROR_NAME = /^[A-Za-z][A-Za-z0-9]{0,63}$/; +const identityPolicy = z.discriminatedUnion("erc8004", [ + z.object({ + erc8004: z.literal("required_fresh"), + chainId: z.literal(AGENT_HANDSHAKE_V2_CHAIN_ID), + registryAddress: z.literal(AGENT_HANDSHAKE_V2_REGISTRY_ADDRESS), + }).strict(), + z.object({ + erc8004: z.literal("required_existing_or_fresh"), + chainId: z.literal(AGENT_HANDSHAKE_V2_CHAIN_ID), + registryAddress: z.literal(AGENT_HANDSHAKE_V2_REGISTRY_ADDRESS), + }).strict(), + z.object({ + erc8004: z.literal("not_required"), + chainId: z.null(), + registryAddress: z.null(), + }).strict(), +]); const definitions = Object.freeze([ { @@ -38,7 +75,7 @@ const definitions = Object.freeze([ description: "Claim one Responder invitation once and receive non-transferable Responder role access.", schema: { invitation: z.string().min(80).max(4096) }, }, - { name: "agent_handshake_join", title: "Join handshake", description: "Bind this fresh local agent and its exact local policy to the assigned role.", schema: { access, helperVersion: z.literal("2.1.0"), sessionKeyAddress: z.string().regex(/^0x[0-9a-f]{40}$/), policyDigest: z.string().regex(/^[0-9a-f]{64}$/) } }, + { name: "agent_handshake_join", title: "Join handshake", description: "Bind this fresh local agent and its exact local policy to the assigned role.", schema: { access, helperVersion: z.literal("2.1.2"), sessionKeyAddress: z.string().regex(/^0x[0-9a-fA-F]{40}$/), policyDigest: z.string().regex(/^[0-9a-f]{64}$/) } }, { name: "agent_handshake_status", title: "Read handshake status", description: "Read public progress for this role and session.", schema: { access } }, { name: "agent_handshake_next", title: "Get next handshake operation", description: "Get the next typed local signing or registration operation, or wait safely.", schema: { access } }, { name: "agent_handshake_submit", title: "Submit local signature", description: "Submit only a signature over the exact bytes returned by the coordinator and the unchanged local-policy digest.", schema: { access, policyDigest: z.string().regex(/^[0-9a-f]{64}$/), signatureHex: z.string().regex(/^0x[0-9a-f]{130}$/) } }, @@ -60,9 +97,40 @@ export function registerV2PublicTools(server: any, invoke: V2PublicInvoke): void }, async (args: Record) => { try { const result = await invoke(definition.name, args); - return { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result as Record }; - } catch { - return { isError: true, content: [{ type: "text", text: JSON.stringify({ error: "HANDSHAKE_UNAVAILABLE" }) }] }; + const record = result as Record; + const authoritativeAccess = typeof record.roleAccess === "string" + ? record.roleAccess + : ROLE_SCOPED_TOOLS.has(definition.name) + ? args.access + : definition.name === "agent_handshake_invite" + ? record.initiatorAccess + : definition.name === "agent_handshake_accept_invitation" + ? record.responderAccess + : undefined; + const { initiatorAccess: _initiator, responderAccess: _responder, ...publicRecord } = record; + const body = typeof authoritativeAccess === "string" + ? { ...publicRecord, roleAccess: authoritativeAccess } + : publicRecord; + return { content: [{ type: "text", text: JSON.stringify(body) }], structuredContent: body }; + } catch (error) { + const observedName = (error as Error)?.name; + const errorName = typeof observedName === "string" && SAFE_ERROR_NAME.test(observedName) + ? observedName + : "Error"; + console.warn(JSON.stringify({ + event: "agent_handshake_tool_failure", + tool: definition.name, + errorName, + })); + const retryable = RETRYABLE_ERROR_NAMES.has((error as Error)?.name) && + !TERMINAL_ERROR_NAMES.has((error as Error)?.name) && + (error as Error)?.message !== "rate_limited"; + const body = retryable + ? { error: "HANDSHAKE_TEMPORARILY_UNAVAILABLE", retryable: true, retryAfterMs: 5000 } + : { error: "HANDSHAKE_UNAVAILABLE", retryable: false }; + return retryable + ? { content: [{ type: "text", text: JSON.stringify(body) }], structuredContent: body } + : { isError: true, content: [{ type: "text", text: JSON.stringify(body) }] }; } }); } diff --git a/packages/mcp-server/src/handshake/evm.ts b/packages/mcp-server/src/handshake/evm.ts index bccbfc2..ebcd443 100644 --- a/packages/mcp-server/src/handshake/evm.ts +++ b/packages/mcp-server/src/handshake/evm.ts @@ -374,7 +374,10 @@ function toHexQuantity(value: bigint): string { } function resolveEarliestBlock(registryAddress: string, fromBlock?: string): bigint { - if (fromBlock !== undefined) return hexQuantity(fromBlock); + if (fromBlock !== undefined) { + if (/^(?:0|[1-9][0-9]*)$/.test(fromBlock)) return BigInt(fromBlock); + return hexQuantity(fromBlock); + } return registryAddress === SEPOLIA_ERC8004_REGISTRY_ADDRESS ? SEPOLIA_ERC8004_REGISTRY_CREATION_BLOCK : 0n; diff --git a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs index a41e6b3..3473332 100644 --- a/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-coordinator.test.mjs @@ -4,9 +4,11 @@ import test from "node:test"; import { createHandshakeStateStore, __resetHandshakeStateStore } from "../dist/handshake/state.js"; import { createV2InvitationService, createV2InvitationStore } from "../dist/agent-handshake/v2/invitation-store.js"; -import { createV2Coordinator } from "../dist/agent-handshake/v2/coordinator.js"; +import * as v2CoordinatorModule from "../dist/agent-handshake/v2/coordinator.js"; import { v2CanonicalRecord } from "../dist/agent-handshake/v2/protocol.js"; +const { createV2Coordinator } = v2CoordinatorModule; + const terms = { reference: "NS-1847", statement: "Northstar Logistics and Harbor Supply authorize these two independently controlled agents to communicate about shipment reference NS-1847 for 90 seconds.", @@ -16,6 +18,7 @@ const terms = { const sessionId = randomUUID(); const nowMs = 1786337000000; const repositorySha = "d".repeat(40); +const verifiedHelperPrefix = "node --verified-helper"; const hostSessionKeyCertificate = { certificate: { schema: "clockchain.host-session-key/v1", rootKid: "root-2026-08", sessionId, repositorySha, sessionPublicKey: "ore80hj1AhLMNPybJXCL6XHyJ9OfmaYSXc4SA8Sk2Pw=", validFromMs: String(nowMs), validUntilMs: String(nowMs + 600000) }, root: { algorithm: "ed25519", keyId: "root-2026-08", publicKey: "6Xgu+IYxQBDx8adVlHHWf9AUYoeo+eqWr8eVQqXrY0Y=", signature: "a".repeat(88) }, @@ -49,6 +52,68 @@ function policy(role) { }; } +test("an unanchored Clockchain ledger response is retryable instead of a terminal protocol rejection", async () => { + assert.equal(typeof v2CoordinatorModule.__advanceRuntimeV2, "function"); + const descriptor = { + agreementExpiresAtMs: String(nowMs + 90_000), + externalBusinessActionPerformed: false, + initiator: { sessionKeyAddress: "0x7564105e977516c53be337314c7e53838967bdac" }, + protocol: "clockchain.agent-handshake/v2", + reference: terms.reference, + responder: { sessionKeyAddress: "0xe1fae9b4fab2f5726677ecfa912d96b0b683e6a9" }, + schema: "clockchain.agent-handshake-descriptor/v2", + statementDigest: v2CanonicalRecord(terms).digest, + }; + const clockchain = { + searchAsset: async () => [], + log: async () => ({ ledgerId: "33333333-4444-4555-8666-777777777770" }), + getLedgerEntry: async () => ({ + ledgerId: "33333333-4444-4555-8666-777777777770", + blockHeight: null, + assetHash: "pending", + assetReferenceId: "pending", + }), + getChainRecord: async () => null, + getBlock: async () => ({}), + }; + + await assert.rejects( + () => v2CoordinatorModule.__advanceRuntimeV2(clockchain, { descriptor, role: "initiator", existing: [] }), + (error) => error?.name === "V2TransientCoordinatorError", + ); +}); + +test("an expired current invitation window is retryable while the host rotates sessions", async () => { + __resetHandshakeStateStore(); + const key = { kid: "role-2026-08", secret: randomBytes(32) }; + const coordinator = createV2Coordinator({ + accessKeys: [key], + activeAccessKey: key, + invitationService: createV2InvitationService({ + activeKey: key, + verificationKeys: [key], + store: createV2InvitationStore(), + nowMs: () => nowMs + 120000, + }), + relay: { + fetchDiscovery: async () => discovery, + getMessages: async () => ({ messages: [] }), + postMessage: async () => ({ ok: true, seq: "1" }), + }, + stateStore: createHandshakeStateStore({}), + now: () => nowMs + 120000, + recoverEip191Address: async () => "0x7564105e977516c53be337314c7e53838967bdac", + resolveRegistration: async () => null, + advanceTransitions: async () => [], + verifiedHelperPrefix, + }); + + await assert.rejects( + () => coordinator.invite(terms), + (error) => error?.name === "V2TransientCoordinatorError", + ); +}); + test("two distinct role capabilities drive the complete v2 local-signing state machine", async () => { __resetHandshakeStateStore(); const key = { kid: "role-2026-08", secret: randomBytes(32) }; @@ -77,12 +142,13 @@ test("two distinct role capabilities drive the complete v2 local-signing state m [addresses.initiator]: { agentId: "9452", chainId: terms.identityPolicy.chainId, registryAddress: terms.identityPolicy.registryAddress, reference: `${terms.identityPolicy.chainId}:${terms.identityPolicy.registryAddress}:9452`, registrationTx: `0x${"a".repeat(64)}`, registrationBlock: "7000" }, [addresses.responder]: { agentId: "9453", chainId: terms.identityPolicy.chainId, registryAddress: terms.identityPolicy.registryAddress, reference: `${terms.identityPolicy.chainId}:${terms.identityPolicy.registryAddress}:9453`, registrationTx: `0x${"b".repeat(64)}`, registrationBlock: "7001" }, }; + const stateStore = createHandshakeStateStore({}); const coordinator = createV2Coordinator({ accessKeys: [key], activeAccessKey: key, invitationService: createV2InvitationService({ activeKey: key, verificationKeys: [key], store: createV2InvitationStore(), nowMs: () => nowMs + 1 }), relay, - stateStore: createHandshakeStateStore({}), + stateStore, now: () => nowMs + 1, recoverEip191Address: async ({ signatureHex }) => signatureHex.endsWith("1b") ? addresses.initiator : addresses.responder, resolveRegistration: async ({ address }) => registrations[address] ?? null, @@ -92,19 +158,60 @@ test("two distinct role capabilities drive the complete v2 local-signing state m message: { kind, sessionDigest: v2CanonicalRecord(descriptor).digest }, onChain: { blockHeight: String(7010 + index), ledgerId: `33333333-4444-4555-8666-77777777777${index}` }, })), + verifiedHelperPrefix, }); const invited = await coordinator.invite(terms); + const invitationCreated = messages.find((message) => message.kind === "agent_v2_invitation_created"); + assert.equal(invitationCreated.role, "initiator"); + assert.equal(invitationCreated.sessionId, sessionId); + assert.deepEqual(Object.keys(invitationCreated.body).sort(), [ + "createdAtMs", + "externalBusinessActionPerformed", + ]); + assert.deepEqual(invitationCreated.body, { + createdAtMs: String(nowMs + 1), + externalBusinessActionPerformed: false, + }); + assert.equal(JSON.stringify(invitationCreated).includes(invited.responderInvitation), false); + assert.equal(JSON.stringify(invitationCreated).includes(invited.initiatorAccess), false); + const initiatorStateDir = `$TMPDIR/.clockchain/handshakes/${sessionId}/initiator`; + assert.deepEqual(invited.localPolicy, policy("initiator")); + assert.deepEqual(invited.localAction, { + executor: "pinned_helper", + operations: ["init", "policy", "inspect"], + payloadEncoding: "base64url_utf8_json", + policyPayload: policy("initiator"), + stateDirectoryCommand: `mkdir -p -m 700 "$TMPDIR/.clockchain/handshakes/${sessionId}/initiator"`, + helperSteps: [ + { operation: "init", argvAfterVerifiedPrefix: ["init", "--state-dir", initiatorStateDir], shellCommand: `${verifiedHelperPrefix} init --state-dir "${initiatorStateDir}"`, shellCommandSuffix: `init --state-dir "${initiatorStateDir}"` }, + { operation: "policy", argvAfterVerifiedPrefix: ["policy", "--state-dir", initiatorStateDir, "--payload-base64url", Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")], shellCommand: `${verifiedHelperPrefix} policy --state-dir "${initiatorStateDir}" --payload-base64url ${Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")}`, shellCommandSuffix: `policy --state-dir "${initiatorStateDir}" --payload-base64url ${Buffer.from(JSON.stringify(policy("initiator")), "utf8").toString("base64url")}` }, + { operation: "inspect", argvAfterVerifiedPrefix: ["inspect", "--state-dir", initiatorStateDir], shellCommand: `${verifiedHelperPrefix} inspect --state-dir "${initiatorStateDir}"`, shellCommandSuffix: `inspect --state-dir "${initiatorStateDir}"` }, + ], + stateDir: "new_private_absolute_state_dir", + registrationGate: "do_not_register_until_agent_handshake_next_returns_erc8004_registration_after_join_and_funding", + afterSuccess: "call_agent_handshake_join_with_helper_output", + }); const accepted = await coordinator.acceptInvitation(invited.responderInvitation); + assert.deepEqual(accepted.localPolicy, policy("responder")); + assert.deepEqual(accepted.localAction.policyPayload, policy("responder")); const invitationClaimed = messages.find((message) => message.kind === "agent_v2_invitation_claimed"); + assert.ok(messages.indexOf(invitationCreated) < messages.indexOf(invitationClaimed)); assert.equal(invitationClaimed.role, "responder"); assert.equal(invitationClaimed.body.claimedAtMs, String(nowMs + 1)); assert.notEqual(invited.initiatorAccess, accepted.responderAccess); const accesses = { initiator: invited.initiatorAccess, responder: accepted.responderAccess }; for (const role of ["initiator", "responder"]) { const localPolicy = policy(role); - const joined = await coordinator.join({ access: accesses[role], helperVersion: "2.1.0", sessionKeyAddress: addresses[role], policyDigest: v2CanonicalRecord(localPolicy).digest }); + const joined = await coordinator.join({ access: accesses[role], helperVersion: "2.1.2", sessionKeyAddress: addresses[role], policyDigest: v2CanonicalRecord(localPolicy).digest }); assert.equal(joined.signingRequest.operation, "identity_claim"); + assert.deepEqual(joined.localAction.payload, joined.signingRequest); + assert.equal(joined.localAction.operation, "sign"); + assert.deepEqual(joined.localAction.helperStep.argvAfterVerifiedPrefix, [ + "sign", "--state-dir", `$TMPDIR/.clockchain/handshakes/${sessionId}/${role}`, "--payload-base64url", + Buffer.from(JSON.stringify(joined.signingRequest), "utf8").toString("base64url"), + ]); + assert.equal(joined.localAction.helperStep.shellCommand, `${verifiedHelperPrefix} ${joined.localAction.helperStep.shellCommandSuffix}`); await coordinator.submit({ access: accesses[role], policyDigest: v2CanonicalRecord(localPolicy).digest, signatureHex: `0x${"1".repeat(128)}${role === "initiator" ? "1b" : "1c"}` }); } const identityMessages = messages.filter((message) => message.kind === "agent_v2_identity_claim"); @@ -118,12 +225,15 @@ test("two distinct role capabilities drive the complete v2 local-signing state m messages.push({ kind: "agent_v2_funding_record", role: "host", body: { role, address: addresses[role] } }); const ready = await coordinator.next({ access: accesses[role] }); assert.equal(ready.stage, "party_ready"); + assert.equal(ready.nextAction, "call_agent_handshake_next_with_unchanged_role_access"); } const proposal = await coordinator.next({ access: accesses.initiator }); assert.equal(proposal.signingRequest.operation, "proposal"); + assert.deepEqual(proposal.localAction.payload, proposal.signingRequest); await coordinator.submit({ access: accesses.initiator, policyDigest: v2CanonicalRecord(policy("initiator")).digest, signatureHex: `0x${"2".repeat(128)}1b` }); const acceptance = await coordinator.next({ access: accesses.responder }); assert.equal(acceptance.signingRequest.operation, "acceptance"); + assert.deepEqual(acceptance.localAction.payload, acceptance.signingRequest); await coordinator.submit({ access: accesses.responder, policyDigest: v2CanonicalRecord(policy("responder")).digest, signatureHex: `0x${"3".repeat(128)}1c` }); const proposalPayload = messages.find((message) => message.kind === "agent_v2_proposal").body.proposalEnvelope.payload; @@ -150,6 +260,7 @@ test("two distinct role capabilities drive the complete v2 local-signing state m for (const role of ["initiator", "responder"]) { const evidence = await coordinator.next({ access: accesses[role] }); assert.equal(evidence.signingRequest.operation, "evidence"); + assert.deepEqual(evidence.localAction.payload, evidence.signingRequest); await coordinator.submit({ access: accesses[role], policyDigest: v2CanonicalRecord(policy(role)).digest, signatureHex: `0x${"4".repeat(128)}${role === "initiator" ? "1b" : "1c"}` }); } result = { result: { @@ -160,6 +271,82 @@ test("two distinct role capabilities drive the complete v2 local-signing state m reference: terms.reference, schema: "clockchain.agent-handshake-result/v2", sessionDigest: v2CanonicalRecord(descriptor).digest, sessionId, statementDigest: v2CanonicalRecord(terms).digest, subjectRun: "stakeholder", }, signer: {}, hostSessionKeyCertificate }; - assert.equal((await coordinator.getCertificate({ access: accesses.initiator })).certificate.result.outcome, "VERIFIED"); - assert.equal((await coordinator.getCertificate({ access: accesses.responder })).certificate.result.outcome, "VERIFIED"); + const initiatorCertificate = await coordinator.getCertificate({ access: accesses.initiator }); + const responderCertificate = await coordinator.getCertificate({ access: accesses.responder }); + assert.equal(initiatorCertificate.certificate.result.outcome, "VERIFIED"); + assert.equal(responderCertificate.certificate.result.outcome, "VERIFIED"); + assert.equal(initiatorCertificate.localAction.operation, "verify-certificate"); + assert.deepEqual(initiatorCertificate.localAction.helperStep.argvAfterVerifiedPrefix.slice(0, 4), [ + "verify-certificate", "--state-dir", `$TMPDIR/.clockchain/handshakes/${sessionId}/initiator`, "--payload-base64url", + ]); + assert.equal(initiatorCertificate.localAction.payload.role, "initiator"); + assert.deepEqual(initiatorCertificate.localAction.payload.certificate, initiatorCertificate.certificate); + assert.equal(responderCertificate.localAction.payload.role, "responder"); + const certificateRecords = await stateStore.list(); + assert.equal(certificateRecords.length, 2); + for (const record of certificateRecords) { + assert.equal(record.status, "active"); + assert.equal(record.data.stage, "certificate_available"); + assert.equal(record.data.certificateAvailable, true); + assert.equal(Object.hasOwn(record.data, "certificateVerified"), false); + } +}); + +test("fresh identity registration is returned as an executable pinned-helper action", async () => { + __resetHandshakeStateStore(); + const key = { kid: "role-2026-08", secret: randomBytes(32) }; + const messages = []; + const address = "0x7564105e977516c53be337314c7e53838967bdac"; + const presentedAddress = "0x7564105E977516c53be337314c7e53838967bdac"; + const relay = { + fetchDiscovery: async () => discovery, + getMessages: async () => ({ messages }), + postMessage: async (input) => { + messages.push({ ...input, body: input.body, senderKey: input.senderKey }); + return { ok: true, seq: String(messages.length) }; + }, + }; + const coordinator = createV2Coordinator({ + accessKeys: [key], + activeAccessKey: key, + invitationService: createV2InvitationService({ activeKey: key, verificationKeys: [key], store: createV2InvitationStore(), nowMs: () => nowMs + 1 }), + relay, + stateStore: createHandshakeStateStore({}), + now: () => nowMs + 1, + recoverEip191Address: async () => address, + resolveRegistration: async () => null, + advanceTransitions: async () => [], + verifiedHelperPrefix, + }); + + const invited = await coordinator.invite(terms); + const localPolicy = policy("initiator"); + const digest = v2CanonicalRecord(localPolicy).digest; + await coordinator.join({ + access: invited.initiatorAccess, + helperVersion: "2.1.2", + sessionKeyAddress: presentedAddress, + policyDigest: digest, + }); + await coordinator.submit({ + access: invited.initiatorAccess, + policyDigest: digest, + signatureHex: `0x${"1".repeat(128)}1b`, + }); + messages.push({ kind: "agent_v2_funding_record", role: "host", body: { role: "initiator", address } }); + + assert.deepEqual(await coordinator.next({ access: invited.initiatorAccess }), { + needed: "erc8004_registration", + role: "initiator", + sessionId, + stage: "awaiting_identity_registration", + identityPolicy: terms.identityPolicy, + localAction: { + executor: "pinned_helper", + operation: "register", + stateDir: "reuse_exact_absolute_state_dir", + helperStep: { operation: "register", argvAfterVerifiedPrefix: ["register", "--state-dir", `$TMPDIR/.clockchain/handshakes/${sessionId}/initiator`], shellCommand: `${verifiedHelperPrefix} register --state-dir "$TMPDIR/.clockchain/handshakes/${sessionId}/initiator"`, shellCommandSuffix: `register --state-dir "$TMPDIR/.clockchain/handshakes/${sessionId}/initiator"` }, + afterSuccess: "call_agent_handshake_next_with_unchanged_role_access", + }, + }); }); diff --git a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs index fecb017..4964879 100644 --- a/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs +++ b/packages/mcp-server/test/agent-handshake-v2-public-server.test.mjs @@ -13,10 +13,10 @@ import { import { V2_VERIFIED_HELPER_BOOTSTRAP, buildV2Instructions, buildV2Manifest } from "../dist/agent-handshake/v2/instructions.js"; const pin = { - version: "2.1.0", + version: "2.1.2", sourceCommit: "d".repeat(40), manifestDigest: "a".repeat(64), - allowedAssetPrefix: "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.0/", + allowedAssetPrefix: "https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/", hostRoots: [ { kid: "root-2026-08", fingerprint: "b".repeat(64) }, { kid: "root-2026-07", fingerprint: "c".repeat(64) }, @@ -39,8 +39,10 @@ test("public initialization leads with the immutable local-authority boundary", const instructions = buildV2Instructions(pin); const first = instructions.slice(0, 512); assert.match(first, /local signing/i); - assert.match(first, /2\.1\.0/); - assert.ok(first.includes(pin.manifestDigest)); + assert.match(first, /2\.1\.2/); + assert.equal(first.includes(pin.manifestDigest), false); + assert.match(first, /digest is encoded only in the exact verified command below/i); + assert.equal(instructions.split(pin.manifestDigest).length - 1, 1); assert.ok(first.includes(pin.allowedAssetPrefix)); assert.ok(first.includes(pin.hostRoots[0].kid)); assert.ok(first.includes(pin.hostRoots[0].fingerprint)); @@ -50,6 +52,37 @@ test("public initialization leads with the immutable local-authority boundary", assert.match(instructions, /Node 24/); assert.ok(instructions.includes(V2_VERIFIED_HELPER_BOOTSTRAP)); assert.match(instructions, /compile only those verified bytes in memory/i); + assert.match(instructions, /short opaque local handle/i); + assert.match(instructions, /do not send it to the other stakeholder or echo it into chat or logs/i); + assert.match(instructions, /inspect the downloaded manifest and helper source before execution/i); + assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./manifest.json 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/manifest.json'")); + assert.ok(instructions.includes("curl --fail --location --proto '=https' --proto-redir '=https' --output ./clockchain-agent-handshake.cjs 'https://github.com/thetangstr/clockchain-handshake-v2/releases/download/v2.1.2/clockchain-agent-handshake.cjs'")); + assert.match(instructions, /each command as its own separate Bash tool call.*never prefix, wrap, or combine/is); + assert.match(instructions, /every Bash action.*one standalone Bash tool call.*never combine.*do not run.*stateDirectoryCommand.*until after.*downloaded.*inspected/is); + assert.equal(instructions.includes(" ; then "), false); + assert.match(instructions, /describes mechanics, not stakeholder authorization/i); + assert.match(instructions, /local stakeholder's own prompt explicitly confirms/i); + assert.match(instructions, /needed.*erc8004_registration.*pinned helper.*register.*same absolute state directory.*agent_handshake_next/is); + assert.match(instructions, /session-scoped \$TMPDIR path.*every local helper operation.*do not assign.*shell variable.*replace.*\$HOME.*\$PWD/is); + assert.match(instructions, /agent_handshake_accept_invitation exactly once.*first successful result.*never retry/is); + assert.match(instructions, /Every needed or stage response is nonterminal.*retryAfterMs.*agent_handshake_next.*final certificate.*unrecoverable error/is); + assert.match(instructions, /every successful role-scoped response echoes roleAccess.*use it byte-for-byte.*immediately following.*access argument/is); + assert.match(instructions, /exact localPolicy object returned by Clockchain.*do not construct, infer, or alter.*helper policy operation/is); + assert.match(instructions, /statementDigest.*sha-256.*canonical.*terms object.*not.*raw statement text/is); + assert.match(instructions, /already.*fresh.*disposable.*working directory.*do not create or switch to another working directory/is); + assert.match(instructions, /never.*shared.*temp.*directory/is); + assert.match(instructions, /manifest digest.*applies only.*manifest\.json.*helper.*separate.*sha-256.*verified manifest/is); + assert.match(instructions, /after.*init.*policy.*inspect.*call agent_handshake_join.*do not.*register.*before.*join.*fund.*agent_handshake_next.*erc8004_registration/is); + assert.match(instructions, /stateDirectoryCommand.*session-scoped.*client.*isolated \$TMPDIR.*helperStep\.shellCommand.*verbatim.*never.*concatenate.*re-encode.*payload/is); + assert.match(instructions, /operation.*does not include.*--payload-base64url.*do not add/is); + assert.match(instructions, /Never infer that the other stakeholder stopped from a waiting response/is); + assert.match(instructions, /HANDSHAKE_TEMPORARILY_UNAVAILABLE.*retryable: true.*retryAfterMs.*retry the same tool.*terminal protocol rejection/is); + assert.match(instructions, /role-scoped.*access argument.*same Clockchain MCP.*required credential use.*not.*disclosure/is); + assert.match(instructions, /access.*byte-for-byte.*never.*decode.*re-encode.*shorten.*reconstruct/is); + assert.match(instructions, /roleAccess.*short opaque local handle.*signed bearer capability.*behind.*handle/is); + assert.match(instructions, /agent_handshake_invite.*only.*roleAccess.*Initiator.*responderInvitation.*copy.*never substitute/is); + assert.match(instructions, /invitation acceptance.*only.*roleAccess.*Responder.*original invitation.*never.*access argument/is); + assert.doesNotMatch(instructions, /keep each returned role access value private/i); const manifest = buildV2Manifest(pin); assert.equal(manifest.endpoint, "https://mcp.clockchain.network/handshake/mcp"); assert.equal(manifest.helper.filename, "clockchain-agent-handshake.cjs"); @@ -58,11 +91,19 @@ test("public initialization leads with the immutable local-authority boundary", assert.ok(manifest.helper.verifiedBootstrapPrefix.includes(V2_VERIFIED_HELPER_BOOTSTRAP)); assert.equal(V2_VERIFIED_HELPER_BOOTSTRAP.includes(","), false); assert.equal(V2_VERIFIED_HELPER_BOOTSTRAP.includes("'"), false); + assert.match(V2_VERIFIED_HELPER_BOOTSTRAP, /manifest\.nodeRuntime/); + assert.match(V2_VERIFIED_HELPER_BOOTSTRAP, /process\.versions\.node/); + assert.match(V2_VERIFIED_HELPER_BOOTSTRAP, /\^24/); }); test("the dedicated MCP server exposes exactly seven tools and no prompts or resources", async () => { const httpServer = createServer(async (req, res) => { - const server = buildV2PublicServer({ pin, invoke: async (name) => ({ ok: true, name }) }); + const server = buildV2PublicServer({ pin, invoke: async (name) => ({ + ok: true, + name, + ...(name === "agent_handshake_invite" ? { initiatorAccess: "i".repeat(80) } : {}), + ...(name === "agent_handshake_accept_invitation" ? { responderAccess: "r".repeat(80) } : {}), + }) }); const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js"); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on("close", () => { void transport.close(); void server.close(); }); @@ -83,7 +124,18 @@ test("the dedicated MCP server exposes exactly seven tools and no prompts or res assert.equal("prompts" in initialized.body.result.capabilities, false); const listed = await rpc(url, "tools/list"); assert.deepEqual(listed.body.result.tools.map((tool) => tool.name), V2_PUBLIC_TOOL_NAMES); + const invite = listed.body.result.tools.find((tool) => tool.name === "agent_handshake_invite"); + const inviteSchema = JSON.stringify(invite.inputSchema); + assert.match(inviteSchema, /eip155:11155111/); + assert.match(inviteSchema, /0x8004a818bfb912233c491871b3d84c89a494bd9e/); + assert.match(inviteSchema, /required_fresh/); assert.equal(listed.body.result.tools.some((tool) => tool.annotations?.requiresUserInteraction === true), false); + const invited = await rpc(url, "tools/call", { name: "agent_handshake_invite", arguments: { reference: "NS-1847", statement: "test", validForSeconds: "90", identityPolicy: { erc8004: "required_fresh", chainId: "eip155:11155111", registryAddress: "0x8004a818bfb912233c491871b3d84c89a494bd9e" } } }); + assert.equal(invited.body.result.structuredContent.roleAccess, "i".repeat(80)); + assert.equal("initiatorAccess" in invited.body.result.structuredContent, false); + const roleAccess = "r".repeat(80); + const status = await rpc(url, "tools/call", { name: "agent_handshake_status", arguments: { access: roleAccess } }); + assert.equal(status.body.result.structuredContent.roleAccess, roleAccess); assert.equal((await rpc(url, "resources/list")).body.error.code, -32601); assert.equal((await rpc(url, "prompts/list")).body.error.code, -32601); } finally { @@ -95,7 +147,17 @@ test("public HTTP routing ignores full-surface credentials, trusts only configur assert.equal(v2PublicClientIp({ "x-forwarded-for": "203.0.113.9" }, "198.51.100.2", "198.51.100.1"), "198.51.100.2"); assert.equal(v2PublicClientIp({ "x-forwarded-for": "203.0.113.9, 198.51.100.1" }, "198.51.100.1", "198.51.100.1"), "203.0.113.9"); let now = 1000; - const handler = createV2PublicHttpHandler({ pin, now: () => now, invitePerHour: 5, callsPerMinute: 120, invoke: async (name) => ({ ok: true, name }) }); + const handler = createV2PublicHttpHandler({ + pin, + now: () => now, + invitePerHour: 5, + callsPerMinute: 120, + invoke: async (name) => ({ + ok: true, + name, + ...(name === "agent_handshake_invite" ? { initiatorAccess: `${"a".repeat(160)}.${"b".repeat(43)}` } : {}), + }), + }); const httpServer = createServer((req, res) => handler(req, res)); await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); const url = `http://127.0.0.1:${httpServer.address().port}/handshake/mcp`; @@ -113,3 +175,109 @@ test("public HTTP routing ignores full-surface credentials, trusts only configur await new Promise((resolve) => httpServer.close(resolve)); } }); + +test("public HTTP keeps signed role capabilities behind short opaque handles", async () => { + const initiatorCapability = `${"a".repeat(160)}.${"b".repeat(43)}`; + const responderCapability = `${"c".repeat(160)}.${"d".repeat(43)}`; + const observed = []; + const handler = createV2PublicHttpHandler({ + pin, + invoke: async (name, args) => { + observed.push({ name, args }); + if (name === "agent_handshake_invite") { + return { initiatorAccess: initiatorCapability, responderInvitation: "v".repeat(80) }; + } + if (name === "agent_handshake_accept_invitation") { + return { responderAccess: responderCapability, sessionId: "session" }; + } + return { ok: true, name }; + }, + }); + const httpServer = createServer((req, res) => handler(req, res)); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${httpServer.address().port}/handshake/mcp`; + try { + const invited = await rpc(url, "tools/call", { + name: "agent_handshake_invite", + arguments: { reference: "NS-1847", statement: "test", validForSeconds: "90", identityPolicy: { erc8004: "required_fresh", chainId: "eip155:11155111", registryAddress: "0x8004a818bfb912233c491871b3d84c89a494bd9e" } }, + }); + const initiatorHandle = invited.body.result.structuredContent.roleAccess; + assert.match(initiatorHandle, /^ccra_[A-Za-z0-9_-]{22}$/); + assert.equal("initiatorAccess" in invited.body.result.structuredContent, false); + assert.equal(JSON.stringify(invited.body).includes(initiatorCapability), false); + + const status = await rpc(url, "tools/call", { + name: "agent_handshake_status", + arguments: { access: initiatorHandle }, + }); + assert.equal(status.body.result.structuredContent.roleAccess, initiatorHandle); + assert.equal(observed.at(-1).args.access, initiatorCapability); + assert.equal(JSON.stringify(status.body).includes(initiatorCapability), false); + + const accepted = await rpc(url, "tools/call", { + name: "agent_handshake_accept_invitation", + arguments: { invitation: "v".repeat(80) }, + }); + const responderHandle = accepted.body.result.structuredContent.roleAccess; + assert.match(responderHandle, /^ccra_[A-Za-z0-9_-]{22}$/); + assert.notEqual(responderHandle, initiatorHandle); + assert.equal("responderAccess" in accepted.body.result.structuredContent, false); + assert.equal(JSON.stringify(accepted.body).includes(responderCapability), false); + + await rpc(url, "tools/call", { + name: "agent_handshake_next", + arguments: { access: responderHandle }, + }); + assert.equal(observed.at(-1).args.access, responderCapability); + + const invalid = await rpc(url, "tools/call", { + name: "agent_handshake_next", + arguments: { access: `${responderHandle.slice(0, -1)}x` }, + }); + assert.equal(invalid.body.result.isError, true); + assert.equal(observed.at(-1).args.access, responderCapability); + } finally { + await new Promise((resolve) => httpServer.close(resolve)); + } +}); + +test("public tools distinguish retryable infrastructure failures from terminal protocol rejection", async () => { + const warnings = []; + const originalWarn = console.warn; + console.warn = (value) => warnings.push(value); + try { + for (const candidate of [ + { error: Object.assign(new Error("rpc unavailable"), { name: "RpcRequestError" }), retryable: true }, + { error: Object.assign(new Error("ledger is not durable yet"), { name: "V2TransientCoordinatorError" }), retryable: true }, + { error: Object.assign(new Error("secret invalid role state"), { name: "V2CoordinatorError" }), retryable: false }, + { error: new Error("unexpected internal state"), retryable: false }, + ]) { + const handler = createV2PublicHttpHandler({ pin, invoke: async () => { throw candidate.error; } }); + const httpServer = createServer((req, res) => handler(req, res)); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${httpServer.address().port}/handshake/mcp`; + try { + const result = await rpc(url, "tools/call", { name: "agent_handshake_status", arguments: { access: "a".repeat(80) } }); + const body = JSON.parse(result.body.result.content[0].text); + assert.equal(body.retryable, candidate.retryable); + assert.equal(result.body.result.isError === true, !candidate.retryable); + if (candidate.retryable) { + assert.equal(body.error, "HANDSHAKE_TEMPORARILY_UNAVAILABLE"); + assert.equal(body.retryAfterMs, 5000); + } + } finally { + await new Promise((resolve) => httpServer.close(resolve)); + } + } + } finally { + console.warn = originalWarn; + } + assert.deepEqual(warnings.map((entry) => JSON.parse(entry)), [ + { event: "agent_handshake_tool_failure", tool: "agent_handshake_status", errorName: "RpcRequestError" }, + { event: "agent_handshake_tool_failure", tool: "agent_handshake_status", errorName: "V2TransientCoordinatorError" }, + { event: "agent_handshake_tool_failure", tool: "agent_handshake_status", errorName: "V2CoordinatorError" }, + { event: "agent_handshake_tool_failure", tool: "agent_handshake_status", errorName: "Error" }, + ]); + assert.equal(warnings.join("\n").includes("secret invalid role state"), false); + assert.equal(warnings.join("\n").includes("unexpected internal state"), false); +}); diff --git a/packages/mcp-server/test/handshake-evm.test.mjs b/packages/mcp-server/test/handshake-evm.test.mjs index af27222..78af18d 100644 --- a/packages/mcp-server/test/handshake-evm.test.mjs +++ b/packages/mcp-server/test/handshake-evm.test.mjs @@ -281,6 +281,32 @@ test("resolveOwnedAgentId honors explicit fromBlock over canonical registry defa assert.equal(filter.fromBlock, "0x10"); }); +test("resolveOwnedAgentId accepts the canonical decimal block carried by v2 discovery", async () => { + const calls = []; + const fetchImpl = async (_url, init) => { + const body = JSON.parse(init.body); + calls.push(body); + const result = body.method === "eth_blockNumber" ? "0xaee226" : []; + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ jsonrpc: "2.0", id: body.id, result }), + }; + }; + + assert.equal(await resolveOwnedAgentId({ + rpcUrl: RPC_URL, + registryAddress: SEPOLIA_ERC8004_REGISTRY, + address: ADDRESS, + fromBlock: "11461142", + fetchImpl, + }), null); + + const filter = calls.find((call) => call.method === "eth_getLogs").params[0]; + assert.equal(filter.fromBlock, "0xaee216"); + assert.equal(filter.toBlock, "0xaee226"); +}); + test("resolveOwnedAgentId fails clearly when the reverse scan range exceeds the cap", async () => { const { fetchImpl } = rpcFetch((body) => { if (body.method === "eth_blockNumber") return "0x500001";