Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 29 additions & 19 deletions BUGS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# BOTCHA — Active Issues Tracker

*Last updated: 2026-04-27 by Choco*
*Last updated: 2026-06-22 by Choco*

---

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/cloudflare-workers/src/challenges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
};
}
102 changes: 97 additions & 5 deletions packages/cloudflare-workers/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1222,8 +1222,11 @@ app.post('/v1/challenges/:id/verify', async (c) => {
type?: string;
speed_answers?: string[];
reasoning_answers?: Record<string, string>;
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)) {
Expand Down Expand Up @@ -1252,14 +1255,73 @@ 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}`,
speed: result.speed,
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 <access_token>',
try_it: 'GET /agent-only',
refresh: 'POST /v1/token/refresh with {"refresh_token":"<refresh_token>"}',
},
// === Badge (shareable proof) ===
badge,
// Backward compatibility
token: tokenResult.access_token,
});
}

Expand Down Expand Up @@ -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<string, string> }>();
const { id, speed_answers, reasoning_answers } = body;
const body = await c.req.json<{ id?: string; speed_answers?: string[]; reasoning_answers?: Record<string, string>; 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({
Expand All @@ -1810,13 +1873,27 @@ 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}`,
speed: result.speed,
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,
});
}
Expand Down Expand Up @@ -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<string, string> }>();
const { id, speed_answers, reasoning_answers } = body;
const body = await c.req.json<{ id?: string; speed_answers?: string[]; reasoning_answers?: Record<string, string>; 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({
Expand All @@ -1889,13 +1967,27 @@ 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}`,
speed: result.speed,
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,
});
}
Expand Down
117 changes: 117 additions & 0 deletions tests/unit/challenges/hybrid-token-issuance.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>; put: (k: string, v: string, opts?: any) => Promise<void>; delete: (k: string) => Promise<void> } {
const store = new Map<string, string>();
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:<id>)
const reasoningStored = await kv.get(`challenge:${hybridData.reasoningChallengeId}`);
const reasoningData = JSON.parse(reasoningStored);
const reasoningAnswers: Record<string, string> = {};
for (const [qid, accepted] of Object.entries(reasoningData.expectedAnswers as Record<string, string[]>)) {
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<string, string> = {};
for (const [qid, accepted] of Object.entries(reasoningData.expectedAnswers as Record<string, string[]>)) {
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);
});
});
Loading