From 90818cf5925c37f271f2c5a0a3ef7b867cf7badd Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 08:34:26 -0600 Subject: [PATCH 01/20] fix(security): validate agent_id character set on registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block characters dangerous in URLs and HTTP headers: - Newlines: signing string injection via (request-target) - Slashes: path traversal in URL routing - Spaces, null bytes, shell metacharacters, angle brackets (XSS) Allowed: [a-zA-Z0-9._\-:] — covers all real patterns in use (auth.backend, clearauth-gm, decisive_redux, did-web:domain.com) Also fix auto-generated agent_id: was `agent://agent-` which contains `://` and would fail its own validation. Changed to `agent-`. DID:web shadow agents call storage.createAgent() directly and are unaffected — system-generated IDs are trusted at that layer. Co-Authored-By: Claude Sonnet 4.6 --- src/services/agent.service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/agent.service.js b/src/services/agent.service.js index 63716d2..9e15c41 100644 --- a/src/services/agent.service.js +++ b/src/services/agent.service.js @@ -30,7 +30,7 @@ export class AgentService { async register({ agent_id, agent_type = 'generic', metadata = {}, webhook_url, webhook_secret, seed, public_key, tenant_id }) { // Generate agent_id if not provided if (!agent_id) { - agent_id = `agent://agent-${uuid()}`; + agent_id = `agent-${uuid()}`; } // Validate agent_id character set. From afe935a547198a524ad304d7428fa33095812cc4 Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 08:51:15 -0600 Subject: [PATCH 02/20] fix(tests): remove agent:// prefix from test IDs and relax envelope validation - Strip agent:// prefix from all test agent_id values (34 occurrences) to match the new validation regex that forbids slashes in agent IDs - Update inbox.service validateEnvelope to accept bare agent IDs (matching ^[a-zA-Z0-9._-:]+$) in addition to agent:// URIs and did:seed: DIDs for backward compatibility This fixes CI test failures caused by the agent_id character validation introduced in the previous commit. Co-Authored-By: Claude Sonnet 4.6 --- src/server.test.js | 70 +++++++++++++++++------------------ src/services/inbox.service.js | 17 +++++---- 2 files changed, 45 insertions(+), 42 deletions(-) diff --git a/src/server.test.js b/src/server.test.js index d9a570b..f40246a 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -18,7 +18,7 @@ async function registerAgent(name, metadata = {}) { const res = await request(app) .post('/api/agents/register') .send({ - agent_id: `agent://${name}-${uniqueSuffix}`, + agent_id: `${name}-${uniqueSuffix}`, agent_type: 'test', metadata }); @@ -279,7 +279,7 @@ test('rejects messages with invalid signature', async () => { test('returns 404 for unknown recipient agent', async () => { const sender = await registerAgent('sender-unknown-recipient'); - const nonExistentRecipient = 'agent://non-existent-recipient'; + const nonExistentRecipient = 'non-existent-recipient'; const res = await sendSignedMessage(sender, nonExistentRecipient, { subject: 'unknown-recipient', @@ -544,7 +544,7 @@ test('webhook happy path delivers and verifies signature', async () => { const port = typeof address === 'object' && address ? address.port : 0; const agent = { - agent_id: 'agent://webhook-happy', + agent_id: 'webhook-happy', webhook_url: `http://127.0.0.1:${port}/webhook-test-ok`, webhook_secret: 'test-webhook-secret' }; @@ -594,7 +594,7 @@ test('webhook failure reports will_retry and pending retries', async () => { const port = typeof address === 'object' && address ? address.port : 0; const agent = { - agent_id: 'agent://webhook-fail', + agent_id: 'webhook-fail', webhook_url: `http://127.0.0.1:${port}/webhook-test-fail`, webhook_secret: null }; @@ -1446,7 +1446,7 @@ test('outbox webhook: delivered event updates outbox message status', async () = const mailgunId = ''; await storage.createOutboxMessage({ id: 'webhook-deliver-test', - agent_id: 'agent://webhook-agent', + agent_id: 'webhook-agent', to: 'someone@example.com', from: 'agent@example.com', subject: 'Webhook test', @@ -1482,7 +1482,7 @@ test('outbox webhook: failed event updates outbox message status', async () => { const mailgunId = ''; await storage.createOutboxMessage({ id: 'webhook-fail-test', - agent_id: 'agent://webhook-fail-agent', + agent_id: 'webhook-fail-agent', to: 'bounce@example.com', from: 'agent@example.com', subject: 'Will fail', @@ -1664,7 +1664,7 @@ test('storage: findOutboxMessageByMailgunId finds message by mailgun_id', async await storage.createOutboxMessage({ id: msgId, - agent_id: 'agent://find-test', + agent_id: 'find-test', to: 'user@example.com', from: 'agent@example.com', subject: 'Find test', @@ -1697,7 +1697,7 @@ test('outbox webhook: handleWebhook uses findOutboxMessageByMailgunId to locate await storage.createOutboxMessage({ id: msgId, - agent_id: 'agent://webhook-find-agent', + agent_id: 'webhook-find-agent', to: 'someone@example.com', from: 'agent@example.com', subject: 'Webhook find test', @@ -1821,7 +1821,7 @@ test('seed-based registration: same seed+tenant+agent = same keypair (determinis const seed = crypto.randomBytes(32); const seedB64 = toBase64(seed); const tenantId = `tenant-determ-${Date.now()}`; - const agentId = `agent://seed-determ-${Date.now()}`; + const agentId = `seed-determ-${Date.now()}`; // Create tenant first await storage.createTenant({ tenant_id: tenantId, name: tenantId, metadata: {} }); @@ -1863,7 +1863,7 @@ test('seed-based registration: different tenant = different keypair (isolation)' const resA = await request(app) .post('/api/agents/register') .send({ - agent_id: `agent://agent-a-${agentSuffix}`, + agent_id: `agent-a-${agentSuffix}`, agent_type: 'test', seed: seedB64, tenant_id: tenantA @@ -1872,7 +1872,7 @@ test('seed-based registration: different tenant = different keypair (isolation)' const resB = await request(app) .post('/api/agents/register') .send({ - agent_id: `agent://agent-b-${agentSuffix}`, + agent_id: `agent-b-${agentSuffix}`, agent_type: 'test', seed: seedB64, tenant_id: tenantB @@ -1893,7 +1893,7 @@ test('seed-based registration requires tenant_id', async () => { const res = await request(app) .post('/api/agents/register') .send({ - agent_id: `agent://no-tenant-${Date.now()}`, + agent_id: `no-tenant-${Date.now()}`, agent_type: 'test', seed: seedB64 }); @@ -1909,7 +1909,7 @@ test('import mode: stores provided key, no secret_key in response, DID generated const res = await request(app) .post('/api/agents/register') .send({ - agent_id: `agent://import-${Date.now()}`, + agent_id: `import-${Date.now()}`, agent_type: 'test', public_key: pubKeyB64 }); @@ -1956,7 +1956,7 @@ test('tenant CRUD: create, get, list agents, delete', async () => { const agentRes = await request(app) .post('/api/agents/register') .send({ - agent_id: `agent://tenant-agent-${Date.now()}`, + agent_id: `tenant-agent-${Date.now()}`, agent_type: 'test', seed: toBase64(seed), tenant_id: tenantId @@ -2257,7 +2257,7 @@ test('key rotation increments version and generates new keypair', async () => { const seed = crypto.randomBytes(32); const seedB64 = toBase64(seed); const tenantId = `tenant-rotate-${Date.now()}`; - const agentId = `agent://rotate-${Date.now()}`; + const agentId = `rotate-${Date.now()}`; await storage.createTenant({ tenant_id: tenantId, name: tenantId, metadata: {} }); @@ -2290,8 +2290,8 @@ test('messages signed with old key still verify during rotation window', async ( const seed = crypto.randomBytes(32); const seedB64 = toBase64(seed); const tenantId = `tenant-rotwin-${Date.now()}`; - const senderAgentId = `agent://rotwin-sender-${Date.now()}`; - const recipientAgentId = `agent://rotwin-recv-${Date.now()}`; + const senderAgentId = `rotwin-sender-${Date.now()}`; + const recipientAgentId = `rotwin-recv-${Date.now()}`; await storage.createTenant({ tenant_id: tenantId, name: tenantId, metadata: {} }); @@ -2372,7 +2372,7 @@ test('key rotation requires valid seed matching current key', async () => { const seed = crypto.randomBytes(32); const seedB64 = toBase64(seed); const tenantId = `tenant-seedmatch-${Date.now()}`; - const agentId = `agent://seedmatch-${Date.now()}`; + const agentId = `seedmatch-${Date.now()}`; await storage.createTenant({ tenant_id: tenantId, name: tenantId, metadata: {} }); @@ -2451,7 +2451,7 @@ test('cryptographic verification requires seed-based + DID', async () => { const seedAgent = await request(app) .post('/api/agents/register') .send({ - agent_id: `agent://crypto-verify-${Date.now()}`, + agent_id: `crypto-verify-${Date.now()}`, agent_type: 'test', seed: toBase64(seed), tenant_id: tenantId @@ -2492,7 +2492,7 @@ test('cryptographic verification fails for import-mode agents', async () => { const regRes = await request(app) .post('/api/agents/register') .send({ - agent_id: `agent://import-nocrypto-${Date.now()}`, + agent_id: `import-nocrypto-${Date.now()}`, agent_type: 'test', public_key: pubKeyB64 }); @@ -2940,7 +2940,7 @@ test('trust model: registration is exempt from API key when API_KEY_REQUIRED=tru const suffix = `${Date.now()}-exempt`; const res = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://exempt-test-${suffix}`, agent_type: 'test' }); + .send({ agent_id: `exempt-test-${suffix}`, agent_type: 'test' }); // No X-API-Key header — should succeed assert.equal(res.status, 201, 'register must succeed without API key even when API_KEY_REQUIRED=true'); assert.ok(res.body.agent_id); @@ -3015,12 +3015,12 @@ test('trust model: single-use token scope enforcement', async () => { // Register two agents without auth key (exempt) const regA = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://scope-a-${suffix}`, agent_type: 'test' }); + .send({ agent_id: `scope-a-${suffix}`, agent_type: 'test' }); assert.equal(regA.status, 201); const regB = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://scope-b-${suffix}`, agent_type: 'test' }); + .send({ agent_id: `scope-b-${suffix}`, agent_type: 'test' }); assert.equal(regB.status, 201); const agentA = regA.body.agent_id; @@ -3061,7 +3061,7 @@ test('trust model: open tenant policy → agent approved immediately', async () const suffix = `${Date.now()}`; const res = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://open-policy-${suffix}`, agent_type: 'test', tenant_id: tenantId }); + .send({ agent_id: `open-policy-${suffix}`, agent_type: 'test', tenant_id: tenantId }); assert.equal(res.status, 201); assert.equal(res.body.registration_status, 'approved', 'open policy should approve immediately'); @@ -3074,7 +3074,7 @@ test('trust model: approval_required tenant policy → agent starts pending', as const suffix = `${Date.now()}`; const res = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://pending-${suffix}`, agent_type: 'test', tenant_id: tenantId }); + .send({ agent_id: `pending-${suffix}`, agent_type: 'test', tenant_id: tenantId }); assert.equal(res.status, 201); assert.equal(res.body.registration_status, 'pending', 'approval_required policy should set status to pending'); @@ -3087,7 +3087,7 @@ test('trust model: pending agent is blocked from API access', async () => { const suffix = `${Date.now()}`; const regRes = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://blocked-${suffix}`, agent_type: 'test', tenant_id: tenantId }); + .send({ agent_id: `blocked-${suffix}`, agent_type: 'test', tenant_id: tenantId }); assert.equal(regRes.status, 201); assert.equal(regRes.body.registration_status, 'pending'); @@ -3114,7 +3114,7 @@ test('trust model: approve pending agent → becomes accessible', async () => { const suffix = `${Date.now()}`; const regRes = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://to-approve-${suffix}`, agent_type: 'test', tenant_id: tenantId }); + .send({ agent_id: `to-approve-${suffix}`, agent_type: 'test', tenant_id: tenantId }); assert.equal(regRes.status, 201); const agentId = regRes.body.agent_id; @@ -3148,7 +3148,7 @@ test('trust model: reject agent → returns REGISTRATION_REJECTED error', async const suffix = `${Date.now()}`; const regRes = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://to-reject-${suffix}`, agent_type: 'test', tenant_id: tenantId }); + .send({ agent_id: `to-reject-${suffix}`, agent_type: 'test', tenant_id: tenantId }); assert.equal(regRes.status, 201); const agentId = regRes.body.agent_id; @@ -3186,18 +3186,18 @@ test('trust model: pending list endpoint returns correct subset', async () => { // Register two pending agents in the tenant const reg1 = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://list-pending-1-${suffix}`, agent_type: 'test', tenant_id: tenantId }); + .send({ agent_id: `list-pending-1-${suffix}`, agent_type: 'test', tenant_id: tenantId }); assert.equal(reg1.status, 201); const reg2 = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://list-pending-2-${suffix}`, agent_type: 'test', tenant_id: tenantId }); + .send({ agent_id: `list-pending-2-${suffix}`, agent_type: 'test', tenant_id: tenantId }); assert.equal(reg2.status, 201); // Register one approved (different tenant — no policy) const reg3 = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://list-approved-${suffix}`, agent_type: 'test' }); + .send({ agent_id: `list-approved-${suffix}`, agent_type: 'test' }); assert.equal(reg3.status, 201); // Fetch pending list @@ -3220,7 +3220,7 @@ test('trust model: pending list endpoint returns correct subset', async () => { test('trust model: existing agents without registration_status are treated as approved', async () => { // Simulate a legacy agent (no registration_status field) - const agentId = `agent://legacy-no-status-${Date.now()}`; + const agentId = `legacy-no-status-${Date.now()}`; await storage.createAgent({ agent_id: agentId, agent_type: 'test', @@ -3455,7 +3455,7 @@ test('trust model: scoped enrollment token — rejects when target_agent_id does process.env.API_KEY_REQUIRED = 'true'; try { - const nonExistentAgentId = `agent://nonexistent-${Date.now()}`; + const nonExistentAgentId = `nonexistent-${Date.now()}`; const res = await request(app) .post('/api/keys/issue') @@ -3487,7 +3487,7 @@ test('trust model: reject is idempotent — second rejection returns success', a const regRes = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://reject-idem-${Date.now()}`, tenant_id: tenantId }); + .send({ agent_id: `reject-idem-${Date.now()}`, tenant_id: tenantId }); assert.equal(regRes.status, 201); const agentId = regRes.body.agent_id; @@ -3546,7 +3546,7 @@ test('trust model: approve is idempotent — second approval returns success', a const regRes = await request(app) .post('/api/agents/register') - .send({ agent_id: `agent://idem-test-${Date.now()}`, tenant_id: tenantId }); + .send({ agent_id: `idem-test-${Date.now()}`, tenant_id: tenantId }); assert.equal(regRes.status, 201); const agentId = regRes.body.agent_id; diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index d70667d..40e09a3 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -390,15 +390,18 @@ export class InboxService { throw new Error(`Unsupported ADMP version: ${envelope.version}`); } - // Validate agent URIs — accept both agent:// and did:seed: schemes - const validScheme = (uri) => uri.startsWith('agent://') || uri.startsWith('did:seed:'); - - if (!validScheme(envelope.from)) { - throw new Error('Invalid from URI (must start with agent:// or did:seed:)'); + // Validate agent identifiers — accept agent:// URIs, did:seed: DIDs, and bare agent IDs + const validId = (id) => + id.startsWith('agent://') || + id.startsWith('did:seed:') || + /^[a-zA-Z0-9._\-:]+$/.test(id); + + if (!validId(envelope.from)) { + throw new Error('Invalid from field (must be agent:// URI, did:seed: DID, or valid agent ID)'); } - if (!validScheme(envelope.to)) { - throw new Error('Invalid to URI (must start with agent:// or did:seed:)'); + if (!validId(envelope.to)) { + throw new Error('Invalid to field (must be agent:// URI, did:seed: DID, or valid agent ID)'); } // Validate timestamp From 1574e71331de4906a5c3f0779341e7c19759b205 Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 09:12:02 -0600 Subject: [PATCH 03/20] =?UTF-8?q?docs:=20regenerate=20documentation=20?= =?UTF-8?q?=E2=80=94=20agent=5Fid=20validation,=20cross-agent=20auth,=20CL?= =?UTF-8?q?I=20v0.2.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated all 6 docs outputs from source (docs-generator.json): - agent_id now enforces ^[a-zA-Z0-9._\-:]+$ — documented in all guides - Auto-generated IDs use agent- format (no agent:// prefix) - Envelope from/to now accept bare agent IDs alongside agent:// and did:seed: - Cross-agent send (POST /agents/:id/messages) auth model clarified - @agentdispatch/cli@0.2.1 subpath exports updated in CLI reference - Added missing error codes (MASTER_KEY_REQUIRED, webhook/tenant errors) - Architecture diagrams updated with cross-agent auth flow Co-Authored-By: Claude Sonnet 4.6 --- docs/AGENT-GUIDE.md | 1613 ++++++------------------------ docs/API-REFERENCE.md | 2218 ++++++++++------------------------------- docs/ARCHITECTURE.md | 181 ++-- docs/CLI-REFERENCE.md | 364 ++++--- docs/ERROR-CODES.md | 98 +- llms.txt | 66 +- 6 files changed, 1244 insertions(+), 3296 deletions(-) diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index 9372a80..8d5e43a 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -1,5 +1,5 @@ - - + + # ADMP Agent Integration Guide @@ -17,13 +17,14 @@ All request and response bodies are JSON (`Content-Type: application/json`). 1. [Authentication](#1-authentication) 2. [Quick Start](#2-quick-start) -3. [CLI and Library](#3-cli-and-library) -4. [Full Endpoint Reference](#4-full-endpoint-reference) -5. [Message Envelope Format](#5-message-envelope-format) -6. [Error Handling](#6-error-handling) -7. [Registration Modes](#7-registration-modes) -8. [Approval Workflow](#8-approval-workflow) -9. [Best Practices](#9-best-practices) +3. [Agent ID Format](#3-agent-id-format) +4. [Message Envelope Format](#4-message-envelope-format) +5. [CLI and Library](#5-cli-and-library) +6. [Full Endpoint Reference](#6-full-endpoint-reference) +7. [Error Handling](#7-error-handling) +8. [Registration Modes](#8-registration-modes) +9. [Approval Workflow](#9-approval-workflow) +10. [Best Practices](#10-best-practices) --- @@ -31,7 +32,7 @@ All request and response bodies are JSON (`Content-Type: application/json`). ADMP supports three authentication methods. They are evaluated in this order on every `/api` request: -### 1a. HTTP Signatures (Ed25519) -- Primary +### 1a. HTTP Signatures (Ed25519) — Primary Every agent receives an Ed25519 keypair at registration. Sign each request and pass the result in the `Signature` header. @@ -48,1501 +49,455 @@ Signature: keyId="",algorithm="ed25519",headers="(request-target) host | `(request-target)` required | Must be included in the signed headers list. Binds the signature to the HTTP method and path. | | `date` required | Must be included in the signed headers list. Provides replay protection. | | Date freshness | The `Date` header value must be within +/- 5 minutes of server time. | -| Agent match | The signing agent (`keyId`) must match the target agent in the URL path. Agent A cannot sign requests for Agent B's resources. | +| Only `ed25519` accepted | Requests with any other `algorithm` value are rejected. | +| Agent identity | `keyId` must match the `:agentId` in the URL for all endpoints except `POST /api/agents/:id/messages` (cross-agent send is allowed). | **Signing string construction:** ``` -(request-target): post /api/agents/my-agent/inbox/pull +(request-target): post /api/agents/recipient/inbox/pull host: agentdispatch.fly.dev -date: Tue, 25 Feb 2026 12:00:00 GMT +date: Thu, 26 Feb 2026 00:00:00 GMT ``` -Each line is `header-name: value`, joined by `\n`. The resulting string is signed with `nacl.sign.detached` using the agent's 64-byte secret key. The detached signature is base64-encoded. +**Using the library:** -If a `Signature` header is present and verification fails, the request is rejected immediately. It does **not** fall through to API key authentication. +```typescript +import { buildAuthHeaders } from '@agentdispatch/cli/auth'; + +const headers = buildAuthHeaders('POST', '/api/agents/my-agent/inbox/pull', 'agentdispatch.fly.dev', secretKey, agentId); +// Returns: { Date: "...", Signature: "keyId=...,algorithm=ed25519,..." } +``` -### 1b. API Keys +### 1b. API Key — Send, Status, Tenants -Pass via `X-Api-Key` header or `Authorization: Bearer `. +Pass the API key in one of two headers: -| Key Type | Scope | -|----------|-------| -| **Master key** (`MASTER_API_KEY` env var) | Full admin access. Required for key issuance, agent approval/rejection, and tenant management. | -| **Issued keys** (created by the master key holder) | Client integration access. Scoped, optional expiry. Single-use enrollment tokens are issued keys with `single_use: true`. | -| **Enrollment tokens** (single-use issued keys) | Scoped to a specific `target_agent_id`. Consumed on first use. | +``` +X-Api-Key: +Authorization: Bearer +``` -API key authentication is only enforced when `API_KEY_REQUIRED=true` is set on the server. +When `API_KEY_REQUIRED=true` (production default), all `/api` routes require an API key unless they carry a valid HTTP Signature. Registration (`POST /api/agents/register`) is always exempt. -### 1c. DID:web Federation +**Cross-agent message sending:** `POST /api/agents/:id/messages` accepts an HTTP Signature from any registered agent (not just the target agent). This enables agent-to-agent messaging without requiring the sender to also be the recipient. The `admp send` CLI command uses API key transport auth and Ed25519 envelope signing simultaneously. -External agents use `did:web` DIDs to authenticate. The server: +### 1c. Master API Key — Admin Endpoints -1. Parses the DID (e.g., `did:web:example.com:agents:alice`). -2. Fetches the DID document from `https://example.com/agents/alice/did.json`. -3. Extracts Ed25519 verification keys from the document. -4. Creates a shadow agent record with `registration_mode: 'did-web'` and `agent_type: 'federated'`. +A separate `MASTER_API_KEY` is required for admin-only endpoints (approve/reject agents, list pending). Standard API keys cannot access these. -The shadow agent's approval status depends on: -- `REGISTRATION_POLICY=open` **and** domain is in `DID_WEB_ALLOWED_DOMAINS` --> auto-approved. -- Otherwise --> `pending` (requires admin approval). +### 1d. DID:web Federation -Use `did:web:` as the `keyId` in the Signature header. +External agents can authenticate using a `did:web:` identifier in the `keyId` field. The server fetches their DID document, extracts Ed25519 keys, and creates a shadow agent record. Auto-approval only occurs when `REGISTRATION_POLICY=open` and the domain is in `DID_WEB_ALLOWED_DOMAINS`. --- ## 2. Quick Start -### Step 1: Register an Agent +### Step 1: Register ```bash -curl -X POST https://agentdispatch.fly.dev/api/agents/register \ - -H "Content-Type: application/json" \ - -d '{ - "agent_id": "agent://my-agent", - "agent_type": "assistant" - }' -``` +# CLI +admp register --name my-agent -Response (201): +# HTTP +POST /api/agents/register +Content-Type: application/json +{} +``` + +Response: ```json { - "agent_id": "agent://my-agent", - "agent_type": "assistant", - "public_key": "BASE64_PUBLIC_KEY", - "secret_key": "BASE64_SECRET_KEY", - "did": "did:seed:abcdef0123456789", + "agent_id": "agent-550e8400-e29b-41d4-a716-446655440000", + "public_key": "base64...", + "did": "did:seed:...", "registration_mode": "legacy", "registration_status": "approved", - "key_version": 1, - "verification_tier": "unverified", - "tenant_id": null, - "webhook_url": null, - "webhook_secret": null, - "heartbeat": { - "last_heartbeat": 1740484800000, - "status": "online", - "interval_ms": 60000, - "timeout_ms": 300000 - } + "secret_key": "base64...(64 bytes, Ed25519)" } ``` -> ⚠️ **`secret_key` is only returned once.** Store it immediately and securely — it cannot be retrieved again. If lost, you must re-register (legacy mode) or rotate your key (seed-based mode). - -Store `secret_key` securely. It is the 64-byte Ed25519 private key (base64-encoded) used for signing. - -### Step 2: Sign a Request - -Build the signing string from the `(request-target)`, `host`, and `date` headers, then sign it with `nacl.sign.detached`. - -**JavaScript (Node.js / Bun):** +Save `agent_id` and `secret_key`. The secret key is not stored server-side and will not be shown again. -```js -import nacl from 'tweetnacl'; +### Step 2: Send a Message -const secretKey = Uint8Array.from(Buffer.from(SECRET_KEY_BASE64, 'base64')); -const agentId = 'agent://my-agent'; - -function signRequest(method, path, host) { - const date = new Date().toUTCString(); - - const signingString = [ - `(request-target): ${method.toLowerCase()} ${path}`, - `host: ${host}`, - `date: ${date}` - ].join('\n'); - - const signature = nacl.sign.detached( - Buffer.from(signingString, 'utf8'), - secretKey - ); +```bash +# CLI +admp send --to analyst-agent --subject task.request --body '{"action":"summarize"}' - const sig = Buffer.from(signature).toString('base64'); +# HTTP (requires api_key for transport auth) +POST /api/agents/analyst-agent/messages +X-Api-Key: +Content-Type: application/json - return { - Date: date, - Signature: `keyId="${agentId}",algorithm="ed25519",headers="(request-target) host date",signature="${sig}"` - }; +{ + "version": "1.0", + "id": "uuid", + "type": "task.request", + "from": "my-agent", + "to": "analyst-agent", + "subject": "task.request", + "body": {"action": "summarize"}, + "timestamp": "2026-02-26T00:00:00Z", + "signature": {"alg": "ed25519", "kid": "my-agent", "sig": "base64..."} } - -// Usage -const headers = signRequest('POST', '/api/agents/agent%3A%2F%2Fmy-agent/inbox/pull', 'agentdispatch.fly.dev'); ``` -**curl (with pre-computed signature):** +### Step 3: Pull a Message ```bash -DATE=$(date -u +"%a, %d %b %Y %H:%M:%S GMT") - -# Build signing string — must use actual newlines (0x0a), not literal \n -SIGNING_STRING=$(printf '%s -%s -%s' \ - "(request-target): post /api/agents/agent%3A%2F%2Fmy-agent/inbox/pull" \ - "host: agentdispatch.fly.dev" \ - "date: ${DATE}") - -# Sign with your Ed25519 secret key (use a helper script or tweetnacl-cli) -SIGNATURE="" - -curl -X POST https://agentdispatch.fly.dev/api/agents/agent%3A%2F%2Fmy-agent/inbox/pull \ - -H "Content-Type: application/json" \ - -H "Host: agentdispatch.fly.dev" \ - -H "Date: ${DATE}" \ - -H "Signature: keyId=\"agent://my-agent\",algorithm=\"ed25519\",headers=\"(request-target) host date\",signature=\"${SIGNATURE}\"" \ - -d '{}' -``` +# CLI +admp pull -### Step 3: Send a Message +# HTTP (requires HTTP Signature) +POST /api/agents/my-agent/inbox/pull +Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="..." +Date: Thu, 26 Feb 2026 00:00:00 GMT -```bash -curl -X POST https://agentdispatch.fly.dev/api/agents/agent%3A%2F%2Frecipient/messages \ - -H "Content-Type: application/json" \ - -H "X-Api-Key: YOUR_API_KEY" \ - -d '{ - "version": "1.0", - "from": "agent://my-agent", - "to": "agent://recipient", - "subject": "task.request", - "body": { "action": "summarize", "input": "..." }, - "timestamp": "2026-02-25T12:00:00Z", - "signature": { - "alg": "ed25519", - "kid": "my-agent", - "sig": "BASE64_MESSAGE_SIGNATURE" - } - }' +{} ``` -> **Note:** The `X-Api-Key` header is required when the server has `API_KEY_REQUIRED=true` (default in production). You can also use `Authorization: Bearer YOUR_API_KEY`. - -Response (201): - +Response (200 OK with message, or 204 No Content if inbox is empty): ```json { - "message_id": "uuid-here", - "status": "queued" -} -``` - -### Step 4: Pull Messages from Inbox - -```js -const res = await fetch('https://agentdispatch.fly.dev/api/agents/agent%3A%2F%2Fmy-agent/inbox/pull', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...signRequest('POST', '/api/agents/agent%3A%2F%2Fmy-agent/inbox/pull', 'agentdispatch.fly.dev') - }, - body: JSON.stringify({ visibility_timeout: 120 }) -}); - -if (res.status === 204) { - console.log('Inbox empty'); -} else { - const { message_id, envelope, lease_until, attempts } = await res.json(); - console.log('Received:', envelope.subject, envelope.body); + "message_id": "uuid", + "envelope": {...}, + "lease_until": 1740000060000, + "attempts": 1 } ``` -### Step 5: Acknowledge the Message - -```js -await fetch(`https://agentdispatch.fly.dev/api/agents/agent%3A%2F%2Fmy-agent/messages/${messageId}/ack`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...signRequest('POST', `/api/agents/agent%3A%2F%2Fmy-agent/messages/${messageId}/ack`, 'agentdispatch.fly.dev') - }, - body: JSON.stringify({ result: { status: 'completed' } }) -}); -``` - ---- - -## 3. CLI and Library - -The `@agentdispatch/cli` npm package (v0.2.0+) provides both a CLI tool and importable library modules. It handles Ed25519 signing, config management, and HTTP requests so you don't have to implement them manually. - -### Install - -```bash -npm install -g @agentdispatch/cli # CLI globally -npm install @agentdispatch/cli # or as a project dependency -``` - -### CLI Quick Start +### Step 4: Acknowledge ```bash -# Register (saves credentials to ~/.admp/config.json) -admp register --name my-agent - -# Send a message -admp send --to analyst-agent --subject task.request --body '{"action":"summarize"}' - -# Pull next message (leases it) -admp pull - -# Acknowledge processing +# CLI admp ack -``` - -All commands accept `--json` for machine-readable output. See [CLI-REFERENCE.md](./CLI-REFERENCE.md) for the full command list. - -### Library Usage - -The package exposes three importable modules: - -```typescript -import { buildAuthHeaders, signEnvelope } from '@agentdispatch/cli/auth'; -import { AdmpClient, AdmpError } from '@agentdispatch/cli/client'; -import { resolveConfig, requireConfig } from '@agentdispatch/cli/config'; -``` - -#### Using AdmpClient (Recommended) -`AdmpClient` handles authentication and request signing automatically: +# HTTP +POST /api/agents/my-agent/messages//ack +Signature: ... +Date: ... -```typescript -import { AdmpClient } from '@agentdispatch/cli/client'; -import { resolveConfig } from '@agentdispatch/cli/config'; - -const client = new AdmpClient(resolveConfig()); - -// Send a message (signed with Ed25519 automatically) -await client.request('POST', '/api/agents/analyst/messages', { - version: '1.0', - type: 'task.request', - subject: 'summarize', - body: { url: 'https://example.com/report.pdf' }, -}); - -// Pull from inbox -const msg = await client.request('GET', '/api/inbox/pull'); - -// Ack -await client.request('POST', `/api/inbox/${msg.id}/ack`); -``` - -#### Auth Module (Low-Level Signing) - -If you need to sign requests yourself (e.g., for a custom HTTP client): - -```typescript -import { buildAuthHeaders, signEnvelope } from '@agentdispatch/cli/auth'; - -// HTTP request signing -const headers = buildAuthHeaders('POST', '/api/agents/foo/messages', 'agentdispatch.fly.dev', secretKey, agentId); -// Returns: { Date: "...", Signature: "keyId=...,algorithm=ed25519,..." } - -// Envelope signing (end-to-end integrity) -const signed = signEnvelope(envelope, secretKey); -// Returns: envelope with `signature` field { alg, kid, sig } +{"result": {"status": "processed"}} ``` -#### Config Resolution - -Config is loaded from `~/.admp/config.json` with environment variable overrides: - -| Variable | Overrides | -|----------|-----------| -| `ADMP_BASE_URL` | `base_url` | -| `ADMP_AGENT_ID` | `agent_id` | -| `ADMP_SECRET_KEY` | `secret_key` | -| `ADMP_API_KEY` | `api_key` | - -See [CLI-REFERENCE.md](./CLI-REFERENCE.md) for complete library API documentation. - --- -## 4. Full Endpoint Reference - -### Agent Management - -#### POST /api/agents/register -Register a new agent. - -- **Auth:** None required (exempt from API key gate). -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `agent_id` | string | No | Custom agent ID (auto-generated `agent://agent-` if omitted). | -| `agent_type` | string | No | Agent type label (default: `"generic"`). | -| `metadata` | object | No | Arbitrary agent metadata. | -| `webhook_url` | string | No | URL for push delivery of messages. | -| `webhook_secret` | string | No | Webhook signing secret (auto-generated if `webhook_url` is set). | -| `seed` | string | No | Base64-encoded master seed for deterministic key derivation. Requires `tenant_id`. | -| `public_key` | string | No | Base64-encoded Ed25519 public key for import mode. | -| `tenant_id` | string | No | Tenant namespace (required for seed-based registration). | +## 3. Agent ID Format -- **Response (201):** +`agent_id` must match the regular expression: -```json -{ - "agent_id": "agent://my-agent", - "agent_type": "assistant", - "public_key": "base64...", - "secret_key": "base64...", - "did": "did:seed:hex...", - "registration_mode": "legacy", - "registration_status": "approved", - "key_version": 1, - "verification_tier": "unverified", - "tenant_id": null, - "webhook_url": null, - "webhook_secret": null, - "heartbeat": { "last_heartbeat": 0, "status": "online", "interval_ms": 60000, "timeout_ms": 300000 } -} ``` - -`secret_key` is only returned for `legacy` and `seed` registration modes. Not returned for `import` mode. - -- **Error:** `400 REGISTRATION_FAILED` - ---- - -#### GET /api/agents/:agentId -Get agent details. - -- **Auth:** HTTP Signature or API key. Signing agent must match `:agentId`. -- **Response (200):** Agent object (without `secret_key`). -- **Error:** `404 AGENT_NOT_FOUND` - ---- - -#### DELETE /api/agents/:agentId -Deregister (delete) an agent. - -- **Auth:** HTTP Signature or API key. Signing agent must match `:agentId`. -- **Response:** `204 No Content` -- **Error:** `400 DEREGISTER_FAILED` - ---- - -#### POST /api/agents/:agentId/heartbeat -Update agent heartbeat to maintain online status. - -- **Auth:** HTTP Signature or API key. Signing agent must match `:agentId`. -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `metadata` | object | No | Optional metadata to merge into agent record. | - -- **Response (200):** - -```json -{ - "ok": true, - "last_heartbeat": 1740484800000, - "timeout_at": 1740485100000, - "status": "online" -} +^[a-zA-Z0-9._\-:]+$ ``` -- **Error:** `400 HEARTBEAT_FAILED` - ---- - -#### POST /api/agents/:agentId/rotate-key -Rotate keypair for seed-based agents. - -- **Auth:** HTTP Signature or API key. Signing agent must match `:agentId`. -- **Request body:** +**Allowed characters:** Letters (a-z, A-Z), digits (0-9), dots (`.`), underscores (`_`), hyphens (`-`), colons (`:`). -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `seed` | string | Yes | Base64-encoded master seed. Must derive the agent's current key. | -| `tenant_id` | string | Yes | Tenant ID used in derivation context. | +**Not allowed:** Slashes, spaces, `agent://` prefix, null bytes, or any other special characters. -- **Response (200):** +**Auto-generated IDs:** If you do not provide an `agent_id` at registration, the server generates one in the format `agent-` (e.g., `agent-550e8400-e29b-41d4-a716-446655440000`). -```json -{ - "agent_id": "agent://my-agent", - "public_key": "new-base64...", - "did": "did:seed:new-hex...", - "key_version": 2, - "secret_key": "new-base64..." -} +**Examples of valid IDs:** ``` - -Previous keys remain valid for 24 hours (rotation window). - -- **Errors:** `400 SEED_AND_TENANT_REQUIRED`, `400 KEY_ROTATION_FAILED`, `403 SEED_MISMATCH` - ---- - -### Trust Management - -#### GET /api/agents/:agentId/trusted -List the agent's trusted agents. - -- **Auth:** HTTP Signature or API key. -- **Response (200):** - -```json -{ - "trusted_agents": ["agent://other-agent"] -} +my-agent +auth.backend +storage-v2 +did-web:example.com +agent-550e8400-e29b-41d4-a716-446655440000 ``` --- -#### POST /api/agents/:agentId/trusted -Add an agent to the trusted list. +## 4. Message Envelope Format -- **Auth:** HTTP Signature or API key. -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `agent_id` | string | Yes | Agent ID to trust. | - -- **Response (200):** +All ADMP messages use this canonical JSON envelope: ```json { - "trusted_agents": ["agent://other-agent"] + "version": "1.0", + "id": "550e8400-e29b-41d4-a716-446655440000", + "type": "task.request", + "from": "sender-agent", + "to": "recipient-agent", + "subject": "create_user", + "correlation_id": "c-12345", + "headers": {"priority": "high"}, + "body": {"email": "user@example.com"}, + "ttl_sec": 86400, + "timestamp": "2026-02-26T00:00:00Z", + "signature": { + "alg": "ed25519", + "kid": "sender-agent", + "sig": "base64-encoded-signature" + } } ``` -- **Error:** `400 AGENT_ID_REQUIRED`, `400 ADD_TRUSTED_FAILED` - ---- +**Required fields:** `version`, `from`, `to`, `subject`, `timestamp` -#### DELETE /api/agents/:agentId/trusted/:trustedAgentId -Remove an agent from the trusted list. +**`from`/`to` field formats — all of the following are accepted:** +- Bare agent ID: `"my-agent"` (must match `^[a-zA-Z0-9._\-:]+$`) +- URI form: `"agent://my-agent"` +- DID form: `"did:seed:abc123..."` -- **Auth:** HTTP Signature or API key. -- **Response (200):** +**Envelope signature (optional but recommended):** -```json -{ - "trusted_agents": [] -} +The `signature` field provides end-to-end message integrity. Signing base string: ``` - -- **Error:** `400 REMOVE_TRUSTED_FAILED` - ---- - -### Webhook Configuration - -#### POST /api/agents/:agentId/webhook -Configure push delivery webhook. - -- **Auth:** HTTP Signature or API key. -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `webhook_url` | string | Yes | HTTPS URL for push delivery. | -| `webhook_secret` | string | No | Signing secret (auto-generated if omitted). | - -- **Response (200):** - -```json -{ - "agent_id": "agent://my-agent", - "webhook_url": "https://example.com/webhook", - "webhook_secret": "auto-generated-secret" -} +timestamp +sha256(JSON.stringify(body ?? {})) +from +to +correlation_id (empty string if absent) ``` -- **Error:** `400 WEBHOOK_URL_REQUIRED`, `400 WEBHOOK_CONFIG_FAILED` - ---- - -#### GET /api/agents/:agentId/webhook -Get current webhook configuration. +All fields joined with `\n` (newlines), hashed as UTF-8, signature computed with Ed25519. -- **Auth:** HTTP Signature or API key. -- **Response (200):** +```typescript +import { signEnvelope } from '@agentdispatch/cli/auth'; -```json -{ - "webhook_url": "https://example.com/webhook", - "webhook_configured": true -} +const signed = signEnvelope(envelope, secretKey); +// Adds: envelope.signature = { alg: "ed25519", kid, sig } +// kid is derived from envelope.from (strips "agent://" prefix) ``` --- -#### DELETE /api/agents/:agentId/webhook -Remove webhook configuration. +## 5. CLI and Library -- **Auth:** HTTP Signature or API key. -- **Response (200):** +### CLI Installation -```json -{ - "message": "Webhook removed", - "webhook_configured": false -} +```bash +npm install -g @agentdispatch/cli +# or +bun install -g @agentdispatch/cli ``` ---- - -### Identity Verification - -#### POST /api/agents/:agentId/verify/github -Link a GitHub handle to the agent. +**Version:** 0.2.1 -- **Auth:** HTTP Signature or API key. -- **Request body:** +### Library Imports (subpath exports) -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `github_handle` | string | Yes | GitHub username. | +| Import Path | Description | +|-------------|-------------| +| `@agentdispatch/cli` | Auth module (default export) | +| `@agentdispatch/cli/auth` | Ed25519 signing utilities | +| `@agentdispatch/cli/client` | HTTP client (`AdmpClient`, `AdmpError`) | +| `@agentdispatch/cli/config` | Config file management | +| `@agentdispatch/cli/cli` | CLI entry point | -- **Response (200):** - -```json -{ - "agent_id": "agent://my-agent", - "verification_tier": "github", - "github_handle": "my-github" -} -``` +**Programmatic usage:** -- **Error:** `400 GITHUB_LINK_FAILED` +```typescript +import { buildAuthHeaders, signEnvelope } from '@agentdispatch/cli/auth'; +import { AdmpClient, AdmpError } from '@agentdispatch/cli/client'; +import { resolveConfig, requireConfig } from '@agentdispatch/cli/config'; ---- +// Build auth headers for a request +const authHeaders = buildAuthHeaders('POST', '/api/agents/recipient/messages', 'agentdispatch.fly.dev', secretKey, agentId); -#### POST /api/agents/:agentId/verify/cryptographic -Upgrade to cryptographic verification tier. +// Make an authenticated request +const config = resolveConfig(); // reads ~/.admp/config.json + env vars +const client = new AdmpClient(config); -- **Auth:** HTTP Signature or API key. -- **Response (200):** +// Send a message (api-key auth at transport, Ed25519 in envelope) +const envelope = signEnvelope({ + version: '1.0', + from: `agent://${config.agent_id}`, + to: 'agent://recipient', + subject: 'task.request', + body: { action: 'summarize' }, + timestamp: new Date().toISOString(), +}, config.secret_key); -```json -{ - "agent_id": "agent://my-agent", - "verification_tier": "cryptographic", - "did": "did:seed:hex..." -} +const res = await client.request('POST', '/api/agents/recipient/messages', envelope, 'api-key'); ``` -- **Error:** `400 CRYPTOGRAPHIC_VERIFY_FAILED` - --- -#### GET /api/agents/:agentId/identity -Get the agent's verification status. +## 6. Full Endpoint Reference -- **Auth:** HTTP Signature or API key. -- **Response (200):** Identity object with verification tier, DID, and linked accounts. -- **Error:** `400 GET_IDENTITY_FAILED` +See [docs/API-REFERENCE.md](./API-REFERENCE.md) for the complete endpoint documentation. ---- +**Quick endpoint index:** -### Inbox (Messaging) +### Public (no auth required) +| Endpoint | Description | +|----------|-------------| +| `GET /health` | Health check | +| `GET /docs` | Swagger UI | +| `GET /openapi.json` | OpenAPI spec | +| `POST /api/agents/register` | Register agent | +| `GET /.well-known/agent-keys.json` | JWKS public key directory | +| `GET /api/agents/:agentId/did.json` | W3C DID document | -#### POST /api/agents/:agentId/messages -Send a message to an agent's inbox. +### Inbox (core messaging) +| Endpoint | Auth | Description | +|----------|------|-------------| +| `POST /api/agents/:id/messages` | API Key (any registered agent) | Send message | +| `POST /api/agents/:id/inbox/pull` | HTTP Sig (self only) | Pull with lease | +| `POST /api/agents/:id/messages/:msgId/ack` | HTTP Sig (self only) | Acknowledge | +| `POST /api/agents/:id/messages/:msgId/nack` | HTTP Sig (self only) | Negative ack | +| `POST /api/agents/:id/messages/:msgId/reply` | HTTP Sig (self only) | Reply | +| `GET /api/messages/:msgId/status` | API Key | Delivery status | -- **Auth:** The `/api` gate applies (API key or HTTP Signature if `API_KEY_REQUIRED=true`). The message envelope itself carries its own signature for sender verification. -- **Request body:** ADMP message envelope (see [Section 4](#4-message-envelope-format)). Additional top-level fields: +--- -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `ephemeral` | boolean | No | If `true`, body is purged after ack. | -| `ttl` | string/number | No | Ephemeral TTL. Supports `"30m"`, `"1h"`, `"7d"`, or seconds. | +## 7. Error Handling -- **Response (201):** +All errors return: ```json { - "message_id": "uuid", - "status": "queued" + "error": "ERROR_CODE", + "message": "Human-readable description" } ``` -- **Errors:** `400 SEND_FAILED`, `404 RECIPIENT_NOT_FOUND`, `403 INVALID_SIGNATURE` - ---- - -#### POST /api/agents/:agentId/inbox/pull -Pull the next message from the inbox (FIFO). The message is leased for the specified duration. - -- **Auth:** HTTP Signature or API key. Signing agent must match `:agentId`. -- **Request body:** +Always match on `error`, not `message`. See [docs/ERROR-CODES.md](./ERROR-CODES.md) for the full reference. -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `visibility_timeout` | number | No | Lease duration in seconds (default: 60). | +**Common patterns:** -- **Response (200):** +```typescript +import { AdmpClient, AdmpError } from '@agentdispatch/cli/client'; -```json -{ - "message_id": "uuid", - "envelope": { "...ADMP envelope..." }, - "lease_until": 1740484860000, - "attempts": 1 +const client = new AdmpClient(config); + +try { + const msg = await client.request('POST', '/api/agents/my-agent/inbox/pull', {}, 'signature'); +} catch (err) { + if (err instanceof AdmpError) { + switch (err.code) { + case 'REGISTRATION_PENDING': + // Wait for admin approval, retry later + break; + case 'REQUEST_EXPIRED': + // Re-sign the request with a fresh Date header and retry + break; + case 'MESSAGE_NOT_FOUND': + // Message was already acked or expired — do not retry + break; + default: + if (err.status >= 500) { + // Transient server error — retry with exponential backoff + } + } + } } ``` -- **Response (204):** Inbox is empty. -- **Error:** `400 PULL_FAILED` - ---- - -#### POST /api/agents/:agentId/messages/:messageId/ack -Acknowledge a leased message. Removes it from the inbox. For ephemeral messages, the body is purged but the delivery log metadata is preserved. - -- **Auth:** HTTP Signature or API key. Signing agent must match `:agentId`. -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `result` | object | No | Processing result metadata. | - -- **Response (200):** - -```json -{ "ok": true } +**Retry strategy for 5xx errors:** ``` - -- **Errors:** `404 MESSAGE_NOT_FOUND`, `400 ACK_FAILED` - ---- - -#### POST /api/agents/:agentId/messages/:messageId/nack -Negative acknowledge. Requeues the message or extends the lease. - -- **Auth:** HTTP Signature or API key. Signing agent must match `:agentId`. -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `extend_sec` | number | No | Extend lease by this many seconds. | -| `requeue` | boolean | No | Requeue immediately (default behavior if `extend_sec` not set). | - -- **Response (200):** - -```json -{ - "ok": true, - "status": "queued", - "lease_until": null -} +attempt 1: wait 1s (+/- jitter) +attempt 2: wait 2s +attempt 3: wait 4s +attempt 4: wait 8s +attempt 5: wait 16s +attempt 6: wait 30s (cap) ``` -- **Errors:** `404 MESSAGE_NOT_FOUND`, `400 NACK_FAILED` - --- -#### POST /api/agents/:agentId/messages/:messageId/reply -Reply to a message. Creates a new message sent to the original sender with `correlation_id` set to the original message ID. +## 8. Registration Modes -- **Auth:** HTTP Signature or API key. Signing agent must match `:agentId`. -- **Request body:** ADMP envelope fields (the `from`, `to`, `correlation_id`, and `timestamp` are auto-populated). +Three registration modes are supported: -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `version` | string | Yes | `"1.0"` | -| `subject` | string | Yes | Reply subject. | -| `body` | object | Yes | Reply payload. | +### Legacy (default) -- **Response (200):** +The server generates a random Ed25519 keypair. Returns `secret_key`. Simple but the private key cannot be regenerated if lost. ```json -{ - "message_id": "uuid", - "status": "queued" -} +POST /api/agents/register +{} ``` -- **Errors:** `404 MESSAGE_NOT_FOUND`, `400 REPLY_FAILED` - ---- - -#### GET /api/messages/:messageId/status -Get message delivery status. No agent authentication required (uses the global `/api` gate). +### Seed-based (deterministic) -- **Auth:** API key if `API_KEY_REQUIRED=true`. -- **Response (200):** +You provide a master seed; the server derives the keypair deterministically via HKDF-SHA256. `tenant_id` is required. If you lose your device, you can re-derive the same key from the same seed. ```json +POST /api/agents/register { - "id": "uuid", - "status": "queued", - "created_at": 1740484800000, - "updated_at": 1740484800000, - "attempts": 0, - "lease_until": null, - "acked_at": null + "seed": "base64-encoded-32-byte-seed", + "tenant_id": "my-tenant", + "agent_id": "my-agent" } ``` -- **Errors:** `404 MESSAGE_NOT_FOUND`, `410 MESSAGE_EXPIRED` (for purged messages) - ---- - -#### GET /api/agents/:agentId/inbox/stats -Get inbox statistics. +Key derivation context: `admp:::ed25519:v1` -- **Auth:** HTTP Signature or API key. Signing agent must match `:agentId`. -- **Response (200):** Object with message counts by status. -- **Error:** `400 STATS_FAILED` +Use `admp register --seed ` or `ADMP_SEED=` (to avoid shell history exposure). ---- - -#### POST /api/agents/:agentId/inbox/reclaim -Manually reclaim expired leases (requeue leased messages whose lease has expired). +### Import (client-provided public key) -- **Auth:** HTTP Signature or API key. Signing agent must match `:agentId`. -- **Response (200):** +You generate the keypair yourself and provide only the public key. The server never sees the private key. No `secret_key` is returned. ```json +POST /api/agents/register { - "reclaimed": 3 + "public_key": "base64-encoded-ed25519-public-key" } ``` -- **Error:** `400 RECLAIM_FAILED` - --- -### Groups - -#### POST /api/groups -Create a new group. The creating agent becomes the owner. +## 9. Approval Workflow -- **Auth:** Agent identity required (via URL param, `X-Agent-ID` header, or HTTP Signature). -- **Request body:** +When `REGISTRATION_POLICY=approval_required` (or the tenant's policy requires it), newly registered agents start with `registration_status: "pending"` and cannot authenticate until approved. -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `name` | string | Yes | Group name (1-100 chars, alphanumeric + spaces/hyphens/underscores/periods). | -| `access` | object | No | Access control (`{ "type": "open" }`, `{ "type": "key", "key": "secret" }`, or `{ "type": "invite-only" }`). | -| `settings` | object | No | Group settings (e.g., `max_members`). | +**Pending agents receive `REGISTRATION_PENDING` (403) on all requests.** -- **Response (201):** Full group object. -- **Errors:** `400 INVALID_NAME`, `400 NAME_TOO_LONG`, `400 INVALID_NAME_CHARS` +### Checking Status ---- - -#### GET /api/groups/:groupId -Get group info. Non-members see limited info (id, name, access type, member count). - -- **Auth:** Agent identity required. -- **Response (200):** Group object (full if member, limited if not). -- **Error:** `404 GROUP_NOT_FOUND` - ---- +The registration response includes `registration_status`. Agents can poll their status by calling `GET /api/agents/:agentId` (once approved, this succeeds). -#### PUT /api/groups/:groupId -Update group name or settings. Requires admin or owner role. +### Admin Approval (Master Key Required) -- **Auth:** Agent identity required. -- **Request body:** +```http +POST /api/agents//approve +X-Api-Key: +Content-Type: application/json -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `name` | string | No | New group name. | -| `settings` | object | No | Updated settings. | +{} +``` -- **Response (200):** Updated group object. -- **Errors:** `403 Requires admin/owner`, `404 GROUP_NOT_FOUND` +```http +POST /api/agents//reject +X-Api-Key: +Content-Type: application/json ---- +{"reason": "Domain not in allowlist"} +``` -#### DELETE /api/groups/:groupId -Delete a group. Requires owner role. +### List Pending Agents -- **Auth:** Agent identity required. -- **Response:** `204 No Content` -- **Errors:** `403 Requires owner`, `404 GROUP_NOT_FOUND` +```http +GET /api/agents/tenants//pending +X-Api-Key: +``` --- -#### GET /api/groups/:groupId/members -List group members. Requires membership. - -- **Auth:** Agent identity required. -- **Response (200):** - -```json -{ - "members": [ - { "agent_id": "agent://alice", "role": "owner", "joined_at": 1740484800000 } - ] -} -``` - -- **Errors:** `403 not a member`, `404 GROUP_NOT_FOUND` - ---- - -#### POST /api/groups/:groupId/members -Add a member (admin action). Requires admin or owner role. - -- **Auth:** Agent identity required. -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `agent_id` | string | Yes | Agent to add. | -| `role` | string | No | Role to assign (default: `"member"`). | - -- **Response (200):** Updated group object. -- **Errors:** `400 AGENT_ID_REQUIRED`, `403 Requires admin/owner`, `409 already a member` - ---- - -#### DELETE /api/groups/:groupId/members/:agentId -Remove a member. Requires admin/owner role. Cannot remove the owner. - -- **Auth:** Agent identity required. -- **Response (200):** Updated group object. -- **Errors:** `403 Cannot remove group owner`, `404 GROUP_NOT_FOUND` - ---- - -#### POST /api/groups/:groupId/join -Join a group (for open or key-protected groups). - -- **Auth:** Agent identity required. -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `key` | string | No | Join key (required for key-protected groups). | - -- **Response (200):** Group object. -- **Errors:** `403 invite-only`, `403 Invalid join key`, `409 already a member` - ---- - -#### POST /api/groups/:groupId/leave -Leave a group. - -- **Auth:** Agent identity required. -- **Response (200):** - -```json -{ - "message": "Left group", - "group_id": "group-uuid" -} -``` - -- **Error:** `403 not a member` - ---- - -#### POST /api/groups/:groupId/messages -Post a message to the group (delivered to all members' inboxes). - -- **Auth:** Agent identity required. -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `subject` | string | Yes | Message subject (max 200 chars). | -| `body` | object/string | Yes | Message payload (max 1MB). | -| `correlation_id` | string | No | Correlation ID. | -| `reply_to` | string | No | Message ID this is replying to. | - -- **Response (201):** Delivery result with per-member message IDs. -- **Errors:** `400 INVALID_MESSAGE`, `400 INVALID_SUBJECT`, `400 BODY_TOO_LARGE`, `403 not a member` - ---- - -#### GET /api/groups/:groupId/messages -Get group message history. - -- **Auth:** Agent identity required. Must be a member. -- **Query params:** - -| Param | Type | Default | Description | -|-------|------|---------|-------------| -| `limit` | number | 50 | Max messages to return. | - -- **Response (200):** - -```json -{ - "messages": [ "..." ], - "count": 10, - "has_more": false -} -``` - -- **Error:** `403 not a member` - ---- - -### Outbox (Email) - -#### POST /api/agents/:agentId/outbox/domain -Configure a custom domain for outbound email (Mailgun). - -- **Auth:** Agent identity required. -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `domain` | string | Yes | Domain name (e.g., `mail.example.com`). | - -- **Response (201):** Domain configuration with DNS records to set. -- **Errors:** `400 DOMAIN_REQUIRED`, `409 DOMAIN_CONFIG_FAILED` (already has domain) - ---- - -#### GET /api/agents/:agentId/outbox/domain -Get domain configuration and verification status. - -- **Auth:** Agent identity required. -- **Response (200):** Domain config object. -- **Error:** `404 NO_DOMAIN` - ---- - -#### POST /api/agents/:agentId/outbox/domain/verify -Trigger DNS verification check for the configured domain. - -- **Auth:** Agent identity required. -- **Response (200):** Updated domain config with verification status. -- **Error:** `404 DOMAIN_VERIFY_FAILED` (no domain configured) - ---- - -#### DELETE /api/agents/:agentId/outbox/domain -Remove domain configuration. - -- **Auth:** Agent identity required. -- **Response:** `204 No Content` -- **Error:** `404 DOMAIN_DELETE_FAILED` (no domain configured) - ---- - -#### POST /api/agents/:agentId/outbox/send -Send an email via Mailgun. Requires a verified domain. - -- **Auth:** Agent identity required. -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `to` | string | Yes | Recipient email address. | -| `subject` | string | Yes | Email subject. | -| `body` | string | Conditional | Plain text body (required if no `html`). | -| `html` | string | Conditional | HTML body (required if no `body`). | -| `from_name` | string | No | Display name for the sender. | - -- **Response (202):** Outbox message record. -- **Errors:** `400 TO_REQUIRED`, `400 INVALID_EMAIL`, `400 SUBJECT_REQUIRED`, `400 BODY_REQUIRED`, `403 SEND_FAILED` (domain not verified), `404 SEND_FAILED` (no domain) - ---- - -#### GET /api/agents/:agentId/outbox/messages -List sent outbox messages. - -- **Auth:** Agent identity required. -- **Query params:** - -| Param | Type | Description | -|-------|------|-------------| -| `status` | string | Filter by status. | -| `limit` | number | Max results. | - -- **Response (200):** - -```json -{ - "messages": [ "..." ], - "count": 5 -} -``` - ---- - -#### GET /api/agents/:agentId/outbox/messages/:messageId -Get a specific outbox message. - -- **Auth:** Agent identity required. Message must belong to the requesting agent. -- **Response (200):** Outbox message object. -- **Errors:** `404 OUTBOX_MESSAGE_NOT_FOUND`, `403 FORBIDDEN` (belongs to different agent) - ---- - -### Discovery - -#### GET /.well-known/agent-keys.json -JWKS-style public key directory for all registered agents. - -- **Auth:** None. -- **Response (200):** - -```json -{ - "keys": [ - { - "kid": "agent://my-agent", - "did": "did:seed:hex...", - "kty": "OKP", - "crv": "Ed25519", - "x": "base64-public-key", - "verification_tier": "unverified", - "key_version": 1 - } - ] -} -``` - ---- - -#### GET /api/agents/:agentId/did.json -W3C DID document for a specific agent. - -- **Auth:** None. -- **Response (200):** - -```json -{ - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/ed25519-2020/v1" - ], - "id": "did:seed:hex...", - "verificationMethod": [ - { - "id": "did:seed:hex...#key-1", - "type": "Ed25519VerificationKey2020", - "controller": "did:seed:hex...", - "publicKeyMultibase": "zBase58BTC..." - } - ], - "authentication": ["did:seed:hex...#key-1"], - "assertionMethod": ["did:seed:hex...#key-1"], - "service": [ - { - "id": "did:seed:hex...#admp-inbox", - "type": "ADMPInbox", - "serviceEndpoint": "/api/agents/agent%3A%2F%2Fmy-agent/messages" - } - ] -} -``` - -- **Error:** `404 AGENT_NOT_FOUND` - ---- - -#### POST /api/agents/tenants -Create a new tenant. - -- **Auth:** API key (master or issued). -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `tenant_id` | string | Yes | Unique tenant identifier. | -| `name` | string | No | Tenant display name (defaults to `tenant_id`). | -| `metadata` | object | No | Arbitrary tenant metadata. | -| `registration_policy` | string | No | `"open"` (default) or `"approval_required"`. | - -- **Response (201):** Tenant object. -- **Errors:** `400 TENANT_ID_REQUIRED`, `400 INVALID_REGISTRATION_POLICY`, `409 TENANT_EXISTS` - ---- - -#### GET /api/agents/tenants/:tenantId -Get tenant details. - -- **Auth:** API key (master or issued). -- **Response (200):** Tenant object. -- **Error:** `404 TENANT_NOT_FOUND` - ---- - -#### GET /api/agents/tenants/:tenantId/agents -List agents belonging to a tenant. - -- **Auth:** API key (master or issued). -- **Response (200):** - -```json -{ - "agents": [ "..." ] -} -``` - ---- - -#### DELETE /api/agents/tenants/:tenantId -Delete a tenant. - -- **Auth:** API key (master or issued). -- **Response:** `204 No Content` - ---- - -#### GET /api/agents/tenants/:tenantId/pending -List agents with `pending` registration status for a tenant. - -- **Auth:** Master key. -- **Response (200):** - -```json -{ - "agents": [ "..." ] -} -``` - ---- - -#### POST /api/agents/:agentId/approve -Approve a pending agent registration. - -- **Auth:** Master key. -- **Response (200):** - -```json -{ - "agent_id": "agent://my-agent", - "registration_status": "approved" -} -``` - -Idempotent: approving an already-approved agent returns the agent as-is. - -- **Error:** `404 AGENT_NOT_FOUND` - ---- - -#### POST /api/agents/:agentId/reject -Reject an agent registration. - -- **Auth:** Master key. -- **Request body:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `reason` | string | No | Rejection reason (max 500 chars). | - -- **Response (200):** - -```json -{ - "agent_id": "agent://my-agent", - "registration_status": "rejected", - "rejection_reason": "Policy violation" -} -``` - -- **Error:** `404 AGENT_NOT_FOUND` - ---- - -### Utility Endpoints - -#### GET /health -Health check. - -- **Auth:** None. -- **Response (200):** - -```json -{ - "status": "healthy", - "timestamp": "2026-02-25T12:00:00.000Z", - "version": "1.0.0" -} -``` - ---- - -#### GET /api/stats -Server-wide statistics. - -- **Auth:** API key if `API_KEY_REQUIRED=true`. -- **Response (200):** Statistics object with agent and message counts. - ---- - -## 5. Message Envelope Format - -All ADMP messages use a canonical JSON envelope: - -```json -{ - "version": "1.0", - "id": "550e8400-e29b-41d4-a716-446655440000", - "type": "task.request", - "from": "agent://sender", - "to": "agent://recipient", - "subject": "action_name", - "correlation_id": "c-12345", - "headers": {}, - "body": { - "key": "value" - }, - "ttl_sec": 86400, - "timestamp": "2026-02-25T12:00:00Z", - "signature": { - "alg": "ed25519", - "kid": "sender", - "sig": "base64-detached-signature" - } -} -``` - -### Required Fields - -| Field | Type | Description | -|-------|------|-------------| -| `version` | string | Must be `"1.0"`. | -| `from` | string | Sender URI. Must start with `agent://` or `did:seed:`. | -| `to` | string | Recipient URI. Must start with `agent://` or `did:seed:`. | -| `subject` | string | Action or message type name. | -| `timestamp` | string | ISO 8601 timestamp. Must be within +/- 5 minutes of server time. | - -### Optional Fields - -| Field | Type | Description | -|-------|------|-------------| -| `id` | string | Message UUID (auto-generated if omitted). | -| `type` | string | Message type for routing (e.g., `task.request`, `task.response`). | -| `correlation_id` | string | Links request/response pairs. | -| `headers` | object | Arbitrary key-value headers. | -| `body` | object | Message payload. | -| `ttl_sec` | number | Time-to-live in seconds (default: 86400 / 24 hours). | -| `signature` | object | Ed25519 message-level signature. | - -### Message Signature - -The message-level signature (`signature` field) is computed over a canonical signing base: - -``` -\n\n\n\n -``` - -This is distinct from the HTTP Signature header, which signs the HTTP request itself. - -### Message Lifecycle - -``` -queued --> delivered --> leased --> acked - | - +--> nack --> queued (requeue) - | - +--> nack --> leased (extend lease) - -queued --> expired (TTL exceeded) - -acked --> purged (ephemeral messages: body stripped, metadata preserved) -``` - -| Status | Description | -|--------|-------------| -| `queued` | Waiting in inbox, available for pull. | -| `leased` | Pulled by agent, held for `visibility_timeout` seconds. | -| `acked` | Successfully processed. | -| `expired` | TTL exceeded before processing. | -| `purged` | Body stripped (ephemeral messages). Metadata preserved. | - ---- - -## 6. Error Handling - -### Error Response Format - -```json -{ - "error": "ERROR_CODE", - "message": "Human-readable description" -} -``` - -### Error Code Reference - -| HTTP Status | Error Code | Description | Retry? | -|-------------|------------|-------------|--------| -| **400** | `REGISTRATION_FAILED` | Agent registration failed (duplicate ID, missing fields). | No -- fix request. | -| **400** | `INVALID_SIGNATURE_HEADER` | Signature header must include `keyId` and `signature`. | No -- fix header. | -| **400** | `UNSUPPORTED_ALGORITHM` | Only `ed25519` signatures are supported. | No -- use ed25519. | -| **400** | `DATE_HEADER_REQUIRED` | `Date` header must be included in signed headers or is missing from request. | No -- add Date header. | -| **400** | `INSUFFICIENT_SIGNED_HEADERS` | Signed headers must include `(request-target)`. | No -- fix signed headers. | -| **400** | `SEND_FAILED` | Message send failed (invalid envelope, missing fields). | No -- fix request. | -| **400** | `INVALID_TIMESTAMP` | Message timestamp is outside allowed window. | No -- use current time. | -| **400** | `AGENT_ID_REQUIRED` | Agent ID missing from request. | No -- provide agent ID. | -| **400** | `SEED_AND_TENANT_REQUIRED` | Key rotation requires seed and tenant_id. | No -- provide fields. | -| **401** | `API_KEY_REQUIRED` | No API key provided when required. | No -- provide key. | -| **401** | `INVALID_API_KEY` | API key is invalid, expired, or unrecognized. | No -- use valid key. | -| **401** | `SIGNATURE_INVALID` | HTTP signature verification failed (at global gate). | No -- fix signature. | -| **403** | `REGISTRATION_PENDING` | Agent registration is pending admin approval. | Yes -- wait for approval, then retry. | -| **403** | `REGISTRATION_REJECTED` | Agent registration has been rejected. | No -- contact admin. | -| **403** | `REQUEST_EXPIRED` | Date header is outside +/- 5 minute window. | Yes -- use current timestamp. | -| **403** | `SIGNATURE_INVALID` | HTTP signature verification failed (at route middleware). | No -- fix signature. | -| **403** | `FORBIDDEN` | Signature keyId does not match target agent, or access denied. | No -- use correct agent. | -| **403** | `ENROLLMENT_TOKEN_USED` | Single-use enrollment token already consumed. | No -- request new token. | -| **403** | `ENROLLMENT_TOKEN_SCOPE` | Enrollment token is scoped to a different agent. | No -- use correct token. | -| **403** | `SEED_MISMATCH` | Provided seed does not derive the agent's current key. | No -- use correct seed. | -| **404** | `AGENT_NOT_FOUND` | Agent ID not found. | No -- verify agent exists. | -| **404** | `MESSAGE_NOT_FOUND` | Message ID not found. | No -- verify message ID. | -| **404** | `RECIPIENT_NOT_FOUND` | Message recipient not found. | No -- verify recipient exists. | -| **404** | `GROUP_NOT_FOUND` | Group not found. | No -- verify group ID. | -| **409** | `TENANT_EXISTS` | Tenant already exists. | No -- use different tenant_id. | -| **410** | `MESSAGE_EXPIRED` | Message has been purged (ephemeral or TTL expired). Body is null; metadata may still be available. | No -- message is gone. | -| **500** | `INTERNAL_ERROR` | Unexpected server error. | Yes -- with backoff. | - ---- - -## 7. Registration Modes - -### Legacy (Default) - -The server generates a random Ed25519 keypair and returns both `public_key` and `secret_key`. - -```bash -curl -X POST .../api/agents/register \ - -H "Content-Type: application/json" \ - -d '{ "agent_type": "assistant" }' -``` - -Response includes `"registration_mode": "legacy"` and `"secret_key": "base64..."`. - -### Seed-Based (Deterministic) - -The client provides a base64-encoded 32-byte seed and a `tenant_id`. The server derives a deterministic keypair via HKDF-SHA256. - -Derivation context: `seedid/v1/admp:::ed25519:v` - -```bash -curl -X POST .../api/agents/register \ - -H "Content-Type: application/json" \ - -d '{ - "agent_id": "agent://my-agent", - "agent_type": "assistant", - "seed": "BASE64_32_BYTE_SEED", - "tenant_id": "my-tenant" - }' -``` - -Response includes `"registration_mode": "seed"` and `"secret_key": "base64..."`. - -Key rotation is supported for seed-based agents via `POST /api/agents/:agentId/rotate-key`. Each rotation increments the `key_version` and derives a new keypair from the same seed. Previous keys remain valid for 24 hours. - -### Import (Client-Provided Key) - -The client generates its own Ed25519 keypair and provides only the `public_key`. The server never sees the private key. - -```bash -curl -X POST .../api/agents/register \ - -H "Content-Type: application/json" \ - -d '{ - "agent_id": "agent://my-agent", - "agent_type": "assistant", - "public_key": "BASE64_ED25519_PUBLIC_KEY" - }' -``` - -Response includes `"registration_mode": "import"`. No `secret_key` is returned. - ---- - -## 8. Approval Workflow - -### Registration Policy - -Controlled by the `REGISTRATION_POLICY` environment variable: - -| Value | Behavior | -|-------|----------| -| `open` (default) | Agents are auto-approved on registration. | -| `approval_required` | Agents are created with `registration_status: "pending"`. They cannot authenticate or send/receive messages until an admin approves them. | - -### Tenant-Level Override - -Each tenant can have its own `registration_policy` that overrides the global setting. Set when creating a tenant: - -```bash -curl -X POST .../api/agents/tenants \ - -H "Content-Type: application/json" \ - -H "X-Api-Key: MASTER_KEY" \ - -d '{ - "tenant_id": "secure-org", - "registration_policy": "approval_required" - }' -``` - -### Admin Operations - -All require the master API key. - -**List pending agents for a tenant:** - -```bash -curl .../api/agents/tenants/secure-org/pending \ - -H "X-Api-Key: MASTER_KEY" -``` - -**Approve an agent:** - -```bash -curl -X POST .../api/agents/agent%3A%2F%2Fmy-agent/approve \ - -H "X-Api-Key: MASTER_KEY" -``` - -**Reject an agent (with reason):** - -```bash -curl -X POST .../api/agents/agent%3A%2F%2Fmy-agent/reject \ - -H "Content-Type: application/json" \ - -H "X-Api-Key: MASTER_KEY" \ - -d '{ "reason": "Not authorized for this tenant" }' -``` - -### Pending Agent Behavior - -A pending agent: -- Exists in storage with its keypair. -- Receives `403 REGISTRATION_PENDING` on any authenticated request. -- Cannot send or receive messages. -- Cannot access any agent-scoped endpoints. - -A rejected agent: -- Receives `403 REGISTRATION_REJECTED` on any authenticated request. -- An admin can re-approve a previously rejected agent. - ---- - -## 9. Best Practices +## 10. Best Practices ### Security -- **Always sign both `(request-target)` and `date` headers.** The server rejects signatures missing either one. -- **Keep your clock synchronized.** The Date header must be within +/- 5 minutes of server time. Use NTP. -- **Store secret keys securely.** For legacy registration, the `secret_key` is only returned once. Losing it means re-registration. -- **Prefer import mode in production.** Generate your keypair locally and only share the public key. The server never sees your private key. -- **Use enrollment tokens for automated provisioning.** Single-use API keys scoped to specific agents can be issued by your ADMP operator (admin function). - -### Messaging Patterns - -- **Use `correlation_id` for request-response pairs.** When sending a task, include a `correlation_id`. Replies automatically set `correlation_id` to the original message ID. -- **Set `visibility_timeout` on pull to match your processing time.** Default is 60 seconds. If processing takes longer, use a higher value or `nack` with `extend_sec` to extend the lease. -- **Use ephemeral messages for sensitive data.** Set `ephemeral: true` on send. The body is purged after ack, but delivery metadata is preserved for auditing. -- **Set `ttl_sec` on time-sensitive messages.** Expired messages are automatically cleaned up by the background job. +- **Never transmit the secret key.** It is used only for local signing. The server stores only the public key. +- **Store the config file with mode 0600.** The `admp` CLI does this automatically via `saveConfig()`. +- **Use HTTPS in production.** The Fly.io deployment forces HTTPS. For local dev, be aware that seed-based registration sends the seed over the wire. +- **Set `DID_WEB_ALLOWED_DOMAINS`** if using DID:web federation to prevent SSRF and unauthorized domain federation. +- **Use `ADMP_SEED` instead of `--seed`** to avoid the seed appearing in shell history and `ps` output. ### Reliability -- **Implement exponential backoff on transient errors.** Retry on `500 INTERNAL_ERROR` and `403 REQUEST_EXPIRED` (fix timestamp first). Do not retry on `400` or `404`. -- **Handle 204 on pull.** An empty inbox returns `204 No Content` with no body. -- **Ack or nack every leased message.** Unacknowledged leases are automatically reclaimed after the `visibility_timeout` expires and the message is requeued. -- **Use the `attempts` field** to detect messages that are repeatedly failing processing. +- **Always ack or nack within the lease window.** Default lease is 60 seconds (`visibility_timeout`). If you do not ack within the window, the lease expires and the message returns to `queued` status for redelivery. +- **Handle `REGISTRATION_PENDING` gracefully.** If your environment uses `approval_required`, implement a startup polling loop or a deferred retry before the agent begins processing. +- **Use `ephemeral: true` for sensitive payloads.** Ephemeral messages have their body permanently deleted on ack. Use `ttl` for time-sensitive secrets. +- **Use correlation IDs for request-response.** Set `correlation_id` on messages you send; use the `/reply` endpoint to send a correlated response. -### Operational +### Performance -- **Send heartbeats regularly.** The default timeout is 5 minutes. If no heartbeat is received within the timeout, the agent is marked offline. -- **Use groups for multi-party communication.** Instead of sending N individual messages, create a group and post once. -- **Monitor with `GET /api/agents/:id/inbox/stats`.** Track queued, leased, and acked message counts. -- **Use the trust list** to restrict which agents can send messages to your inbox. When the trusted agents list is non-empty, only those agents can deliver messages. -- **Configure webhooks for real-time delivery.** Instead of polling, set a `webhook_url` to receive messages pushed to your HTTP endpoint. Webhook delivery retries with exponential backoff on failure, and messages remain in the queue as a fallback. +- **Pull in a loop** with a reasonable `visibility_timeout` (30-60s) to avoid re-processing. +- **Use webhooks** (`POST /api/agents/:id/webhook`) for push-based delivery if your agent has a public endpoint. This eliminates polling latency. +- **Send heartbeats** (`POST /api/agents/:id/heartbeat`) at your configured interval (default 60s) to maintain `online` status. diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index 5cc8571..c90eb7e 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -1,4 +1,4 @@ - + # Agent Dispatch (ADMP) API Reference @@ -17,181 +17,60 @@ ## Table of Contents - [Authentication](#authentication) - - [API Key Authentication](#api-key-authentication) - - [HTTP Signature Authentication](#http-signature-authentication) - - [Master API Key Authentication](#master-api-key-authentication) - [System](#system) - - [GET /health](#get-health) - - [GET /api/stats](#get-apistats) - - [GET /openapi.json](#get-openapijson) - - [GET /docs](#get-docs) -- [Agent Registration & Management](#agent-registration--management) - - [POST /api/agents/register](#post-apiagentsregister) - - [GET /api/agents/:agentId](#get-apiagentsagentid) - - [DELETE /api/agents/:agentId](#delete-apiagentsagentid) - - [POST /api/agents/:agentId/heartbeat](#post-apiagentsagentidheartbeat) - - [POST /api/agents/:agentId/rotate-key](#post-apiagentsagentidrotate-key) +- [Agent Registration and Management](#agent-registration-and-management) - [Trust Management](#trust-management) - - [GET /api/agents/:agentId/trusted](#get-apiagentsagentidtrusted) - - [POST /api/agents/:agentId/trusted](#post-apiagentsagentidtrusted) - - [DELETE /api/agents/:agentId/trusted/:trustedAgentId](#delete-apiagentsagentidtrustedtrustedagentid) - [Webhook Configuration](#webhook-configuration) - - [POST /api/agents/:agentId/webhook](#post-apiagentsagentidwebhook) - - [GET /api/agents/:agentId/webhook](#get-apiagentsagentidwebhook) - - [DELETE /api/agents/:agentId/webhook](#delete-apiagentsagentidwebhook) - [Identity Verification](#identity-verification) - - [POST /api/agents/:agentId/verify/github](#post-apiagentsagentidverifygithub) - - [POST /api/agents/:agentId/verify/cryptographic](#post-apiagentsagentidverifycryptographic) - - [GET /api/agents/:agentId/identity](#get-apiagentsagentididentity) -- [Messaging (Inbox)](#messaging-inbox) - - [POST /api/agents/:agentId/messages](#post-apiagentsagentidmessages) - - [POST /api/agents/:agentId/inbox/pull](#post-apiagentsagentidinboxpull) - - [POST /api/agents/:agentId/messages/:messageId/ack](#post-apiagentsagentidmessagesmessageidack) - - [POST /api/agents/:agentId/messages/:messageId/nack](#post-apiagentsagentidmessagesmessageidnack) - - [POST /api/agents/:agentId/messages/:messageId/reply](#post-apiagentsagentidmessagesmessageidreply) - - [GET /api/messages/:messageId/status](#get-apimessagesmessageidstatus) - - [GET /api/agents/:agentId/inbox/stats](#get-apiagentsagentidinboxstats) - - [POST /api/agents/:agentId/inbox/reclaim](#post-apiagentsagentidinboxreclaim) +- [Key Rotation](#key-rotation) +- [Inbox: Message Operations](#inbox-message-operations) - [Groups](#groups) - - [POST /api/groups](#post-apigroups) - - [GET /api/groups/:groupId](#get-apigroupsgroupid) - - [PUT /api/groups/:groupId](#put-apigroupsgroupid) - - [DELETE /api/groups/:groupId](#delete-apigroupsgroupid) - - [GET /api/groups/:groupId/members](#get-apigroupsgroupidmembers) - - [POST /api/groups/:groupId/members](#post-apigroupsgroupidmembers) - - [DELETE /api/groups/:groupId/members/:agentId](#delete-apigroupsgroupidmembersagentid) - - [POST /api/groups/:groupId/join](#post-apigroupsgroupidjoin) - - [POST /api/groups/:groupId/leave](#post-apigroupsgroupidleave) - - [POST /api/groups/:groupId/messages](#post-apigroupsgroupidmessages) - - [GET /api/groups/:groupId/messages](#get-apigroupsgroupidmessages) - - [GET /api/agents/:agentId/groups](#get-apiagentsagentidgroups) -- [Outbox (Email via Mailgun)](#outbox-email-via-mailgun) - - [POST /api/agents/:agentId/outbox/domain](#post-apiagentsagentidoutboxdomain) - - [GET /api/agents/:agentId/outbox/domain](#get-apiagentsagentidoutboxdomain) - - [POST /api/agents/:agentId/outbox/domain/verify](#post-apiagentsagentidoutboxdomainverify) - - [DELETE /api/agents/:agentId/outbox/domain](#delete-apiagentsagentidoutboxdomain) - - [POST /api/agents/:agentId/outbox/send](#post-apiagentsagentidoutboxsend) - - [GET /api/agents/:agentId/outbox/messages](#get-apiagentsagentidoutboxmessages) - - [GET /api/agents/:agentId/outbox/messages/:messageId](#get-apiagentsagentidoutboxmessagesmessageid) - - [POST /api/webhooks/mailgun](#post-apiwebhooksmailgun) +- [Outbox (Email)](#outbox-email) +- [Tenants](#tenants) +- [Admin: Approval Workflow](#admin-approval-workflow) - [Discovery](#discovery) - - [GET /.well-known/agent-keys.json](#get-well-knownagent-keysjson) - - [GET /api/agents/:agentId/did.json](#get-apiagentsagentiddidjson) -- [Tenant Management](#tenant-management) - - [POST /api/agents/tenants](#post-apiagentstenants) - - [GET /api/agents/tenants/:tenantId](#get-apiagentstenantstenantid) - - [GET /api/agents/tenants/:tenantId/agents](#get-apiagentstenantstenantidagents) - - [DELETE /api/agents/tenants/:tenantId](#delete-apiagentstenantstenantid) -- [Approval Workflow (Admin)](#approval-workflow-admin) - - [GET /api/agents/tenants/:tenantId/pending](#get-apiagentstenantstenantidpending) - - [POST /api/agents/:agentId/approve](#post-apiagentsagentidapprove) - - [POST /api/agents/:agentId/reject](#post-apiagentsagentidreject) +- [Stats](#stats) --- ## Authentication -ADMP supports three authentication mechanisms. Requests to `/api/*` endpoints (except agent registration) must authenticate via one of these methods. - ### API Key Authentication -Provide an API key via the `X-Api-Key` header or `Authorization: Bearer ` header. API key enforcement is controlled by the `API_KEY_REQUIRED` environment variable. - -``` -X-Api-Key: admp_abc123... -``` - -or +Include in one of: ``` -Authorization: Bearer admp_abc123... +X-Api-Key: +Authorization: Bearer ``` -**Key types:** - -| Type | Description | -|---|---| -| Master key | Set via `MASTER_API_KEY` env var. Full admin access. | -**Single-use enrollment tokens:** Keys with `single_use: true` are burned (marked used) on first successful authentication. Tokens with `target_agent_id` are scoped to only authenticate requests for that specific agent's endpoints. - ### HTTP Signature Authentication -Agents authenticate using Ed25519 cryptographic signatures. When a `Signature` header is present on a request, it is verified against the agent's registered public key. If verification succeeds, the API key requirement is bypassed. - -**How to construct an HTTP Signature:** - -**Step 1:** Create the canonical signing string from the headers you intend to sign. The `(request-target)` pseudo-header and the `date` header are mandatory. +Used for agent-scoped endpoints (pull, ack, nack, reply, heartbeat, etc.). ``` -(request-target): post /api/agents/agent-123/heartbeat -host: agentdispatch.fly.dev -date: Thu, 20 Feb 2026 12:00:00 GMT +Signature: keyId="",algorithm="ed25519",headers="(request-target) host date",signature="" +Date: ``` -**Step 2:** Sign the string with the agent's Ed25519 private key using `nacl.sign.detached()`. - -```javascript -import nacl from 'tweetnacl'; - -const signingString = [ - `(request-target): post /api/agents/agent-123/heartbeat`, - `host: agentdispatch.fly.dev`, - `date: ${new Date().toUTCString()}` -].join('\n'); - -const message = Buffer.from(signingString, 'utf8'); -const signature = nacl.sign.detached(message, secretKeyBytes); +**Signing string format:** +``` +(request-target): +host: +date: ``` -**Step 3:** Base64-encode the raw signature bytes. +Lines joined with `\n`. Date must be within +/- 5 minutes of server time. -```javascript -const sigBase64 = Buffer.from(signature).toString('base64'); -``` +**Cross-agent send exception:** `POST /api/agents/:id/messages` accepts any registered agent's HTTP Signature — the signing agent does not have to match the `:agentId` URL parameter. -**Step 4:** Build the `Signature` header value: +### Master API Key Authentication ``` -Signature: keyId="agent-123",algorithm="ed25519",headers="(request-target) host date",signature="" +X-Api-Key: ``` -**Requirements:** - -| Requirement | Details | -|---|---| -| `(request-target)` | MUST be included in the signed headers list. Binds the signature to the specific HTTP method and path. | -| `date` | MUST be included in the signed headers list. Enables replay protection. | -| Date freshness | The `Date` header value must be within +/- 5 minutes of the server's clock. | -| `keyId` | Must match the agent ID in the URL path, or be a valid DID (`did:seed:*` or `did:web:*`). | -| Algorithm | Only `ed25519` is supported. If `algorithm` is specified, it must be `ed25519`. | -| Agent-URL binding | The signing agent's ID must match the target agent in the URL path (prevents Agent A from accessing Agent B's resources). | - -**Signature verification errors:** - -| HTTP Status | Error Code | Cause | -|---|---|---| -| 400 | `INVALID_SIGNATURE_HEADER` | Missing `keyId` or `signature` in header | -| 400 | `UNSUPPORTED_ALGORITHM` | Algorithm is not `ed25519` | -| 400 | `INSUFFICIENT_SIGNED_HEADERS` | `(request-target)` not in signed headers | -| 400 | `DATE_HEADER_REQUIRED` | `date` not in signed headers or Date header missing | -| 403 | `REQUEST_EXPIRED` | Date header outside +/- 5 minute window | -| 403 | `SIGNATURE_INVALID` | Cryptographic verification failed | -| 403 | `FORBIDDEN` | Signature keyId does not match target agent | -| 403 | `REGISTRATION_PENDING` | Agent registration awaiting approval | -| 403 | `REGISTRATION_REJECTED` | Agent registration was rejected | -| 404 | `AGENT_NOT_FOUND` | No agent found for the given keyId | - -**DID-based keyId resolution:** - -The `keyId` field supports three formats: - -1. **Plain agent ID**: `keyId="my-agent"` -- looks up the agent directly. -2. **did:seed**: `keyId="did:seed:..."` -- resolves agent by DID seed. -3. **did:web**: `keyId="did:web:example.com"` -- fetches the DID document from `https://example.com/.well-known/did.json`, extracts Ed25519 keys, and creates/reuses a shadow agent record. DID documents are cached in-process for 5 minutes. - -### Master API Key Authentication - -Administrative endpoints (key issuance, agent approval/rejection, pending agent listing) require the master API key. The master key is set via the `MASTER_API_KEY` environment variable. Provide it via `X-Api-Key` or `Authorization: Bearer `. +Required for admin endpoints: approve/reject agents, list pending agents. --- @@ -199,367 +78,187 @@ Administrative endpoints (key issuance, agent approval/rejection, pending agent ### GET /health -Health check endpoint. No authentication required. - -**Request:** - -``` -GET /health -``` - -**Response: `200 OK`** +Health check. No authentication required. +**Response 200:** ```json { "status": "healthy", - "timestamp": "2026-02-25T12:00:00.000Z", + "timestamp": "2026-02-26T00:00:00.000Z", "version": "1.0.0" } ``` --- -### GET /api/stats - -Returns storage-level statistics (agent count, message count, etc.). - -**Authentication:** API key (when `API_KEY_REQUIRED=true`) - -**Request:** - -``` -GET /api/stats -X-Api-Key: -``` - -**Response: `200 OK`** - -```json -{ - "agents": 42, - "messages": 1500, - "groups": 8 -} -``` - -**Error Responses:** +### GET /docs -| Status | Error Code | Description | -|---|---|---| -| 500 | `STATS_FAILED` | Internal error retrieving statistics | +Swagger UI. No authentication required. --- ### GET /openapi.json -Returns the full OpenAPI 3.1 specification as JSON. No authentication required. - -**Request:** - -``` -GET /openapi.json -``` - -**Response: `200 OK`** - -The OpenAPI specification document in JSON format. +Raw OpenAPI specification. No authentication required. --- -### GET /docs +### GET /api/stats -Serves the Swagger UI documentation page. No authentication required. +System-wide statistics. -**Request:** +**Auth:** API Key +**Response 200:** +```json +{ + "agents": 42, + "messages": 1337, + "groups": 5 +} ``` -GET /docs -``` - -**Response: `200 OK`** - -An interactive Swagger UI HTML page for exploring the API. --- -## Agent Registration & Management +## Agent Registration and Management ### POST /api/agents/register -Register a new agent. This endpoint is exempt from API key authentication. - -Three registration modes are supported depending on which parameters are provided: - -| Mode | Parameters | Behavior | -|---|---|---| -| **Legacy** (default) | Neither `seed` nor `public_key` | Server generates a random Ed25519 keypair. Returns `secret_key`. | -| **Seed-based** | `seed` + `tenant_id` | Deterministic keypair derived via HKDF from the seed. Returns `secret_key`. | -| **Import** | `public_key` | Client retains private key. `secret_key` is NOT returned. | - -**Request:** +Register a new agent. No authentication required. -``` -POST /api/agents/register -Content-Type: application/json -``` - -```json -{ - "agent_id": "my-agent", - "agent_type": "worker", - "metadata": { "purpose": "data-processing" }, - "webhook_url": "https://example.com/webhook", - "webhook_secret": "my-secret", - "seed": "base64-encoded-32-byte-seed", - "public_key": "base64-encoded-ed25519-public-key", - "tenant_id": "acme-corp" -} -``` +**Request body:** | Field | Type | Required | Description | -|---|---|---|---| -| `agent_id` | string | No | Unique agent identifier. Auto-generated if omitted. | -| `agent_type` | string | No | Agent classification (e.g., `worker`, `supervisor`). | -| `metadata` | object | No | Arbitrary metadata attached to the agent. | -| `webhook_url` | string | No | URL for push-based message delivery. | -| `webhook_secret` | string | No | Secret for webhook signature verification. | -| `seed` | string | No | Base64-encoded 32-byte seed for deterministic key derivation. Requires `tenant_id`. | -| `public_key` | string | No | Base64-encoded Ed25519 public key for import mode. | -| `tenant_id` | string | No | Tenant namespace for the agent. Required with `seed`. | - -**Response: `201 Created`** +|-------|------|----------|-------------| +| `agent_id` | string | No | Custom agent ID. Must match `^[a-zA-Z0-9._\-:]+$`. If omitted, server generates `agent-`. | +| `agent_type` | string | No | Agent type label (e.g., `claude_session`). Default: `generic`. | +| `metadata` | object | No | Arbitrary metadata. | +| `webhook_url` | string | No | URL for push delivery of incoming messages. | +| `webhook_secret` | string | No | HMAC secret for webhook verification. Auto-generated if `webhook_url` is set and this is omitted. | +| `seed` | string | No | Base64-encoded 32-byte seed for deterministic keypair derivation. Requires `tenant_id`. | +| `public_key` | string | No | Base64-encoded Ed25519 public key (import mode). If provided, `seed` is ignored and no `secret_key` is returned. | +| `tenant_id` | string | No | Tenant identifier. Required for seed-based registration. | + +**Registration modes:** +- **Legacy** (no `seed`, no `public_key`): Server generates random Ed25519 keypair. Returns `secret_key`. +- **Seed-based** (`seed` + `tenant_id`): Server derives keypair via HKDF-SHA256. Returns `secret_key`. Deterministic — same seed + tenant + agent ID always yields the same keypair. +- **Import** (`public_key`): Client provides public key. No `secret_key` returned. +**Response 201:** ```json { "agent_id": "my-agent", - "agent_type": "worker", - "public_key": "base64-encoded-public-key", + "agent_type": "generic", + "public_key": "base64-ed25519-public-key", "did": "did:seed:...", "registration_mode": "legacy", "registration_status": "approved", "key_version": 1, "verification_tier": "unverified", "tenant_id": null, - "webhook_url": "https://example.com/webhook", - "webhook_secret": "my-secret", + "webhook_url": null, + "webhook_secret": null, "heartbeat": { - "last_heartbeat": 1740489600000, + "last_heartbeat": 1740000000000, "status": "online", "interval_ms": 60000, "timeout_ms": 300000 }, - "secret_key": "base64-encoded-secret-key" + "secret_key": "base64-64-byte-ed25519-secret-key" } ``` -Note: `secret_key` is only included for legacy and seed-based registration modes. - -**Error Responses:** +`secret_key` is only present for legacy and seed-based registration. It is never stored server-side — save it immediately. -| Status | Error Code | Description | -|---|---|---| -| 400 | `REGISTRATION_FAILED` | Registration error (e.g., duplicate agent_id, invalid parameters) | +**Response 400:** +```json +{"error": "REGISTRATION_FAILED", "message": "agent_id may only contain letters, numbers, dots, underscores, hyphens, and colons"} +``` --- ### GET /api/agents/:agentId -Retrieve agent details. The `secret_key` field is never included in the response. +Get agent details. -**Authentication:** HTTP Signature +**Auth:** HTTP Signature (must be the agent itself) -**Request Headers:** - -``` -GET /api/agents/my-agent -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` +**Path parameters:** +- `agentId` — Agent ID -**Response: `200 OK`** +**Response 200:** Agent record (secret_key excluded) ```json { "agent_id": "my-agent", - "agent_type": "worker", - "public_key": "base64-encoded-public-key", + "agent_type": "generic", + "public_key": "base64...", "did": "did:seed:...", "registration_mode": "legacy", "registration_status": "approved", "key_version": 1, "verification_tier": "unverified", "tenant_id": null, - "webhook_url": "https://example.com/webhook", - "webhook_secret": "my-secret", - "heartbeat": { - "last_heartbeat": 1740489600000, - "status": "online", - "interval_ms": 60000, - "timeout_ms": 300000 - }, + "webhook_url": null, + "heartbeat": {"last_heartbeat": 1740000000000, "status": "online", "interval_ms": 60000, "timeout_ms": 300000}, "trusted_agents": [], - "blocked_agents": [], "metadata": {} } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 404 | `AGENT_NOT_FOUND` | Agent does not exist | - --- ### DELETE /api/agents/:agentId -Deregister (delete) an agent and all associated data. +Deregister and permanently delete an agent. -**Authentication:** HTTP Signature +**Auth:** HTTP Signature (must be the agent itself) -**Request Headers:** +**Response 204:** No content +**Response 400:** +```json +{"error": "DEREGISTER_FAILED", "message": "..."} ``` -DELETE /api/agents/my-agent -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` - -**Response: `204 No Content`** - -No response body. - -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `DEREGISTER_FAILED` | Deregistration error | --- ### POST /api/agents/:agentId/heartbeat -Update agent heartbeat to indicate the agent is still active. The server uses heartbeats to mark agents as offline after a configurable timeout. - -**Authentication:** HTTP Signature - -**Request Headers:** - -``` -POST /api/agents/my-agent/heartbeat -Content-Type: application/json -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` +Update agent heartbeat (liveness signal). -**Request Body (optional):** +**Auth:** HTTP Signature (must be the agent itself) -```json -{ - "metadata": { "cpu": 0.45, "queue_depth": 12 } -} -``` +**Request body:** | Field | Type | Required | Description | -|---|---|---|---| -| `metadata` | object | No | Arbitrary metadata to attach to the heartbeat | - -**Response: `200 OK`** +|-------|------|----------|-------------| +| `metadata` | object | No | Metadata to merge into agent record | +**Response 200:** ```json { "ok": true, - "last_heartbeat": 1740489600000, - "timeout_at": 1740489900000, + "last_heartbeat": 1740000000000, + "timeout_at": 1740000300000, "status": "online" } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `HEARTBEAT_FAILED` | Heartbeat update error | - ---- - -### POST /api/agents/:agentId/rotate-key - -Rotate the Ed25519 keypair for a seed-based agent. Derives a new keypair at the next version using the same seed and HKDF. - -**Authentication:** HTTP Signature - -**Request Headers:** - -``` -POST /api/agents/my-agent/rotate-key -Content-Type: application/json -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` - -**Request Body:** - -```json -{ - "seed": "base64-encoded-32-byte-seed", - "tenant_id": "acme-corp" -} -``` - -| Field | Type | Required | Description | -|---|---|---|---| -| `seed` | string | Yes | Base64-encoded seed that was used during registration | -| `tenant_id` | string | Yes | Tenant ID that was used during registration | - -**Response: `200 OK`** - -```json -{ - "agent_id": "my-agent", - "public_key": "new-base64-encoded-public-key", - "did": "did:seed:...", - "key_version": 2, - "secret_key": "new-base64-encoded-secret-key" -} -``` - -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `SEED_AND_TENANT_REQUIRED` | Both `seed` and `tenant_id` must be provided | -| 400 | `KEY_ROTATION_FAILED` | Rotation error (e.g., agent is not seed-based) | -| 403 | `SEED_MISMATCH` | Provided seed does not derive a key matching the agent's current public key | - --- ## Trust Management ### GET /api/agents/:agentId/trusted -List agents that this agent trusts. - -**Authentication:** HTTP Signature - -**Request Headers:** - -``` -GET /api/agents/my-agent/trusted -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` +List agents trusted by this agent. -**Response: `200 OK`** +**Auth:** HTTP Signature (must be the agent itself) +**Response 200:** ```json { - "trusted_agents": ["agent-alpha", "agent-beta"] + "trusted_agents": ["agent-a", "agent-b"] } ``` @@ -567,151 +266,78 @@ Host: agentdispatch.fly.dev ### POST /api/agents/:agentId/trusted -Add an agent to the trusted list. - -**Authentication:** HTTP Signature - -**Request Headers:** - -``` -POST /api/agents/my-agent/trusted -Content-Type: application/json -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` +Add an agent to the trusted list. Messages from untrusted agents are rejected when the trusted list is non-empty. -**Request Body:** +**Auth:** HTTP Signature (must be the agent itself) -```json -{ - "agent_id": "agent-alpha" -} -``` +**Request body:** | Field | Type | Required | Description | -|---|---|---|---| -| `agent_id` | string | Yes | The agent ID to add to the trusted list | - -**Response: `200 OK`** +|-------|------|----------|-------------| +| `agent_id` | string | Yes | Agent ID to trust | +**Response 200:** ```json { - "trusted_agents": ["agent-alpha"] + "trusted_agents": ["agent-a", "agent-b", "new-agent"] } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `AGENT_ID_REQUIRED` | `agent_id` field is missing | -| 400 | `ADD_TRUSTED_FAILED` | Failed to add trusted agent | - --- ### DELETE /api/agents/:agentId/trusted/:trustedAgentId Remove an agent from the trusted list. -**Authentication:** HTTP Signature - -**Request Headers:** - -``` -DELETE /api/agents/my-agent/trusted/agent-alpha -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` - -**Response: `200 OK`** +**Auth:** HTTP Signature (must be the agent itself) +**Response 200:** ```json { - "trusted_agents": [] + "trusted_agents": ["agent-a"] } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `REMOVE_TRUSTED_FAILED` | Failed to remove trusted agent | - --- ## Webhook Configuration ### POST /api/agents/:agentId/webhook -Configure a webhook URL for push-based message delivery. When a message arrives in the agent's inbox, ADMP will POST the message envelope to this URL. - -**Authentication:** HTTP Signature - -**Request Headers:** - -``` -POST /api/agents/my-agent/webhook -Content-Type: application/json -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` +Configure a push delivery webhook. When set, the server POSTs each incoming message to this URL (fire-and-forget; messages remain in inbox for pull as fallback). -**Request Body:** +**Auth:** HTTP Signature (must be the agent itself) -```json -{ - "webhook_url": "https://example.com/agent-webhook", - "webhook_secret": "optional-shared-secret" -} -``` +**Request body:** | Field | Type | Required | Description | -|---|---|---|---| -| `webhook_url` | string | Yes | HTTPS URL to receive message deliveries | -| `webhook_secret` | string | No | Shared secret for HMAC signature verification of webhook payloads | - -**Response: `200 OK`** +|-------|------|----------|-------------| +| `webhook_url` | string | Yes | Webhook endpoint URL | +| `webhook_secret` | string | No | HMAC-SHA256 secret for payload verification. Auto-generated if omitted. | +**Response 200:** ```json { "agent_id": "my-agent", - "webhook_url": "https://example.com/agent-webhook", - "webhook_secret": "optional-shared-secret" + "webhook_url": "https://example.com/webhook", + "webhook_secret": "auto-generated-or-provided-secret" } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `WEBHOOK_URL_REQUIRED` | `webhook_url` field is missing | -| 400 | `WEBHOOK_CONFIG_FAILED` | Failed to configure webhook | +Webhook delivery includes headers: `X-ADMP-Event`, `X-ADMP-Message-ID`, `X-ADMP-Delivery-Attempt`. --- ### GET /api/agents/:agentId/webhook -Get the current webhook configuration for an agent. +Get webhook configuration (secret not included). -**Authentication:** HTTP Signature - -**Request Headers:** - -``` -GET /api/agents/my-agent/webhook -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` - -**Response: `200 OK`** +**Auth:** HTTP Signature (must be the agent itself) +**Response 200:** ```json { - "webhook_url": "https://example.com/agent-webhook", + "webhook_url": "https://example.com/webhook", "webhook_configured": true } ``` @@ -720,21 +346,11 @@ Host: agentdispatch.fly.dev ### DELETE /api/agents/:agentId/webhook -Remove the webhook configuration for an agent. - -**Authentication:** HTTP Signature - -**Request Headers:** +Remove webhook configuration. -``` -DELETE /api/agents/my-agent/webhook -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` - -**Response: `200 OK`** +**Auth:** HTTP Signature (must be the agent itself) +**Response 200:** ```json { "message": "Webhook removed", @@ -746,38 +362,19 @@ Host: agentdispatch.fly.dev ## Identity Verification -ADMP supports progressive identity verification tiers. Agents start as `unverified` and can upgrade by linking external identities or proving cryptographic key ownership. - ### POST /api/agents/:agentId/verify/github -Link a GitHub handle to the agent, upgrading the agent's verification tier. - -**Authentication:** HTTP Signature - -**Request Headers:** - -``` -POST /api/agents/my-agent/verify/github -Content-Type: application/json -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` +Link a GitHub handle to the agent. Upgrades `verification_tier` to `github`. -**Request Body:** +**Auth:** HTTP Signature (must be the agent itself) -```json -{ - "github_handle": "octocat" -} -``` +**Request body:** | Field | Type | Required | Description | -|---|---|---|---| -| `github_handle` | string | Yes | The GitHub username to link | - -**Response: `200 OK`** +|-------|------|----------|-------------| +| `github_handle` | string | Yes | GitHub username | +**Response 200:** ```json { "agent_id": "my-agent", @@ -786,31 +383,15 @@ Host: agentdispatch.fly.dev } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `GITHUB_LINK_FAILED` | Failed to link GitHub handle | - --- ### POST /api/agents/:agentId/verify/cryptographic -Upgrade the agent to cryptographic verification tier. Confirms that the agent controls the private key corresponding to its registered public key. - -**Authentication:** HTTP Signature - -**Request Headers:** - -``` -POST /api/agents/my-agent/verify/cryptographic -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` +Confirm cryptographic verification tier (requires seed-based registration with DID). -**Response: `200 OK`** +**Auth:** HTTP Signature (must be the agent itself) +**Response 200:** ```json { "agent_id": "my-agent", @@ -819,383 +400,272 @@ Host: agentdispatch.fly.dev } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `CRYPTOGRAPHIC_VERIFY_FAILED` | Verification failed | - --- ### GET /api/agents/:agentId/identity -Get the full identity and verification status for an agent. +Get verification status and identity details. -**Authentication:** HTTP Signature - -**Request Headers:** - -``` -GET /api/agents/my-agent/identity -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` - -**Response: `200 OK`** +**Auth:** HTTP Signature (must be the agent itself) +**Response 200:** ```json { "agent_id": "my-agent", - "verification_tier": "cryptographic", - "did": "did:seed:...", - "github_handle": "octocat" + "verification_tier": "github", + "github_handle": "octocat", + "did": "did:seed:..." } ``` --- -## Messaging (Inbox) +## Key Rotation -The ADMP inbox provides at-least-once delivery with lease-based processing. Messages follow the lifecycle: `queued` -> `delivered` -> `leased` -> `acked`. A `nack` returns the message to `queued` for reprocessing. - -### POST /api/agents/:agentId/messages +### POST /api/agents/:agentId/rotate-key -Send a message to an agent's inbox. This is the primary endpoint for inter-agent communication. +Rotate the Ed25519 signing key. Only supported for seed-based agents (`registration_mode: "seed"`). -**Authentication:** API key (when enforcement is enabled). Message-level Ed25519 signatures in the envelope body are optional but verified if present. +The old key remains valid for 24 hours during the rotation window so in-flight messages still verify. -**Request Headers:** +**Auth:** HTTP Signature (must be the agent itself, signed with current key) -``` -POST /api/agents/recipient-agent/messages -Content-Type: application/json -X-Api-Key: -``` +**Request body:** -**Request Body:** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `seed` | string | Yes | Base64-encoded master seed (must derive the current public key) | +| `tenant_id` | string | Yes | Tenant ID used in key derivation | +**Response 200:** ```json { - "version": "1.0", - "type": "task.request", - "from": "sender-agent", - "to": "recipient-agent", - "subject": "process_data", - "correlation_id": "corr-abc-123", - "headers": { - "priority": "high" - }, - "body": { - "dataset": "users", - "action": "export" - }, - "ttl_sec": 86400, - "timestamp": "2026-02-25T12:00:00Z", - "signature": { - "alg": "ed25519", - "kid": "sender-agent", - "sig": "base64-encoded-signature" - }, - "ephemeral": false, - "ttl": 3600 + "agent_id": "my-agent", + "public_key": "new-base64-public-key", + "did": "new-did:seed:...", + "key_version": 2, + "secret_key": "new-base64-secret-key" } ``` -| Field | Type | Required | Description | -|---|---|---|---| -| `version` | string | No | Protocol version (default `"1.0"`) | -| `type` | string | No | Message type (e.g., `task.request`, `task.response`) | -| `from` | string | Yes | Sender agent ID | -| `to` | string | No | Recipient agent ID. Auto-set from URL path if omitted. | -| `subject` | string | No | Message subject/action | -| `correlation_id` | string | No | ID for correlating request/response pairs | -| `headers` | object | No | Custom headers (e.g., priority, routing hints) | -| `body` | any | Yes | Message payload (object, string, or any JSON value) | -| `ttl_sec` | number | No | Time-to-live in seconds for the message | -| `timestamp` | string | No | ISO 8601 timestamp of message creation | -| `signature` | object | No | Optional message-level Ed25519 signature | -| `ephemeral` | boolean | No | If `true`, message is auto-purged after acknowledgment or TTL expiry | -| `ttl` | number | No | Ephemeral TTL in seconds | - -**Response: `201 Created`** - +**Response 403:** ```json -{ - "message_id": "msg-uuid-1234", - "status": "delivered" -} +{"error": "SEED_MISMATCH", "message": "Provided seed does not match current agent key"} ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `SEND_FAILED` | General send failure | -| 400 | `INVALID_TIMESTAMP` | Message timestamp is malformed or out of acceptable range | -| 403 | `INVALID_SIGNATURE` | Message-level signature verification failed | -| 404 | `RECIPIENT_NOT_FOUND` | Target agent does not exist | - --- -### POST /api/agents/:agentId/inbox/pull +## Inbox: Message Operations -Pull the next available message from the agent's inbox. The message is leased (locked) for a configurable duration to prevent other consumers from processing it concurrently. +### POST /api/agents/:agentId/messages -**Authentication:** HTTP Signature +Send a message to an agent's inbox. -**Request Headers:** +**Auth:** API Key — any registered, approved agent may send (cross-agent messaging is the core use case). HTTP Signatures are also accepted. -``` -POST /api/agents/my-agent/inbox/pull -Content-Type: application/json -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` +**Request body:** ADMP message envelope with optional top-level fields: -**Request Body (optional):** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `version` | string | Yes | Must be `"1.0"` | +| `from` | string | Yes | Sender identifier. Accepts: bare agent ID, `agent://` URI, or `did:seed:` DID. | +| `to` | string | Yes | Recipient identifier. Accepts: bare agent ID, `agent://` URI, or `did:seed:` DID. If omitted, defaults to `:agentId` from URL. | +| `subject` | string | Yes | Message subject / type | +| `timestamp` | string | Yes | ISO-8601 timestamp. Must be within +/- 5 minutes. | +| `id` | string | No | Message UUID. Auto-generated if omitted. | +| `type` | string | No | Message type (e.g., `task.request`) | +| `body` | any | No | Message payload | +| `correlation_id` | string | No | Correlation ID for threading | +| `headers` | object | No | Custom metadata headers | +| `ttl_sec` | number | No | Message TTL in seconds (default: `MESSAGE_TTL_SEC` env, 86400) | +| `signature` | object | No | Envelope Ed25519 signature `{alg, kid, sig}` | +| `ephemeral` | boolean | No | *Top-level send option.* If `true`, message body is purged on ack. | +| `ttl` | string/number | No | *Top-level send option.* Auto-purge TTL (e.g., `"5m"`, `3600`). | +**Response 201:** ```json { - "visibility_timeout": 30 + "message_id": "uuid", + "status": "queued" } ``` -| Field | Type | Required | Description | -|---|---|---|---| -| `visibility_timeout` | number | No | Lease duration in seconds. The message is hidden from other pull requests until this timeout expires. | +**Response 404:** +```json +{"error": "RECIPIENT_NOT_FOUND", "message": "Recipient agent my-agent not found"} +``` + +--- + +### POST /api/agents/:agentId/inbox/pull + +Pull the next message from the inbox. The message is leased (locked) for the duration of `visibility_timeout` — it will not be returned to other pull calls until the lease expires or is released via ack/nack. + +Returns 204 (no content) when the inbox is empty. + +**Auth:** HTTP Signature (must be the agent itself) -**Response: `200 OK`** (message available) +**Request body:** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `visibility_timeout` | number | No | Lease duration in seconds. Default: 60. | + +**Response 200:** ```json { - "message_id": "msg-uuid-1234", + "message_id": "uuid", "envelope": { "version": "1.0", - "type": "task.request", + "id": "uuid", "from": "sender-agent", "to": "my-agent", - "subject": "process_data", - "body": { "dataset": "users" } + "subject": "task.request", + "body": {"action": "summarize"}, + "timestamp": "2026-02-26T00:00:00Z" }, - "lease_until": 1740490200000, + "lease_until": 1740000060000, "attempts": 1 } ``` -**Response: `204 No Content`** (inbox empty) - -No response body. - -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `PULL_FAILED` | Failed to pull from inbox | +**Response 204:** Inbox empty (no body) --- ### POST /api/agents/:agentId/messages/:messageId/ack -Acknowledge successful processing of a message. The message is permanently removed from the inbox. +Acknowledge a message, confirming successful processing. The message must currently be in `leased` status. Ephemeral messages have their body purged on ack. -**Authentication:** HTTP Signature +**Auth:** HTTP Signature (must be the agent itself) -**Request Headers:** +**Request body:** -``` -POST /api/agents/my-agent/messages/msg-uuid-1234/ack -Content-Type: application/json -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `result` | any | No | Processing result (stored with message record) | -**Request Body (optional):** +**Response 200:** +```json +{"ok": true} +``` +**Response 404:** ```json -{ - "result": { "status": "completed", "output": "42 records exported" } -} +{"error": "MESSAGE_NOT_FOUND", "message": "Message uuid not found"} ``` -| Field | Type | Required | Description | -|---|---|---|---| -| `result` | any | No | Optional processing result to attach to the acknowledgment | - -**Response: `200 OK`** - -```json -{ - "ok": true -} -``` - -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `ACK_FAILED` | Failed to acknowledge message | -| 404 | `MESSAGE_NOT_FOUND` | Message does not exist or is not leased by this agent | - --- ### POST /api/agents/:agentId/messages/:messageId/nack -Negative acknowledgment. Requeue the message for later processing or extend the lease duration. +Negative acknowledge — either requeue the message or extend the current lease. -**Authentication:** HTTP Signature +**Auth:** HTTP Signature (must be the agent itself) -**Request Headers:** +**Request body:** -``` -POST /api/agents/my-agent/messages/msg-uuid-1234/nack -Content-Type: application/json -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` - -**Request Body (optional):** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `extend_sec` | number | No | Extend the lease by this many seconds from the current lease base | +| `requeue` | boolean | No | Requeue immediately (default behavior if `extend_sec` not provided) | +**Response 200:** ```json { - "extend_sec": 60, - "requeue": true + "ok": true, + "status": "queued", + "lease_until": null } ``` -| Field | Type | Required | Description | -|---|---|---|---| -| `extend_sec` | number | No | Extend the lease by this many seconds | -| `requeue` | boolean | No | If `true`, immediately requeue the message for other consumers | - -**Response: `200 OK`** - +Or with `extend_sec`: ```json { "ok": true, - "status": "queued", - "lease_until": null + "status": "leased", + "lease_until": 1740000180000 } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `NACK_FAILED` | Failed to nack message | -| 404 | `MESSAGE_NOT_FOUND` | Message does not exist | - --- ### POST /api/agents/:agentId/messages/:messageId/reply -Send a correlated reply to a previously received message. The reply is delivered to the original sender's inbox with the `correlation_id` set to the original message's ID. - -**Authentication:** HTTP Signature - -**Request Headers:** - -``` -POST /api/agents/my-agent/messages/msg-uuid-1234/reply -Content-Type: application/json -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` - -**Request Body:** +Send a correlated reply to a message. The `correlation_id` is automatically set to the original message ID, and the reply is routed to the original sender's inbox. -An ADMP message envelope (same structure as the send endpoint body, minus `ephemeral` and `ttl`): +**Auth:** HTTP Signature (must be the agent itself) -```json -{ - "version": "1.0", - "type": "task.response", - "from": "my-agent", - "subject": "process_data_result", - "body": { - "status": "success", - "records": 42 - } -} -``` +**Request body:** ADMP message envelope (partial — `from`, `to`, and `correlation_id` are set automatically) -**Response: `200 OK`** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subject` | string | Yes | Reply subject | +| `body` | any | No | Reply payload | +| `version` | string | No | Defaults to `"1.0"` | +**Response 200:** ```json { - "message_id": "reply-msg-uuid-5678", - "status": "delivered" + "message_id": "uuid", + "status": "queued" } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `REPLY_FAILED` | Failed to send reply | -| 404 | `MESSAGE_NOT_FOUND` | Original message not found | - --- ### GET /api/messages/:messageId/status -Get the delivery status of a specific message. - -**Authentication:** API key (when enforcement is enabled) - -**Request:** - -``` -GET /api/messages/msg-uuid-1234/status -X-Api-Key: -``` +Get delivery status of a message. Does not require agent authentication — useful for senders tracking their own messages. -**Response: `200 OK`** +**Auth:** API Key +**Response 200:** ```json { - "message_id": "msg-uuid-1234", + "id": "uuid", "status": "acked", - "delivered_at": 1740489600000, - "acked_at": 1740489660000 + "created_at": 1740000000000, + "updated_at": 1740000060000, + "attempts": 1, + "lease_until": null, + "acked_at": 1740000060000 } ``` -**Error Responses:** +Status values: `queued`, `leased`, `acked`, `expired`, `purged` -| Status | Error Code | Description | -|---|---|---| -| 404 | `MESSAGE_NOT_FOUND` | Message not found | -| 410 | `MESSAGE_EXPIRED` | Message has been purged (ephemeral or TTL expired) | +**Response 410 (purged/ephemeral):** +```json +{ + "error": "MESSAGE_EXPIRED", + "message": "This message has been purged (ephemeral or TTL expired)", + "id": "uuid", + "from": "sender", + "to": "recipient", + "subject": "task.request", + "status": "purged", + "purged_at": 1740000120000, + "purge_reason": "acked", + "body": null +} +``` --- ### GET /api/agents/:agentId/inbox/stats -Get statistics for an agent's inbox (pending messages, leased count, etc.). - -**Authentication:** HTTP Signature - -**Request Headers:** +Get inbox statistics for the agent. -``` -GET /api/agents/my-agent/inbox/stats -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` - -**Response: `200 OK`** +**Auth:** HTTP Signature (must be the agent itself) +**Response 200:** ```json { - "pending": 5, - "leased": 1, - "total": 6 + "total": 10, + "queued": 7, + "leased": 2, + "acked": 1 } ``` @@ -1203,21 +673,11 @@ Host: agentdispatch.fly.dev ### POST /api/agents/:agentId/inbox/reclaim -Manually reclaim expired leases across all inboxes. Messages whose lease has expired are returned to `queued` status. - -**Authentication:** HTTP Signature +Manually trigger reclamation of expired leases for this agent. -**Request Headers:** - -``` -POST /api/agents/my-agent/inbox/reclaim -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` - -**Response: `200 OK`** +**Auth:** HTTP Signature (must be the agent itself) +**Response 200:** ```json { "reclaimed": 3 @@ -1228,569 +688,262 @@ Host: agentdispatch.fly.dev ## Groups -Groups allow multiple agents to communicate in a shared channel. Groups support three access modes: `open` (anyone can join), `key` (requires a join key), and `invite_only` (admin must add members). - ### POST /api/groups -Create a new group. The creating agent becomes the group owner. - -**Authentication:** Agent auth (URL parameter `:agentId` or `X-Agent-ID` header) - -**Request Headers:** - -``` -POST /api/groups -Content-Type: application/json -X-Agent-ID: my-agent -``` +Create a new group. -**Request Body:** +**Auth:** Agent auth (any registered agent) -```json -{ - "name": "data-pipeline-team", - "access": { - "type": "key", - "key": "secret-join-key" - }, - "settings": { - "max_members": 50, - "message_retention_days": 30 - } -} -``` +**Request body:** | Field | Type | Required | Description | -|---|---|---|---| -| `name` | string | Yes | Group name. 1-100 characters. Only letters, numbers, spaces, hyphens, underscores, and periods. | -| `access` | object | No | Access control configuration | -| `access.type` | string | No | One of `"open"`, `"key"`, `"invite_only"`. Defaults to `"open"`. | -| `access.key` | string | No | Join key for `"key"` access type | -| `settings` | object | No | Group-level settings | - -**Response: `201 Created`** +|-------|------|----------|-------------| +| `name` | string | Yes | Group name. Max 100 chars. Only letters, numbers, spaces, hyphens, underscores, periods. | +| `access` | object | No | Access configuration `{type: "open"|"key"|"invite-only", key?}` | +| `settings` | object | No | Group settings `{max_members?, message_ttl_sec?, history_visible?}` | -```json -{ - "id": "grp-uuid-1234", - "name": "data-pipeline-team", - "members": [ - { "agent_id": "my-agent", "role": "owner", "joined_at": "2026-02-25T12:00:00Z" } - ], - "access": { "type": "key" }, - "settings": { "max_members": 50, "message_retention_days": 30 }, - "created_at": "2026-02-25T12:00:00Z" -} -``` - -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `INVALID_NAME` | Name is empty or not a string | -| 400 | `NAME_TOO_LONG` | Name exceeds 100 characters | -| 400 | `INVALID_NAME_CHARS` | Name contains disallowed characters | +**Response 201:** Group record --- ### GET /api/groups/:groupId -Get group information. Members see full group details; non-members see limited information. - -**Authentication:** Agent auth +Get group info. -**Request Headers:** +**Auth:** Agent auth -``` -GET /api/groups/grp-uuid-1234 -X-Agent-ID: my-agent -``` - -**Response: `200 OK`** (member view) +Non-members see limited info: `{id, name, access_type, member_count}`. +Members see full group record. +**Response 200 (member):** ```json { - "id": "grp-uuid-1234", - "name": "data-pipeline-team", - "members": [ - { "agent_id": "my-agent", "role": "owner", "joined_at": "2026-02-25T12:00:00Z" }, - { "agent_id": "agent-beta", "role": "member", "joined_at": "2026-02-25T13:00:00Z" } - ], - "access": { "type": "key" }, - "settings": {}, - "created_at": "2026-02-25T12:00:00Z" + "id": "group-uuid", + "name": "My Group", + "access": {"type": "open"}, + "members": [{"agent_id": "my-agent", "role": "owner", "joined_at": 1740000000000}], + "settings": {"max_members": 50, "message_ttl_sec": 604800, "history_visible": true}, + "created_by": "my-agent", + "created_at": 1740000000000 } ``` -**Response: `200 OK`** (non-member view) - -```json -{ - "id": "grp-uuid-1234", - "name": "data-pipeline-team", - "access_type": "key", - "member_count": 2 -} -``` - -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 404 | `GROUP_NOT_FOUND` | Group does not exist | - --- ### PUT /api/groups/:groupId -Update group name or settings. Requires `owner` or `admin` role. - -**Authentication:** Agent auth (owner/admin) +Update group settings. -**Request Headers:** +**Auth:** Agent auth (admin or owner role required) -``` -PUT /api/groups/grp-uuid-1234 -Content-Type: application/json -X-Agent-ID: my-agent -``` - -**Request Body:** - -```json -{ - "name": "updated-team-name", - "settings": { "max_members": 100 } -} -``` +**Request body:** | Field | Type | Required | Description | -|---|---|---|---| +|-------|------|----------|-------------| | `name` | string | No | New group name | -| `settings` | object | No | Updated group settings | - -**Response: `200 OK`** +| `settings` | object | No | Updated settings | -The full updated group object. - -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 403 | `UPDATE_GROUP_FAILED` | Insufficient permissions (requires owner or admin role) | -| 404 | `UPDATE_GROUP_FAILED` | Group not found | +**Response 200:** Updated group record --- ### DELETE /api/groups/:groupId -Delete a group. Requires `owner` role. - -**Authentication:** Agent auth (owner) - -**Request Headers:** - -``` -DELETE /api/groups/grp-uuid-1234 -X-Agent-ID: my-agent -``` - -**Response: `204 No Content`** - -No response body. +Delete a group. Owner only. -**Error Responses:** +**Auth:** Agent auth (owner role required) -| Status | Error Code | Description | -|---|---|---| -| 403 | `DELETE_GROUP_FAILED` | Insufficient permissions (requires owner role) | -| 404 | `DELETE_GROUP_FAILED` | Group not found | +**Response 204:** No content --- ### GET /api/groups/:groupId/members -List all members of a group. Requires membership. +List group members. -**Authentication:** Agent auth (member) - -**Request Headers:** - -``` -GET /api/groups/grp-uuid-1234/members -X-Agent-ID: my-agent -``` - -**Response: `200 OK`** +**Auth:** Agent auth (must be a member) +**Response 200:** ```json { "members": [ - { "agent_id": "my-agent", "role": "owner", "joined_at": "2026-02-25T12:00:00Z" }, - { "agent_id": "agent-beta", "role": "member", "joined_at": "2026-02-25T13:00:00Z" } + {"agent_id": "my-agent", "role": "owner", "joined_at": 1740000000000}, + {"agent_id": "other-agent", "role": "member", "joined_at": 1740000060000} ] } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 403 | `LIST_MEMBERS_FAILED` | Not a member of the group | -| 404 | `LIST_MEMBERS_FAILED` | Group not found | - --- ### POST /api/groups/:groupId/members -Add a member to the group. Requires `owner` or `admin` role. +Add a member to the group. Admin or owner only. -**Authentication:** Agent auth (owner/admin) +**Auth:** Agent auth (admin or owner role required) -**Request Headers:** - -``` -POST /api/groups/grp-uuid-1234/members -Content-Type: application/json -X-Agent-ID: my-agent -``` - -**Request Body:** - -```json -{ - "agent_id": "agent-gamma", - "role": "member" -} -``` +**Request body:** | Field | Type | Required | Description | -|---|---|---|---| -| `agent_id` | string | Yes | The agent to add to the group | -| `role` | string | No | Role for the new member (default: `"member"`) | - -**Response: `200 OK`** - -The full updated group object. +|-------|------|----------|-------------| +| `agent_id` | string | Yes | Agent ID to add | +| `role` | string | No | Role: `member` (default), `admin` | -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `AGENT_ID_REQUIRED` | `agent_id` field is missing | -| 403 | `ADD_MEMBER_FAILED` | Insufficient permissions | -| 409 | `ADD_MEMBER_FAILED` | Agent is already a member or group is at maximum capacity | +**Response 200:** Updated group record --- ### DELETE /api/groups/:groupId/members/:agentId -Remove a member from the group. Requires `owner` or `admin` role. - -**Authentication:** Agent auth (owner/admin) - -**Request Headers:** +Remove a member from the group. Cannot remove the owner. -``` -DELETE /api/groups/grp-uuid-1234/members/agent-gamma -X-Agent-ID: my-agent -``` - -**Response: `200 OK`** - -The full updated group object. +**Auth:** Agent auth (admin or owner role required) -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 403 | `REMOVE_MEMBER_FAILED` | Insufficient permissions or cannot remove group owner | -| 404 | `REMOVE_MEMBER_FAILED` | Group or member not found | +**Response 200:** Updated group record --- ### POST /api/groups/:groupId/join -Join a group. Available for `open` and `key`-protected groups. +Join a group. -**Authentication:** Agent auth +**Auth:** Agent auth -**Request Headers:** +**Request body:** -``` -POST /api/groups/grp-uuid-1234/join -Content-Type: application/json -X-Agent-ID: my-agent -``` +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `key` | string | No | Join key (required for key-protected groups) | -**Request Body (for key-protected groups):** +**Response 200:** Group record +**Response 403:** ```json -{ - "key": "secret-join-key" -} +{"error": "JOIN_FAILED", "message": "invite-only group requires explicit invitation"} ``` -| Field | Type | Required | Description | -|---|---|---|---| -| `key` | string | Conditional | Required for groups with `access.type: "key"` | - -**Response: `200 OK`** - -The full group object (now including the new member). - -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 403 | `JOIN_FAILED` | Group is invite-only, or invalid join key provided | -| 409 | `JOIN_FAILED` | Already a member | - --- ### POST /api/groups/:groupId/leave -Leave a group. - -**Authentication:** Agent auth - -**Request Headers:** - -``` -POST /api/groups/grp-uuid-1234/leave -X-Agent-ID: my-agent -``` +Leave a group. The group owner cannot leave. -**Response: `200 OK`** +**Auth:** Agent auth +**Response 200:** ```json -{ - "message": "Left group", - "group_id": "grp-uuid-1234" -} +{"message": "Left group", "group_id": "group-uuid"} ``` --- ### POST /api/groups/:groupId/messages -Post a message to all group members. Requires membership. - -**Authentication:** Agent auth (member) - -**Request Headers:** - -``` -POST /api/groups/grp-uuid-1234/messages -Content-Type: application/json -X-Agent-ID: my-agent -``` +Post a message to the group. Fans out to each member's individual inbox (excluding the sender). -**Request Body:** +**Auth:** Agent auth (must be a member) -```json -{ - "subject": "pipeline-status", - "body": { "stage": "complete", "records": 1500 }, - "correlation_id": "job-789", - "reply_to": "msg-uuid-previous" -} -``` +**Request body:** | Field | Type | Required | Description | -|---|---|---|---| -| `subject` | string | Yes | Message subject. Maximum 200 characters. | -| `body` | any | Yes | Message payload. Maximum 1 MB when serialized. | -| `correlation_id` | string | No | Correlation ID for threading | -| `reply_to` | string | No | ID of the message being replied to | - -**Response: `201 Created`** +|-------|------|----------|-------------| +| `subject` | string | Yes | Message subject (max 200 chars) | +| `body` | any | Yes | Message body (max 1MB) | +| `correlation_id` | string | No | Correlation ID | +| `reply_to` | string | No | Reply-to agent ID | +**Response 201:** ```json { - "message_id": "grp-msg-uuid-1234", - "delivered_to": 3, - "group_id": "grp-uuid-1234" + "group_id": "group-uuid", + "delivered_to": ["agent-a", "agent-b"], + "message_ids": ["uuid-a", "uuid-b"] } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `INVALID_MESSAGE` | Missing `subject` or `body` | -| 400 | `INVALID_SUBJECT` | Subject exceeds 200 characters | -| 400 | `BODY_TOO_LARGE` | Message body exceeds 1 MB | -| 403 | `POST_MESSAGE_FAILED` | Not a member of the group | - --- ### GET /api/groups/:groupId/messages -Get group message history. Requires membership. - -**Authentication:** Agent auth (member) - -**Request Headers:** - -``` -GET /api/groups/grp-uuid-1234/messages?limit=25 -X-Agent-ID: my-agent -``` - -**Query Parameters:** +Get group message history. -| Parameter | Type | Default | Description | -|---|---|---|---| -| `limit` | number | 50 | Maximum number of messages to return | +**Auth:** Agent auth (must be a member) -**Response: `200 OK`** +**Query parameters:** +- `limit` — Number of messages to return (default: 50) +**Response 200:** ```json { - "messages": [ - { - "id": "grp-msg-uuid-1234", - "from": "my-agent", - "subject": "pipeline-status", - "body": { "stage": "complete", "records": 1500 }, - "timestamp": "2026-02-25T12:00:00Z" - } - ], - "count": 1, + "messages": [...], + "count": 10, "has_more": false } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 403 | `GET_MESSAGES_FAILED` | Not a member of the group | -| 404 | `GET_MESSAGES_FAILED` | Group not found | - --- ### GET /api/agents/:agentId/groups -List all groups the agent is a member of. - -**Authentication:** HTTP Signature +List groups the agent belongs to. -**Request Headers:** - -``` -GET /api/agents/my-agent/groups -Signature: keyId="my-agent",algorithm="ed25519",headers="(request-target) host date",signature="" -Date: Thu, 20 Feb 2026 12:00:00 GMT -Host: agentdispatch.fly.dev -``` - -**Response: `200 OK`** +**Auth:** HTTP Signature (must be the agent itself) +**Response 200:** ```json { "groups": [ - { - "id": "grp-uuid-1234", - "name": "data-pipeline-team", - "role": "owner", - "member_count": 5 - }, - { - "id": "grp-uuid-5678", - "name": "monitoring", - "role": "member", - "member_count": 12 - } + {"id": "group-uuid", "name": "My Group", "role": "owner", "member_count": 3} ] } ``` --- -## Outbox (Email via Mailgun) - -The outbox enables agents to send emails via Mailgun. Agents must first configure and verify a custom domain before sending. +## Outbox (Email) ### POST /api/agents/:agentId/outbox/domain -Configure a custom domain for outbound email. Each agent can have one domain. - -**Authentication:** Agent auth +Configure a custom sending domain for outbound email. -**Request Headers:** +**Auth:** Agent auth (must be the agent itself) -``` -POST /api/agents/my-agent/outbox/domain -Content-Type: application/json -X-Agent-ID: my-agent -``` - -**Request Body:** - -```json -{ - "domain": "mail.example.com" -} -``` +**Request body:** | Field | Type | Required | Description | -|---|---|---|---| -| `domain` | string | Yes | The domain to configure for outbound email | +|-------|------|----------|-------------| +| `domain` | string | Yes | Domain to configure (e.g., `agents.example.com`) | -**Response: `201 Created`** +**Response 201:** Domain config record +**Response 409:** ```json -{ - "agent_id": "my-agent", - "domain": "mail.example.com", - "status": "unverified", - "dns_records": [ - { "type": "TXT", "name": "mail.example.com", "value": "v=spf1 include:mailgun.org ~all" }, - { "type": "CNAME", "name": "email.mail.example.com", "value": "mailgun.org" } - ] -} +{"error": "DOMAIN_CONFIG_FAILED", "message": "Agent already has domain configured"} ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `DOMAIN_REQUIRED` | `domain` field is missing | -| 409 | `DOMAIN_CONFIG_FAILED` | Agent already has a domain configured | - --- ### GET /api/agents/:agentId/outbox/domain -Get the current domain configuration and verification status. - -**Authentication:** Agent auth +Get domain configuration and DNS verification status. -**Request Headers:** - -``` -GET /api/agents/my-agent/outbox/domain -X-Agent-ID: my-agent -``` - -**Response: `200 OK`** +**Auth:** Agent auth (must be the agent itself) +**Response 200:** ```json { - "agent_id": "my-agent", - "domain": "mail.example.com", - "status": "verified", - "dns_records": [] + "domain": "agents.example.com", + "verified": false, + "dns_records": [ + {"type": "TXT", "name": "_dkim...", "value": "..."}, + {"type": "TXT", "name": "_domainkey...", "value": "..."} + ] } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 404 | `NO_DOMAIN` | No domain configured for this agent | +**Response 404:** +```json +{"error": "NO_DOMAIN", "message": "No domain configured for agent my-agent"} +``` --- @@ -1798,150 +951,68 @@ X-Agent-ID: my-agent Trigger a DNS verification check for the configured domain. -**Authentication:** Agent auth +**Auth:** Agent auth (must be the agent itself) -**Request Headers:** - -``` -POST /api/agents/my-agent/outbox/domain/verify -X-Agent-ID: my-agent -``` - -**Response: `200 OK`** - -```json -{ - "agent_id": "my-agent", - "domain": "mail.example.com", - "status": "verified" -} -``` - -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `DOMAIN_VERIFY_FAILED` | DNS verification failed | -| 404 | `DOMAIN_VERIFY_FAILED` | No domain configured | +**Response 200:** Updated domain config with verification status --- ### DELETE /api/agents/:agentId/outbox/domain -Remove the domain configuration for an agent. - -**Authentication:** Agent auth +Remove domain configuration. -**Request Headers:** +**Auth:** Agent auth (must be the agent itself) -``` -DELETE /api/agents/my-agent/outbox/domain -X-Agent-ID: my-agent -``` - -**Response: `204 No Content`** - -No response body. - -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `DOMAIN_DELETE_FAILED` | Deletion error | -| 404 | `DOMAIN_DELETE_FAILED` | No domain configured | +**Response 204:** No content --- ### POST /api/agents/:agentId/outbox/send -Send an email via Mailgun. Requires a verified domain. - -**Authentication:** Agent auth +Send an email via Mailgun. -**Request Headers:** +**Auth:** Agent auth (must be the agent itself) -``` -POST /api/agents/my-agent/outbox/send -Content-Type: application/json -X-Agent-ID: my-agent -``` +**Request body:** -**Request Body:** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `to` | string | Yes | Recipient email address (must be valid email format) | +| `subject` | string | Yes | Email subject | +| `body` | string | No | Plain text body (one of `body` or `html` required) | +| `html` | string | No | HTML body | +| `from_name` | string | No | Display name for the From header | +**Response 202:** ```json { - "to": "user@example.com", - "subject": "Task Complete", - "body": "The data export has finished. 42 records were processed.", - "html": "

The data export has finished. 42 records were processed.

", - "from_name": "Data Pipeline Agent" + "id": "outbox-message-uuid", + "status": "queued" } ``` -| Field | Type | Required | Description | -|---|---|---|---| -| `to` | string | Yes | Recipient email address | -| `subject` | string | Yes | Email subject line | -| `body` | string | Conditional | Plain text body. Either `body` or `html` must be provided. | -| `html` | string | Conditional | HTML body. Either `body` or `html` must be provided. | -| `from_name` | string | No | Display name for the sender | - -**Response: `202 Accepted`** - +**Response 403:** ```json -{ - "message_id": "outbox-msg-uuid-1234", - "status": "queued", - "to": "user@example.com", - "subject": "Task Complete" -} +{"error": "SEND_FAILED", "message": "Domain not verified"} ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `TO_REQUIRED` | `to` field is missing | -| 400 | `INVALID_EMAIL` | `to` is not a valid email address | -| 400 | `SUBJECT_REQUIRED` | `subject` field is missing | -| 400 | `BODY_REQUIRED` | Neither `body` nor `html` provided | -| 403 | `SEND_FAILED` | Domain is not verified | -| 404 | `SEND_FAILED` | No outbox domain configured | - --- ### GET /api/agents/:agentId/outbox/messages -List outbox messages (sent emails) for an agent. - -**Authentication:** Agent auth - -**Request Headers:** - -``` -GET /api/agents/my-agent/outbox/messages?status=delivered&limit=25 -X-Agent-ID: my-agent -``` +List sent outbox messages. -**Query Parameters:** +**Auth:** Agent auth (must be the agent itself) -| Parameter | Type | Default | Description | -|---|---|---|---| -| `status` | string | (all) | Filter by delivery status | -| `limit` | number | (all) | Maximum number of messages to return | - -**Response: `200 OK`** +**Query parameters:** +- `status` — Filter by status: `queued`, `sent`, `delivered`, `failed` +- `limit` — Max messages to return +**Response 200:** ```json { "messages": [ - { - "id": "outbox-msg-uuid-1234", - "to": "user@example.com", - "subject": "Task Complete", - "status": "delivered", - "sent_at": "2026-02-25T12:00:00Z" - } + {"id": "uuid", "to": "user@example.com", "subject": "Hello", "status": "delivered", "sent_at": 1740000000000} ], "count": 1 } @@ -1951,275 +1022,80 @@ X-Agent-ID: my-agent ### GET /api/agents/:agentId/outbox/messages/:messageId -Get details for a specific outbox message. - -**Authentication:** Agent auth - -**Request Headers:** +Get status of a specific outbox message. -``` -GET /api/agents/my-agent/outbox/messages/outbox-msg-uuid-1234 -X-Agent-ID: my-agent -``` +**Auth:** Agent auth (must be the agent itself) -**Response: `200 OK`** +**Response 200:** Outbox message record +**Response 404:** ```json -{ - "id": "outbox-msg-uuid-1234", - "agent_id": "my-agent", - "to": "user@example.com", - "subject": "Task Complete", - "body": "The data export has finished.", - "status": "delivered", - "sent_at": "2026-02-25T12:00:00Z", - "delivered_at": "2026-02-25T12:00:05Z" -} +{"error": "OUTBOX_MESSAGE_NOT_FOUND", "message": "Outbox message uuid not found"} ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 403 | `FORBIDDEN` | Message belongs to a different agent | -| 404 | `OUTBOX_MESSAGE_NOT_FOUND` | Message not found | - --- ### POST /api/webhooks/mailgun -Receive delivery status updates from Mailgun. This endpoint is called by Mailgun's webhook system, not by agents directly. +Mailgun delivery status callback. Called by Mailgun to report delivery, bounce, and failure events. -**Authentication:** Mailgun webhook signature (when `MAILGUN_WEBHOOK_SIGNING_KEY` is set). If the signing key is not configured, requests are accepted without signature verification. - -**Request Body:** +**Auth:** Mailgun HMAC signature (when `MAILGUN_WEBHOOK_SIGNING_KEY` is configured). No agent auth. +**Request body:** ```json { "signature": { - "timestamp": "1740489600", - "token": "random-token-string", - "signature": "hmac-sha256-hex-signature" + "timestamp": "1740000000", + "token": "random-token", + "signature": "hmac-sha256-sig" }, "event_data": { "event": "delivered", - "message": { - "headers": { - "message-id": "outbox-msg-uuid-1234" - } - } + "message": {"headers": {"message-id": "mailgun-id"}} } } ``` -**Response: `200 OK`** - +**Response 200:** ```json -{ - "status": "ok" -} +{"status": "ok"} ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `SIGNATURE_REQUIRED` | Signing key is configured but no signature provided | -| 403 | `INVALID_SIGNATURE` | Webhook signature verification failed | -| 500 | `WEBHOOK_FAILED` | Internal processing error | - --- -## Discovery - -Public endpoints for key discovery and DID document resolution. No authentication required. - -### GET /.well-known/agent-keys.json - -JWKS-style public key directory listing all registered agents and their Ed25519 public keys. Used for out-of-band key discovery and verification. - -**Authentication:** None - -**Request:** - -``` -GET /.well-known/agent-keys.json -``` - -**Response: `200 OK`** - -```json -{ - "keys": [ - { - "kid": "my-agent", - "did": "did:seed:abc123...", - "kty": "OKP", - "crv": "Ed25519", - "x": "base64-encoded-public-key", - "verification_tier": "cryptographic", - "key_version": 1 - }, - { - "kid": "agent-beta", - "did": null, - "kty": "OKP", - "crv": "Ed25519", - "x": "base64-encoded-public-key", - "verification_tier": "unverified", - "key_version": 1 - } - ] -} -``` - -| Field | Type | Description | -|---|---|---| -| `kid` | string | Key ID (the agent's ID) | -| `did` | string or null | Decentralized Identifier, if assigned | -| `kty` | string | Key type. Always `"OKP"` (Octet Key Pair). | -| `crv` | string | Curve. Always `"Ed25519"`. | -| `x` | string | Base64-encoded raw Ed25519 public key | -| `verification_tier` | string | One of `"unverified"`, `"github"`, `"cryptographic"` | -| `key_version` | number | Current key version (incremented on rotation) | - ---- - -### GET /api/agents/:agentId/did.json - -Returns a W3C DID document for a specific agent. Supports agents with multiple active keys (from key rotation). - -**Authentication:** None - -**Request:** - -``` -GET /api/agents/my-agent/did.json -``` - -**Response: `200 OK`** - -```json -{ - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/ed25519-2020/v1" - ], - "id": "did:seed:abc123...", - "verificationMethod": [ - { - "id": "did:seed:abc123...#key-1", - "type": "Ed25519VerificationKey2020", - "controller": "did:seed:abc123...", - "publicKeyMultibase": "z6Mkf5rGMoatrSj1f..." - } - ], - "authentication": ["did:seed:abc123...#key-1"], - "assertionMethod": ["did:seed:abc123...#key-1"], - "service": [ - { - "id": "did:seed:abc123...#admp-inbox", - "type": "ADMPInbox", - "serviceEndpoint": "/api/agents/my-agent/messages" - } - ] -} -``` - -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 404 | `AGENT_NOT_FOUND` | Agent not found | - ---- - -## Tenant Management - -Tenants provide namespace isolation for agents. Agents registered with a `tenant_id` are scoped to that tenant's namespace. +## Tenants ### POST /api/agents/tenants -Create a new tenant. - -**Authentication:** API key - -**Request Headers:** +Create a new tenant namespace. -``` -POST /api/agents/tenants -Content-Type: application/json -X-Api-Key: -``` +**Auth:** API Key -**Request Body:** - -```json -{ - "tenant_id": "acme-corp", - "name": "Acme Corporation", - "metadata": { "plan": "enterprise" }, - "registration_policy": "approval_required" -} -``` +**Request body:** | Field | Type | Required | Description | -|---|---|---|---| +|-------|------|----------|-------------| | `tenant_id` | string | Yes | Unique tenant identifier | -| `name` | string | No | Human-readable name. Defaults to `tenant_id`. | -| `metadata` | object | No | Arbitrary tenant metadata | -| `registration_policy` | string | No | One of `"open"` or `"approval_required"`. Defaults to `"open"`. | +| `name` | string | No | Display name (defaults to `tenant_id`) | +| `metadata` | object | No | Arbitrary metadata | +| `registration_policy` | string | No | `"open"` or `"approval_required"` (default: `"open"`) | -**Response: `201 Created`** +**Response 201:** Tenant record +**Response 409:** ```json -{ - "tenant_id": "acme-corp", - "name": "Acme Corporation", - "metadata": { "plan": "enterprise" }, - "registration_policy": "approval_required" -} +{"error": "TENANT_EXISTS", "message": "Tenant my-tenant already exists"} ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 400 | `TENANT_ID_REQUIRED` | `tenant_id` field is missing | -| 400 | `INVALID_REGISTRATION_POLICY` | Invalid policy value | -| 409 | `TENANT_EXISTS` | Tenant with this ID already exists | - --- ### GET /api/agents/tenants/:tenantId Get tenant details. -**Authentication:** API key - -**Request Headers:** - -``` -GET /api/agents/tenants/acme-corp -X-Api-Key: -``` - -**Response: `200 OK`** - -```json -{ - "tenant_id": "acme-corp", - "name": "Acme Corporation", - "metadata": { "plan": "enterprise" }, - "registration_policy": "approval_required" -} -``` +**Auth:** API Key -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 404 | `TENANT_NOT_FOUND` | Tenant does not exist | +**Response 200:** Tenant record --- @@ -2227,27 +1103,12 @@ X-Api-Key: List all agents belonging to a tenant. -**Authentication:** API key - -**Request Headers:** - -``` -GET /api/agents/tenants/acme-corp/agents -X-Api-Key: -``` - -**Response: `200 OK`** +**Auth:** API Key +**Response 200:** ```json { - "agents": [ - { - "agent_id": "acme-worker-1", - "agent_type": "worker", - "tenant_id": "acme-corp", - "registration_status": "approved" - } - ] + "agents": [...] } ``` @@ -2257,198 +1118,139 @@ X-Api-Key: Delete a tenant. -**Authentication:** API key - -**Request Headers:** - -``` -DELETE /api/agents/tenants/acme-corp -X-Api-Key: -``` +**Auth:** API Key -**Response: `204 No Content`** - -No response body. +**Response 204:** No content --- -## Approval Workflow (Admin) - -When a tenant uses `registration_policy: "approval_required"`, newly registered agents are placed in `pending` status and cannot authenticate until approved. These endpoints manage the approval workflow. +## Admin: Approval Workflow ### GET /api/agents/tenants/:tenantId/pending -List agents with `pending` registration status for a specific tenant. - -**Authentication:** Master API Key - -**Request Headers:** - -``` -GET /api/agents/tenants/acme-corp/pending -X-Api-Key: -``` +List agents with `registration_status: "pending"` for a tenant. -**Response: `200 OK`** +**Auth:** Master API Key +**Response 200:** ```json { "agents": [ { - "agent_id": "acme-worker-2", - "agent_type": "worker", - "tenant_id": "acme-corp", + "agent_id": "pending-agent", "registration_status": "pending", - "public_key": "base64-encoded-public-key", - "did": "did:seed:..." + "agent_type": "generic", + "created_at": 1740000000000 } ] } ``` -Note: The `secret_key` field is never included in the response. - --- ### POST /api/agents/:agentId/approve -Approve a pending agent registration. The agent's status changes from `pending` to `approved`, enabling it to authenticate and use the API. - -**Authentication:** Master API Key - -**Request Headers:** - -``` -POST /api/agents/acme-worker-2/approve -X-Api-Key: -``` +Approve a pending agent registration. Idempotent — approving an already-approved agent returns success. -**Response: `200 OK`** +**Auth:** Master API Key +**Response 200:** ```json { - "agent_id": "acme-worker-2", + "agent_id": "my-agent", "registration_status": "approved" } ``` -**Error Responses:** - -| Status | Error Code | Description | -|---|---|---| -| 404 | `AGENT_NOT_FOUND` | Agent does not exist | +**Response 404:** +```json +{"error": "AGENT_NOT_FOUND", "message": "Agent my-agent not found"} +``` --- ### POST /api/agents/:agentId/reject -Reject an agent registration. The agent's status changes to `rejected` and it cannot authenticate. - -**Authentication:** Master API Key - -**Request Headers:** - -``` -POST /api/agents/acme-worker-2/reject -Content-Type: application/json -X-Api-Key: -``` +Reject an agent registration. Idempotent. -**Request Body (optional):** +**Auth:** Master API Key -```json -{ - "reason": "Agent does not meet security requirements" -} -``` +**Request body:** | Field | Type | Required | Description | -|---|---|---|---| -| `reason` | string | No | Rejection reason. Maximum 500 characters. | - -**Response: `200 OK`** +|-------|------|----------|-------------| +| `reason` | string | No | Rejection reason (max 500 characters) | +**Response 200:** ```json { - "agent_id": "acme-worker-2", + "agent_id": "my-agent", "registration_status": "rejected", - "rejection_reason": "Agent does not meet security requirements" + "rejection_reason": "Domain not in allowlist" } ``` -**Error Responses:** +--- -| Status | Error Code | Description | -|---|---|---| -| 400 | `INVALID_REASON` | `reason` is not a string | -| 400 | `REASON_TOO_LONG` | `reason` exceeds 500 characters | -| 404 | `AGENT_NOT_FOUND` | Agent does not exist | +## Discovery ---- +### GET /.well-known/agent-keys.json -## Common Error Response Format +JWKS-style public key directory for all registered agents. -All error responses follow a consistent structure: +**Auth:** None +**Response 200:** ```json { - "error": "ERROR_CODE", - "message": "Human-readable description of the error" + "keys": [ + { + "kid": "my-agent", + "did": "did:seed:...", + "kty": "OKP", + "crv": "Ed25519", + "x": "base64-public-key", + "verification_tier": "unverified", + "key_version": 1 + } + ] } ``` -## Global Error Responses - -These errors can occur on any endpoint: - -| Status | Error Code | Description | -|---|---|---| -| 401 | `API_KEY_REQUIRED` | API key enforcement is enabled and no key was provided | -| 401 | `INVALID_API_KEY` | The provided API key is invalid or expired | -| 401 | `SIGNATURE_INVALID` | HTTP Signature header present but verification failed | -| 401 | `MASTER_KEY_REQUIRED` | Admin endpoint requires the master API key | -| 403 | `REGISTRATION_PENDING` | Agent exists but registration is pending approval | -| 403 | `REGISTRATION_REJECTED` | Agent registration has been rejected | -| 403 | `ENROLLMENT_TOKEN_USED` | Single-use enrollment token already consumed | -| 403 | `ENROLLMENT_TOKEN_SCOPE` | Enrollment token is scoped to a different agent | -| 404 | `NOT_FOUND` | Endpoint does not exist | -| 500 | `INTERNAL_ERROR` | Unexpected server error | - --- -## Environment Variables Reference - -| Variable | Description | Default | -|---|---|---| -| `PORT` | Server listen port | `8080` | -| `API_KEY_REQUIRED` | Enable API key enforcement (`"true"` to enable) | `undefined` (disabled) | -| `MASTER_API_KEY` | Master key for admin endpoints | `undefined` (admin endpoints reject all) | -| `CORS_ORIGIN` | Allowed CORS origin | `*` | -| `CLEANUP_INTERVAL_MS` | Background job interval in milliseconds | `60000` | -| `REGISTRATION_POLICY` | Default registration policy (`"open"` or `"approval_required"`) | `"open"` | -| `DID_WEB_ALLOWED_DOMAINS` | Comma-separated allowlist of domains for DID:web federation | `undefined` (all public domains allowed under open policy) | -| `MAILGUN_API_KEY` | Mailgun API key for outbound email | `undefined` | -| `MAILGUN_WEBHOOK_SIGNING_KEY` | Mailgun webhook signing key for signature verification | `undefined` (webhooks accepted without verification) | -| `NODE_ENV` | Environment mode (`"production"`, `"test"`, etc.) | `undefined` | - ---- - -## Rate Limiting +### GET /api/agents/:agentId/did.json -ADMP does not currently enforce server-side rate limiting. Clients should implement their own backoff strategies, particularly for high-volume messaging and inbox polling. +W3C DID document for a specific agent. -## Message Lifecycle +**Auth:** None +**Response 200:** +```json +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/ed25519-2020/v1" + ], + "id": "did:seed:...", + "verificationMethod": [ + { + "id": "did:seed:...#key-1", + "type": "Ed25519VerificationKey2020", + "controller": "did:seed:...", + "publicKeyMultibase": "z..." + } + ], + "authentication": ["did:seed:...#key-1"], + "assertionMethod": ["did:seed:...#key-1"], + "service": [ + { + "id": "did:seed:...#admp-inbox", + "type": "ADMPInbox", + "serviceEndpoint": "/api/agents/my-agent/messages" + } + ] +} ``` -queued --> delivered --> leased --> acked (removed) - | - +--> nack --> queued (reprocessed) -``` - -1. **queued**: Message accepted and waiting for delivery. -2. **delivered**: Message placed in the recipient's inbox. -3. **leased**: Message pulled by the recipient and locked for processing. -4. **acked**: Processing confirmed. Message permanently deleted. -5. **nacked**: Processing failed or deferred. Message returned to queue. -Expired leases are automatically reclaimed by a background job (configurable via `CLEANUP_INTERVAL_MS`). +Agents with multiple active keys (after rotation) include all active keys in `verificationMethod`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a17a1d..b52e75d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,9 +1,9 @@ - + # Agent Dispatch (ADMP) Architecture -**Agent Dispatch Messaging Protocol (ADMP)** -- Universal inbox for autonomous AI agents with at-least-once delivery, Ed25519 authentication, and DID federation. +**Agent Dispatch Messaging Protocol (ADMP)** — Universal inbox for autonomous AI agents with at-least-once delivery, Ed25519 authentication, and DID federation. --- @@ -60,11 +60,11 @@ graph TB subgraph "Route Handlers" R_AGENTS["/api/agents/*"
Registration, Heartbeat,
Trust, Webhook, Identity,
Key Rotation, Tenants,
Approval Workflow] - R_INBOX["/api/agents/:id/inbox/*"
Send, Pull, Ack,
Nack, Reply, Stats] + R_INBOX["/api/agents/:id/inbox/*"
Send, Pull, Ack,
Nack, Reply, Stats"] R_GROUPS["/api/groups/*"
CRUD, Membership,
Join/Leave, Fanout] R_OUTBOX["/api/agents/:id/outbox/*"
Domain Config, Send,
Message Queries] R_DISCOVERY["/.well-known/agent-keys.json"
"/api/agents/:id/did.json"
Key Directory & DID Docs] - R_WEBHOOKS["/api/webhooks/mailgun"
Delivery Status] + R_WEBHOOKS["/api/webhooks/mailgun"
Delivery Status"] end subgraph "Services" @@ -99,7 +99,7 @@ graph TB A3 -->|HTTPS + Master API Key| MW_HELMET A4 -->|HTTPS + DID:web Sig| MW_HELMET - MW_AUTH --> R_AGENTS & R_INBOX & R_GROUPS & R_OUTBOX & R_DISCOVERY & R_KEYS & R_WEBHOOKS + MW_AUTH --> R_AGENTS & R_INBOX & R_GROUPS & R_OUTBOX & R_DISCOVERY & R_WEBHOOKS R_AGENTS --> S_AGENT R_AGENTS --> S_IDENTITY @@ -141,12 +141,9 @@ sequenceDiagram Note over Sender,Recipient: 1. SEND — Sender delivers message to recipient's inbox - Sender->>Server: POST /api/agents/{recipientId}/messages
Signature: keyId="sender",algorithm="ed25519",... - Server->>Auth: Verify HTTP Signature - Auth->>Store: getAgent(sender_id) - Store-->>Auth: Agent record + public keys - Auth->>Auth: Ed25519 verify(signing_string, sig, pubkey)
Check Date header +-5 min
Check (request-target) signed - Auth-->>Server: Verified (req.agent set) + Sender->>Server: POST /api/agents/{recipientId}/messages
X-Api-Key: (any registered agent's key) + Server->>Auth: requireApiKey / verifyHttpSignature + Auth-->>Server: Authorized (cross-agent send allowed) Server->>Inbox: send(envelope, options) Inbox->>Inbox: validateEnvelope()
version, from, to, subject, timestamp @@ -167,7 +164,7 @@ sequenceDiagram Note over Sender,Recipient: 2. PULL — Recipient claims message with lease Recipient->>Server: POST /api/agents/{recipientId}/inbox/pull
Signature: keyId="recipient",... - Server->>Auth: Verify HTTP Signature
(same flow as above) + Server->>Auth: Verify Ed25519 HTTP Signature
keyId must match URL agentId Auth-->>Server: Verified Server->>Inbox: pull(recipientId, {visibility_timeout: 60}) @@ -202,30 +199,30 @@ sequenceDiagram ### Message State Machine ``` - +-----------+ - | queued | - +-----+-----+ - | - pull() | TTL expires - +-----------++-----------+ - | | - +-----v-----+ +-----v-----+ - | leased | | expired | - +-----+-----+ +-----------+ - | - +--------+--------+ - | | - ack() nack() - | | - +-----v-----+ +-----+-----+ - | acked | | queued | (requeue) - +-----+-----+ +-----------+ - | - | (if ephemeral) - | - +-----v-----+ - | purged | (body deleted, metadata retained) - +-----------+ + +-----------+ + | queued | + +-----+-----+ + | + pull() | TTL expires + +-----------++-----------+ + | | + +-----v-----+ +-----v-----+ + | leased | | expired | + +-----+-----+ +-----------+ + | + +--------+--------+ + | | + ack() nack() + | | ++-----v-----+ +-----+-----+ +| acked | | queued | (requeue) ++-----+-----+ +-----------+ + | + | (if ephemeral) + | ++-----v-----+ +| purged | (body deleted, metadata retained) ++-----------+ ``` ### Alternate Flows @@ -288,9 +285,9 @@ graph LR ``` **Scaling characteristics:** -- `auto_stop_machines = stop` -- instances scale to zero when idle -- `auto_start_machines = true` -- cold-start on first request -- `min_machines_running = 0` -- no always-on cost +- `auto_stop_machines = stop` — instances scale to zero when idle +- `auto_start_machines = true` — cold-start on first request +- `min_machines_running = 0` — no always-on cost - 1 shared CPU, 1 GB memory per VM - Health check: `GET /health` every 15 seconds @@ -307,7 +304,7 @@ graph LR | **Entry point** | `node src/index.js` | | **Docker base** | `node:18-alpine` | | **Install** | `npm ci --only=production` | -| **Health check** | Docker: every 30s via `/health`; Fly.io: every 15s via `GET /health` | +| **Health check** | Fly.io: every 15s via `GET /health` | | **Graceful shutdown** | SIGTERM/SIGINT handlers stop background jobs, close server | --- @@ -318,7 +315,6 @@ graph LR src/ index.js # Entry point — starts server, background jobs, graceful shutdown server.js # Express app setup — middleware chain, route mounting, background job lifecycle - server.test.js # Integration tests (node --test) middleware/ auth.js # Authentication: HTTP Signatures, API keys, DID:web federation, @@ -334,6 +330,8 @@ src/ # message queries; /api/webhooks/mailgun discovery.js # /.well-known/agent-keys.json — JWKS key directory # /api/agents/:id/did.json — W3C DID document + keys.js # /api/keys/* — issued key management + services/ agent.service.js # Agent lifecycle: register (3 modes), heartbeat, approve/reject, # trust management, webhook config, key rotation @@ -352,6 +350,18 @@ src/ crypto.js # Ed25519 keypair generation (tweetnacl), HKDF-SHA256, # message signing/verification, HTTP request signing, # DID generation, timestamp validation, TTL parsing + +cli/ + src/ + index.ts # CLI entry point — commander.js program, 16 command modules + auth.ts # Ed25519 signing library (self-contained, no server imports) + client.ts # AdmpClient HTTP client class + config.ts # Config file management (~/.admp/config.json) + commands/ # Individual command implementations + init.ts register.ts agent.ts send.ts pull.ts ack.ts nack.ts + reply.ts status.ts inbox.ts heartbeat.ts rotate-key.ts + webhook.ts groups.ts outbox.ts config.ts + package.json # @agentdispatch/cli@0.2.1 — subpath exports, bin entry ``` --- @@ -364,7 +374,8 @@ Manages the full agent lifecycle. | Capability | Description | |---|---| -| **Registration** | Three modes: *Legacy* (random keypair), *Seed-based* (HKDF deterministic), *Import* (client-provided public key) | +| **Registration** | Three modes: *Legacy* (random keypair), *Seed-based* (HKDF deterministic from `LABEL_ADMP:::ed25519:vN`), *Import* (client-provided public key) | +| **agent_id validation** | Enforces `^[a-zA-Z0-9._\-:]+$`; auto-generates `agent-` if omitted | | **Heartbeat** | Periodic liveness signal; background job marks agents offline after `timeout_ms` | | **Approval Workflow** | `approve()` / `reject(reason)` for pending agents; master key required | | **Trust Management** | Per-agent trusted/blocked agent lists; enforced at message send time | @@ -377,7 +388,7 @@ Core message processing engine. | Operation | Description | |---|---| -| **send()** | Validates envelope, resolves recipient (agent:// or did:seed:), checks trust list, verifies signature against all active keys, persists with status `queued`, triggers optional webhook push | +| **send()** | Validates envelope (from/to accept bare IDs, `agent://` URIs, or `did:seed:` DIDs), resolves recipient, checks trust list, verifies envelope signature, persists with status `queued`, triggers optional webhook push | | **pull()** | FIFO retrieval with visibility timeout (lease); filters expired ephemeral messages | | **ack()** | Confirms processing; ephemeral messages have body purged on ack | | **nack()** | Requeue or extend lease duration | @@ -392,10 +403,10 @@ Multi-agent group messaging with role-based access control. |---|---| | **CRUD** | Create, read, update, delete groups | | **Access types** | `open`, `key-protected` (SHA-256 hashed join key), `invite-only` | -| **Roles** | `owner`, `admin`, `member` -- with permission checks on mutations | +| **Roles** | `owner`, `admin`, `member` — with permission checks on mutations | | **Message fanout** | Post to group fans out as individual messages to each member's inbox (via InboxService) | -| **History** | Configurable `history_visible` flag; deduplicated by `group_message_id` | -| **Limits** | `max_members` (default 50), `message_ttl_sec` (default 7 days) | +| **History** | Configurable `history_visible` flag | +| **Limits** | `max_members` (default 50), `message_ttl_sec` (default 7 days), body max 1MB | ### OutboxService @@ -405,9 +416,8 @@ Outbound email delivery through Mailgun. |---|---| | **Domain management** | Add, verify DNS, remove custom sending domains | | **Send** | Constructs RFC 5322 From header, sends via Mailgun HTTP API | -| **Retry** | Exponential backoff (1s, 2s, 4s), max 3 attempts | | **Webhooks** | Receives Mailgun delivery/bounce events; HMAC-SHA256 signature verification | -| **Status tracking** | `queued` -> `sent` -> `delivered` or `failed` | +| **Status tracking** | `queued` → `sent` → `delivered` or `failed` | ### IdentityService @@ -416,7 +426,7 @@ Tiered identity verification for agents. | Tier | Requirements | |---|---| | `unverified` | Default on registration | -| `github` | Agent links a GitHub handle (claim-based, no OAuth in Phase 1) | +| `github` | Agent links a GitHub handle (claim-based) | | `cryptographic` | Seed-based registration with DID; strongest tier | ### WebhookService @@ -451,10 +461,14 @@ Request arrives at /api/* | valid +-> Check agent approval status | | | | | approved -> Set req.agent, req.authMethod = 'http-signature' - | | | Authorization check: signing agent == URL target agent - | | | -> NEXT (bypass API key) | | | - | | pending -> 403 REGISTRATION_PENDING + | | +-> Is this POST /agents/:id/messages? + | | | yes -> allow (cross-agent send) + | | | no -> enforce keyId == URL agentId + | | | + | | -> NEXT (bypass API key) + | | | + | | pending -> 403 REGISTRATION_PENDING | | rejected -> 403 REGISTRATION_REJECTED | | | invalid -> 401 SIGNATURE_INVALID (NO fallthrough to API key) @@ -503,6 +517,10 @@ date: - Date header freshness: +/- 5 minutes - Only `ed25519` algorithm accepted +### Cross-Agent Message Sending + +`POST /api/agents/:id/messages` is the only endpoint where the signing agent does not have to match the `:agentId` URL parameter. Any registered, approved agent may send to any other agent's inbox. This is the core of the protocol — agents send messages to each other's inboxes, not to themselves. + ### DID:web Federation When a `Signature` header contains `keyId="did:web:..."`: @@ -535,8 +553,8 @@ When a `Signature` header contains `keyId="did:web:..."`: |---|---| | **Timing attacks on API keys** | `crypto.timingSafeEqual` for master key comparison; issued keys use hash lookup (timing-safe by design) | | **Replay attacks** | `Date` header must be signed and within +/- 5 minutes of server time | -| **Endpoint confusion** | `(request-target)` must be in signed headers; signing agent must match URL target agent | -| **Signature fallthrough** | If `Signature` header is present but invalid, reject immediately -- never fall through to API key auth | +| **Endpoint confusion** | `(request-target)` must be in signed headers; signing agent must match URL target agent (except message send) | +| **Signature fallthrough** | If `Signature` header is present but invalid, reject immediately — never fall through to API key auth | | **SSRF via DID:web** | Private IP blocklist (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10, 0.0.0.0/8, ::1, all raw IPv6); redirect target validation; `DID_WEB_ALLOWED_DOMAINS` allowlist; 5s fetch timeout; 64KB document size limit | | **DID key cache DoS** | Cache bounded to 1000 entries; oldest entry evicted on overflow | | **Enrollment token reuse** | Atomic `burnSingleUseKey()` with TOCTOU race protection; `used_at` set only if currently null | @@ -544,8 +562,9 @@ When a `Signature` header contains `keyId="did:web:..."`: | **Algorithm confusion** | Only `ed25519` accepted; any other `algorithm` value is rejected | | **Multicodec confusion** | DID document keys must be exactly 34 bytes with `0xed01` prefix; other key types rejected | | **Namespace collision** | Shadow agent creation checks for existing non-federated agent with same ID | +| **agent_id injection** | Regex `^[a-zA-Z0-9._\-:]+$` blocks newlines (signing string injection), slashes (path traversal), spaces, null bytes | | **HTTP headers** | `helmet` middleware sets security headers (X-Content-Type-Options, X-Frame-Options, etc.) | -| **Input validation** | 10MB JSON body limit; group name length/charset validation; rejection reason 500 char limit; From header sanitization for outbox | +| **Input validation** | 10MB JSON body limit; group name length/charset validation; rejection reason 500 char limit | | **Error response uniformity** | 401 for all bad-credential scenarios (expired, revoked, unknown) to prevent existence leaking | ### Approval Workflow @@ -585,7 +604,7 @@ Selected via `STORAGE_BACKEND` environment variable. - HTTP client for the Mech Storage API at `MECH_BASE_URL` - Persistent data across restarts -- Used in Fly.io production deployment +- Used in Fly.io production deployment (`STORAGE_BACKEND=mech`) ### Storage Interface @@ -636,8 +655,9 @@ Two `setInterval` timers started after the server begins listening. Both run eve | `GET` | `/openapi.json` | Raw OpenAPI specification | | `POST` | `/api/agents/register` | Agent self-registration | | `GET` | `/.well-known/agent-keys.json` | JWKS-style public key directory | +| `GET` | `/api/agents/:agentId/did.json` | W3C DID document (no auth) | -### Agent Endpoints (HTTP Signature or API Key) +### Agent Endpoints (HTTP Signature) | Method | Path | Description | |---|---|---| @@ -655,19 +675,18 @@ Two `setInterval` timers started after the server begins listening. Both run eve | `POST` | `/api/agents/:agentId/verify/github` | Link GitHub handle | | `POST` | `/api/agents/:agentId/verify/cryptographic` | Confirm cryptographic tier | | `GET` | `/api/agents/:agentId/identity` | Get verification status | -| `GET` | `/api/agents/:agentId/did.json` | W3C DID document | ### Inbox Endpoints | Method | Path | Auth | Description | |---|---|---|---| -| `POST` | `/api/agents/:agentId/messages` | API Key | Send message to inbox | -| `POST` | `/api/agents/:agentId/inbox/pull` | HTTP Sig | Pull message (with lease) | -| `POST` | `/api/agents/:agentId/messages/:messageId/ack` | HTTP Sig | Acknowledge message | -| `POST` | `/api/agents/:agentId/messages/:messageId/nack` | HTTP Sig | Negative acknowledge | -| `POST` | `/api/agents/:agentId/messages/:messageId/reply` | HTTP Sig | Reply to message | -| `GET` | `/api/agents/:agentId/inbox/stats` | HTTP Sig | Inbox statistics | -| `POST` | `/api/agents/:agentId/inbox/reclaim` | HTTP Sig | Reclaim expired leases | +| `POST` | `/api/agents/:agentId/messages` | API Key (any agent) | Send message to inbox | +| `POST` | `/api/agents/:agentId/inbox/pull` | HTTP Sig (self) | Pull message (with lease) | +| `POST` | `/api/agents/:agentId/messages/:messageId/ack` | HTTP Sig (self) | Acknowledge message | +| `POST` | `/api/agents/:agentId/messages/:messageId/nack` | HTTP Sig (self) | Negative acknowledge | +| `POST` | `/api/agents/:agentId/messages/:messageId/reply` | HTTP Sig (self) | Reply to message | +| `GET` | `/api/agents/:agentId/inbox/stats` | HTTP Sig (self) | Inbox statistics | +| `POST` | `/api/agents/:agentId/inbox/reclaim` | HTTP Sig (self) | Reclaim expired leases | | `GET` | `/api/messages/:messageId/status` | API Key | Get message delivery status | ### Group Endpoints (Agent Auth) @@ -715,17 +734,12 @@ Two `setInterval` timers started after the server begins listening. Both run eve | `GET` | `/api/agents/tenants/:tenantId/agents` | List tenant agents | | `DELETE` | `/api/agents/tenants/:tenantId` | Delete tenant | -### Webhook Endpoints (Mailgun) +### Webhook and Stats Endpoints -| Method | Path | Description | -|---|---|---| -| `POST` | `/api/webhooks/mailgun` | Mailgun delivery status callback | - -### Stats Endpoint - -| Method | Path | Description | -|---|---|---| -| `GET` | `/api/stats` | System-wide statistics | +| Method | Path | Auth | Description | +|---|---|---|---| +| `POST` | `/api/webhooks/mailgun` | Mailgun sig | Mailgun delivery status callback | +| `GET` | `/api/stats` | API Key | System-wide statistics | --- @@ -737,17 +751,17 @@ Two `setInterval` timers started after the server begins listening. Both run eve | `NODE_ENV` | `production` | Node.js environment (`production` = info-level logging, generic error messages) | | `CORS_ORIGIN` | `*` | Allowed CORS origins | | `API_KEY_REQUIRED` | `true` | Enforce API key authentication on `/api/*` routes | -| `MASTER_API_KEY` | *(none, required for admin)* | Master key for admin endpoints (key issuance, agent approval/rejection). **Secret -- set via Fly.io secrets, never in fly.toml.** | -| `HEARTBEAT_INTERVAL_MS` | `60000` | Agent heartbeat expected interval (informational, logged at startup) | +| `MASTER_API_KEY` | *(none, required for admin)* | Master key for admin endpoints. **Secret — set via Fly.io secrets, never in fly.toml.** | +| `HEARTBEAT_INTERVAL_MS` | `60000` | Agent heartbeat expected interval | | `HEARTBEAT_TIMEOUT_MS` | `300000` | Time after last heartbeat before agent is marked offline | | `MESSAGE_TTL_SEC` | `86400` | Default message time-to-live (24 hours) | | `CLEANUP_INTERVAL_MS` | `60000` | Interval for background cleanup and heartbeat check jobs | | `MAX_MESSAGE_SIZE_KB` | `256` | Maximum message size (configured, enforcement in storage layer) | -| `MAX_MESSAGES_PER_AGENT` | `1000` | Maximum inbox messages per agent (configured, enforcement in storage layer) | +| `MAX_MESSAGES_PER_AGENT` | `1000` | Maximum inbox messages per agent | | `STORAGE_BACKEND` | `mech` (fly.toml) / `memory` (code default) | Storage backend: `memory` or `mech` | | `MECH_BASE_URL` | *(deployment-specific)* | Mech Storage API base URL (when `STORAGE_BACKEND=mech`) | | `REGISTRATION_POLICY` | `approval_required` (fly.toml) / `open` (code default) | Agent registration policy: `open` or `approval_required`. Tenant-level policy overrides. | -| `DID_WEB_ALLOWED_DOMAINS` | *(none)* | Comma-separated domain allowlist for DID:web federation. When set, only listed domains can federate. When unset, all public domains can attempt federation (subject to SSRF blocklist). | +| `DID_WEB_ALLOWED_DOMAINS` | *(none)* | Comma-separated domain allowlist for DID:web federation. When set, only listed domains can federate. | | `MAILGUN_API_KEY` | *(none)* | Mailgun API key for outbound email. **Secret.** | | `MAILGUN_API_URL` | `https://api.mailgun.net/v3` | Mailgun API base URL (override for EU regions or testing) | | `MAILGUN_WEBHOOK_SIGNING_KEY` | *(none)* | Mailgun webhook signing key for verifying delivery callbacks. **Secret.** When unset, webhooks accept unauthenticated requests (warning logged). | @@ -756,7 +770,7 @@ Two `setInterval` timers started after the server begins listening. Both run eve ## Dependencies -### Production +### Production (server) | Package | Version | Purpose | |---|---|---| @@ -771,13 +785,12 @@ Two `setInterval` timers started after the server begins listening. Both run eve | `yamljs` | ^0.3.0 | OpenAPI YAML spec loading | | `dotenv` | ^17.2.3 | Environment variable loading from `.env` files | -### Development +### CLI (@agentdispatch/cli@0.2.1) | Package | Version | Purpose | |---|---|---| -| `supertest` | ^6.3.4 | HTTP assertion testing | -| `nodemon` | ^3.0.2 | Auto-restart on file changes (dev mode) | -| `agentbootup` | ^0.7.1 | Agent skill and memory management framework | +| `commander` | ^12.1.0 | CLI argument parsing | +| `tweetnacl` | ^1.0.3 | Ed25519 signing (self-contained, no server import) | ### Node.js Built-ins Used diff --git a/docs/CLI-REFERENCE.md b/docs/CLI-REFERENCE.md index 31eb87c..5e2f64d 100644 --- a/docs/CLI-REFERENCE.md +++ b/docs/CLI-REFERENCE.md @@ -1,10 +1,12 @@ - + # @agentdispatch/cli Reference > CLI and library for the Agent Dispatch Messaging Protocol (ADMP) +**Package:** `@agentdispatch/cli@0.2.1` + ## Installation **Global install (npm):** @@ -29,6 +31,8 @@ bunx @agentdispatch/cli Requires Node.js >= 18. +--- + ## CLI Commands ### Setup @@ -39,27 +43,41 @@ Requires Node.js >= 18. | `admp config show` | Show the resolved configuration (secret key is masked in human output). | `--json` | | `admp config set ` | Set a single config value. Valid keys: `base_url`, `agent_id`, `secret_key`, `api_key`. | `--json` | +--- + ### Agent Lifecycle | Command | Description | Flags | |---------|-------------|-------| -| `admp register` | Register a new agent with the hub. Returns agent ID and Ed25519 keypair. Credentials are saved to config automatically. | `--name ` Agent display name. `--seed ` Deterministic key from seed. `--json` | -| `admp deregister` | Permanently delete the registered agent and all its messages. Requires confirmation. | `--json` | -| `admp agent get` | View your agent's registration details (ID, name, public key, created timestamp). | `--json` | +| `admp register` | Register a new agent with the hub. Returns agent ID and Ed25519 keypair. Credentials are saved to config automatically. | `--name ` Human-readable agent name. `--seed ` Deterministic 32-byte seed (hex). Prefer `ADMP_SEED` env var to avoid shell history exposure. `--capabilities ` Comma-separated capability list. `--json` | +| `admp deregister` | Permanently delete the registered agent and all its messages. Requires interactive confirmation (`y/N`). | `--json` | +| `admp agent get` | View your agent's registration details (ID, name, public key, status). | `--json` | | `admp heartbeat` | Send a keepalive heartbeat to the hub. | `--metadata ` Arbitrary JSON metadata to include. `--json` | -| `admp rotate-key` | Rotate the agent's Ed25519 signing key. The new secret key is saved to config. | `--seed ` Deterministic key from seed. `--json` | +| `admp rotate-key` | Rotate the agent's Ed25519 signing key (seed-based agents only). The new secret key is saved to config. | `--seed ` Deterministic seed. `--json` | + +**Security note for `register --seed`:** The seed appears in shell history and `ps` output. Use the `ADMP_SEED` environment variable instead: + +```bash +ADMP_SEED=deadbeef... admp register --name my-agent +``` + +--- ### Messaging | Command | Description | Flags | |---------|-------------|-------| -| `admp send` | Send a message to another agent's inbox. The envelope is signed with your Ed25519 key before transmission. | `--to ` **(required)** Recipient agent. `--subject ` **(required)** Message type (e.g. `task.request`). `--body ` **(required)** JSON message body. `--json` | -| `admp pull` | Pull the next message from your inbox. The message is leased (locked) until you ack or nack it. | `--timeout ` Long-poll timeout (server holds connection until a message arrives or timeout elapses). `--json` | -| `admp ack ` | Acknowledge a message, confirming successful processing. The message is permanently removed from the inbox. | `--result ` Optional JSON result to attach to the ack. `--json` | -| `admp nack ` | Reject or defer a message. The message is requeued for later processing. | `--extend` Extend the lease instead of requeuing. `--requeue` Explicitly requeue the message. `--json` | -| `admp reply ` | Send a correlated reply to a previously received message. The `correlation_id` is set automatically. | `--subject ` **(required)** Reply message type. `--body ` **(required)** JSON reply body. `--json` | -| `admp status ` | Check the delivery status of a sent message. Returns lifecycle state (`queued`, `delivered`, `leased`, `acked`). | `--json` | -| `admp inbox stats` | Show queue counts for your inbox (total, pending, leased). | `--json` | +| `admp send` | Send a message to another agent's inbox. The envelope is signed with your Ed25519 key. Transport auth uses `api_key`. | `--to ` **(required)** Recipient agent ID. `--subject ` **(required)** Message type (e.g. `task.request`). `--body ` JSON body or `@filename` to read from file (relative paths only, max 1MB). Default: `{}`. `--type ` Message type field. Default: `task.request`. `--correlation-id ` Correlation ID for threading. `--ttl ` Time-to-live (max 86400). `--ephemeral` Do not persist message body after ack. `--json` | +| `admp pull` | Pull the next message from your inbox. The message is leased (locked) until you ack or nack it. Returns empty message if inbox is empty. | `--timeout ` Long-poll timeout (max 300 seconds). Adds 5s buffer to client timeout to avoid racing the server. `--json` | +| `admp ack ` | Acknowledge a message, confirming successful processing. | `--result ` Optional JSON result to attach. `--json` | +| `admp nack ` | Reject or defer a message. | `--extend ` Extend the lease instead of requeuing. `--requeue` Explicitly requeue the message. `--json` | +| `admp reply ` | Send a correlated reply to a previously received message. The `correlation_id` is set automatically. | `--subject ` **(required)** Reply message type. `--body ` **(required)** JSON reply body. `--json` | +| `admp status ` | Check the delivery status of a sent message. Returns lifecycle state (`queued`, `leased`, `acked`, `expired`, `purged`). | `--json` | +| `admp inbox stats` | Show queue counts for your inbox. | `--json` | + +**Note on `admp send`:** Requires `api_key` for transport authentication (set via `admp config set api_key ` or `ADMP_API_KEY`). Also requires `secret_key` for envelope signing. + +--- ### Webhooks @@ -69,35 +87,53 @@ Requires Node.js >= 18. | `admp webhook get` | Show the current webhook configuration. | `--json` | | `admp webhook delete` | Remove the webhook. Messages will queue in the inbox for pull-based retrieval. | `--json` | +--- + ### Groups | Command | Description | Flags | |---------|-------------|-------| -| `admp groups create` | Create a new agent group for broadcast messaging. | `--name ` **(required)** Group display name. `--access ` **(required)** Access level. Private groups require a join key. `--json` | +| `admp groups create` | Create a new agent group for broadcast messaging. | `--name ` **(required)** Group display name (max 100 chars). `--access ` **(required)** Access level: `open`, `key` (key-protected), or `invite` (invite-only). `--json` | | `admp groups list` | List groups you belong to. | `--json` | -| `admp groups join ` | Join an existing group. | `--key ` Join key (required for private groups). `--json` | +| `admp groups join ` | Join an existing group. | `--key ` Join key (required for key-protected groups). `--json` | | `admp groups leave ` | Leave a group. | `--json` | -| `admp groups send ` | Broadcast a message to all members of a group. | `--subject ` **(required)** Message type. `--body ` **(required)** JSON body. `--json` | -| `admp groups messages ` | List recent messages in a group. | `--limit ` Max messages to return. `--json` | +| `admp groups send ` | Broadcast a message to all members of a group. | `--subject ` **(required)** Message type. `--body ` **(required)** JSON body. `--json` | +| `admp groups messages ` | List recent messages in a group. | `--limit ` Max messages to return (default: 50). `--json` | + +--- ### SMTP Outbox | Command | Description | Flags | |---------|-------------|-------| -| `admp outbox domain set` | Configure a sending domain for federated SMTP delivery. | `--domain ` **(required)** The domain to send from. `--json` | -| `admp outbox domain verify` | Verify DNS records (DKIM, SPF) for the configured sending domain. | `--json` | +| `admp outbox domain set` | Configure a sending domain for outbound email. | `--domain ` **(required)** Domain to configure. `--json` | +| `admp outbox domain verify` | Verify DNS records for the configured domain. | `--json` | | `admp outbox domain delete` | Remove the sending domain configuration. | `--json` | -| `admp outbox send` | Send an email via the SMTP outbox (federated delivery to external agents). | `--to
` **(required)** Recipient email address. `--subject ` **(required)** Email subject. `--json` | -| `admp outbox messages` | List messages in the SMTP outbox. | `--status ` Filter by status. `--limit ` Max messages to return. `--json` | +| `admp outbox send` | Send an email via the outbox (Mailgun). | `--to
` **(required)** Recipient email. `--subject ` **(required)** Subject. `--body ` Plain text body. `--html ` HTML body. `--json` | +| `admp outbox messages` | List messages in the outbox. | `--status ` Filter by status. `--limit ` Max messages to return. `--json` | + +--- + +## Global Flags + +These flags are available on every command: + +| Flag | Description | +|------|-------------| +| `--json` | Output machine-readable JSON instead of human-friendly text. Also available via `ADMP_JSON=1`. | +| `--version` | Print the CLI version and exit. | +| `--help` | Show help for the command. | + +--- ## Library Usage (Programmatic API) -The package exposes three subpath imports for use as a library in your own Node.js or Bun projects: +The package exposes subpath imports for use as a library in Node.js or Bun projects: ```typescript import { buildAuthHeaders, signEnvelope } from '@agentdispatch/cli/auth'; import { AdmpClient, AdmpError } from '@agentdispatch/cli/client'; -import { loadConfig, resolveConfig } from '@agentdispatch/cli/config'; +import { loadConfig, resolveConfig, requireConfig } from '@agentdispatch/cli/config'; ``` All subpath exports are defined in `package.json`: @@ -105,45 +141,104 @@ All subpath exports are defined in `package.json`: | Import Path | Entry Point | Description | |-------------|-------------|-------------| | `@agentdispatch/cli` | `dist/lib/auth.js` | Default export (auth module) | -| `@agentdispatch/cli/auth` | `dist/lib/auth.js` | Auth utilities | -| `@agentdispatch/cli/client` | `dist/lib/client.js` | HTTP client | +| `@agentdispatch/cli/auth` | `dist/lib/auth.js` | Auth utilities (Ed25519 signing) | +| `@agentdispatch/cli/client` | `dist/lib/client.js` | HTTP client (`AdmpClient`) | | `@agentdispatch/cli/config` | `dist/lib/config.js` | Config management | | `@agentdispatch/cli/cli` | `dist/cli.js` | CLI entry point | -### Auth Module (`@agentdispatch/cli/auth`) +--- + +## Auth Module (`@agentdispatch/cli/auth`) Standalone Ed25519 signing utilities. All cryptographic operations use `tweetnacl`. This module is self-contained and does not import from the server codebase. -#### `buildAuthHeaders(method, path, host, secretKey, agentId)` +### `buildAuthHeaders(method, path, host, secretKey, agentId)` Build HTTP Signature auth headers (`Date` and `Signature`) ready to merge into a fetch call. ```typescript function buildAuthHeaders( - method: string, // HTTP method (e.g. "GET", "POST") - path: string, // Request path (e.g. "/v1/agents/foo/messages") - host: string, // Target host (no scheme, no port) - secretKey: string, // Base64-encoded 64-byte Ed25519 secret key + method: string, // HTTP method (e.g. "POST") + path: string, // Request path including query string (e.g. "/api/agents/foo/messages") + host: string, // Target host (no scheme, no port: "agentdispatch.fly.dev") + secretKey: string, // Base64-encoded 64-byte Ed25519 secret key (from config) agentId: string, // Agent ID used as keyId in the Signature header ): Record; -// Returns: { Date: "...", Signature: "..." } +// Returns: { Date: "Thu, 26 Feb 2026 00:00:00 GMT", Signature: "keyId=...,algorithm=ed25519,..." } ``` -#### `signEnvelope(envelope, secretKey)` +**Example:** -Add an Ed25519 signature field to an ADMP message envelope. +```typescript +import { buildAuthHeaders } from '@agentdispatch/cli/auth'; + +const headers = buildAuthHeaders( + 'POST', + '/api/agents/my-agent/inbox/pull', + 'agentdispatch.fly.dev', + config.secret_key, + config.agent_id, +); + +const response = await fetch('https://agentdispatch.fly.dev/api/agents/my-agent/inbox/pull', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: JSON.stringify({ visibility_timeout: 60 }), +}); +``` + +--- + +### `signEnvelope(envelope, secretKey)` + +Add an Ed25519 `signature` field to an ADMP message envelope. ```typescript function signEnvelope( - envelope: object, // Envelope with timestamp, from, to, body + envelope: object, // Envelope with timestamp, from, to, and optionally body secretKey: string, // Base64-encoded 64-byte Ed25519 secret key ): object; -// Returns: new envelope with `signature` field { alg: "ed25519", kid, sig } +// Returns: new envelope object with `signature` field added +// signature: { alg: "ed25519", kid: , sig: } +``` + +The signing base string: +``` +timestamp +sha256(JSON.stringify(body ?? {})) +from +to +correlation_id (empty string if absent) +``` + +`kid` is derived from `envelope.from` by stripping the `agent://` prefix. Throws if `envelope.from` is missing or is a bare `"agent://"`. + +**Example:** + +```typescript +import { signEnvelope } from '@agentdispatch/cli/auth'; + +const envelope = { + version: '1.0', + id: crypto.randomUUID(), + type: 'task.request', + from: 'agent://my-agent', // or bare "my-agent" + to: 'agent://analyst', + subject: 'summarize', + body: { url: 'https://example.com' }, + timestamp: new Date().toISOString(), +}; + +const signed = signEnvelope(envelope, config.secret_key); +// signed.signature = { alg: "ed25519", kid: "my-agent", sig: "base64..." } ``` -The `kid` (key ID) is derived from `envelope.from` by stripping the `agent://` prefix. Throws if `envelope.from` is missing. +--- -#### `createSigningBase(envelope)` +### `createSigningBase(envelope)` Create the canonical signing base string for an ADMP message. Used internally by `signEnvelope`. @@ -162,31 +257,29 @@ function createSigningBase(envelope: AdmpEnvelope): string; // where bodyHash = base64(sha256(JSON.stringify(body ?? {}))) ``` -#### `toBase64(bytes)` +--- -Convert a `Uint8Array` to a base64-encoded string. +### `toBase64(bytes)` / `fromBase64(base64)` ```typescript function toBase64(bytes: Uint8Array): string; -``` - -#### `fromBase64(base64)` - -Convert a base64-encoded string to a `Uint8Array`. - -```typescript function fromBase64(base64: string): Uint8Array; ``` -#### `decodeSecretKey(base64)` +--- + +### `decodeSecretKey(base64)` Decode and validate an Ed25519 secret key from a base64 string. Throws a friendly error if the key is not exactly 64 bytes. ```typescript function decodeSecretKey(base64: string): Uint8Array; +// Throws: "secret_key is invalid: expected 64 bytes, got N — re-run `admp register` to obtain a fresh key" ``` -#### `sha256(input)` +--- + +### `sha256(input)` SHA-256 hash of input, returned as a base64-encoded string. @@ -194,7 +287,9 @@ SHA-256 hash of input, returned as a base64-encoded string. function sha256(input: string | Buffer): string; ``` -#### Types +--- + +### Types ```typescript interface AdmpEnvelope { @@ -213,21 +308,27 @@ interface EnvelopeSignature { } ``` -### Client Module (`@agentdispatch/cli/client`) +--- + +## Client Module (`@agentdispatch/cli/client`) HTTP client for making authenticated requests to an ADMP hub. -#### `AdmpClient` +### `AdmpClient` ```typescript -type AdmpClientConfig = Pick & Partial>; +type AdmpClientConfig = { base_url: string } & Partial<{ + agent_id: string; + secret_key: string; + api_key: string; +}>; class AdmpClient { constructor(config: AdmpClientConfig | ResolvedConfig); request( - method: string, // HTTP method - path: string, // Request path (e.g. "/api/agents/me") + method: string, // HTTP method ("GET", "POST", "DELETE", etc.) + path: string, // Request path (e.g. "/api/agents/my-agent/inbox/pull") body?: unknown, // JSON body (omit for GET) auth?: AuthMode, // "signature" (default), "api-key", or "none" timeoutOverrideMs?: number, // Override default 30s timeout @@ -239,58 +340,86 @@ class AdmpClient { | Mode | Requires | Behavior | |------|----------|----------| -| `"signature"` (default) | `agent_id` + `secret_key` | Signs request with Ed25519 HTTP Signature header | -| `"api-key"` | `api_key` | Sends `X-Api-Key` header | -| `"none"` | nothing | No auth headers (for public endpoints) | +| `"signature"` (default) | `agent_id` + `secret_key` | Signs request with Ed25519 HTTP Signature (`Date` + `Signature` headers). Query string is included in the signed path. | +| `"api-key"` | `api_key` | Sends `X-Api-Key: ` header | +| `"none"` | nothing | No auth headers (for public endpoints like `/api/agents/register`) | + +**Timeout behavior:** Uses `timeoutOverrideMs` if provided, else `ADMP_TIMEOUT` env var (validated), else 30000ms. Throws `AdmpError` with code `TIMEOUT` on abort. -**Timeout behavior:** Uses `timeoutOverrideMs` if provided, else `ADMP_TIMEOUT` env var, else 30000ms default. Throws `AdmpError` with code `TIMEOUT` on abort. +**Error handling:** Throws `AdmpError` on HTTP errors (non-2xx) or network/timeout failures. **Example:** ```typescript import { AdmpClient } from '@agentdispatch/cli/client'; import { resolveConfig } from '@agentdispatch/cli/config'; +import { signEnvelope } from '@agentdispatch/cli/auth'; -const client = new AdmpClient(resolveConfig()); +const config = resolveConfig(); +const client = new AdmpClient(config); -// Send a message (signature auth, default) -await client.request('POST', '/api/agents/analyst/messages', { - version: '1.0', - type: 'task.request', - subject: 'summarize', - body: { url: 'https://example.com/report.pdf' }, -}); +// Register (no auth) +const agent = await client.request('POST', '/api/agents/register', { agent_id: 'my-agent' }, 'none'); -// Pull from inbox -const msg = await client.request('GET', '/api/inbox/pull'); +// Pull from inbox (signature auth) +const msg = await client.request('POST', `/api/agents/${config.agent_id}/inbox/pull`, {}, 'signature'); + +// Send message (api-key transport + Ed25519 envelope signature) +const envelope = signEnvelope({ + version: '1.0', + from: `agent://${config.agent_id}`, + to: 'agent://recipient', + subject: 'task.request', + body: { action: 'process' }, + timestamp: new Date().toISOString(), +}, config.secret_key); + +const sent = await client.request('POST', '/api/agents/recipient/messages', envelope, 'api-key'); ``` -#### `AdmpError` +--- + +### `AdmpError` Error class thrown by `AdmpClient.request()` on HTTP or network failures. ```typescript class AdmpError extends Error { - code: string; // e.g. "TIMEOUT", "NETWORK_ERROR", "MISSING_CONFIG", "UNKNOWN_ERROR" + code: string; // e.g. "TIMEOUT", "NETWORK_ERROR", "MISSING_CONFIG", error field from server status: number; // HTTP status code (0 for network/timeout errors) constructor(message: string, code: string, status: number); } ``` -#### `AuthMode` +**Known error codes from the client:** + +| Code | When | +|------|------| +| `TIMEOUT` | Request aborted due to timeout | +| `NETWORK_ERROR` | Could not connect to server | +| `MISSING_CONFIG` | `agent_id` or `secret_key` not set when `"signature"` auth is used | +| `INVALID_API_KEY` | `api_key` not set when `"api-key"` auth is used | + +Server-returned error codes are passed through in `err.code` (e.g., `AGENT_NOT_FOUND`, `REGISTRATION_PENDING`). + +--- + +### `AuthMode` ```typescript type AuthMode = 'signature' | 'api-key' | 'none'; ``` -### Config Module (`@agentdispatch/cli/config`) +--- + +## Config Module (`@agentdispatch/cli/config`) Manages reading, writing, and resolving ADMP configuration from the config file and environment variables. -#### `loadConfig()` +### `loadConfig()` -Load the config file from disk. Returns an empty object if the file does not exist or contains invalid JSON. +Load the config file from disk. Returns an empty object if the file does not exist or contains invalid JSON (logs a warning to stderr). ```typescript function loadConfig(): Partial; @@ -298,38 +427,46 @@ function loadConfig(): Partial; Config file location: `$ADMP_CONFIG_PATH` or `~/.admp/config.json`. -#### `saveConfig(config)` +--- -Atomically write configuration to disk. Creates the parent directory if needed. The file is written with mode `0600` (owner read/write only). Uses a temp file + rename to avoid TOCTOU race conditions. +### `saveConfig(config)` + +Atomically write configuration to disk. Creates the parent directory if needed. The file is written with mode `0600` (owner read/write only). Uses a temp file + rename to eliminate a TOCTOU race where the target file could briefly hold new secrets with wrong permissions. ```typescript function saveConfig(config: Partial): void; ``` -#### `resolveConfig()` +--- + +### `resolveConfig()` -Merge config file values with environment variable overrides. Environment variables always take precedence. The `base_url` always has a default value; other fields may be undefined. +Merge config file values with environment variable overrides. Environment variables always take precedence. The `base_url` always has a default value; other fields may be `undefined`. ```typescript function resolveConfig(): AdmpClientConfig; -// AdmpClientConfig = { base_url: string } & Partial<{ agent_id, secret_key, api_key }> +// Result type: { base_url: string } & Partial<{ agent_id, secret_key, api_key }> ``` **Resolution order** (highest precedence first): -1. Environment variable (`ADMP_BASE_URL`, `ADMP_AGENT_ID`, etc.) +1. Environment variable (`ADMP_BASE_URL`, `ADMP_AGENT_ID`, `ADMP_SECRET_KEY`, `ADMP_API_KEY`) 2. Config file (`~/.admp/config.json`) 3. Built-in default (`https://agentdispatch.fly.dev` for `base_url` only) -#### `requireConfig(fields)` +--- + +### `requireConfig(fields)` -Resolve config and validate that all specified fields are present and non-empty. Throws with a helpful message naming the missing env var if a field is unset. +Resolve config and validate that all specified fields are present and non-empty. Throws with a helpful error message naming the missing env var if a field is unset. ```typescript function requireConfig(fields: (keyof AdmpConfig)[]): ResolvedConfig; -// Throws: Error("agent_id not set -- run `admp init` or set ADMP_AGENT_ID") +// Throws: Error("agent_id not set — run `admp init` or set ADMP_AGENT_ID") ``` -#### `getConfigPath()` +--- + +### `getConfigPath()` Returns the resolved config file path. @@ -338,7 +475,9 @@ function getConfigPath(): string; // Returns: $ADMP_CONFIG_PATH or ~/.admp/config.json ``` -#### Types +--- + +### Types ```typescript interface AdmpConfig { @@ -351,6 +490,8 @@ interface AdmpConfig { type ResolvedConfig = Required; ``` +--- + ## Configuration The CLI stores credentials in `~/.admp/config.json` with file permissions `0600` (owner read/write only). @@ -369,9 +510,13 @@ The CLI stores credentials in `~/.admp/config.json` with file permissions `0600` | Field | Type | Required | Description | |-------|------|----------|-------------| | `base_url` | `string` | No | ADMP hub URL. Defaults to `https://agentdispatch.fly.dev`. | -| `agent_id` | `string` | Yes | Your registered agent identifier. | -| `secret_key` | `string` | Yes | Base64-encoded 64-byte Ed25519 secret key. Never transmitted; used only for local signing. | -| `api_key` | `string` | No | Optional API key for `X-Api-Key` authentication. | +| `agent_id` | `string` | Yes* | Your registered agent identifier. Must match `^[a-zA-Z0-9._\-:]+$`. | +| `secret_key` | `string` | Yes* | Base64-encoded 64-byte Ed25519 secret key. **Never transmitted** over the network; used only for local signing. | +| `api_key` | `string` | No | API key for `X-Api-Key` authentication (required for `admp send`). | + +*Required for most commands. Set via `admp init` or `admp register`. + +--- ### Environment Variable Overrides @@ -380,54 +525,47 @@ Environment variables always take precedence over config file values. | Variable | Overrides | Default | |----------|-----------|---------| | `ADMP_BASE_URL` | `base_url` | `https://agentdispatch.fly.dev` | -| `ADMP_AGENT_ID` | `agent_id` | _(required)_ | -| `ADMP_SECRET_KEY` | `secret_key` | _(required)_ | -| `ADMP_API_KEY` | `api_key` | _(optional)_ | +| `ADMP_AGENT_ID` | `agent_id` | _(required for most commands)_ | +| `ADMP_SECRET_KEY` | `secret_key` | _(required for most commands)_ | +| `ADMP_API_KEY` | `api_key` | _(required for `admp send`)_ | +| `ADMP_SEED` | seed in `admp register` | _(optional, avoids shell history exposure)_ | | `ADMP_JSON=1` | Same as `--json` flag | | | `ADMP_TIMEOUT` | Request timeout in milliseconds | `30000` | | `ADMP_CONFIG_PATH` | Config file path | `~/.admp/config.json` | | `NO_COLOR` | Disables ANSI color output (any value) | | -## Authentication +--- -All ADMP requests are authenticated using **Ed25519 HTTP Signatures** ([draft-cavage-http-signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures)). +## Authentication Details + +All ADMP requests use **Ed25519 HTTP Signatures**. ### How It Works -1. The CLI reads the `secret_key` from your config (a base64-encoded 64-byte Ed25519 secret key). +1. The CLI reads `secret_key` from config (base64-encoded 64-byte Ed25519 secret key). 2. For each request, `buildAuthHeaders` constructs a signing string from: - - `(request-target)`: lowercase method + space + request path (including query string) + - `(request-target)`: lowercase method + space + path + query string - `host`: target hostname - `date`: current UTC timestamp 3. The signing string is signed with Ed25519 (`tweetnacl.sign.detached`). -4. Two headers are added to the request: - - `Date`: the UTC timestamp used in signing +4. Two headers are added: + - `Date`: UTC timestamp used in signing - `Signature`: `keyId="",algorithm="ed25519",headers="(request-target) host date",signature=""` 5. The server independently verifies the signature using the agent's registered public key. ### Envelope Signatures -In addition to HTTP-level auth, ADMP message envelopes carry their own `signature` field for end-to-end integrity. The signing base is: +In addition to HTTP-level auth, ADMP message envelopes carry their own `signature` field for end-to-end integrity: ``` -timestamp\nsha256(body)\nfrom\nto\ncorrelation_id +signing base = timestamp\nsha256(body)\nfrom\nto\ncorrelation_id ``` -This is produced by `createSigningBase()` and signed by `signEnvelope()`. +Produced by `createSigningBase()` and signed by `signEnvelope()`. The `kid` is derived from `envelope.from` by stripping the `agent://` prefix. ### Key Storage -- The `secret_key` is stored locally in `~/.admp/config.json` with mode `0600`. +- `secret_key` is stored locally in `~/.admp/config.json` with mode `0600`. - It is **never transmitted** over the network. -- The server only stores the corresponding public key. -- Use `admp rotate-key` to generate a new keypair if your key is compromised. - -## Global Flags - -These flags are available on every command: - -| Flag | Description | -|------|-------------| -| `--json` | Output machine-readable JSON instead of human-friendly text. Also available via `ADMP_JSON=1`. | -| `--version` | Print the CLI version and exit. | -| `--help` | Show help for the command. | +- The server stores only the corresponding public key. +- Use `admp rotate-key` to generate a new keypair if your key is compromised (seed-based agents only). diff --git a/docs/ERROR-CODES.md b/docs/ERROR-CODES.md index 3088cd7..83f3293 100644 --- a/docs/ERROR-CODES.md +++ b/docs/ERROR-CODES.md @@ -1,4 +1,4 @@ - + # ADMP Error Codes Reference @@ -28,34 +28,48 @@ Complete reference of all error codes returned by the Agent Dispatch Messaging P |------|------|-----------|-------------|------| | `API_KEY_REQUIRED` | 401 | No | No API key provided | Include `X-Api-Key` header or `Authorization: Bearer` | | `INVALID_API_KEY` | 401 | No | API key not recognized or expired | Check key is correct and not expired. Expired keys return this same error to avoid leaking key existence | +| `MASTER_KEY_REQUIRED` | 401 | No | Master API key required for this endpoint | Use the `MASTER_API_KEY` value, not a regular issued key | | `SIGNATURE_INVALID` | 403 | No | HTTP Signature header verification failed | Verify signing string matches: method, path, host, date headers. Check Ed25519 keypair. **Not the same as `INVALID_SIGNATURE`** — this code is for the HTTP `Signature:` header; `INVALID_SIGNATURE` is for the message envelope `signature` field. | | `INVALID_SIGNATURE_HEADER` | 400 | No | Signature header missing keyId or signature | Format: `keyId="id",algorithm="ed25519",headers="(request-target) host date",signature="base64"` | | `UNSUPPORTED_ALGORITHM` | 400 | No | Signature algorithm is not ed25519 | Only ed25519 is supported | | `INSUFFICIENT_SIGNED_HEADERS` | 400 | No | `(request-target)` not in signed headers | Always include `(request-target)` in the headers param | | `DATE_HEADER_REQUIRED` | 400 | No | Date header not in signed headers or missing | Include `Date` header and list `date` in signed headers | -| `REQUEST_EXPIRED` | 403 | Yes (with fresh timestamp) | Date header outside +/-5 minute window | Ensure system clock is synced. Regenerate `Date` header | -| `SIGNATURE_VERIFICATION_FAILED` | 400 | No | General signature verification error (catch-all) | Check signing string construction. Distinct from `SIGNATURE_INVALID` (specific Ed25519 mismatch) — this covers parse errors and unexpected failures. | +| `REQUEST_EXPIRED` | 403 | Yes (with fresh timestamp) | Date header outside +/-5 minute window | Ensure system clock is synced. Regenerate `Date` header and re-sign | +| `SIGNATURE_VERIFICATION_FAILED` | 400 | No | General signature verification error (catch-all) | Check signing string construction. Covers parse errors and unexpected failures. | | `REGISTRATION_PENDING` | 403 | Yes (after approval) | Agent registration awaiting admin approval | Contact admin or wait for `POST /:agentId/approve` | -| `REGISTRATION_REJECTED` | 403 | No | Agent registration was rejected | Contact admin. Check `rejection_reason` | -| `FORBIDDEN` | 403 | No | Signature keyId doesn't match target agent | Agent can only access its own resources | +| `REGISTRATION_REJECTED` | 403 | No | Agent registration was rejected | Contact admin. Check rejection reason in the agent record | +| `FORBIDDEN` | 403 | No | Signature keyId doesn't match target agent | Agent can only access its own resources (except sending to others' inboxes) | | `ENROLLMENT_TOKEN_USED` | 403 | No | Single-use enrollment token already consumed | Request a new enrollment token | | `ENROLLMENT_TOKEN_SCOPE` | 403 | No | Token scoped to a different agent | Use the token only for the agent specified in `target_agent_id` | -| `INVALID_SIGNATURE` | 403 | No | Message-level envelope signature verification failed | Check the `signature` field in the message envelope body. **Not the same as `SIGNATURE_INVALID`** — this code is for the message envelope `signature` field; `SIGNATURE_INVALID` is for the HTTP `Signature:` header. | +| `INVALID_SIGNATURE` | 403 | No | Message-level envelope signature verification failed | Check the `signature` field in the message envelope body. **Not the same as `SIGNATURE_INVALID`** | + +--- ## Agent Errors | Code | HTTP | Retryable | Description | Hint | |------|------|-----------|-------------|------| -| `REGISTRATION_FAILED` | 400 | No | Agent registration failed | Check `agent_id` uniqueness, required fields | +| `REGISTRATION_FAILED` | 400 | No | Agent registration failed | Check `agent_id` uniqueness, character set (`^[a-zA-Z0-9._\-:]+$`), and required fields | | `AGENT_NOT_FOUND` | 404 | No | Agent ID not found in storage | Verify `agent_id` is correct and agent is registered | | `AGENT_ID_REQUIRED` | 400 | No | No agent ID provided | Include `agent_id` in URL path or `X-Agent-ID` header | | `HEARTBEAT_FAILED` | 400 | Yes | Heartbeat update failed | Verify agent exists | | `DEREGISTER_FAILED` | 400 | No | Agent deregistration failed | Verify agent exists | | `ADD_TRUSTED_FAILED` | 400 | No | Failed to add trusted agent | Check both agent IDs exist | | `REMOVE_TRUSTED_FAILED` | 400 | No | Failed to remove trusted agent | Verify trust relationship exists | +| `GET_WEBHOOK_FAILED` | 400 | No | Failed to get webhook config | Check agent exists | +| `WEBHOOK_URL_REQUIRED` | 400 | No | No webhook URL provided | Include `webhook_url` in request body | +| `WEBHOOK_CONFIG_FAILED` | 400 | No | Webhook configuration failed | Check URL format and agent exists | +| `REMOVE_WEBHOOK_FAILED` | 400 | No | Webhook removal failed | Verify agent exists | +| `LIST_GROUPS_FAILED` | 400 | No | Failed to list agent groups | Check agent exists | | `KEY_ROTATION_FAILED` | 400 | No | Key rotation failed | Only seed-based agents support rotation. Verify seed matches current key | | `SEED_MISMATCH` | 403 | No | Provided seed doesn't match current key | The seed must derive the agent's current public key | | `SEED_AND_TENANT_REQUIRED` | 400 | No | Missing seed or tenant_id for rotation | Provide both `seed` and `tenant_id` | +| `APPROVE_FAILED` | 400 | No | Agent approval failed | Check agent exists | +| `REJECT_FAILED` | 400 | No | Agent rejection failed | Check agent exists | +| `INVALID_REASON` | 400 | No | Rejection reason not a string | Must be a string | +| `REASON_TOO_LONG` | 400 | No | Rejection reason too long | Max 500 characters | + +--- ## Message and Inbox Errors @@ -63,26 +77,28 @@ Complete reference of all error codes returned by the Agent Dispatch Messaging P | Code | HTTP | Retryable | Description | Hint | |------|------|-----------|-------------|------| -| `SEND_FAILED` | 400 | Yes | Inbox message send failed | Check envelope format, recipient exists | +| `SEND_FAILED` | 400 | Yes | Inbox message send failed | Check envelope format, recipient exists, signature valid | | `RECIPIENT_NOT_FOUND` | 404 | No | Target agent not found | Verify recipient `agent_id` | -| `INVALID_TIMESTAMP` | 400 | No | Message timestamp invalid | Use ISO-8601 format | +| `INVALID_TIMESTAMP` | 400 | No | Message timestamp invalid or outside window | Use ISO-8601 format. Timestamp must be within +/- 5 minutes | | `PULL_FAILED` | 400 | Yes | Inbox pull failed | Verify agent exists and has messages | -| `ACK_FAILED` | 400 | No | Message acknowledgment failed | Ensure message is leased to this agent | +| `ACK_FAILED` | 400 | No | Message acknowledgment failed | Ensure message is leased to this agent and in `leased` status | | `NACK_FAILED` | 400 | No | Message negative ack failed | Ensure message is leased to this agent | | `REPLY_FAILED` | 400 | No | Reply failed | Verify original message exists | | `MESSAGE_NOT_FOUND` | 404 | No | Message ID not found | Message may have been acked or expired | | `MESSAGE_EXPIRED` | 410 | No | Message purged (ephemeral or TTL) | Message data is gone permanently | -| `STATS_FAILED` | 500 | Yes | Failed to retrieve inbox stats | Transient storage error. **Note:** This code also appears in [System Errors](#system-errors) for the `GET /api/stats` endpoint — context determines which endpoint failed. | +| `STATS_FAILED` | 500 | Yes | Failed to retrieve inbox stats | Transient storage error. **Note:** This code also appears in [System Errors](#system-errors) for the `GET /api/stats` endpoint | | `RECLAIM_FAILED` | 400 | Yes | Lease reclaim failed | Transient error, retry | +--- + ## Group Errors | Code | HTTP | Retryable | Description | Hint | |------|------|-----------|-------------|------| -| `CREATE_GROUP_FAILED` | 400 | No | Group creation failed | Check name format (alphanumeric, max 100 chars) | +| `CREATE_GROUP_FAILED` | 400 | No | Group creation failed | Check name format | | `INVALID_NAME` | 400 | No | Group name empty or invalid | Non-empty string required | | `NAME_TOO_LONG` | 400 | No | Group name exceeds 100 chars | Shorten the name | -| `INVALID_NAME_CHARS` | 400 | No | Name has invalid characters | Only letters, numbers, spaces, hyphens, underscores, periods | +| `INVALID_NAME_CHARS` | 400 | No | Name has invalid characters | Only letters, numbers, spaces, hyphens, underscores, periods allowed | | `GROUP_NOT_FOUND` | 404 | No | Group not found | Verify group ID | | `GET_GROUP_FAILED` | 400/404 | No | Failed to get group | Check group ID | | `UPDATE_GROUP_FAILED` | 400/403 | No | Group update failed | Must be owner or admin | @@ -90,31 +106,36 @@ Complete reference of all error codes returned by the Agent Dispatch Messaging P | `LIST_MEMBERS_FAILED` | 400/403 | No | Cannot list members | Must be a group member | | `ADD_MEMBER_FAILED` | 400/403/409 | No | Cannot add member | Need admin/owner role, member limit not reached | | `REMOVE_MEMBER_FAILED` | 400/403 | No | Cannot remove member | Cannot remove group owner | -| `JOIN_FAILED` | 400/403 | No | Cannot join group | Check access type (open/key/invite-only) | -| `LEAVE_FAILED` | 400 | No | Cannot leave group | Owner cannot leave | +| `JOIN_FAILED` | 400/403 | No | Cannot join group | Check access type (open/key/invite-only) and key validity | +| `LEAVE_FAILED` | 400 | No | Cannot leave group | Owner cannot leave; transfer ownership first | | `POST_MESSAGE_FAILED` | 400/403 | No | Cannot post to group | Must be a member | | `INVALID_MESSAGE` | 400 | No | Missing subject or body | Both `subject` and `body` required | | `INVALID_SUBJECT` | 400 | No | Subject too long | Max 200 characters | | `BODY_TOO_LARGE` | 400 | No | Message body exceeds 1MB | Reduce body size | | `GET_MESSAGES_FAILED` | 400/403 | No | Cannot get messages | Must be a member | +--- + ## Outbox (Email) Errors | Code | HTTP | Retryable | Description | Hint | |------|------|-----------|-------------|------| -| `DOMAIN_CONFIG_FAILED` | 400/409 | No | Domain configuration failed | Agent may already have a domain | +| `DOMAIN_CONFIG_FAILED` | 400/409 | No | Domain configuration failed | Agent may already have a domain (409 Conflict) | | `DOMAIN_REQUIRED` | 400 | No | No domain provided | Include `domain` in request body | -| `NO_DOMAIN` | 404 | No | No domain configured for agent | Configure domain first | +| `NO_DOMAIN` | 404 | No | No domain configured for agent | Configure domain first via `POST /outbox/domain` | | `DOMAIN_FETCH_FAILED` | 500 | Yes | Failed to fetch domain config | Transient error | -| `DOMAIN_VERIFY_FAILED` | 400/404 | Yes | DNS verification failed | Check DNS records are set correctly | +| `DOMAIN_VERIFY_FAILED` | 400/404 | Yes | DNS verification failed | Check DNS records are set correctly. DNS propagation may take time | | `DOMAIN_DELETE_FAILED` | 400/404 | No | Domain removal failed | Check domain exists | -| `SEND_FAILED` | 400/403/404 | Depends | Outbox email send failed (see also [Message and Inbox Errors](#message-and-inbox-errors) for the inbox variant) | Domain must be verified. Check recipient format | +| `SEND_FAILED` | 400/403/404 | Depends | Outbox email send failed | Domain must be verified (403). Check recipient format. See also inbox variant. | | `TO_REQUIRED` | 400 | No | No recipient email | Include `to` field | | `INVALID_EMAIL` | 400 | No | Invalid email format | Use valid email: `user@domain.com` | | `SUBJECT_REQUIRED` | 400 | No | No email subject | Include `subject` field | | `BODY_REQUIRED` | 400 | No | No body or html content | Include `body` or `html` field | | `OUTBOX_MESSAGE_NOT_FOUND` | 404 | No | Outbox message not found | Check message ID | | `OUTBOX_FETCH_FAILED` | 500 | Yes | Failed to fetch outbox data | Transient error | +| `FORBIDDEN` | 403 | No | Message belongs to a different agent | Agent can only access its own outbox messages | + +--- ## Tenant Errors @@ -125,12 +146,12 @@ Complete reference of all error codes returned by the Agent Dispatch Messaging P | `TENANT_NOT_FOUND` | 404 | No | Tenant not found | Check `tenant_id` | | `INVALID_REGISTRATION_POLICY` | 400 | No | Invalid policy value | Must be `open` or `approval_required` | | `CREATE_TENANT_FAILED` | 400 | No | Tenant creation failed | Check required fields | +| `GET_TENANT_FAILED` | 400 | No | Failed to get tenant | Check `tenant_id` | | `DELETE_TENANT_FAILED` | 400 | No | Tenant deletion failed | Verify tenant exists | -| `LIST_PENDING_FAILED` | 400 | No | Failed to list pending agents | Check `tenant_id` | -| `APPROVE_FAILED` | 400 | No | Agent approval failed | Check agent exists | -| `REJECT_FAILED` | 400 | No | Agent rejection failed | Check agent exists | -| `INVALID_REASON` | 400 | No | Rejection reason not a string | Must be a string | -| `REASON_TOO_LONG` | 400 | No | Rejection reason too long | Max 500 characters | +| `LIST_TENANT_AGENTS_FAILED` | 400 | No | Failed to list tenant agents | Check `tenant_id` | +| `LIST_PENDING_FAILED` | 400 | No | Failed to list pending agents | Check `tenant_id` and master key | + +--- ## System Errors @@ -138,18 +159,20 @@ Complete reference of all error codes returned by the Agent Dispatch Messaging P |------|------|-----------|-------------|------| | `NOT_FOUND` | 404 | No | Endpoint doesn't exist | Check URL path | | `INTERNAL_ERROR` | 500 | Yes | Unhandled server error | Transient, retry with backoff | -| `STATS_FAILED` | 500 | Yes | System stats retrieval failed | Transient error. **Note:** This code also appears in [Message and Inbox Errors](#message-and-inbox-errors) for `GET /api/agents/:agentId/inbox/stats` — context determines which endpoint failed. | +| `STATS_FAILED` | 500 | Yes | System stats retrieval failed | Transient error. **Note:** This code also appears in [Message and Inbox Errors](#message-and-inbox-errors) for `GET /api/agents/:agentId/inbox/stats` | | `DISCOVERY_FAILED` | 500 | Yes | Public key directory failed | Transient error | | `DID_DOCUMENT_FAILED` | 500 | Yes | DID document generation failed | Transient error | | `WEBHOOK_FAILED` | 500 | Yes | Mailgun webhook processing failed | Transient error | | `SIGNATURE_REQUIRED` | 400 | No | Mailgun webhook missing signature | Signing key is configured but no signature in request | +--- + ## Identity Verification Errors | Code | HTTP | Retryable | Description | Hint | |------|------|-----------|-------------|------| | `GITHUB_LINK_FAILED` | 400 | No | GitHub handle linking failed | Check handle format | -| `CRYPTOGRAPHIC_VERIFY_FAILED` | 400 | No | Cryptographic tier verification failed | Agent must have a DID | +| `CRYPTOGRAPHIC_VERIFY_FAILED` | 400 | No | Cryptographic tier verification failed | Agent must have a DID (seed-based registration) | | `GET_IDENTITY_FAILED` | 400 | No | Failed to get identity info | Check agent exists | --- @@ -175,7 +198,17 @@ Content-Type: application/json { "error": "API_KEY_REQUIRED", - "message": "No API key provided" + "message": "API key is required" +} +``` + +```json +HTTP/1.1 400 Bad Request +Content-Type: application/json + +{ + "error": "REGISTRATION_FAILED", + "message": "agent_id may only contain letters, numbers, dots, underscores, hyphens, and colons" } ``` @@ -185,7 +218,7 @@ Content-Type: application/json { "error": "AGENT_NOT_FOUND", - "message": "Agent ID not found in storage" + "message": "Agent my-agent not found" } ``` @@ -195,7 +228,7 @@ Content-Type: application/json { "error": "INTERNAL_ERROR", - "message": "Unhandled server error" + "message": "Internal server error" } ``` @@ -211,8 +244,8 @@ Not all errors should be retried. Use the HTTP status code and the **Retryable** |--------|----------|--------| | **400** | Client error | Fix the request before retrying. The request is malformed or missing required fields. | | **401** | Authentication issue | Check credentials. Verify the API key or bearer token is correct and not expired. | -| **403** | Authorization issue | The caller lacks permission. Do not retry, except for `REQUEST_EXPIRED` which can be retried with a fresh `Date` header and regenerated signature. `REGISTRATION_PENDING` can be retried after the agent is approved. | -| **404** | Resource not found | Verify the resource ID (agent, message, group, key, tenant). Do not retry unless the resource is expected to be created by another process. | +| **403** | Authorization issue | The caller lacks permission. Do not retry, except for `REQUEST_EXPIRED` (re-sign with fresh Date) and `REGISTRATION_PENDING` (retry after approval). | +| **404** | Resource not found | Verify the resource ID (agent, message, group, tenant). Do not retry unless the resource is expected to be created by another process. | | **409** | Conflict | The resource already exists. Use a different identifier or retrieve the existing resource. | | **410** | Gone permanently | The resource has been permanently removed (e.g., expired messages). Do not retry. | | **500** | Server error | Retry with exponential backoff. Start at 1 second, double each attempt, cap at 30 seconds. Include jitter to avoid thundering herd. | @@ -234,7 +267,8 @@ After 5-6 attempts, log the error and alert. Continued retries are unlikely to s ### Special Cases -- **`REQUEST_EXPIRED` (403)**: Retryable, but you must regenerate the `Date` header and re-sign the request. Simply replaying the same request will fail again. +- **`REQUEST_EXPIRED` (403)**: Retryable, but you must regenerate the `Date` header and re-sign the request with a fresh timestamp. Simply replaying the same request will fail again. - **`REGISTRATION_PENDING` (403)**: Retryable after the agent has been approved by an admin. Poll infrequently (e.g., every 30 seconds) or use a webhook/callback if available. - **`SEND_FAILED` in outbox context (400/403/404)**: Retryability depends on the underlying cause. A 403 (unverified domain) is not retryable until the domain is verified. A transient 500 is retryable. - **`DOMAIN_VERIFY_FAILED` (400/404)**: DNS propagation may take time. Retry after a delay (e.g., 60 seconds) if you have just configured DNS records. +- **`REGISTRATION_FAILED` agent_id validation**: If you receive this because of an invalid `agent_id`, fix the character set — only `^[a-zA-Z0-9._\-:]+$` is allowed. Slashes, spaces, and `agent://` prefixes are not valid agent IDs. diff --git a/llms.txt b/llms.txt index 4ea7ec7..6654c41 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,7 @@ # Agent Dispatch (ADMP) + -> Universal inbox for autonomous AI agents -- at-least-once delivery, Ed25519 auth, DID federation +> Universal inbox for autonomous AI agents — at-least-once delivery, Ed25519 auth, DID federation Base URL: https://agentdispatch.fly.dev Docs: https://agentdispatch.fly.dev/docs @@ -15,17 +16,23 @@ admp pull # 3. Pull admp ack # 4. Ack ``` +## Agent ID Format + +`agent_id` must match `^[a-zA-Z0-9._\-:]+$` — letters, digits, dots, underscores, hyphens, colons. +No slashes, spaces, or `agent://` prefix. Auto-generated IDs are `agent-`. + ## Message Envelope -Required fields: version, from, to, subject, timestamp. URIs: `agent://` or `did:seed:`. +Required fields: version, from, to, subject, timestamp. +`from`/`to` accept: bare agent IDs, `agent://` URIs, or `did:seed:` DIDs. ```json { "version": "1.0", "id": "uuid", "type": "task.request", - "from": "agent://sender-id", - "to": "agent://recipient-id", + "from": "sender-id", + "to": "recipient-id", "subject": "create_user", "correlation_id": "c-12345", "headers": {"priority": "high"}, @@ -49,14 +56,15 @@ leased --> purged (ephemeral + ack) ## Authentication -### HTTP Signatures (Ed25519) -- inbox pull/ack/nack/reply +### HTTP Signatures (Ed25519) — inbox pull/ack/nack/reply ``` Signature: keyId="",algorithm="ed25519",headers="(request-target) host date",signature="" ``` Signing string: `(request-target): post /api/agents/{id}/inbox/pull\nhost: agentdispatch.fly.dev\ndate: ` Date must be within +-5 minutes. Signing agent must match `:agentId` in URL. +Exception: `POST /api/agents/:id/messages` — any registered agent may sign (cross-agent messaging). -### API Keys -- send, status, tenants +### API Keys — send, status, tenants ``` X-Api-Key: ``` @@ -73,12 +81,13 @@ GET /docs Swagger UI GET /openapi.json OpenAPI spec POST /api/agents/register Register agent GET /.well-known/agent-keys.json JWKS public key directory +GET /api/agents/:agentId/did.json W3C DID document ``` ### Agent Management [HTTP Sig] ``` GET /api/agents/:agentId Get agent details -DELETE /api/agents/:agentId Deregister agent +DELETE /api/agents/:agentId Deregister agent POST /api/agents/:agentId/heartbeat Heartbeat (body: {metadata?}) POST /api/agents/:agentId/rotate-key Rotate key (body: {seed, tenant_id}) GET /api/agents/:agentId/trusted List trusted agents @@ -90,12 +99,11 @@ DELETE /api/agents/:agentId/webhook Remove webhook POST /api/agents/:agentId/verify/github Link GitHub (body: {github_handle}) POST /api/agents/:agentId/verify/cryptographic Confirm crypto tier GET /api/agents/:agentId/identity Verification status -GET /api/agents/:agentId/did.json W3C DID document (no auth) ``` ### Inbox ``` -POST /api/agents/:agentId/messages Send message [API Key] +POST /api/agents/:agentId/messages Send message [API Key — any registered agent] Body: {...envelope, ephemeral?, ttl?} -> {message_id, status} POST /api/agents/:agentId/inbox/pull Pull with lease [HTTP Sig] Body: {visibility_timeout?} -> {message_id, envelope, lease_until, attempts} | 204 @@ -115,7 +123,7 @@ POST /api/agents/:agentId/inbox/reclaim Reclaim expired leases [HTTP POST /api/groups Create (body: {name, access?, settings?}) GET /api/groups/:groupId Get info PUT /api/groups/:groupId Update (admin/owner) -DELETE /api/groups/:groupId Delete (owner) +DELETE /api/groups/:groupId Delete (owner) GET /api/groups/:groupId/members List members POST /api/groups/:groupId/members Add member (body: {agent_id, role?}) DELETE /api/groups/:groupId/members/:agentId Remove member @@ -131,9 +139,9 @@ GET /api/agents/:agentId/groups Agent's groups [HTTP Sig] POST /api/agents/:agentId/outbox/domain Set domain (body: {domain}) GET /api/agents/:agentId/outbox/domain Get domain config POST /api/agents/:agentId/outbox/domain/verify Verify DNS -DELETE /api/agents/:agentId/outbox/domain Remove domain +DELETE /api/agents/:agentId/outbox/domain Remove domain POST /api/agents/:agentId/outbox/send Send email (body: {to, subject, body?, html?}) -GET /api/agents/:agentId/outbox/messages List sent (?status=&limit=) +GET /api/agents/:agentId/outbox/messages List sent (?status=&limit=) GET /api/agents/:agentId/outbox/messages/:msgId Get status ``` @@ -141,8 +149,8 @@ GET /api/agents/:agentId/outbox/messages/:msgId Get status ``` POST /api/agents/tenants Create tenant GET /api/agents/tenants/:tenantId Get tenant -GET /api/agents/tenants/:tenantId/agents List agents -DELETE /api/agents/tenants/:tenantId Delete tenant +GET /api/agents/tenants/:tenantId/agents List agents +DELETE /api/agents/tenants/:tenantId Delete tenant GET /api/agents/tenants/:tenantId/pending List pending [Master] POST /api/agents/:agentId/approve Approve agent [Master] POST /api/agents/:agentId/reject Reject (body: {reason?}) [Master] @@ -156,35 +164,34 @@ All commands support `--json` for machine-readable output. ``` admp init Interactive config wizard admp config show | set Show/set config -admp register [--name] [--seed] Register new agent -admp deregister Delete agent +admp register [--name] [--seed ] Register new agent admp agent get View agent details -admp heartbeat [--metadata] Send keepalive -admp rotate-key [--seed] Rotate signing key -admp send --to --subject --body Send message -admp pull [--timeout] Pull next message -admp ack [--result] Acknowledge +admp heartbeat [--metadata ] Send keepalive +admp rotate-key [--seed ] Rotate signing key +admp send --to --subject --body Send message +admp pull [--timeout ] Pull next message (max 300s) +admp ack [--result ] Acknowledge admp nack [--extend] [--requeue] Reject/defer admp reply --subject --body Correlated reply admp status Delivery status admp inbox stats Queue counts -admp webhook set --url --secret | get | delete Webhook config -admp groups create --name --access Create group (open|key|invite) -admp groups list | join | leave Group membership +admp webhook set --url --secret | get | delete Webhook config +admp groups create --name --access Create group +admp groups list | join [--key] | leave Group membership admp groups send --subject --body Broadcast to group -admp groups messages [--limit] Group message history admp outbox domain set --domain | verify | delete Domain config admp outbox send --to --subject Send email admp outbox messages [--status] [--limit] List sent emails ``` -## Library (@agentdispatch/cli) +## Library (@agentdispatch/cli@0.2.1) ```ts import { buildAuthHeaders, signEnvelope, toBase64, fromBase64, sha256 } from "@agentdispatch/cli"; // or "/auth" -import { AdmpClient, AdmpError, AuthMode } from "@agentdispatch/cli/client"; // AuthMode = "signature"|"api-key"|"none" +import { AdmpClient, AdmpError, AuthMode } from "@agentdispatch/cli/client"; import { loadConfig, saveConfig, resolveConfig, requireConfig } from "@agentdispatch/cli/config"; +// AuthMode = "signature" | "api-key" | "none" buildAuthHeaders(method, path, host, secretKey, agentId): Record signEnvelope(envelope, secretKey): object // returns envelope with .signature added new AdmpClient({base_url, agent_id?, secret_key?, api_key?}) @@ -207,7 +214,6 @@ File: `~/.admp/config.json` (mode 0600) | `ADMP_API_KEY` | api_key | _(optional)_ | | `ADMP_TIMEOUT` | request timeout ms | `30000` | | `ADMP_CONFIG_PATH` | config file path | `~/.admp/config.json` | -| `ADMP_JSON=1` | force JSON output | | ### Server env vars | Variable | Default | Description | @@ -216,8 +222,8 @@ File: `~/.admp/config.json` (mode 0600) | `MASTER_API_KEY` | _(none)_ | Admin endpoints (secret) | | `STORAGE_BACKEND` | `memory` | `memory` or `mech` | | `REGISTRATION_POLICY` | `open` | `open` or `approval_required` | -| `MESSAGE_TTL_SEC` | `86400` | Default message TTL | | `MAILGUN_API_KEY` | _(none)_ | Outbound email (secret) | +| `DID_WEB_ALLOWED_DOMAINS` | _(none)_ | Comma-separated DID:web allowlist | ## Error Codes @@ -233,11 +239,11 @@ Format: `{"error": "CODE", "message": "description"}` | `INVALID_API_KEY` | 401 | No | Key not recognized | | `FORBIDDEN` | 403 | No | Agent mismatch (signer != target) | | `REGISTRATION_PENDING` | 403 | Yes* | Awaiting approval (*poll after approve) | +| `REGISTRATION_REJECTED` | 403 | No | Registration was rejected | | `RECIPIENT_NOT_FOUND` | 404 | No | Target agent not found | | `MESSAGE_NOT_FOUND` | 404 | No | Message ID not found | | `MESSAGE_EXPIRED` | 410 | No | Purged (ephemeral/TTL) | | `SEND_FAILED` | 400 | Yes | Message or email send failed | -| `PULL_FAILED` | 400 | Yes | Inbox pull failed | | `NOT_FOUND` | 404 | No | Endpoint does not exist | | `INTERNAL_ERROR` | 500 | Yes | Server error (backoff: 1s, 2s, 4s, 8s, 16s, 30s cap) | From 31487bff42f9e94f1987c5397fa982ccdca2ccdb Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 09:17:23 -0600 Subject: [PATCH 04/20] fix(review): close injection gap in validId, add length cap, add negative tests Address code review feedback on PR #16: - Fix validId() in inbox.service.js to validate full URI (not just prefix). Before: agent://evil\nX-Injected: header passed because startsWith('agent://') After: /^agent:\/\/[a-zA-Z0-9._:-]+$/ validates the full string. Same fix applied to did:seed: prefix. - Add 255-char max length cap on agent_id at registration to prevent abuse and silent truncation at the storage layer. - Fix regex style: move hyphen to end of character class ([a-zA-Z0-9._:-]) to avoid ambiguity (conventional form, noted in review). - Add negative test: 7 dangerous agent_id values all return 400 (spaces, newlines, slashes, null bytes, XSS, agent:// scheme, >255 chars). - Add negative test: 5 malicious envelope from fields all return 400 (newline injection, agent:// with injected newline, did:seed: with newline, spaces, path traversal). Also verifies legacy agent:// is accepted. Co-Authored-By: Claude Sonnet 4.6 --- src/server.test.js | 76 +++++++++++++++++++++++++++++++++++ src/services/agent.service.js | 6 ++- src/services/inbox.service.js | 11 +++-- 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/server.test.js b/src/server.test.js index f40246a..72c7d01 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -92,6 +92,82 @@ test('GET /api/stats returns stats object', async () => { assert.ok(res.body.messages); }); +test('agent_id validation rejects dangerous characters', async () => { + const bad = [ + 'has space', + 'newline\ninjection', + 'path/traversal', + 'null\x00byte', + '', + 'agent://legacy-scheme', + 'a'.repeat(256), + ]; + + for (const id of bad) { + const res = await request(app) + .post('/api/agents/register') + .send({ agent_id: id, agent_type: 'test' }); + assert.equal(res.status, 400, `Expected 400 for agent_id: ${JSON.stringify(id)}`); + } + + // Valid IDs should still work + const valid = ['simple', 'with-hyphens', 'dots.allowed', 'colons:ok', 'ALL_CAPS', 'a'.repeat(255)]; + for (const id of valid) { + const res = await request(app) + .post('/api/agents/register') + .send({ agent_id: id, agent_type: 'test' }); + assert.equal(res.status, 201, `Expected 201 for agent_id: ${JSON.stringify(id)}`); + } +}); + +test('envelope from/to validation rejects injection attempts', async () => { + const sender = await registerAgent('env-sender'); + const recipient = await registerAgent('env-recipient'); + + // Malicious from fields that should be rejected + const badFromIds = [ + 'evil\nX-Injected: header', + 'agent://bad\ninjected', + 'did:seed:\ninjected', + 'has spaces', + '../traversal', + ]; + + for (const badId of badFromIds) { + const envelope = { + version: '1.0', + id: `msg-${Date.now()}`, + type: 'task.request', + from: badId, + to: recipient.agent_id, + subject: 'injection-test', + body: { test: true }, + timestamp: new Date().toISOString(), + }; + const res = await request(app) + .post(`/api/agents/${encodeURIComponent(recipient.agent_id)}/messages`) + .send(envelope); + assert.equal(res.status, 400, `Expected 400 for from: ${JSON.stringify(badId)}`); + } + + // Legacy agent:// URI in from field should still pass (backward-compat) + const legacyEnvelope = { + version: '1.0', + id: `msg-${Date.now()}`, + type: 'task.request', + from: 'agent://legacy-sender', + to: recipient.agent_id, + subject: 'legacy-compat', + body: { test: true }, + timestamp: new Date().toISOString(), + }; + const legacyRes = await request(app) + .post(`/api/agents/${encodeURIComponent(recipient.agent_id)}/messages`) + .send(legacyEnvelope); + // 201 (sender not in storage so signature skipped) or 404 — either is fine; just not 400 + assert.notEqual(legacyRes.status, 400, 'Legacy agent:// envelope from should not be rejected by validation'); +}); + test('agent registration, heartbeat, and get agent', async () => { const agent = await registerAgent('test-agent', { role: 'tester' }); diff --git a/src/services/agent.service.js b/src/services/agent.service.js index 9e15c41..da9ab1e 100644 --- a/src/services/agent.service.js +++ b/src/services/agent.service.js @@ -37,10 +37,14 @@ export class AgentService { // Allowed: letters, digits, dots, underscores, hyphens, colons (for did-web: prefixes). // Blocks newlines (signing string injection), slashes (path traversal), spaces, // null bytes, and other characters that are dangerous in URLs or HTTP headers. - if (!/^[a-zA-Z0-9._\-:]+$/.test(agent_id)) { + if (!/^[a-zA-Z0-9._:-]+$/.test(agent_id)) { throw new Error('agent_id may only contain letters, numbers, dots, underscores, hyphens, and colons'); } + if (agent_id.length > 255) { + throw new Error('agent_id must be 255 characters or fewer'); + } + // Check if agent already exists const existing = await storage.getAgent(agent_id); if (existing) { diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index 40e09a3..79ac9b4 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -390,11 +390,14 @@ export class InboxService { throw new Error(`Unsupported ADMP version: ${envelope.version}`); } - // Validate agent identifiers — accept agent:// URIs, did:seed: DIDs, and bare agent IDs + // Validate agent identifiers — accept agent:// URIs, did:seed: DIDs, and bare agent IDs. + // All three forms validate the full string (not just the prefix) to block injection + // via malicious suffixes like agent://foo\nX-Injected: header. + const SAFE_CHARS = /^[a-zA-Z0-9._:-]+$/; const validId = (id) => - id.startsWith('agent://') || - id.startsWith('did:seed:') || - /^[a-zA-Z0-9._\-:]+$/.test(id); + /^agent:\/\/[a-zA-Z0-9._:-]+$/.test(id) || + /^did:seed:[a-zA-Z0-9._:-]+$/.test(id) || + SAFE_CHARS.test(id); if (!validId(envelope.from)) { throw new Error('Invalid from field (must be agent:// URI, did:seed: DID, or valid agent ID)'); From 25b27eb2cf044f214590da3266beb52189d1e7ea Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 09:22:52 -0600 Subject: [PATCH 05/20] fix(review): module-scope regex constants, length-before-regex, tighter assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address second round of claude[bot] review feedback: - Move SAFE_CHARS, VALID_AGENT_URI, VALID_DID_SEED to module scope in inbox.service.js — compiled once, not recreated on every message send - Swap length check before regex in agent.service.js — O(1) guard runs first to avoid linear regex scan on pathological long input - Tighten backward-compat test: assert 201 (not just notEqual(400)) since recipient exists and signature is skipped for unknown senders - DID:web shadow agent bypass tracked in issue #17 Co-Authored-By: Claude Sonnet 4.6 --- src/server.test.js | 6 ++++-- src/services/agent.service.js | 9 +++++---- src/services/inbox.service.js | 11 ++++++++--- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/server.test.js b/src/server.test.js index 72c7d01..2224692 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -164,8 +164,10 @@ test('envelope from/to validation rejects injection attempts', async () => { const legacyRes = await request(app) .post(`/api/agents/${encodeURIComponent(recipient.agent_id)}/messages`) .send(legacyEnvelope); - // 201 (sender not in storage so signature skipped) or 404 — either is fine; just not 400 - assert.notEqual(legacyRes.status, 400, 'Legacy agent:// envelope from should not be rejected by validation'); + // 201: envelope accepted, sender not in storage so signature verification is skipped. + // 404 is NOT expected because the recipient exists. + // 400 would indicate the validation wrongly rejected a valid agent:// URI. + assert.equal(legacyRes.status, 201, 'Legacy agent:// envelope from should pass validation and be accepted'); }); test('agent registration, heartbeat, and get agent', async () => { diff --git a/src/services/agent.service.js b/src/services/agent.service.js index da9ab1e..21adba8 100644 --- a/src/services/agent.service.js +++ b/src/services/agent.service.js @@ -37,14 +37,15 @@ export class AgentService { // Allowed: letters, digits, dots, underscores, hyphens, colons (for did-web: prefixes). // Blocks newlines (signing string injection), slashes (path traversal), spaces, // null bytes, and other characters that are dangerous in URLs or HTTP headers. - if (!/^[a-zA-Z0-9._:-]+$/.test(agent_id)) { - throw new Error('agent_id may only contain letters, numbers, dots, underscores, hyphens, and colons'); - } - + // Length check first (O(1)) so we never run the regex on pathological input. if (agent_id.length > 255) { throw new Error('agent_id must be 255 characters or fewer'); } + if (!/^[a-zA-Z0-9._:-]+$/.test(agent_id)) { + throw new Error('agent_id may only contain letters, numbers, dots, underscores, hyphens, and colons'); + } + // Check if agent already exists const existing = await storage.getAgent(agent_id); if (existing) { diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index 79ac9b4..12f2b2d 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -9,6 +9,12 @@ import { verifySignature, fromBase64, validateTimestamp, parseTTL } from '../uti import { agentService } from './agent.service.js'; import { webhookService } from './webhook.service.js'; +// Safe agent identifier patterns — module-level constants so they are compiled once, +// not recreated on every message send. +const SAFE_CHARS = /^[a-zA-Z0-9._:-]+$/; +const VALID_AGENT_URI = /^agent:\/\/[a-zA-Z0-9._:-]+$/; +const VALID_DID_SEED = /^did:seed:[a-zA-Z0-9._:-]+$/; + export class InboxService { /** * Send message to agent's inbox @@ -393,10 +399,9 @@ export class InboxService { // Validate agent identifiers — accept agent:// URIs, did:seed: DIDs, and bare agent IDs. // All three forms validate the full string (not just the prefix) to block injection // via malicious suffixes like agent://foo\nX-Injected: header. - const SAFE_CHARS = /^[a-zA-Z0-9._:-]+$/; const validId = (id) => - /^agent:\/\/[a-zA-Z0-9._:-]+$/.test(id) || - /^did:seed:[a-zA-Z0-9._:-]+$/.test(id) || + VALID_AGENT_URI.test(id) || + VALID_DID_SEED.test(id) || SAFE_CHARS.test(id); if (!validId(envelope.from)) { From 8b9bc6a3df2b2b91eeee23798eff9f3a1aee091b Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 09:34:36 -0600 Subject: [PATCH 06/20] docs: update for length-first validation and issue #17 DID:web gap - Document 255-char length check runs before regex (O(1) guard) in AGENT-GUIDE, ARCHITECTURE, API-REFERENCE, ERROR-CODES, llms.txt - Add Known Limitations section in AGENT-GUIDE noting DID:web shadow agent bypass (tracked in issue #17) with DID_WEB_ALLOWED_DOMAINS mitigation - Update ARCHITECTURE security table to reference issue #17 Co-Authored-By: Claude Sonnet 4.6 --- docs/AGENT-GUIDE.md | 28 ++++++++++++++++++++++------ docs/API-REFERENCE.md | 2 +- docs/ARCHITECTURE.md | 4 ++-- docs/ERROR-CODES.md | 4 ++-- llms.txt | 4 ++-- 5 files changed, 29 insertions(+), 13 deletions(-) diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index 8d5e43a..a40b7bc 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -24,7 +24,8 @@ All request and response bodies are JSON (`Content-Type: application/json`). 7. [Error Handling](#7-error-handling) 8. [Registration Modes](#8-registration-modes) 9. [Approval Workflow](#9-approval-workflow) -10. [Best Practices](#10-best-practices) +10. [Known Limitations and Security Notes](#10-known-limitations-and-security-notes) +11. [Best Practices](#11-best-practices) --- @@ -187,11 +188,12 @@ Date: ... ## 3. Agent ID Format -`agent_id` must match the regular expression: +`agent_id` must satisfy two constraints, checked in this order: -``` -^[a-zA-Z0-9._\-:]+$ -``` +1. **Length:** 255 characters or fewer (checked first, O(1) guard). +2. **Character set:** Must match `^[a-zA-Z0-9._\-:]+$`. + +The length check runs before the regex so that pathologically long inputs are rejected immediately without executing the pattern match. **Allowed characters:** Letters (a-z, A-Z), digits (0-9), dots (`.`), underscores (`_`), hyphens (`-`), colons (`:`). @@ -479,7 +481,21 @@ X-Api-Key: --- -## 10. Best Practices +## 10. Known Limitations and Security Notes + +### Issue #17 — DID:web Shadow Agent Character Validation Bypass + +When a `did:web:` agent authenticates for the first time, the server auto-creates a shadow agent record. The `agent_id` for that shadow agent is derived from the DID's domain and path segments (e.g., `did-web:example.com/alice`) and is **not** run through the same 255-character length check and regex validation that applies to manually registered agents. + +This means a DID:web agent with a crafted long or unusual domain path could create a shadow agent with an `agent_id` that would normally be rejected at `POST /api/agents/register`. + +**Status:** Tracked in issue #17. A fix to apply the same character validation to shadow agent IDs at creation time is planned. + +**Mitigation:** Set `DID_WEB_ALLOWED_DOMAINS` to a strict allowlist of trusted domains. This prevents shadow agent creation for all domains not explicitly permitted. + +--- + +## 11. Best Practices ### Security diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index c90eb7e..fcb979a 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -130,7 +130,7 @@ Register a new agent. No authentication required. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `agent_id` | string | No | Custom agent ID. Must match `^[a-zA-Z0-9._\-:]+$`. If omitted, server generates `agent-`. | +| `agent_id` | string | No | Custom agent ID. Must be 255 characters or fewer AND match `^[a-zA-Z0-9._\-:]+$`. Length is checked first (O(1) guard before regex). If omitted, server generates `agent-`. | | `agent_type` | string | No | Agent type label (e.g., `claude_session`). Default: `generic`. | | `metadata` | object | No | Arbitrary metadata. | | `webhook_url` | string | No | URL for push delivery of incoming messages. | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b52e75d..15679fa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -375,7 +375,7 @@ Manages the full agent lifecycle. | Capability | Description | |---|---| | **Registration** | Three modes: *Legacy* (random keypair), *Seed-based* (HKDF deterministic from `LABEL_ADMP:::ed25519:vN`), *Import* (client-provided public key) | -| **agent_id validation** | Enforces `^[a-zA-Z0-9._\-:]+$`; auto-generates `agent-` if omitted | +| **agent_id validation** | Length check (> 255 chars) runs first (O(1) guard), then enforces `^[a-zA-Z0-9._\-:]+$`; auto-generates `agent-` if omitted | | **Heartbeat** | Periodic liveness signal; background job marks agents offline after `timeout_ms` | | **Approval Workflow** | `approve()` / `reject(reason)` for pending agents; master key required | | **Trust Management** | Per-agent trusted/blocked agent lists; enforced at message send time | @@ -562,7 +562,7 @@ When a `Signature` header contains `keyId="did:web:..."`: | **Algorithm confusion** | Only `ed25519` accepted; any other `algorithm` value is rejected | | **Multicodec confusion** | DID document keys must be exactly 34 bytes with `0xed01` prefix; other key types rejected | | **Namespace collision** | Shadow agent creation checks for existing non-federated agent with same ID | -| **agent_id injection** | Regex `^[a-zA-Z0-9._\-:]+$` blocks newlines (signing string injection), slashes (path traversal), spaces, null bytes | +| **agent_id injection** | Length check (> 255 chars, O(1)) runs before regex `^[a-zA-Z0-9._\-:]+$`, blocking newlines (signing string injection), slashes (path traversal), spaces, null bytes. Note: DID:web shadow agent IDs currently bypass this validation — see issue #17. | | **HTTP headers** | `helmet` middleware sets security headers (X-Content-Type-Options, X-Frame-Options, etc.) | | **Input validation** | 10MB JSON body limit; group name length/charset validation; rejection reason 500 char limit | | **Error response uniformity** | 401 for all bad-credential scenarios (expired, revoked, unknown) to prevent existence leaking | diff --git a/docs/ERROR-CODES.md b/docs/ERROR-CODES.md index 83f3293..cf6009c 100644 --- a/docs/ERROR-CODES.md +++ b/docs/ERROR-CODES.md @@ -49,7 +49,7 @@ Complete reference of all error codes returned by the Agent Dispatch Messaging P | Code | HTTP | Retryable | Description | Hint | |------|------|-----------|-------------|------| -| `REGISTRATION_FAILED` | 400 | No | Agent registration failed | Check `agent_id` uniqueness, character set (`^[a-zA-Z0-9._\-:]+$`), and required fields | +| `REGISTRATION_FAILED` | 400 | No | Agent registration failed | Check `agent_id` uniqueness, length (max 255 chars, checked before regex), character set (`^[a-zA-Z0-9._\-:]+$`), and required fields | | `AGENT_NOT_FOUND` | 404 | No | Agent ID not found in storage | Verify `agent_id` is correct and agent is registered | | `AGENT_ID_REQUIRED` | 400 | No | No agent ID provided | Include `agent_id` in URL path or `X-Agent-ID` header | | `HEARTBEAT_FAILED` | 400 | Yes | Heartbeat update failed | Verify agent exists | @@ -271,4 +271,4 @@ After 5-6 attempts, log the error and alert. Continued retries are unlikely to s - **`REGISTRATION_PENDING` (403)**: Retryable after the agent has been approved by an admin. Poll infrequently (e.g., every 30 seconds) or use a webhook/callback if available. - **`SEND_FAILED` in outbox context (400/403/404)**: Retryability depends on the underlying cause. A 403 (unverified domain) is not retryable until the domain is verified. A transient 500 is retryable. - **`DOMAIN_VERIFY_FAILED` (400/404)**: DNS propagation may take time. Retry after a delay (e.g., 60 seconds) if you have just configured DNS records. -- **`REGISTRATION_FAILED` agent_id validation**: If you receive this because of an invalid `agent_id`, fix the character set — only `^[a-zA-Z0-9._\-:]+$` is allowed. Slashes, spaces, and `agent://` prefixes are not valid agent IDs. +- **`REGISTRATION_FAILED` agent_id validation**: If you receive this because of an invalid `agent_id`, check both constraints: the `agent_id` must be 255 characters or fewer (checked first) AND match `^[a-zA-Z0-9._\-:]+$`. Slashes, spaces, and `agent://` prefixes are not valid agent IDs. diff --git a/llms.txt b/llms.txt index 6654c41..bc6afb9 100644 --- a/llms.txt +++ b/llms.txt @@ -18,8 +18,8 @@ admp ack # 4. Ack ## Agent ID Format -`agent_id` must match `^[a-zA-Z0-9._\-:]+$` — letters, digits, dots, underscores, hyphens, colons. -No slashes, spaces, or `agent://` prefix. Auto-generated IDs are `agent-`. +`agent_id` must be 255 characters or fewer AND match `^[a-zA-Z0-9._\-:]+$` — letters, digits, dots, underscores, hyphens, colons. +The length check runs before the regex (O(1) guard). No slashes, spaces, or `agent://` prefix. Auto-generated IDs are `agent-`. ## Message Envelope From e5156011a695004967d5acc2a9e6181e202ca19b Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 09:41:05 -0600 Subject: [PATCH 07/20] fix(review): length guard in validId, restrict did:seed: regex, to-field tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add id.length > 255 guard in validId() — O(1) check runs before any regex, protecting against DoS via huge envelope from/to fields - Restrict VALID_DID_SEED to [a-zA-Z0-9._-] (no colons) — did:seed: suffixes are hex fingerprints, nested colons are not valid - Add comment documenting that envelope from is UNTRUSTED when sender not found in storage (signature verification skipped); from must not be used for authorization without signature verification - Add negative tests for malicious to field (newline injection, agent:// suffix injection, path traversal, 256-char overflow) Co-Authored-By: Claude Sonnet 4.6 --- src/server.test.js | 25 +++++++++++++++++++++++++ src/services/inbox.service.js | 18 +++++++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/server.test.js b/src/server.test.js index 2224692..f49c09c 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -150,6 +150,31 @@ test('envelope from/to validation rejects injection attempts', async () => { assert.equal(res.status, 400, `Expected 400 for from: ${JSON.stringify(badId)}`); } + // Malicious to fields — same validation applies + const badToIds = [ + 'evil\nX-Injected: header', + 'agent://bad\ninjected', + '../traversal', + 'a'.repeat(256), + ]; + + for (const badId of badToIds) { + const envelope = { + version: '1.0', + id: `msg-${Date.now()}`, + type: 'task.request', + from: sender.agent_id, + to: badId, + subject: 'injection-test', + body: { test: true }, + timestamp: new Date().toISOString(), + }; + const res = await request(app) + .post(`/api/agents/${encodeURIComponent(recipient.agent_id)}/messages`) + .send(envelope); + assert.equal(res.status, 400, `Expected 400 for to: ${JSON.stringify(badId)}`); + } + // Legacy agent:// URI in from field should still pass (backward-compat) const legacyEnvelope = { version: '1.0', diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index 12f2b2d..a29f657 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -13,7 +13,8 @@ import { webhookService } from './webhook.service.js'; // not recreated on every message send. const SAFE_CHARS = /^[a-zA-Z0-9._:-]+$/; const VALID_AGENT_URI = /^agent:\/\/[a-zA-Z0-9._:-]+$/; -const VALID_DID_SEED = /^did:seed:[a-zA-Z0-9._:-]+$/; +// did:seed: suffixes are hex fingerprints (sha256 truncated to 16 bytes); no colons expected. +const VALID_DID_SEED = /^did:seed:[a-zA-Z0-9._-]+$/; export class InboxService { /** @@ -399,10 +400,17 @@ export class InboxService { // Validate agent identifiers — accept agent:// URIs, did:seed: DIDs, and bare agent IDs. // All three forms validate the full string (not just the prefix) to block injection // via malicious suffixes like agent://foo\nX-Injected: header. - const validId = (id) => - VALID_AGENT_URI.test(id) || - VALID_DID_SEED.test(id) || - SAFE_CHARS.test(id); + // Length is checked first (O(1) guard) to avoid running the regex on huge inputs. + // + // NOTE: `from` is used for display and signature verification only. When the sender + // is not found in storage, signature verification is skipped and `from` is UNTRUSTED. + // Callers must not use `from` for authorization without first verifying the signature. + // The agent:// scheme is still accepted in envelopes for backward compatibility with + // senders registered before bare-ID format was introduced. + const validId = (id) => { + if (!id || id.length > 255) return false; + return VALID_AGENT_URI.test(id) || VALID_DID_SEED.test(id) || SAFE_CHARS.test(id); + }; if (!validId(envelope.from)) { throw new Error('Invalid from field (must be agent:// URI, did:seed: DID, or valid agent ID)'); From 905810491337fea9a1710a1775675376bd4e2aa7 Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 09:48:01 -0600 Subject: [PATCH 08/20] fix(review): reserved prefix guard, unique test IDs, docs + asymmetry note - Add reserved prefix guard in agent.service.js: reject IDs starting with 'did:' or 'agent:' to prevent spoofing system-generated DIDs - Fix flaky test: valid ID cases now use unique suffix (Date.now()) to avoid conflicts when in-memory store persists across test runs - Add did:bare and did:web test cases to the rejection suite - Update docs (AGENT-GUIDE, API-REFERENCE, ERROR-CODES, llms.txt) to document 3-step validation order and agent:// registration asymmetry (rejected at registration, accepted in envelopes for backward compat; from field is untrusted when sender not in storage) Co-Authored-By: Claude Sonnet 4.6 --- docs/AGENT-GUIDE.md | 9 ++++++--- docs/API-REFERENCE.md | 2 +- docs/ERROR-CODES.md | 4 ++-- llms.txt | 4 ++-- src/server.test.js | 11 ++++++++--- src/services/agent.service.js | 8 ++++++++ 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index a40b7bc..1f43af6 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -188,16 +188,19 @@ Date: ... ## 3. Agent ID Format -`agent_id` must satisfy two constraints, checked in this order: +`agent_id` must satisfy three constraints, checked in this order: 1. **Length:** 255 characters or fewer (checked first, O(1) guard). 2. **Character set:** Must match `^[a-zA-Z0-9._\-:]+$`. +3. **Reserved prefixes:** Must not start with `did:` or `agent:` (case-insensitive). These prefixes are reserved for system-generated DID identifiers. -The length check runs before the regex so that pathologically long inputs are rejected immediately without executing the pattern match. +The length check runs before the regex so that pathologically long inputs are rejected immediately without executing the pattern match. The reserved-prefix check runs last and catches IDs that pass character validation but would spoof system identifiers. **Allowed characters:** Letters (a-z, A-Z), digits (0-9), dots (`.`), underscores (`_`), hyphens (`-`), colons (`:`). -**Not allowed:** Slashes, spaces, `agent://` prefix, null bytes, or any other special characters. +**Not allowed:** Slashes, spaces, null bytes, or any other special characters. The prefixes `did:` and `agent:` are also not allowed at the start of a registered ID. + +**agent:// asymmetry — registration vs. envelopes:** The `agent://` URI prefix is rejected at registration (no newly registered agent can have an ID starting with `agent:`), but `agent://` is still accepted in envelope `from`/`to` fields for backward compatibility with pre-existing systems. When a sender using an `agent://` URI is not found in storage, signature verification is skipped and `from` is treated as untrusted. **Auto-generated IDs:** If you do not provide an `agent_id` at registration, the server generates one in the format `agent-` (e.g., `agent-550e8400-e29b-41d4-a716-446655440000`). diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index fcb979a..3f2a5a4 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -130,7 +130,7 @@ Register a new agent. No authentication required. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `agent_id` | string | No | Custom agent ID. Must be 255 characters or fewer AND match `^[a-zA-Z0-9._\-:]+$`. Length is checked first (O(1) guard before regex). If omitted, server generates `agent-`. | +| `agent_id` | string | No | Custom agent ID. Must be 255 characters or fewer AND match `^[a-zA-Z0-9._\-:]+$`. Length is checked first (O(1) guard before regex). May not start with `did:` or `agent:` (reserved prefixes). If omitted, server generates `agent-`. | | `agent_type` | string | No | Agent type label (e.g., `claude_session`). Default: `generic`. | | `metadata` | object | No | Arbitrary metadata. | | `webhook_url` | string | No | URL for push delivery of incoming messages. | diff --git a/docs/ERROR-CODES.md b/docs/ERROR-CODES.md index cf6009c..1dde5ce 100644 --- a/docs/ERROR-CODES.md +++ b/docs/ERROR-CODES.md @@ -49,7 +49,7 @@ Complete reference of all error codes returned by the Agent Dispatch Messaging P | Code | HTTP | Retryable | Description | Hint | |------|------|-----------|-------------|------| -| `REGISTRATION_FAILED` | 400 | No | Agent registration failed | Check `agent_id` uniqueness, length (max 255 chars, checked before regex), character set (`^[a-zA-Z0-9._\-:]+$`), and required fields | +| `REGISTRATION_FAILED` | 400 | No | Agent registration failed | Check `agent_id` uniqueness, length (max 255 chars, checked before regex), character set (`^[a-zA-Z0-9._\-:]+$`), reserved prefixes (`did:` and `agent:` are not allowed at the start of a registered ID), and required fields | | `AGENT_NOT_FOUND` | 404 | No | Agent ID not found in storage | Verify `agent_id` is correct and agent is registered | | `AGENT_ID_REQUIRED` | 400 | No | No agent ID provided | Include `agent_id` in URL path or `X-Agent-ID` header | | `HEARTBEAT_FAILED` | 400 | Yes | Heartbeat update failed | Verify agent exists | @@ -271,4 +271,4 @@ After 5-6 attempts, log the error and alert. Continued retries are unlikely to s - **`REGISTRATION_PENDING` (403)**: Retryable after the agent has been approved by an admin. Poll infrequently (e.g., every 30 seconds) or use a webhook/callback if available. - **`SEND_FAILED` in outbox context (400/403/404)**: Retryability depends on the underlying cause. A 403 (unverified domain) is not retryable until the domain is verified. A transient 500 is retryable. - **`DOMAIN_VERIFY_FAILED` (400/404)**: DNS propagation may take time. Retry after a delay (e.g., 60 seconds) if you have just configured DNS records. -- **`REGISTRATION_FAILED` agent_id validation**: If you receive this because of an invalid `agent_id`, check both constraints: the `agent_id` must be 255 characters or fewer (checked first) AND match `^[a-zA-Z0-9._\-:]+$`. Slashes, spaces, and `agent://` prefixes are not valid agent IDs. +- **`REGISTRATION_FAILED` agent_id validation**: If you receive this because of an invalid `agent_id`, check all three constraints in order: (1) the `agent_id` must be 255 characters or fewer; (2) it must match `^[a-zA-Z0-9._\-:]+$`; (3) it must not start with `did:` or `agent:` (reserved prefixes that protect system-generated DID identifiers). Slashes, spaces, and `agent://` prefixes are not valid registered agent IDs. diff --git a/llms.txt b/llms.txt index bc6afb9..856d249 100644 --- a/llms.txt +++ b/llms.txt @@ -18,8 +18,8 @@ admp ack # 4. Ack ## Agent ID Format -`agent_id` must be 255 characters or fewer AND match `^[a-zA-Z0-9._\-:]+$` — letters, digits, dots, underscores, hyphens, colons. -The length check runs before the regex (O(1) guard). No slashes, spaces, or `agent://` prefix. Auto-generated IDs are `agent-`. +`agent_id` must be 255 chars or fewer AND match `^[a-zA-Z0-9._\-:]+$` AND must not start with `did:` or `agent:` (reserved prefixes). Length checked first (O(1) guard), then regex, then reserved-prefix check. Auto-generated IDs are `agent-`. +`agent://` URIs are rejected at registration but still accepted in envelope `from`/`to` for backward compatibility; unrecognised `agent://` senders skip signature verification and are treated as untrusted. ## Message Envelope diff --git a/src/server.test.js b/src/server.test.js index f49c09c..a9839a3 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -99,7 +99,10 @@ test('agent_id validation rejects dangerous characters', async () => { 'path/traversal', 'null\x00byte', '', - 'agent://legacy-scheme', + 'agent://legacy-scheme', // slashes + reserved prefix + 'agent:bare', // reserved prefix (no slashes) + 'did:seed:spoofed', // reserved DID prefix + 'did:web:example.com', // reserved DID prefix 'a'.repeat(256), ]; @@ -110,8 +113,10 @@ test('agent_id validation rejects dangerous characters', async () => { assert.equal(res.status, 400, `Expected 400 for agent_id: ${JSON.stringify(id)}`); } - // Valid IDs should still work - const valid = ['simple', 'with-hyphens', 'dots.allowed', 'colons:ok', 'ALL_CAPS', 'a'.repeat(255)]; + // Valid IDs should still work — use unique suffix to avoid conflicts across test runs + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const valid = ['simple', 'with-hyphens', 'dots.allowed', 'colons:ok', 'ALL_CAPS'].map(id => `${id}-${suffix}`); + valid.push(`${'a'.repeat(248)}-${suffix.slice(0, 6)}`); // 255 chars total for (const id of valid) { const res = await request(app) .post('/api/agents/register') diff --git a/src/services/agent.service.js b/src/services/agent.service.js index 21adba8..f4d93fe 100644 --- a/src/services/agent.service.js +++ b/src/services/agent.service.js @@ -46,6 +46,14 @@ export class AgentService { throw new Error('agent_id may only contain letters, numbers, dots, underscores, hyphens, and colons'); } + // Reserve scheme-like prefixes to prevent spoofing system-generated identifiers. + // DIDs (did:*) are generated by the server; agent: was the legacy URI scheme. + // Bare IDs that start with these prefixes would be indistinguishable from + // system-generated ones in storage lookups and audit logs. + if (/^(did:|agent:)/i.test(agent_id)) { + throw new Error('agent_id may not start with reserved prefixes "did:" or "agent:"'); + } + // Check if agent already exists const existing = await storage.getAgent(agent_id); if (existing) { From 920ed508a4f48a9aaf709a50a2789f5ad407b02a Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 10:02:01 -0600 Subject: [PATCH 09/20] fix(review): DID:web segment validation, named isValidAgentId, docs regex fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SAFE_DID_SEGMENT validation in resolveDIDWebAgent() (auth.js) before constructing agent_id from domain/path segments. A crafted keyId like did:web:evil.com\nX-Injected: header would otherwise inject into signing strings and storage keys. Resolves the DID:web bypass (closes issue #17). - Promote validId() to module-level named function isValidAgentId() in inbox.service.js — improves stack traces and makes the invariant explicit. - Fix regex style in all docs: [a-zA-Z0-9._\-:] -> [a-zA-Z0-9._:-] (trailing hyphen, consistent with code). No functional change. Co-Authored-By: Claude Sonnet 4.6 --- docs/AGENT-GUIDE.md | 4 ++-- docs/API-REFERENCE.md | 2 +- docs/ARCHITECTURE.md | 4 ++-- docs/CLI-REFERENCE.md | 2 +- docs/ERROR-CODES.md | 4 ++-- llms.txt | 2 +- src/middleware/auth.js | 8 ++++++++ src/services/inbox.service.js | 30 ++++++++++++++++-------------- 8 files changed, 33 insertions(+), 23 deletions(-) diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index 1f43af6..c1dd74f 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -191,7 +191,7 @@ Date: ... `agent_id` must satisfy three constraints, checked in this order: 1. **Length:** 255 characters or fewer (checked first, O(1) guard). -2. **Character set:** Must match `^[a-zA-Z0-9._\-:]+$`. +2. **Character set:** Must match `^[a-zA-Z0-9._:-]+$`. 3. **Reserved prefixes:** Must not start with `did:` or `agent:` (case-insensitive). These prefixes are reserved for system-generated DID identifiers. The length check runs before the regex so that pathologically long inputs are rejected immediately without executing the pattern match. The reserved-prefix check runs last and catches IDs that pass character validation but would spoof system identifiers. @@ -243,7 +243,7 @@ All ADMP messages use this canonical JSON envelope: **Required fields:** `version`, `from`, `to`, `subject`, `timestamp` **`from`/`to` field formats — all of the following are accepted:** -- Bare agent ID: `"my-agent"` (must match `^[a-zA-Z0-9._\-:]+$`) +- Bare agent ID: `"my-agent"` (must match `^[a-zA-Z0-9._:-]+$`) - URI form: `"agent://my-agent"` - DID form: `"did:seed:abc123..."` diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index 3f2a5a4..e81bae9 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -130,7 +130,7 @@ Register a new agent. No authentication required. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `agent_id` | string | No | Custom agent ID. Must be 255 characters or fewer AND match `^[a-zA-Z0-9._\-:]+$`. Length is checked first (O(1) guard before regex). May not start with `did:` or `agent:` (reserved prefixes). If omitted, server generates `agent-`. | +| `agent_id` | string | No | Custom agent ID. Must be 255 characters or fewer AND match `^[a-zA-Z0-9._:-]+$`. Length is checked first (O(1) guard before regex). May not start with `did:` or `agent:` (reserved prefixes). If omitted, server generates `agent-`. | | `agent_type` | string | No | Agent type label (e.g., `claude_session`). Default: `generic`. | | `metadata` | object | No | Arbitrary metadata. | | `webhook_url` | string | No | URL for push delivery of incoming messages. | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 15679fa..6335806 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -375,7 +375,7 @@ Manages the full agent lifecycle. | Capability | Description | |---|---| | **Registration** | Three modes: *Legacy* (random keypair), *Seed-based* (HKDF deterministic from `LABEL_ADMP:::ed25519:vN`), *Import* (client-provided public key) | -| **agent_id validation** | Length check (> 255 chars) runs first (O(1) guard), then enforces `^[a-zA-Z0-9._\-:]+$`; auto-generates `agent-` if omitted | +| **agent_id validation** | Length check (> 255 chars) runs first (O(1) guard), then enforces `^[a-zA-Z0-9._:-]+$`; auto-generates `agent-` if omitted | | **Heartbeat** | Periodic liveness signal; background job marks agents offline after `timeout_ms` | | **Approval Workflow** | `approve()` / `reject(reason)` for pending agents; master key required | | **Trust Management** | Per-agent trusted/blocked agent lists; enforced at message send time | @@ -562,7 +562,7 @@ When a `Signature` header contains `keyId="did:web:..."`: | **Algorithm confusion** | Only `ed25519` accepted; any other `algorithm` value is rejected | | **Multicodec confusion** | DID document keys must be exactly 34 bytes with `0xed01` prefix; other key types rejected | | **Namespace collision** | Shadow agent creation checks for existing non-federated agent with same ID | -| **agent_id injection** | Length check (> 255 chars, O(1)) runs before regex `^[a-zA-Z0-9._\-:]+$`, blocking newlines (signing string injection), slashes (path traversal), spaces, null bytes. Note: DID:web shadow agent IDs currently bypass this validation — see issue #17. | +| **agent_id injection** | Length check (> 255 chars, O(1)) runs before regex `^[a-zA-Z0-9._:-]+$`, blocking newlines (signing string injection), slashes (path traversal), spaces, null bytes. Note: DID:web shadow agent IDs currently bypass this validation — see issue #17. | | **HTTP headers** | `helmet` middleware sets security headers (X-Content-Type-Options, X-Frame-Options, etc.) | | **Input validation** | 10MB JSON body limit; group name length/charset validation; rejection reason 500 char limit | | **Error response uniformity** | 401 for all bad-credential scenarios (expired, revoked, unknown) to prevent existence leaking | diff --git a/docs/CLI-REFERENCE.md b/docs/CLI-REFERENCE.md index 5e2f64d..bc5905d 100644 --- a/docs/CLI-REFERENCE.md +++ b/docs/CLI-REFERENCE.md @@ -510,7 +510,7 @@ The CLI stores credentials in `~/.admp/config.json` with file permissions `0600` | Field | Type | Required | Description | |-------|------|----------|-------------| | `base_url` | `string` | No | ADMP hub URL. Defaults to `https://agentdispatch.fly.dev`. | -| `agent_id` | `string` | Yes* | Your registered agent identifier. Must match `^[a-zA-Z0-9._\-:]+$`. | +| `agent_id` | `string` | Yes* | Your registered agent identifier. Must match `^[a-zA-Z0-9._:-]+$`. | | `secret_key` | `string` | Yes* | Base64-encoded 64-byte Ed25519 secret key. **Never transmitted** over the network; used only for local signing. | | `api_key` | `string` | No | API key for `X-Api-Key` authentication (required for `admp send`). | diff --git a/docs/ERROR-CODES.md b/docs/ERROR-CODES.md index 1dde5ce..d7ad037 100644 --- a/docs/ERROR-CODES.md +++ b/docs/ERROR-CODES.md @@ -49,7 +49,7 @@ Complete reference of all error codes returned by the Agent Dispatch Messaging P | Code | HTTP | Retryable | Description | Hint | |------|------|-----------|-------------|------| -| `REGISTRATION_FAILED` | 400 | No | Agent registration failed | Check `agent_id` uniqueness, length (max 255 chars, checked before regex), character set (`^[a-zA-Z0-9._\-:]+$`), reserved prefixes (`did:` and `agent:` are not allowed at the start of a registered ID), and required fields | +| `REGISTRATION_FAILED` | 400 | No | Agent registration failed | Check `agent_id` uniqueness, length (max 255 chars, checked before regex), character set (`^[a-zA-Z0-9._:-]+$`), reserved prefixes (`did:` and `agent:` are not allowed at the start of a registered ID), and required fields | | `AGENT_NOT_FOUND` | 404 | No | Agent ID not found in storage | Verify `agent_id` is correct and agent is registered | | `AGENT_ID_REQUIRED` | 400 | No | No agent ID provided | Include `agent_id` in URL path or `X-Agent-ID` header | | `HEARTBEAT_FAILED` | 400 | Yes | Heartbeat update failed | Verify agent exists | @@ -271,4 +271,4 @@ After 5-6 attempts, log the error and alert. Continued retries are unlikely to s - **`REGISTRATION_PENDING` (403)**: Retryable after the agent has been approved by an admin. Poll infrequently (e.g., every 30 seconds) or use a webhook/callback if available. - **`SEND_FAILED` in outbox context (400/403/404)**: Retryability depends on the underlying cause. A 403 (unverified domain) is not retryable until the domain is verified. A transient 500 is retryable. - **`DOMAIN_VERIFY_FAILED` (400/404)**: DNS propagation may take time. Retry after a delay (e.g., 60 seconds) if you have just configured DNS records. -- **`REGISTRATION_FAILED` agent_id validation**: If you receive this because of an invalid `agent_id`, check all three constraints in order: (1) the `agent_id` must be 255 characters or fewer; (2) it must match `^[a-zA-Z0-9._\-:]+$`; (3) it must not start with `did:` or `agent:` (reserved prefixes that protect system-generated DID identifiers). Slashes, spaces, and `agent://` prefixes are not valid registered agent IDs. +- **`REGISTRATION_FAILED` agent_id validation**: If you receive this because of an invalid `agent_id`, check all three constraints in order: (1) the `agent_id` must be 255 characters or fewer; (2) it must match `^[a-zA-Z0-9._:-]+$`; (3) it must not start with `did:` or `agent:` (reserved prefixes that protect system-generated DID identifiers). Slashes, spaces, and `agent://` prefixes are not valid registered agent IDs. diff --git a/llms.txt b/llms.txt index 856d249..79a5b3a 100644 --- a/llms.txt +++ b/llms.txt @@ -18,7 +18,7 @@ admp ack # 4. Ack ## Agent ID Format -`agent_id` must be 255 chars or fewer AND match `^[a-zA-Z0-9._\-:]+$` AND must not start with `did:` or `agent:` (reserved prefixes). Length checked first (O(1) guard), then regex, then reserved-prefix check. Auto-generated IDs are `agent-`. +`agent_id` must be 255 chars or fewer AND match `^[a-zA-Z0-9._:-]+$` AND must not start with `did:` or `agent:` (reserved prefixes). Length checked first (O(1) guard), then regex, then reserved-prefix check. Auto-generated IDs are `agent-`. `agent://` URIs are rejected at registration but still accepted in envelope `from`/`to` for backward compatibility; unrecognised `agent://` senders skip signature verification and are treated as untrusted. ## Message Envelope diff --git a/src/middleware/auth.js b/src/middleware/auth.js index b534ed1..b645a20 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -562,6 +562,14 @@ async function resolveDIDWebAgent(did, req) { return null; } + // Defense-in-depth: validate domain and path segments contain only safe + // characters before using them in agent_id construction or HTTP requests. + // A crafted keyId like "did:web:evil.com\nX-Injected: header" could + // otherwise inject into signing strings or storage keys. + const SAFE_DID_SEGMENT = /^[a-zA-Z0-9._:-]+$/; + if (!SAFE_DID_SEGMENT.test(domain)) return null; + if (pathSegments.some(seg => !SAFE_DID_SEGMENT.test(seg))) return null; + // Compute DID document URL once (per W3C DID:web spec): // did:web:domain.com → https://domain.com/.well-known/did.json // did:web:domain.com:path:seg → https://domain.com/path/seg/did.json diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index a29f657..3547507 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -16,6 +16,17 @@ const VALID_AGENT_URI = /^agent:\/\/[a-zA-Z0-9._:-]+$/; // did:seed: suffixes are hex fingerprints (sha256 truncated to 16 bytes); no colons expected. const VALID_DID_SEED = /^did:seed:[a-zA-Z0-9._-]+$/; +/** + * Return true if `id` is a syntactically valid agent identifier. + * Accepts: bare agent IDs, legacy agent:// URIs (backward-compat), did:seed: DIDs. + * Rejects: injection characters, empty/oversized strings. + * NOTE: a valid id does not mean the agent exists or that the sender is trusted. + */ +function isValidAgentId(id) { + if (!id || id.length > 255) return false; + return VALID_AGENT_URI.test(id) || VALID_DID_SEED.test(id) || SAFE_CHARS.test(id); +} + export class InboxService { /** * Send message to agent's inbox @@ -397,26 +408,17 @@ export class InboxService { throw new Error(`Unsupported ADMP version: ${envelope.version}`); } - // Validate agent identifiers — accept agent:// URIs, did:seed: DIDs, and bare agent IDs. - // All three forms validate the full string (not just the prefix) to block injection - // via malicious suffixes like agent://foo\nX-Injected: header. - // Length is checked first (O(1) guard) to avoid running the regex on huge inputs. - // + // Validate agent identifiers using module-level isValidAgentId(). // NOTE: `from` is used for display and signature verification only. When the sender // is not found in storage, signature verification is skipped and `from` is UNTRUSTED. // Callers must not use `from` for authorization without first verifying the signature. - // The agent:// scheme is still accepted in envelopes for backward compatibility with - // senders registered before bare-ID format was introduced. - const validId = (id) => { - if (!id || id.length > 255) return false; - return VALID_AGENT_URI.test(id) || VALID_DID_SEED.test(id) || SAFE_CHARS.test(id); - }; - - if (!validId(envelope.from)) { + // agent:// URIs are still accepted in envelopes for backward compatibility with senders + // registered before the bare-ID format was introduced (PR #16). + if (!isValidAgentId(envelope.from)) { throw new Error('Invalid from field (must be agent:// URI, did:seed: DID, or valid agent ID)'); } - if (!validId(envelope.to)) { + if (!isValidAgentId(envelope.to)) { throw new Error('Invalid to field (must be agent:// URI, did:seed: DID, or valid agent ID)'); } From 69711e27d9806675f150b0a1549b08043aa4f871 Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 10:17:39 -0600 Subject: [PATCH 10/20] fix(review): storage createAgent backstop, case-insensitive prefix tests, asymmetry docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Proxy wrapper in storage/index.js to validate agent_id before any storage backend writes it — blocks control chars and backslashes but intentionally allows slashes (DID:web paths use them). Closes the storage.createAgent() bypass gap flagged in code review. - Add DID:bare and case-insensitive prefix tests (DID:spoofed, AGENT:foo) to cover the /^(did:|agent:)/i flag in register(). - Expand isValidAgentId() JSDoc to explain the registration-vs-envelope asymmetry (agent:bare passes envelopes but fails registration). Co-Authored-By: Claude Sonnet 4.6 --- src/server.test.js | 2 ++ src/services/inbox.service.js | 17 ++++++++++++---- src/storage/index.js | 38 ++++++++++++++++++++++++++++++++--- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/server.test.js b/src/server.test.js index a9839a3..09b0e70 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -103,6 +103,8 @@ test('agent_id validation rejects dangerous characters', async () => { 'agent:bare', // reserved prefix (no slashes) 'did:seed:spoofed', // reserved DID prefix 'did:web:example.com', // reserved DID prefix + 'DID:spoofed', // reserved prefix — case-insensitive check + 'AGENT:foo', // reserved prefix — case-insensitive check 'a'.repeat(256), ]; diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index 3547507..05c6dd4 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -17,10 +17,19 @@ const VALID_AGENT_URI = /^agent:\/\/[a-zA-Z0-9._:-]+$/; const VALID_DID_SEED = /^did:seed:[a-zA-Z0-9._-]+$/; /** - * Return true if `id` is a syntactically valid agent identifier. - * Accepts: bare agent IDs, legacy agent:// URIs (backward-compat), did:seed: DIDs. - * Rejects: injection characters, empty/oversized strings. - * NOTE: a valid id does not mean the agent exists or that the sender is trusted. + * Return true if `id` is a syntactically valid agent identifier for use in + * message envelopes. Accepts bare agent IDs, legacy agent:// URIs (backward-compat + * for pre-PR#16 senders), and did:seed: DIDs. Rejects injection characters and + * oversized strings. + * + * NOTE: validation here is intentionally more permissive than registration. + * For example, `agent:bare` (single colon, no slashes) passes SAFE_CHARS and + * is accepted in envelopes but would be blocked at register() by the reserved-prefix + * guard. This is by design — the envelope layer cannot know whether a given ID was + * ever registered, so it only rejects clearly-unsafe inputs. + * + * A valid `id` does NOT imply the agent exists in storage or that the sender is + * trusted. Signature verification is required for that. */ function isValidAgentId(id) { if (!id || id.length > 255) return false; diff --git a/src/storage/index.js b/src/storage/index.js index 832c718..d262cd7 100644 --- a/src/storage/index.js +++ b/src/storage/index.js @@ -15,17 +15,49 @@ config(); const backend = (process.env.STORAGE_BACKEND || 'memory').toLowerCase(); -let storage; +let _storage; switch (backend) { case 'mech': - storage = createMechStorage(); + _storage = createMechStorage(); break; case 'memory': default: - storage = memoryStorage; + _storage = memoryStorage; break; } +// Defense-in-depth: validate agent_id before any storage backend writes it. +// Callers that bypass register() (e.g. DID:web shadow agents, migrations) +// still go through this guard, ensuring no unsafe ID is ever persisted. +// +// The regex blocks only control characters (newlines, null bytes, DEL) and +// backslashes — the characters that cause signing-string injection or escaping +// issues in storage backends. Slashes are intentionally allowed because +// DID:web shadow agent IDs use them as path separators (did-web:host/path/seg). +// The stricter character-set and reserved-prefix checks live in register() and +// resolveDIDWebAgent() for agents that go through those code paths. +const STORAGE_AGENT_ID_RE = /^[^\x00-\x1f\x7f\\]+$/; +const storage = new Proxy(_storage, { + get(target, prop) { + if (prop === 'createAgent') { + return async (agent) => { + if (!agent?.agent_id || typeof agent.agent_id !== 'string') { + throw new Error('createAgent: agent_id is required and must be a string'); + } + if (agent.agent_id.length > 255) { + throw new Error('createAgent: agent_id must be 255 characters or fewer'); + } + if (!STORAGE_AGENT_ID_RE.test(agent.agent_id)) { + throw new Error('createAgent: agent_id contains unsafe characters (control chars, slashes)'); + } + return target.createAgent(agent); + }; + } + const value = target[prop]; + return typeof value === 'function' ? value.bind(target) : value; + } +}); + export { storage }; export const storageBackend = backend; From 7bf30e1f6501840af52e0b2509bbc311d5c21a1d Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 10:23:30 -0600 Subject: [PATCH 11/20] fix(review): correct error message in storage proxy, document DID:web envelope format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix misleading error message: "control chars, slashes" → "control chars, backslashes" since slashes are intentionally allowed (DID:web path separators) - Expand isValidAgentId() JSDoc to explain DID:web from/to format: federated agents use W3C colon-form (did:web:domain.com:users:alice) in envelopes, which passes SAFE_CHARS; stored form with slashes is not used in envelope fields Co-Authored-By: Claude Sonnet 4.6 --- src/services/inbox.service.js | 5 +++++ src/storage/index.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index 05c6dd4..c3d13fa 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -28,6 +28,11 @@ const VALID_DID_SEED = /^did:seed:[a-zA-Z0-9._-]+$/; * guard. This is by design — the envelope layer cannot know whether a given ID was * ever registered, so it only rejects clearly-unsafe inputs. * + * DID:web agents: when sending messages, federated agents use their W3C canonical + * DID form in `from` (e.g. `did:web:domain.com:users:alice`, colon-separated). + * This passes SAFE_CHARS. Their stored agent_id uses slashes (`did-web:domain.com/users/alice`) + * but that stored form is not used in envelope fields. + * * A valid `id` does NOT imply the agent exists in storage or that the sender is * trusted. Signature verification is required for that. */ diff --git a/src/storage/index.js b/src/storage/index.js index d262cd7..db742f3 100644 --- a/src/storage/index.js +++ b/src/storage/index.js @@ -49,7 +49,7 @@ const storage = new Proxy(_storage, { throw new Error('createAgent: agent_id must be 255 characters or fewer'); } if (!STORAGE_AGENT_ID_RE.test(agent.agent_id)) { - throw new Error('createAgent: agent_id contains unsafe characters (control chars, slashes)'); + throw new Error('createAgent: agent_id contains unsafe characters (control chars, backslashes)'); } return target.createAgent(agent); }; From 72df6a5469dc3071ce8166f97e7a3b5f828f8b53 Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 10:33:13 -0600 Subject: [PATCH 12/20] fix(review): move SAFE_DID_SEGMENT to module scope, document Proxy scope, harden boundary test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move SAFE_DID_SEGMENT regex to module scope in auth.js — compiled once per import, not on every DID:web auth attempt (mirrors SAFE_CHARS in inbox.service.js) - Document in storage/index.js that only createAgent is intercepted by the Proxy: update paths go through register() or resolveDIDWebAgent() which have their own guards, and neither changes an existing agent_id - Add explicit assert.equal(boundaryId.length, 255) in the 255-char boundary test so any format change in the suffix is caught immediately rather than silently producing a 254 or 256-char ID Co-Authored-By: Claude Sonnet 4.6 --- src/middleware/auth.js | 5 ++++- src/server.test.js | 4 +++- src/storage/index.js | 4 ++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index b645a20..b8444e7 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -485,6 +485,10 @@ const _didKeyCache = new Map(); const _DID_KEY_CACHE_TTL_MS = 5 * 60 * 1000; const _DID_KEY_CACHE_MAX = 1000; +// Allowlist for DID:web domain and path segment characters. +// Module-level so it is compiled once, not on every DID auth attempt. +const SAFE_DID_SEGMENT = /^[a-zA-Z0-9._:-]+$/; + /** * Returns true if the hostname should be blocked from DID web resolution * to prevent SSRF attacks targeting internal/private infrastructure. @@ -566,7 +570,6 @@ async function resolveDIDWebAgent(did, req) { // characters before using them in agent_id construction or HTTP requests. // A crafted keyId like "did:web:evil.com\nX-Injected: header" could // otherwise inject into signing strings or storage keys. - const SAFE_DID_SEGMENT = /^[a-zA-Z0-9._:-]+$/; if (!SAFE_DID_SEGMENT.test(domain)) return null; if (pathSegments.some(seg => !SAFE_DID_SEGMENT.test(seg))) return null; diff --git a/src/server.test.js b/src/server.test.js index 09b0e70..472e1b6 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -118,7 +118,9 @@ test('agent_id validation rejects dangerous characters', async () => { // Valid IDs should still work — use unique suffix to avoid conflicts across test runs const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const valid = ['simple', 'with-hyphens', 'dots.allowed', 'colons:ok', 'ALL_CAPS'].map(id => `${id}-${suffix}`); - valid.push(`${'a'.repeat(248)}-${suffix.slice(0, 6)}`); // 255 chars total + const boundaryId = `${'a'.repeat(248)}-${suffix.slice(0, 6)}`; + assert.equal(boundaryId.length, 255, 'boundary test ID must be exactly 255 chars'); + valid.push(boundaryId); for (const id of valid) { const res = await request(app) .post('/api/agents/register') diff --git a/src/storage/index.js b/src/storage/index.js index db742f3..8554345 100644 --- a/src/storage/index.js +++ b/src/storage/index.js @@ -31,6 +31,10 @@ switch (backend) { // Callers that bypass register() (e.g. DID:web shadow agents, migrations) // still go through this guard, ensuring no unsafe ID is ever persisted. // +// Only createAgent is intercepted: update paths go through register() or +// resolveDIDWebAgent() which have their own character-set and prefix guards, +// and they never change an existing agent_id. +// // The regex blocks only control characters (newlines, null bytes, DEL) and // backslashes — the characters that cause signing-string injection or escaping // issues in storage backends. Slashes are intentionally allowed because From 45e12328d693cfe33985e0dda33e4d8f9a1233d0 Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 10:48:09 -0600 Subject: [PATCH 13/20] fix(review): split domain/segment regexes, deterministic boundary test, document redundancy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split SAFE_DID_SEGMENT into SAFE_DID_DOMAIN (no colons, hostnames) and SAFE_DID_SEGMENT (colons allowed, W3C DID path segments) in auth.js — colons are not valid in hostnames but are valid in path segment identifiers - Fix 255-char boundary test: use fixed 'xxxxxx' suffix instead of Math.random().toString(36).slice(2,6) which can produce <4 chars - Add comment in inbox.service.js explaining VALID_AGENT_URI and VALID_DID_SEED are intentionally redundant with SAFE_CHARS — included for documentation and independent extensibility, not as meaningful guards Co-Authored-By: Claude Sonnet 4.6 --- src/middleware/auth.js | 6 ++++-- src/server.test.js | 4 +++- src/services/inbox.service.js | 4 ++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index b8444e7..6dda403 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -485,8 +485,10 @@ const _didKeyCache = new Map(); const _DID_KEY_CACHE_TTL_MS = 5 * 60 * 1000; const _DID_KEY_CACHE_MAX = 1000; -// Allowlist for DID:web domain and path segment characters. +// Allowlist for DID:web domain names (no colons — colons are not valid in hostnames). // Module-level so it is compiled once, not on every DID auth attempt. +const SAFE_DID_DOMAIN = /^[a-zA-Z0-9._-]+$/; +// Allowlist for DID:web path segments (colons are valid per W3C DID Core spec). const SAFE_DID_SEGMENT = /^[a-zA-Z0-9._:-]+$/; /** @@ -570,7 +572,7 @@ async function resolveDIDWebAgent(did, req) { // characters before using them in agent_id construction or HTTP requests. // A crafted keyId like "did:web:evil.com\nX-Injected: header" could // otherwise inject into signing strings or storage keys. - if (!SAFE_DID_SEGMENT.test(domain)) return null; + if (!SAFE_DID_DOMAIN.test(domain)) return null; if (pathSegments.some(seg => !SAFE_DID_SEGMENT.test(seg))) return null; // Compute DID document URL once (per W3C DID:web spec): diff --git a/src/server.test.js b/src/server.test.js index 472e1b6..dff067b 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -118,7 +118,9 @@ test('agent_id validation rejects dangerous characters', async () => { // Valid IDs should still work — use unique suffix to avoid conflicts across test runs const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const valid = ['simple', 'with-hyphens', 'dots.allowed', 'colons:ok', 'ALL_CAPS'].map(id => `${id}-${suffix}`); - const boundaryId = `${'a'.repeat(248)}-${suffix.slice(0, 6)}`; + // Use a fixed 6-char suffix for the boundary ID to guarantee exactly 255 chars — + // Math.random().toString(36) can produce fewer than 4 chars for small values. + const boundaryId = `${'a'.repeat(248)}-xxxxxx`; assert.equal(boundaryId.length, 255, 'boundary test ID must be exactly 255 chars'); valid.push(boundaryId); for (const id of valid) { diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index c3d13fa..dfbecff 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -12,6 +12,10 @@ import { webhookService } from './webhook.service.js'; // Safe agent identifier patterns — module-level constants so they are compiled once, // not recreated on every message send. const SAFE_CHARS = /^[a-zA-Z0-9._:-]+$/; +// VALID_AGENT_URI and VALID_DID_SEED are subsets of SAFE_CHARS (both would pass the +// SAFE_CHARS fallback). They are kept as named constants for documentation: they +// make the accepted formats explicit and provide a place to tighten each scheme +// independently if requirements change. They do not alter the validation outcome. const VALID_AGENT_URI = /^agent:\/\/[a-zA-Z0-9._:-]+$/; // did:seed: suffixes are hex fingerprints (sha256 truncated to 16 bytes); no colons expected. const VALID_DID_SEED = /^did:seed:[a-zA-Z0-9._-]+$/; From 7d8332a98d475eb52d013ccec74146152c9dfa0e Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 10:54:29 -0600 Subject: [PATCH 14/20] fix(review): correct VALID_AGENT_URI comment, block .. in DID:web paths, proxy fragility note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix incorrect comment: VALID_AGENT_URI is NOT a subset of SAFE_CHARS (agent://foo contains slashes which are not in the allowlist). It is the only branch accepting legacy agent:// URIs — removing it would silently break backward compatibility - Clarify VALID_DID_SEED comment: did:seed:ab:cd passes SAFE_CHARS fallback, so the no-colons restriction in the suffix is not strictly enforced at envelope layer - Block '..' path segments in resolveDIDWebAgent(): SAFE_DID_SEGMENT allows dots, so '..' passes the character check; explicitly reject it to prevent path traversal in DID document URLs (e.g. did:web:example.com:.. → https://example.com/../did.json) - Add comment in storage Proxy: the method-name string 'createAgent' is a silent failure point if the storage interface is ever renamed Co-Authored-By: Claude Sonnet 4.6 --- src/middleware/auth.js | 5 ++++- src/services/inbox.service.js | 13 ++++++++----- src/storage/index.js | 2 ++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 6dda403..9740c7d 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -573,7 +573,10 @@ async function resolveDIDWebAgent(did, req) { // A crafted keyId like "did:web:evil.com\nX-Injected: header" could // otherwise inject into signing strings or storage keys. if (!SAFE_DID_DOMAIN.test(domain)) return null; - if (pathSegments.some(seg => !SAFE_DID_SEGMENT.test(seg))) return null; + // Also block '..' explicitly: SAFE_DID_SEGMENT allows dots, so '..' passes the + // character check — but it would produce a path-traversal URL like + // https://domain.com/../did.json which may escape the intended path prefix. + if (pathSegments.some(seg => !SAFE_DID_SEGMENT.test(seg) || seg === '..')) return null; // Compute DID document URL once (per W3C DID:web spec): // did:web:domain.com → https://domain.com/.well-known/did.json diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index dfbecff..52c4d9e 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -12,12 +12,15 @@ import { webhookService } from './webhook.service.js'; // Safe agent identifier patterns — module-level constants so they are compiled once, // not recreated on every message send. const SAFE_CHARS = /^[a-zA-Z0-9._:-]+$/; -// VALID_AGENT_URI and VALID_DID_SEED are subsets of SAFE_CHARS (both would pass the -// SAFE_CHARS fallback). They are kept as named constants for documentation: they -// make the accepted formats explicit and provide a place to tighten each scheme -// independently if requirements change. They do not alter the validation outcome. +// VALID_AGENT_URI is NOT a subset of SAFE_CHARS — agent://foo contains slashes which +// are not in the allowlist. It is the only branch that accepts legacy agent:// URIs. +// Do not delete it assuming it is a no-op; doing so would silently break backward +// compatibility for pre-PR#16 senders. const VALID_AGENT_URI = /^agent:\/\/[a-zA-Z0-9._:-]+$/; -// did:seed: suffixes are hex fingerprints (sha256 truncated to 16 bytes); no colons expected. +// VALID_DID_SEED is a subset of SAFE_CHARS (did:seed:abc passes both). It is kept +// for documentation and to provide a place to tighten did:seed: validation independently +// if requirements change. Note: did:seed:ab:cd also passes SAFE_CHARS, so the no-colons +// restriction in the suffix is not strictly enforced at the envelope layer. const VALID_DID_SEED = /^did:seed:[a-zA-Z0-9._-]+$/; /** diff --git a/src/storage/index.js b/src/storage/index.js index 8554345..d2cd595 100644 --- a/src/storage/index.js +++ b/src/storage/index.js @@ -44,6 +44,8 @@ switch (backend) { const STORAGE_AGENT_ID_RE = /^[^\x00-\x1f\x7f\\]+$/; const storage = new Proxy(_storage, { get(target, prop) { + // NOTE: if the storage interface renames createAgent, update this string — + // a name mismatch silently bypasses the guard with no error or test failure. if (prop === 'createAgent') { return async (agent) => { if (!agent?.agent_id || typeof agent.agent_id !== 'string') { From 2d8048c20b18620a52c5ae376fad32bef0f87449 Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 11:18:25 -0600 Subject: [PATCH 15/20] test: add storage proxy unit test, DID:web SSRF guard test, did:web envelope pass test - Storage proxy unit test confirms Proxy backstop fires independently of register() - DID:web SSRF guard test confirms '..' segment blocked before outbound fetch - did:web canonical form (did:web:domain:path) added as explicit passing envelope case Co-Authored-By: Claude Sonnet 4.6 --- src/server.test.js | 98 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/server.test.js b/src/server.test.js index dff067b..1c09a4f 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -131,6 +131,39 @@ test('agent_id validation rejects dangerous characters', async () => { } }); +test('storage proxy: createAgent directly rejects unsafe agent_ids', async () => { + // Verify the storage Proxy backstop fires independently of register() — + // catches callers (DID:web shadow agents, migrations) that bypass registration. + const dummyKey = toBase64(nacl.sign.keyPair().publicKey); + const base = { agent_type: 'test', public_key: dummyKey, registration_status: 'approved' }; + + const badIds = [ + 'evil\nX-Injected: header', // newline injection + 'null\x00byte', // null byte + 'back\\slash', // backslash (signing-string escape) + '\x01control', // control char (SOH) + 'a'.repeat(256), // exceeds 255-char limit + '', // empty string + ]; + + for (const agent_id of badIds) { + await assert.rejects( + () => storage.createAgent({ ...base, agent_id }), + (err) => { + assert.ok(err.message.startsWith('createAgent:'), + `Expected 'createAgent:' error for ${JSON.stringify(agent_id)}, got: ${err.message}`); + return true; + }, + `storage.createAgent should reject agent_id: ${JSON.stringify(agent_id)}` + ); + } + + // Slashes are allowed — DID:web shadow agent IDs use them as path separators + const shadowId = `did-web-proxy-test-${Date.now()}.example.com/users/alice`; + const shadowAgent = await storage.createAgent({ ...base, agent_id: shadowId }); + assert.equal(shadowAgent.agent_id, shadowId, 'storage proxy should allow slashes for DID:web IDs'); +}); + test('envelope from/to validation rejects injection attempts', async () => { const sender = await registerAgent('env-sender'); const recipient = await registerAgent('env-recipient'); @@ -204,6 +237,24 @@ test('envelope from/to validation rejects injection attempts', async () => { // 404 is NOT expected because the recipient exists. // 400 would indicate the validation wrongly rejected a valid agent:// URI. assert.equal(legacyRes.status, 201, 'Legacy agent:// envelope from should pass validation and be accepted'); + + // DID:web canonical form in from should pass envelope validation (colons pass SAFE_CHARS) + const didWebEnvelope = { + version: '1.0', + id: `msg-${Date.now()}`, + type: 'task.request', + from: 'did:web:example.com:users:alice', + to: recipient.agent_id, + subject: 'did-web-compat', + body: { test: true }, + timestamp: new Date().toISOString(), + }; + const didWebRes = await request(app) + .post(`/api/agents/${encodeURIComponent(recipient.agent_id)}/messages`) + .send(didWebEnvelope); + // 201: accepted. Sender not in storage so signature check skipped (from is untrusted). + // 400 would mean did:web canonical form was wrongly rejected by isValidAgentId(). + assert.equal(didWebRes.status, 201, 'DID:web canonical from (did:web:domain:path) should pass envelope validation'); }); test('agent registration, heartbeat, and get agent', async () => { @@ -4061,3 +4112,50 @@ test('trust model: DID web with path segments — resolves URL and creates shado else delete process.env.DID_WEB_ALLOWED_DOMAINS; } }); + +test('trust model: DID web — crafted keyId with .. segment is rejected (SSRF guard)', async () => { + // Confirm SAFE_DID_SEGMENT's '..' guard in resolveDIDWebAgent() fires before any + // outbound fetch. A crafted keyId like did:web:evil.com:.. would produce the URL + // https://evil.com/../did.json — blocked before the fetch is attempted. + // Note: newline injection in keyId is blocked at the HTTP client level (headers + // cannot contain newlines), not at the server validation layer. + const domain = `did-web-ssrf-${Date.now()}.example.com`; + const maliciousKeyIds = [ + `did:web:${domain}:..`, // path traversal via '..' segment + `did:web:${domain}:a:..`, // '..' deeper in path + ]; + + const keypair = nacl.sign.keyPair(); + const savedAllowedDomains = process.env.DID_WEB_ALLOWED_DOMAINS; + process.env.DID_WEB_ALLOWED_DOMAINS = domain; + let fetchCalled = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + fetchCalled = true; + throw new Error('fetch should not be called for crafted DID:web keyIds'); + }; + + try { + for (const keyId of maliciousKeyIds) { + fetchCalled = false; + const dateStr = new Date().toUTCString(); + const targetPath = `/api/agents/any-agent/heartbeat`; + const headers = { host: '127.0.0.1', date: dateStr }; + const signatureHeader = signRequest('POST', targetPath, headers, keypair.secretKey, keyId); + + const res = await request(app) + .post(targetPath) + .set('Host', headers.host) + .set('Signature', signatureHeader) + .set('Date', dateStr) + .send({}); + + assert.equal(res.status, 401, `Crafted DID:web keyId ${JSON.stringify(keyId)} should return 401`); + assert.equal(fetchCalled, false, `fetch should not be called for crafted keyId ${JSON.stringify(keyId)}`); + } + } finally { + globalThis.fetch = originalFetch; + if (savedAllowedDomains !== undefined) process.env.DID_WEB_ALLOWED_DOMAINS = savedAllowedDomains; + else delete process.env.DID_WEB_ALLOWED_DOMAINS; + } +}); From 736a96cc56918cb5c8ad5e1fa1cd66a55844bb6d Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 11:29:13 -0600 Subject: [PATCH 16/20] fix(review): tighten STORAGE_AGENT_ID_RE, startup assertion, migration note, comment fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tighten STORAGE_AGENT_ID_RE from negated control-char blocklist to explicit allowlist ^[a-zA-Z0-9._:/-]+$ — only registration chars plus slash for DID:web paths. Unexpected chars now surface as a caller bug rather than silently passing. - Add startup assertion: if _storage.createAgent is missing, crash immediately rather than silently bypassing the Proxy guard on future interface renames - Update error message to name the allowed set explicitly - Fix SAFE_DID_DOMAIN comment: 'strips colons' -> 'excludes colons' (doesn't strip, rejects) - Add whitespace-only ' ' to registration bad-ID test (closes minor coverage gap) - Add migration note to AGENT-GUIDE.md: documents the agent:// ID format change, explains existing agents remain routable but cannot re-register with old IDs Co-Authored-By: Claude Sonnet 4.6 --- docs/AGENT-GUIDE.md | 8 ++++++++ src/middleware/auth.js | 2 +- src/server.test.js | 1 + src/storage/index.js | 20 ++++++++++++++------ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index c1dd74f..5dd9f2b 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -486,6 +486,14 @@ X-Api-Key: ## 10. Known Limitations and Security Notes +### Migration Note — Auto-Generated ID Format Change (PR #16) + +Prior to PR #16, the server auto-generated agent IDs in the format `agent://agent-`. The new format is `agent-` (no `agent://` prefix). This is a **breaking change** for deployments that did not use custom IDs: + +- **Existing agents** with `agent://agent-` IDs stored in the database continue to work for message routing and envelope delivery. The backward-compat layer in envelope validation still accepts `agent://` in `from`/`to` fields. +- **Re-registration is blocked**: if a client attempts to call `POST /api/agents/register` with a stored `agent://…` ID, registration will be rejected (reserved prefix). The agent is still reachable but cannot update its registration. +- **Action required**: if your deployment relies on re-registration with the auto-generated ID, export the existing ID before upgrading, then register a new bare ID and update your configuration. + ### Issue #17 — DID:web Shadow Agent Character Validation Bypass When a `did:web:` agent authenticates for the first time, the server auto-creates a shadow agent record. The `agent_id` for that shadow agent is derived from the DID's domain and path segments (e.g., `did-web:example.com/alice`) and is **not** run through the same 255-character length check and regex validation that applies to manually registered agents. diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 9740c7d..344aca5 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -485,7 +485,7 @@ const _didKeyCache = new Map(); const _DID_KEY_CACHE_TTL_MS = 5 * 60 * 1000; const _DID_KEY_CACHE_MAX = 1000; -// Allowlist for DID:web domain names (no colons — colons are not valid in hostnames). +// Allowlist for DID:web domain names (excludes colons — colons are not valid in hostnames). // Module-level so it is compiled once, not on every DID auth attempt. const SAFE_DID_DOMAIN = /^[a-zA-Z0-9._-]+$/; // Allowlist for DID:web path segments (colons are valid per W3C DID Core spec). diff --git a/src/server.test.js b/src/server.test.js index 1c09a4f..11a4d3f 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -106,6 +106,7 @@ test('agent_id validation rejects dangerous characters', async () => { 'DID:spoofed', // reserved prefix — case-insensitive check 'AGENT:foo', // reserved prefix — case-insensitive check 'a'.repeat(256), + ' ', // whitespace-only (truthy but fails charset regex) ]; for (const id of bad) { diff --git a/src/storage/index.js b/src/storage/index.js index d2cd595..5dd603a 100644 --- a/src/storage/index.js +++ b/src/storage/index.js @@ -35,13 +35,21 @@ switch (backend) { // resolveDIDWebAgent() which have their own character-set and prefix guards, // and they never change an existing agent_id. // -// The regex blocks only control characters (newlines, null bytes, DEL) and -// backslashes — the characters that cause signing-string injection or escaping -// issues in storage backends. Slashes are intentionally allowed because -// DID:web shadow agent IDs use them as path separators (did-web:host/path/seg). +// Allowlist: letters, digits, and the characters allowed by register() plus forward +// slash (for DID:web shadow agent IDs like did-web:host/path/seg). Characters outside +// this set indicate a caller bug — surfacing them here is preferable to silent storage. // The stricter character-set and reserved-prefix checks live in register() and // resolveDIDWebAgent() for agents that go through those code paths. -const STORAGE_AGENT_ID_RE = /^[^\x00-\x1f\x7f\\]+$/; +// Legacy agent://agent- IDs already at rest in storage are intentionally NOT +// re-validated here — this guard only fires on new writes via createAgent(). +const STORAGE_AGENT_ID_RE = /^[a-zA-Z0-9._:/-]+$/; + +// Startup assertion: if the storage interface renames createAgent, the Proxy guard +// silently becomes a no-op. Crashing at startup is better than a silent bypass. +if (typeof _storage.createAgent !== 'function') { + throw new Error('storage: createAgent is missing — update the Proxy guard in storage/index.js'); +} + const storage = new Proxy(_storage, { get(target, prop) { // NOTE: if the storage interface renames createAgent, update this string — @@ -55,7 +63,7 @@ const storage = new Proxy(_storage, { throw new Error('createAgent: agent_id must be 255 characters or fewer'); } if (!STORAGE_AGENT_ID_RE.test(agent.agent_id)) { - throw new Error('createAgent: agent_id contains unsafe characters (control chars, backslashes)'); + throw new Error('createAgent: agent_id contains characters outside the allowed set [a-zA-Z0-9._:/-]'); } return target.createAgent(agent); }; From 503541596eeb3b2c1cffeaf3a7d17258a4ad5937 Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 11:42:37 -0600 Subject: [PATCH 17/20] fix(review): domain .. guard, unique boundary ID, clarify VALID_DID_SEED SAFE_CHARS precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add domain === '..' explicit guard in resolveDIDWebAgent() — SAFE_DID_DOMAIN allows dots so '..' passes the charset check; explicit rejection prevents path traversal in did:web:.. DID documents (mirrors the same guard on path segments) - Use timestamp-based prefix for 255-char boundary test ID to ensure uniqueness across test runs (in-memory store persists per process; static 'xxxxxx' suffix would conflict) - Clarify VALID_DID_SEED comment: explicitly state that SAFE_CHARS supersedes it at the envelope layer — did:seed:ab:cd fails VALID_DID_SEED but passes SAFE_CHARS Co-Authored-By: Claude Sonnet 4.6 --- src/middleware/auth.js | 2 +- src/server.test.js | 7 ++++--- src/services/inbox.service.js | 8 +++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 344aca5..729522d 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -572,7 +572,7 @@ async function resolveDIDWebAgent(did, req) { // characters before using them in agent_id construction or HTTP requests. // A crafted keyId like "did:web:evil.com\nX-Injected: header" could // otherwise inject into signing strings or storage keys. - if (!SAFE_DID_DOMAIN.test(domain)) return null; + if (!SAFE_DID_DOMAIN.test(domain) || domain === '..') return null; // Also block '..' explicitly: SAFE_DID_SEGMENT allows dots, so '..' passes the // character check — but it would produce a path-traversal URL like // https://domain.com/../did.json which may escape the intended path prefix. diff --git a/src/server.test.js b/src/server.test.js index 11a4d3f..997596a 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -119,9 +119,10 @@ test('agent_id validation rejects dangerous characters', async () => { // Valid IDs should still work — use unique suffix to avoid conflicts across test runs const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const valid = ['simple', 'with-hyphens', 'dots.allowed', 'colons:ok', 'ALL_CAPS'].map(id => `${id}-${suffix}`); - // Use a fixed 6-char suffix for the boundary ID to guarantee exactly 255 chars — - // Math.random().toString(36) can produce fewer than 4 chars for small values. - const boundaryId = `${'a'.repeat(248)}-xxxxxx`; + // Use a timestamp-based prefix for the boundary ID so it is unique across test runs. + // Pad to exactly 255 chars. assert.equal pins the length so any format change fails fast. + const tsStr = Date.now().toString(); // 13 chars in 2026 — assert catches length change + const boundaryId = tsStr + 'a'.repeat(255 - tsStr.length); assert.equal(boundaryId.length, 255, 'boundary test ID must be exactly 255 chars'); valid.push(boundaryId); for (const id of valid) { diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index 52c4d9e..79fcb88 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -18,9 +18,11 @@ const SAFE_CHARS = /^[a-zA-Z0-9._:-]+$/; // compatibility for pre-PR#16 senders. const VALID_AGENT_URI = /^agent:\/\/[a-zA-Z0-9._:-]+$/; // VALID_DID_SEED is a subset of SAFE_CHARS (did:seed:abc passes both). It is kept -// for documentation and to provide a place to tighten did:seed: validation independently -// if requirements change. Note: did:seed:ab:cd also passes SAFE_CHARS, so the no-colons -// restriction in the suffix is not strictly enforced at the envelope layer. +// for documentation and as a named anchor if suffix rules need to diverge from SAFE_CHARS +// in the future. Important: SAFE_CHARS supersedes it at the envelope layer — +// did:seed:ab:cd fails VALID_DID_SEED (colons in suffix) but passes SAFE_CHARS, so +// it is still accepted here. The no-colons restriction is enforced at registration, +// not at the envelope layer. const VALID_DID_SEED = /^did:seed:[a-zA-Z0-9._-]+$/; /** From b210f63def2434fc5687cc5a124d8f96bc1b2f63 Mon Sep 17 00:00:00 2001 From: dundas Date: Thu, 26 Feb 2026 12:44:13 -0600 Subject: [PATCH 18/20] =?UTF-8?q?fix(review):=20Round=2014=20=E2=80=94=20t?= =?UTF-8?q?rust=20bypass,=20dead=20code,=20HTTPS=20redirect,=20comment=20n?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH: Make signature mandatory when sender is in trust list but not registered. Attacker could impersonate a deregistered trusted agent: from=trusted-agent-id passes trust check, sender not in storage so sig check silently skipped. Now rejects explicitly when sender is in trust list but not registered in storage. Medium: Remove unreachable re-registration approval-preservation code in agent.service.js. The existingAgent re-lookup was dead code since lines 57-61 already throw if the agent exists, so existingAgent is always null there. Low: Enforce HTTPS for DID:web redirect targets in auth.js. A redirect to http:// could expose DID document fetches to MitM attacks even if the original request used TLS. Nit: Fix VALID_DID_SEED comment in inbox.service.js. Said SAFE_CHARS supersedes it but VALID_DID_SEED runs first in the OR chain. SAFE_CHARS is the fallback. Co-Authored-By: Claude Sonnet 4.6 --- src/middleware/auth.js | 3 +++ src/services/agent.service.js | 9 +-------- src/services/inbox.service.js | 7 ++++++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 729522d..f08d31b 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -611,6 +611,9 @@ async function resolveDIDWebAgent(did, req) { if (!location) return null; try { const redirectUrl = new URL(location, didUrl); + // Enforce HTTPS: an http:// redirect would expose DID document fetches + // to MitM attacks even if the original request was over TLS. + if (redirectUrl.protocol !== 'https:') return null; if (isBlockedDIDWebHost(redirectUrl.hostname)) return null; // Follow one validated redirect const redirectResp = await fetch(redirectUrl.href, { signal: controller.signal, redirect: 'error' }); diff --git a/src/services/agent.service.js b/src/services/agent.service.js index f4d93fe..1bdc2f4 100644 --- a/src/services/agent.service.js +++ b/src/services/agent.service.js @@ -133,14 +133,7 @@ export class AgentService { const tenant = tenant_id ? await storage.getTenant(tenant_id) : null; const policy = tenant?.registration_policy || process.env.REGISTRATION_POLICY || 'open'; - // Preserve existing approval status on re-registration to prevent - // an already-approved agent from being downgraded back to 'pending'. - const existingAgent = await storage.getAgent(agent_id); - if (existingAgent && existingAgent.registration_status === 'approved') { - agent.registration_status = 'approved'; - } else { - agent.registration_status = policy === 'approval_required' ? 'pending' : 'approved'; - } + agent.registration_status = policy === 'approval_required' ? 'pending' : 'approved'; await storage.createAgent(agent); diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index 79fcb88..0a057bd 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -19,7 +19,7 @@ const SAFE_CHARS = /^[a-zA-Z0-9._:-]+$/; const VALID_AGENT_URI = /^agent:\/\/[a-zA-Z0-9._:-]+$/; // VALID_DID_SEED is a subset of SAFE_CHARS (did:seed:abc passes both). It is kept // for documentation and as a named anchor if suffix rules need to diverge from SAFE_CHARS -// in the future. Important: SAFE_CHARS supersedes it at the envelope layer — +// in the future. Important: SAFE_CHARS acts as a fallback when VALID_DID_SEED rejects — // did:seed:ab:cd fails VALID_DID_SEED (colons in suffix) but passes SAFE_CHARS, so // it is still accepted here. The no-colons restriction is enforced at registration, // not at the envelope layer. @@ -111,6 +111,11 @@ export class InboxService { throw new Error('Invalid message signature'); } } + } else if (recipient.trusted_agents && recipient.trusted_agents.length > 0) { + // Sender is named in the trust list but is not registered — cannot verify identity. + // Reject rather than silently skip: an unregistered sender cannot prove they are + // the trusted agent they claim to be (deregistered agent impersonation attack). + throw new Error(`Sender ${envelope.from} is not registered — signature required for trust-list delivery`); } // Parse ephemeral options (top-level on send body, not inside envelope) From 1862182ef8d6581ce24d8d36663533a5f9888b82 Mon Sep 17 00:00:00 2001 From: David D Date: Thu, 26 Feb 2026 13:21:24 -0600 Subject: [PATCH 19/20] fix(review): tighten trust-list sender verification and docs - reject only when an unregistered sender claims a trusted ID - remove redundant VALID_DID_SEED envelope branch - add regression test for trusted-ID impersonation - document did:web explicit-port limitation in AGENT-GUIDE --- docs/AGENT-GUIDE.md | 8 ++++++++ src/server.test.js | 32 ++++++++++++++++++++++++++++++++ src/services/inbox.service.js | 15 ++++++--------- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md index 5dd9f2b..adfd162 100644 --- a/docs/AGENT-GUIDE.md +++ b/docs/AGENT-GUIDE.md @@ -504,6 +504,14 @@ This means a DID:web agent with a crafted long or unusual domain path could crea **Mitigation:** Set `DID_WEB_ALLOWED_DOMAINS` to a strict allowlist of trusted domains. This prevents shadow agent creation for all domains not explicitly permitted. +### DID:web Port Numbers Not Supported + +`did:web:` identifiers that encode a port in the domain component (for example `did:web:localhost%3A8080`) are currently rejected by domain safety checks. + +**Impact:** these DIDs fail resolution and cannot authenticate as shadow agents. + +**Mitigation:** use default HTTPS port 443 with a hostname-only DID domain, or front the service with a reverse proxy so the DID does not require an explicit port. + --- ## 11. Best Practices diff --git a/src/server.test.js b/src/server.test.js index 997596a..fb29cfb 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -538,6 +538,38 @@ test('trust list restricts message senders', async () => { assert.ok(blockedRes.body.message.includes('not trusted')); }); +test('trust list rejects unregistered sender claiming a trusted ID', async () => { + const recipient = await registerAgent('trusted-recipient-missing-sender'); + const ghostTrustedId = `ghost-trusted-${Date.now()}`; + + const addRes = await request(app) + .post(`/api/agents/${encodeURIComponent(recipient.agent_id)}/trusted`) + .send({ agent_id: ghostTrustedId }); + + assert.equal(addRes.status, 200); + assert.ok(addRes.body.trusted_agents.includes(ghostTrustedId)); + + const forgedEnvelope = { + version: '1.0', + id: `msg-${Date.now()}`, + type: 'task.request', + from: ghostTrustedId, + to: recipient.agent_id, + subject: 'forged-trusted-sender', + body: { test: 'impersonation-attempt' }, + timestamp: new Date().toISOString(), + ttl_sec: 3600 + }; + + const forgedRes = await request(app) + .post(`/api/agents/${encodeURIComponent(recipient.agent_id)}/messages`) + .send(forgedEnvelope); + + assert.equal(forgedRes.status, 403); + assert.equal(forgedRes.body.error, 'INVALID_SIGNATURE'); + assert.ok(forgedRes.body.message.includes('not registered')); +}); + test('mech storage persists agents', { skip: !MECH_CONFIGURED }, async () => { const agent = await registerAgent('mech-persist-agent', { role: 'mech-test' }); diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index 0a057bd..2b7b9d8 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -17,13 +17,6 @@ const SAFE_CHARS = /^[a-zA-Z0-9._:-]+$/; // Do not delete it assuming it is a no-op; doing so would silently break backward // compatibility for pre-PR#16 senders. const VALID_AGENT_URI = /^agent:\/\/[a-zA-Z0-9._:-]+$/; -// VALID_DID_SEED is a subset of SAFE_CHARS (did:seed:abc passes both). It is kept -// for documentation and as a named anchor if suffix rules need to diverge from SAFE_CHARS -// in the future. Important: SAFE_CHARS acts as a fallback when VALID_DID_SEED rejects — -// did:seed:ab:cd fails VALID_DID_SEED (colons in suffix) but passes SAFE_CHARS, so -// it is still accepted here. The no-colons restriction is enforced at registration, -// not at the envelope layer. -const VALID_DID_SEED = /^did:seed:[a-zA-Z0-9._-]+$/; /** * Return true if `id` is a syntactically valid agent identifier for use in @@ -47,7 +40,7 @@ const VALID_DID_SEED = /^did:seed:[a-zA-Z0-9._-]+$/; */ function isValidAgentId(id) { if (!id || id.length > 255) return false; - return VALID_AGENT_URI.test(id) || VALID_DID_SEED.test(id) || SAFE_CHARS.test(id); + return VALID_AGENT_URI.test(id) || SAFE_CHARS.test(id); } export class InboxService { @@ -110,8 +103,12 @@ export class InboxService { if (!valid) { throw new Error('Invalid message signature'); } + } else if (recipient.trusted_agents?.includes(envelope.from)) { + // Sender claims a trusted identity but has no registered key material. + // Reject to prevent impersonation when a trusted ID is missing from storage. + throw new Error(`Sender ${envelope.from} is not registered — signature required for trust-list delivery`); } - } else if (recipient.trusted_agents && recipient.trusted_agents.length > 0) { + } else if (recipient.trusted_agents?.includes(envelope.from)) { // Sender is named in the trust list but is not registered — cannot verify identity. // Reject rather than silently skip: an unregistered sender cannot prove they are // the trusted agent they claim to be (deregistered agent impersonation attack). From ceaea51dfb6b93a8d51a595fa88e1c925a33bfd6 Mon Sep 17 00:00:00 2001 From: David D Date: Thu, 26 Feb 2026 13:33:33 -0600 Subject: [PATCH 20/20] fix(review): clarify trust-list signature errors and registration semantics - document duplicate registration as intentional non-upsert path - clarify registered-but-unsigned trust-list error message - make trust-list regression test resilient to both signature-required variants --- src/server.test.js | 5 ++++- src/services/agent.service.js | 3 +++ src/services/inbox.service.js | 6 +++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/server.test.js b/src/server.test.js index fb29cfb..2933f1e 100644 --- a/src/server.test.js +++ b/src/server.test.js @@ -567,7 +567,10 @@ test('trust list rejects unregistered sender claiming a trusted ID', async () => assert.equal(forgedRes.status, 403); assert.equal(forgedRes.body.error, 'INVALID_SIGNATURE'); - assert.ok(forgedRes.body.message.includes('not registered')); + assert.ok( + forgedRes.body.message.includes('signature required') || + forgedRes.body.message.includes('not registered') + ); }); test('mech storage persists agents', { skip: !MECH_CONFIGURED }, async () => { diff --git a/src/services/agent.service.js b/src/services/agent.service.js index 1bdc2f4..75153e5 100644 --- a/src/services/agent.service.js +++ b/src/services/agent.service.js @@ -57,6 +57,9 @@ export class AgentService { // Check if agent already exists const existing = await storage.getAgent(agent_id); if (existing) { + // Re-registration/update is intentionally not supported on this endpoint. + // Existing agents must use explicit admin flows (approve/reject/rotate-key) + // rather than silently re-registering and mutating registration_status. throw new Error(`Agent ${agent_id} already exists`); } diff --git a/src/services/inbox.service.js b/src/services/inbox.service.js index 2b7b9d8..581e0d0 100644 --- a/src/services/inbox.service.js +++ b/src/services/inbox.service.js @@ -104,9 +104,9 @@ export class InboxService { throw new Error('Invalid message signature'); } } else if (recipient.trusted_agents?.includes(envelope.from)) { - // Sender claims a trusted identity but has no registered key material. - // Reject to prevent impersonation when a trusted ID is missing from storage. - throw new Error(`Sender ${envelope.from} is not registered — signature required for trust-list delivery`); + // Sender is registered but omitted signature; trust-list delivery requires + // cryptographic proof of identity, not just a claimed from value. + throw new Error(`Sender ${envelope.from} is registered but missing signature — signature required for trust-list delivery`); } } else if (recipient.trusted_agents?.includes(envelope.from)) { // Sender is named in the trust list but is not registered — cannot verify identity.