From 053a27be4cb3ba6f96152df4f9cf0a225fe8b9a6 Mon Sep 17 00:00:00 2001 From: chocothebot Date: Mon, 22 Jun 2026 18:28:37 +0000 Subject: [PATCH 1/2] fix(hybrid-challenge): issue access_token after passing hybrid test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hybrid challenge (now the default via GET /v1/challenges) only returned a `badge` on success, leaving agents with no usable API token. The speed-only challenge issued a full botcha-verified JWT; the hybrid path did not, breaking the challenge → token → API-call flow for any agent using the default challenge type. Root cause: - verifyHybridChallenge did not propagate app_id in its return value, so route handlers couldn't scope the issued token to the right app. - All three hybrid verify handlers (POST /v1/challenges/:id/verify, POST /v1/hybrid, POST /api/hybrid-challenge) only called createBadgeResponse() and never called generateToken(). Fix: - challenges.ts: add app_id?: string to verifyHybridChallenge return type; propagate hybrid.app_id in the return value (mirrors verifySpeedChallenge's ChallengeResult.app_id). - index.tsx: in all three hybrid verify success paths, call generateToken() with the propagated app_id, issue gate code for human handoff, fire token.created webhook, and include access_token / refresh_token / expires_in in the response alongside the existing badge. backward-compat `token` alias preserved. Tests: hybrid-token-issuance.test.ts — verifies app_id propagation for app-scoped, anonymous, and failed challenges. --- packages/cloudflare-workers/src/challenges.ts | 3 + packages/cloudflare-workers/src/index.tsx | 102 ++++++++++++++- .../challenges/hybrid-token-issuance.test.ts | 117 ++++++++++++++++++ 3 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 tests/unit/challenges/hybrid-token-issuance.test.ts diff --git a/packages/cloudflare-workers/src/challenges.ts b/packages/cloudflare-workers/src/challenges.ts index ca81597..959992b 100644 --- a/packages/cloudflare-workers/src/challenges.ts +++ b/packages/cloudflare-workers/src/challenges.ts @@ -1234,6 +1234,7 @@ export async function verifyHybridChallenge( speed: { passed: boolean; solveTimeMs?: number; reason?: string }; reasoning: { passed: boolean; score?: string; solveTimeMs?: number; reason?: string }; totalTimeMs?: number; + app_id?: string; // propagated from challenge for token generation (parity with speed challenge) }> { let hybrid: HybridChallenge | null = null; @@ -1306,5 +1307,7 @@ export async function verifyHybridChallenge( reason: reasoningResult.reason, }, totalTimeMs, + // Propagate app_id for token generation (parity with verifySpeedChallenge) + app_id: hybrid!.app_id, }; } diff --git a/packages/cloudflare-workers/src/index.tsx b/packages/cloudflare-workers/src/index.tsx index f88a276..b887b68 100644 --- a/packages/cloudflare-workers/src/index.tsx +++ b/packages/cloudflare-workers/src/index.tsx @@ -1222,8 +1222,11 @@ app.post('/v1/challenges/:id/verify', async (c) => { type?: string; speed_answers?: string[]; reasoning_answers?: Record; + audience?: string; + bind_ip?: boolean; + app_id?: string; }>(); - const { answers, answer, type, speed_answers, reasoning_answers } = body; + const { answers, answer, type, speed_answers, reasoning_answers, audience, bind_ip } = body; // Hybrid challenge (default) if (type === 'hybrid' || (speed_answers && reasoning_answers)) { @@ -1252,6 +1255,47 @@ app.post('/v1/challenges/:id/verify', async (c) => { const baseUrl = new URL(c.req.url).origin; const badge = await createBadgeResponse('hybrid-challenge', c.env.JWT_SECRET, baseUrl, result.speed.solveTimeMs); + // Issue a botcha-verified JWT (same as speed-only challenge) so agents can + // immediately use the token for API calls. Previously the hybrid path only + // returned a badge, leaving agents unable to authenticate after passing. + const challengeAppId = result.app_id; + const signingKey = getSigningKey(c.env); + const tokenResult = await generateToken( + id, + result.speed.solveTimeMs || 0, + c.env.JWT_SECRET, + c.env, + { + aud: audience, + clientIp: bind_ip ? clientIP : undefined, + app_id: challengeAppId, + }, + signingKey + ); + + // Generate short human-readable gate code (BOTCHA-XXXX) + const gateChars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let gateCode = 'BOTCHA-'; + for (let i = 0; i < 6; i++) gateCode += gateChars[Math.floor(Math.random() * gateChars.length)]; + try { + await c.env.CHALLENGES.put(`gate:${gateCode}`, tokenResult.access_token, { expirationTtl: 300 }); + } catch { + // Fail-open: if KV fails, agent can still use the JWT directly + } + + // Webhook: token.created + if (challengeAppId) { + const webhookCtx = c.executionCtx; + if (webhookCtx?.waitUntil) { + webhookCtx.waitUntil(triggerWebhook( + c.env.AGENTS as unknown as WebhookKVNamespace, + challengeAppId, + 'token.created', + { solve_time_ms: result.speed.solveTimeMs, audience } + )); + } + } + return c.json({ success: true, message: `🔥 HYBRID TEST PASSED! Speed: ${result.speed.solveTimeMs}ms, Reasoning: ${result.reasoning.score}`, @@ -1259,7 +1303,25 @@ app.post('/v1/challenges/:id/verify', async (c) => { reasoning: result.reasoning, totalTimeMs: result.totalTimeMs, verdict: '🤖 VERIFIED AI AGENT (speed + reasoning confirmed)', + // === Token (use this for API calls) === + access_token: tokenResult.access_token, + expires_in: tokenResult.expires_in, + refresh_token: tokenResult.refresh_token, + refresh_expires_in: tokenResult.refresh_expires_in, + // === Human handoff === + human_link: `${baseUrl}/go/${gateCode}`, + human_code: gateCode, + human_instruction: `Give your human this link to open in their browser: ${baseUrl}/go/${gateCode}`, + // === What to do next === + usage: { + header: 'Authorization: Bearer ', + try_it: 'GET /agent-only', + refresh: 'POST /v1/token/refresh with {"refresh_token":""}', + }, + // === Badge (shareable proof) === badge, + // Backward compatibility + token: tokenResult.access_token, }); } @@ -1793,8 +1855,9 @@ app.get('/v1/hybrid', rateLimitMiddleware, async (c) => { // Verify hybrid challenge (v1 API) app.post('/v1/hybrid', async (c) => { - const body = await c.req.json<{ id?: string; speed_answers?: string[]; reasoning_answers?: Record }>(); - const { id, speed_answers, reasoning_answers } = body; + const body = await c.req.json<{ id?: string; speed_answers?: string[]; reasoning_answers?: Record; audience?: string; bind_ip?: boolean }>(); + const { id, speed_answers, reasoning_answers, audience, bind_ip } = body; + const clientIP = getClientIP(c.req.raw); if (!id || !speed_answers || !reasoning_answers) { return c.json({ @@ -1810,6 +1873,16 @@ app.post('/v1/hybrid', async (c) => { const baseUrl = new URL(c.req.url).origin; const badge = await createBadgeResponse('hybrid-challenge', c.env.JWT_SECRET, baseUrl, result.speed.solveTimeMs); + const signingKey = getSigningKey(c.env); + const tokenResult = await generateToken( + id, + result.speed.solveTimeMs || 0, + c.env.JWT_SECRET, + c.env, + { aud: audience, clientIp: bind_ip ? clientIP : undefined, app_id: result.app_id }, + signingKey + ); + return c.json({ success: true, message: `🔥 HYBRID TEST PASSED! Speed: ${result.speed.solveTimeMs}ms, Reasoning: ${result.reasoning.score}`, @@ -1817,6 +1890,10 @@ app.post('/v1/hybrid', async (c) => { reasoning: result.reasoning, totalTimeMs: result.totalTimeMs, verdict: '🤖 VERIFIED AI AGENT (speed + reasoning confirmed)', + access_token: tokenResult.access_token, + expires_in: tokenResult.expires_in, + refresh_token: tokenResult.refresh_token, + token: tokenResult.access_token, // backward compat badge, }); } @@ -1872,8 +1949,9 @@ app.get('/api/hybrid-challenge', async (c) => { }); app.post('/api/hybrid-challenge', async (c) => { - const body = await c.req.json<{ id?: string; speed_answers?: string[]; reasoning_answers?: Record }>(); - const { id, speed_answers, reasoning_answers } = body; + const body = await c.req.json<{ id?: string; speed_answers?: string[]; reasoning_answers?: Record; audience?: string; bind_ip?: boolean }>(); + const { id, speed_answers, reasoning_answers, audience, bind_ip } = body; + const clientIP = getClientIP(c.req.raw); if (!id || !speed_answers || !reasoning_answers) { return c.json({ @@ -1889,6 +1967,16 @@ app.post('/api/hybrid-challenge', async (c) => { const baseUrl = new URL(c.req.url).origin; const badge = await createBadgeResponse('hybrid-challenge', c.env.JWT_SECRET, baseUrl, result.speed.solveTimeMs); + const signingKey = getSigningKey(c.env); + const tokenResult = await generateToken( + id, + result.speed.solveTimeMs || 0, + c.env.JWT_SECRET, + c.env, + { aud: audience, clientIp: bind_ip ? clientIP : undefined, app_id: result.app_id }, + signingKey + ); + return c.json({ success: true, message: `🔥 HYBRID TEST PASSED! Speed: ${result.speed.solveTimeMs}ms, Reasoning: ${result.reasoning.score}`, @@ -1896,6 +1984,10 @@ app.post('/api/hybrid-challenge', async (c) => { reasoning: result.reasoning, totalTimeMs: result.totalTimeMs, verdict: '🤖 VERIFIED AI AGENT (speed + reasoning confirmed)', + access_token: tokenResult.access_token, + expires_in: tokenResult.expires_in, + refresh_token: tokenResult.refresh_token, + token: tokenResult.access_token, // backward compat badge, }); } diff --git a/tests/unit/challenges/hybrid-token-issuance.test.ts b/tests/unit/challenges/hybrid-token-issuance.test.ts new file mode 100644 index 0000000..2690ae2 --- /dev/null +++ b/tests/unit/challenges/hybrid-token-issuance.test.ts @@ -0,0 +1,117 @@ +/** + * Test: Hybrid challenge returns access_token after passing + * + * Bug: verifyHybridChallenge did not propagate app_id, and the hybrid + * challenge verify handlers returned only a `badge`, leaving agents with + * no usable API token after passing the hybrid test. + * + * Fix: propagate app_id in verifyHybridChallenge return value; issue + * generateToken() in all three hybrid verify handlers, mirroring the + * speed-only challenge flow. + */ + +import { describe, it, expect } from 'vitest'; +import { + generateHybridChallenge, + verifyHybridChallenge, + HybridChallenge, +} from '../../../packages/cloudflare-workers/src/challenges'; + +// --------------------------------------------------------------------------- +// Minimal KV mock (in-memory) +// --------------------------------------------------------------------------- +function makeMockKV(): { get: (k: string) => Promise; put: (k: string, v: string, opts?: any) => Promise; delete: (k: string) => Promise } { + const store = new Map(); + return { + async get(key: string) { return store.get(key) ?? null; }, + async put(key: string, value: string) { store.set(key, value); }, + async delete(key: string) { store.delete(key); }, + }; +} + +// --------------------------------------------------------------------------- +// Helpers to extract reasoning answers from challenge +// --------------------------------------------------------------------------- +import * as crypto from 'node:crypto'; + +function solveSpeed(problems: { num: number; operation: string }[]): string[] { + return problems.map(p => { + const hash = crypto.createHash('sha256').update(String(p.num)).digest('hex'); + return hash.slice(0, 8); + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('verifyHybridChallenge — app_id propagation', () => { + it('propagates app_id in the return value when challenge was issued with app_id', async () => { + const kv = makeMockKV(); + const APP_ID = 'app_test123'; + const challenge = await generateHybridChallenge(kv, undefined, APP_ID); + + // Manually solve (we trust reasoning is tested elsewhere; just use known-good answers) + const speedAnswers = solveSpeed(challenge.speed.problems); + + // Build "correct" reasoning answers from the internal expected answers + // by reading the stored challenge JSON directly + const stored = await kv.get(`hybrid:${challenge.id}`); + const hybridData: HybridChallenge = JSON.parse(stored); + + // Pull reasoning challenge to get expected answers (stored under challenge:) + const reasoningStored = await kv.get(`challenge:${hybridData.reasoningChallengeId}`); + const reasoningData = JSON.parse(reasoningStored); + const reasoningAnswers: Record = {}; + for (const [qid, accepted] of Object.entries(reasoningData.expectedAnswers as Record)) { + reasoningAnswers[qid] = (accepted as string[])[0]; + } + + // Re-put the challenge (reading deleted it during solve attempt below) + // Actually we haven't called verify yet, so nothing is deleted. + + const result = await verifyHybridChallenge(challenge.id, speedAnswers, reasoningAnswers, kv); + + expect(result.valid).toBe(true); + // app_id must be propagated for token generation in the route handler + expect(result.app_id).toBe(APP_ID); + }); + + it('returns app_id as undefined when challenge was issued without app_id', async () => { + const kv = makeMockKV(); + const challenge = await generateHybridChallenge(kv); // no app_id + + const speedAnswers = solveSpeed(challenge.speed.problems); + + const stored = await kv.get(`hybrid:${challenge.id}`); + const hybridData: HybridChallenge = JSON.parse(stored); + + const reasoningStored = await kv.get(`challenge:${hybridData.reasoningChallengeId}`); + const reasoningData = JSON.parse(reasoningStored); + const reasoningAnswers: Record = {}; + for (const [qid, accepted] of Object.entries(reasoningData.expectedAnswers as Record)) { + reasoningAnswers[qid] = (accepted as string[])[0]; + } + + const result = await verifyHybridChallenge(challenge.id, speedAnswers, reasoningAnswers, kv); + + expect(result.valid).toBe(true); + expect(result.app_id).toBeUndefined(); + }); + + it('propagates app_id on failure (not just on success)', async () => { + const kv = makeMockKV(); + const APP_ID = 'app_fail_test'; + const challenge = await generateHybridChallenge(kv, undefined, APP_ID); + + // Wrong speed answers + const badSpeedAnswers = challenge.speed.problems.map(() => '00000000'); + const reasoningAnswers = { dummy: 'wrong' }; + + const result = await verifyHybridChallenge(challenge.id, badSpeedAnswers, reasoningAnswers, kv); + + expect(result.valid).toBe(false); + // app_id is still propagated so callers can log/audit which app attempted + expect(result.app_id).toBe(APP_ID); + }); +}); From f23e91adc03fc197376fec877fafd36d759f3bd6 Mon Sep 17 00:00:00 2001 From: chocothebot Date: Mon, 22 Jun 2026 18:33:35 +0000 Subject: [PATCH 2/2] docs(bugs): update with PR #55 status + 3 new technical debt items from 2026-06-22 sprint --- BUGS.md | 48 +++++++++++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/BUGS.md b/BUGS.md index 2ed48e7..15fa08b 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,6 +1,6 @@ # BOTCHA — Active Issues Tracker -*Last updated: 2026-04-27 by Choco* +*Last updated: 2026-06-22 by Choco* --- @@ -36,30 +36,40 @@ Dual ESM/CJS build via tsconfig.cjs.json + verify-cjs.cjs CI check. ## 🔄 IN PROGRESS -### PR — Four UX/correctness bugs (2026-04-27 sprint, Choco) -**Branch:** `fix/token-validate-all-types` -**Bugs confirmed on live API:** +### PR #55 — Hybrid challenge missing access_token (2026-06-22 sprint, Choco) +**Branch:** `fix/hybrid-challenge-missing-token` +**PR:** https://github.com/dupe-com/botcha/pull/55 -**Bug 1 (2026-04-20): `GET /v1/agents/me` rejects agent-identity tokens** -`verifyToken` called with `undefined` options (defaults to `botcha-verified` only). OAuth-refresh tokens are blocked on the one route designed specifically to help agents identify themselves. -- **Fix:** Pass `{ allowedTypes: ['botcha-verified', 'botcha-agent-identity'] }` - -**Bug 2 (2026-04-20): `GET /v1/agents/:id/reputation` → 400 MISSING_AGENT_ID** -Alias route registered with `:id` param but `getReputationRoute` reads `c.req.param('agent_id')` — always undefined via this alias. -- **Fix:** Try `c.req.param('agent_id') || c.req.param('id')` in handler - -**Bug 3 (2026-04-20): `POST /v1/delegations` — string capabilities give misleading error** -Passing `["browse", "search"]` returns "Invalid capability action. Valid: browse…" — implying the value is wrong when the actual issue is the format (`{action: "browse"}` required). -- **Fix:** Normalize strings to `{action: string}` objects before validation; clearer error message - -**Bug 4 (2026-04-27): `POST /v1/token/validate` rejects attestation and agent-identity tokens** -The public validation endpoint is documented as "verify any BOTCHA token" but calls `verifyToken` with `undefined` options — defaulting to `allowedTypes: ['botcha-verified']`. Any non-challenge token (agent-identity, attestation, ANS badge, VC) gets `{"valid": false, "error": "Invalid token type"}`. -- **Fix:** Export `ALL_BOTCHA_ACCESS_TOKEN_TYPES` constant from `auth.ts`; pass it as `allowedTypes` to the validate endpoint. Refresh tokens intentionally excluded. +**Bug (2026-06-22): Hybrid challenge returns badge but no access_token** +The hybrid challenge is now the default (`GET /v1/challenges` returns hybrid by default). +On success it returned only a `badge` JWT — not a `botcha-verified` access_token + refresh_token. +The speed-only path correctly called `generateToken()`. All three hybrid handlers did not. +- **Root cause:** `verifyHybridChallenge` didn't propagate `app_id`; handlers never called `generateToken()` +- **Fix:** Propagate `app_id` in return value; add `generateToken()` to all 3 hybrid verify handlers +- **Tests:** `tests/unit/challenges/hybrid-token-issuance.test.ts` (3 tests, all passing) --- ## 🔮 TECHNICAL DEBT (existing in main, deprioritized) +### 7. POST /v1/sessions/tap requires agent_id in body (no JWT binding) +**Location:** `tap-routes.ts` → `createTAPSessionRoute` +**Issue:** Handler requires `agent_id` in body but never validates it matches the authenticated agent's JWT claim. An agent with a valid token could theoretically pass a different agent_id — no access control check. +**Fix:** Extract `agent_id` from JWT payload (`c.get('tokenPayload')?.agent_id`); use it instead of / in addition to body `agent_id`. If both present, verify they match. +**Priority:** 🟠 MAJOR — authentication gap + +### 8. Capability format inconsistency across API surfaces +**Location:** `POST /v1/sessions/tap` vs `POST /v1/attestations` vs `POST /v1/delegations` +**Issue:** Three different capability formats: `{action: "browse"}` objects (sessions), `"browse:*"` strings (attestations), `{action: "browse"}` objects (delegations). Agents consistently pass the wrong format. +**Fix:** Normalize all inputs to a single canonical format, or document clearly in each endpoint's error message what format is expected. +**Priority:** 🟡 MINOR — developer experience + +### 9. Hybrid challenge solver patterns needed (reasoning question coverage) +**Location:** `challenges.ts` → REASONING_QUESTIONS bank +**Issue:** During agent sprint, ~30% of reasoning questions were unmatched by any solver pattern. New question types discovered: LIFO → stack, "neck but no head" → bottle, string length questions, "occurs once in a minute..." → letter M, "remove letter from startling" → starling, "what connects: river, money, blood" → bank, etc. +**Note:** Not a blocking bug but shows the question bank has grown past common patterns. Adding these to the Python solver library for future sprints. +**Priority:** 🟡 MINOR — agent usability + ### 1. KV Read-Modify-Write Race Condition **Location:** `last_verified_at` updates on TAP session creation **Risk:** Two simultaneous requests updating agent metadata can silently lose one update