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
1 change: 1 addition & 0 deletions agents/file-explorer/code-searcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const codeSearcher: SecretAgentDefinition = {
yield {
toolName: 'set_output',
input: {
message: '',
results: toolResults,
},
includeToolCall: false,
Expand Down
2 changes: 1 addition & 1 deletion cli/release/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codebuff",
"version": "1.0.673",
"version": "1.0.674",
"description": "AI coding agent",
"license": "MIT",
"bin": {
Expand Down
45 changes: 45 additions & 0 deletions cli/src/components/tools/__tests__/code-search.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'bun:test'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'

import { initializeThemeStore } from '../../../hooks/use-theme'
import { CodeSearchComponent } from '../code-search'

import type { ChatTheme } from '../../../types/theme-system'
import type { ToolBlock } from '../types'

initializeThemeStore()

const createToolBlock = (
output?: string,
): ToolBlock & { toolName: 'code_search' } => ({
type: 'tool',
toolName: 'code_search',
toolCallId: 'code-search-test',
input: {
pattern: 'getAgentBaseName',
cwd: 'cli/src/utils',
},
output,
})

describe('CodeSearchComponent', () => {
test('uses formatted match count from current code search output', () => {
const result = CodeSearchComponent.render(
createToolBlock(`Found 2 matches
./message-block-helpers.ts:
Line 13: export const getAgentBaseName = (type: string): string => {
Line 196: getAgentBaseName(options.agentType ?? '') === 'code-searcher'`),
{} as ChatTheme,
{
availableWidth: 80,
indentationOffset: 0,
labelWidth: 10,
},
)

const markup = renderToStaticMarkup(<>{result.content}</>)

expect(markup).toContain('getAgentBaseName in cli/src/utils (2 results)')
})
})
26 changes: 15 additions & 11 deletions cli/src/components/tools/code-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,22 @@ export const CodeSearchComponent = defineToolComponent({

if (toolBlock.output && typeof toolBlock.output === 'string') {
const lines = toolBlock.output.split('\n')
const matchCountLine = lines.find((line) =>
/^Found \d+ matches?$/.test(line.trim()),
)
const parsedTotalResults = matchCountLine
?.trim()
.match(/^Found (\d+) matches?$/)?.[1]

for (const line of lines) {
const trimmed = line.trim()
if (parsedTotalResults !== undefined) {
totalResults = Number(parsedTotalResults)
} else {
for (const line of lines) {
const trimmed = line.trim()

// Result lines start with a number followed by a colon
if (/^\d+:/.test(trimmed)) {
totalResults++
if (/^(?:Line\s+)?\d+:/.test(trimmed)) {
totalResults++
}
}
}
}
Expand All @@ -52,12 +61,7 @@ export const CodeSearchComponent = defineToolComponent({

// Return as content using SimpleToolCallItem
return {
content: (
<SimpleToolCallItem
name="Search"
description={summary}
/>
),
content: <SimpleToolCallItem name="Search" description={summary} />,
}
},
})
17 changes: 17 additions & 0 deletions cli/src/utils/__tests__/message-block-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,23 @@ describe('extractSpawnAgentResultContent', () => {
hasError: false,
})
})

test('uses an empty structuredOutput message as no display content', () => {
const result = extractSpawnAgentResultContent({
type: 'structuredOutput',
value: {
message: '',
results: [
{
stdout: 'Found 1 match\n./file.ts:\nLine 1: needle',
message: 'Exit code: 0',
},
],
},
})

expect(result).toEqual({ content: '', hasError: false })
})
})

describe('appendInterruptionNotice', () => {
Expand Down
1 change: 0 additions & 1 deletion common/src/constants/model-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ export type openrouterModel =
(typeof openrouterModels)[keyof typeof openrouterModels]

export const openCodeZenModels = {
opencode_minimax_m2_7: 'opencode/minimax-m2.7',
opencode_kimi_k2_6: 'opencode/kimi-k2.6',
} as const
export type OpenCodeZenModel =
Expand Down
4 changes: 2 additions & 2 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ sequenceDiagram
CLI->>CLI: Open browser
Note over Web: User completes OAuth
Web->>DB: Resolve opaque token to signed payload
Web->>DB: Delete opaque token
Web->>DB: Mark opaque token consumed
Web->>DB: Check fingerprint ownership
Web->>DB: Create/update session
loop Every 5s
Expand Down Expand Up @@ -74,7 +74,7 @@ sequenceDiagram

- Signed auth payloads expire after 1 hour
- Browser login URLs use opaque 43-character tokens instead of exposing the signed auth payload
- Opaque browser tokens are stored in `verificationToken` under `cli-login:<token>` and consumed with `DELETE ... RETURNING` when onboarding resolves them
- Opaque browser tokens are stored in `verificationToken` under `cli-login:<token>` and atomically moved to `cli-login-consumed:<token-hash>` when onboarding resolves them; consumed markers scrub the signed auth payload from the `token` column
- Fingerprint uniqueness: hardware info + 8 random bytes
- Ownership conflicts blocked and logged
- Sessions linked to fingerprint_id in database
Expand Down
34 changes: 34 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,37 @@ CLI hook testing note: React 19 + Bun + RTL `renderHook()` is unreliable; prefer
## CLI tmux Testing

For testing CLI behavior via tmux, use the helper scripts in `scripts/tmux/`. These handle bracketed paste mode and session logging automatically. Session data is saved to `debug/tmux-sessions/` in YAML format and can be viewed with `bun scripts/tmux/tmux-viewer/index.tsx`. See `scripts/tmux/README.md` for details.

Useful workflow for agents:

```bash
# Start the dev CLI in a detached tmux session.
SESSION=$(./scripts/tmux/tmux-cli.sh start --name cli-check -w 160 -h 40 --wait 6)

# Capture the initial screen. Captures are written to debug/tmux-sessions/$SESSION/.
./scripts/tmux/tmux-cli.sh capture "$SESSION" --label initial

# Send a prompt. The helper uses bracketed paste so text is not dropped.
./scripts/tmux/tmux-cli.sh send "$SESSION" "Search for getAgentBaseName and report what you find" --wait-idle 4

# Capture after the run, then inspect the saved capture text.
./scripts/tmux/tmux-cli.sh capture "$SESSION" --label after-search --wait 2

# Clean up when finished.
./scripts/tmux/tmux-cli.sh stop "$SESSION"
```

If a change can be verified with a small local harness instead of a live model-backed CLI run, run that harness inside tmux too. This still checks terminal rendering and produces a capture:

```bash
SESSION=$(./scripts/tmux/tmux-cli.sh start \
--name render-check \
-w 160 -h 20 \
--wait 1 \
--command "bun .context/my-render-check.tsx")

./scripts/tmux/tmux-cli.sh capture "$SESSION" --label rendered
./scripts/tmux/tmux-cli.sh stop "$SESSION"
```

When verifying UI output, prefer checking the saved capture file for concrete strings that should and should not appear. For example, after expanding a code-searcher agent, check that the capture shows the search summary but not raw structured payload keys like `results:` or `stdout:`.
2 changes: 1 addition & 1 deletion freebuff/cli/release/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "freebuff",
"version": "0.0.84",
"version": "0.0.85",
"description": "The world's strongest free coding agent",
"license": "MIT",
"bin": {
Expand Down
27 changes: 25 additions & 2 deletions freebuff/web/src/app/api/auth/cli/code/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { and, eq, gt } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod/v4'

import { buildCliAuthCode } from '@/app/onboard/_helpers'
import {
buildCliAuthCode,
getCliAuthCodeHashPrefix,
getCliAuthCodeTokenIdentifier,
} from '@/app/onboard/_helpers'
import { logger } from '@/util/logger'

import { getLoginUrlOrigin } from './_origin'
Expand Down Expand Up @@ -66,7 +70,7 @@ export async function POST(req: Request) {
const loginToken = randomBytes(32).toString('base64url')

await db.insert(schema.verificationToken).values({
identifier: `cli-login:${loginToken}`,
identifier: getCliAuthCodeTokenIdentifier(loginToken),
token: authCode,
expires: new Date(expiresAt),
})
Expand All @@ -82,6 +86,25 @@ export async function POST(req: Request) {
)
loginUrl.searchParams.set('auth_code', loginToken)

logger.info(
{
authCodeTokenHashPrefix: getCliAuthCodeHashPrefix(loginToken),
authCodeTokenLength: loginToken.length,
fingerprintIdPrefix: fingerprintId.slice(0, 24),
fingerprintIdLength: fingerprintId.length,
expiresAt,
loginUrlOrigin: loginUrl.origin,
requestOrigin: new URL(req.url).origin,
requestHost: req.headers.get('host'),
forwardedHost: req.headers.get('x-forwarded-host'),
forwardedProto: req.headers.get('x-forwarded-proto'),
originHeader: req.headers.get('origin'),
configuredAppUrl: env.NEXT_PUBLIC_CODEBUFF_APP_URL,
environment: env.NEXT_PUBLIC_CB_ENVIRONMENT,
},
'Issued Freebuff CLI auth code token',
)

return NextResponse.json({
fingerprintId,
fingerprintHash,
Expand Down
65 changes: 59 additions & 6 deletions freebuff/web/src/app/onboard/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'

import {
buildCliAuthCode,
getCliAuthCodeHashPrefix,
getCliAuthCodeTokenIdentifier,
getConsumedCliAuthCodeTokenIdentifier,
getConsumedCliAuthCodeTokenValue,
isAuthCodeExpired,
isOpaqueCliAuthCodeToken,
parseAuthCode,
Expand Down Expand Up @@ -110,6 +114,23 @@ describe('freebuff onboard/_helpers', () => {
expect(isOpaqueCliAuthCodeToken(`${'A'.repeat(42)}.`)).toBe(false)
})

test('hashes auth codes for log correlation without logging the token', () => {
expect(getCliAuthCodeHashPrefix('a'.repeat(43))).toBe('66d34fba71f8')
expect(getCliAuthCodeHashPrefix(` ${'a'.repeat(43)}\n`)).toBe(
'66d34fba71f8',
)
})

test('builds active and consumed token identifiers', () => {
expect(getCliAuthCodeTokenIdentifier('token-123')).toBe(
'cli-login:token-123',
)
expect(getConsumedCliAuthCodeTokenIdentifier('token-123')).toBe(
'cli-login-consumed:034192845dc489deca291f9f5ae0bb8e5472c991020bf64b3ebc6dec5a1d7e47',
)
expect(getConsumedCliAuthCodeTokenValue()).toBe('consumed')
})

test('resolves an opaque browser token before validation', async () => {
const expiresAt = '4102444800000'
const fingerprintHash = genAuthCode(
Expand All @@ -126,10 +147,11 @@ describe('freebuff onboard/_helpers', () => {

const result = await resolveCliAuthCode(opaqueToken, async (token) => {
expect(token).toBe(opaqueToken)
return signedAuthCode
return { status: 'resolved', authCode: signedAuthCode }
})

expect(result).toEqual({
status: 'ready',
authCode: signedAuthCode,
resolvedOpaqueToken: true,
})
Expand All @@ -155,16 +177,47 @@ describe('freebuff onboard/_helpers', () => {

const result = await resolveCliAuthCode(signedAuthCode, async () => {
lookedUp = true
return null
return { status: 'missing' }
})

expect(lookedUp).toBe(false)
expect(result).toEqual({
status: 'ready',
authCode: signedAuthCode,
resolvedOpaqueToken: false,
})
})

test('classifies reused opaque browser tokens as already consumed', async () => {
const opaqueToken = 'c'.repeat(43)

const result = await resolveCliAuthCode(opaqueToken, async (token) => {
expect(token).toBe(opaqueToken)
return { status: 'already_consumed' }
})

expect(result).toEqual({
status: 'already_consumed',
authCode: opaqueToken,
resolvedOpaqueToken: false,
})
})

test('keeps never-issued opaque browser tokens invalid', async () => {
const opaqueToken = 'd'.repeat(43)

const result = await resolveCliAuthCode(opaqueToken, async (token) => {
expect(token).toBe(opaqueToken)
return { status: 'missing' }
})

expect(result).toEqual({
status: 'missing',
authCode: opaqueToken,
resolvedOpaqueToken: false,
})
})

test('resolves expired stored payloads so callers can show expired', async () => {
const expiresAt = '0'
const fingerprintHash = genAuthCode(
Expand All @@ -178,10 +231,10 @@ describe('freebuff onboard/_helpers', () => {
fingerprintHash,
)

const result = await resolveCliAuthCode(
'b'.repeat(43),
async () => signedAuthCode,
)
const result = await resolveCliAuthCode('b'.repeat(43), async () => ({
status: 'resolved',
authCode: signedAuthCode,
}))
const parsed = parseAuthCode(result.authCode)

expect(isAuthCodeExpired(parsed.expiresAt)).toBe(true)
Expand Down
Loading
Loading