Skip to content

Commit 02c33eb

Browse files
committed
Fix freebuff VPN block messaging
1 parent 18b0f12 commit 02c33eb

11 files changed

Lines changed: 165 additions & 24 deletions

File tree

cli/src/components/waiting-room-screen.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,23 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
263263
⚠ Free mode isn't available in your region
264264
</text>
265265
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
266-
{session.countryCode === 'UNKNOWN' ? (
266+
{session.countryBlockReason === 'anonymous_network' ? (
267+
<>
268+
We detected VPN, Tor, proxy, relay, or anonymized network
269+
traffic
270+
{session.countryCode === 'UNKNOWN' ? (
271+
''
272+
) : (
273+
<>
274+
{' '}
275+
from{' '}
276+
<span fg={theme.foreground}>{session.countryCode}</span>
277+
</>
278+
)}
279+
. Freebuff can't be used from anonymized networks. Press
280+
Ctrl+C to exit.
281+
</>
282+
) : session.countryCode === 'UNKNOWN' ? (
267283
<>
268284
We couldn't verify an eligible location for this request.
269285
VPN, Tor, proxy, or unknown-location traffic can't use

cli/src/hooks/helpers/send-message.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { IS_FREEBUFF } from '../../utils/constants'
1212
import { processBashContext } from '../../utils/bash-context-processor'
1313
import { markRunningAgentsAsCancelled } from '../../utils/block-operations'
1414
import {
15-
getCountryCodeFromFreeModeError,
15+
getCountryBlockFromFreeModeError,
1616
getFreebuffGateErrorKind,
1717
isOutOfCreditsError,
1818
isFreeModeUnavailableError,
@@ -394,7 +394,9 @@ export const handleRunCompletion = (params: {
394394
updater.setError(FREE_MODE_UNAVAILABLE_MESSAGE)
395395
if (IS_FREEBUFF) {
396396
markFreebuffSessionCountryBlocked(
397-
getCountryCodeFromFreeModeError(output) ?? 'UNKNOWN',
397+
getCountryBlockFromFreeModeError(output) ?? {
398+
countryCode: 'UNKNOWN',
399+
},
398400
)
399401
}
400402
finalizeAfterError()
@@ -494,7 +496,9 @@ export const handleRunError = (params: {
494496
updater.setError(FREE_MODE_UNAVAILABLE_MESSAGE)
495497
if (IS_FREEBUFF) {
496498
markFreebuffSessionCountryBlocked(
497-
getCountryCodeFromFreeModeError(error) ?? 'UNKNOWN',
499+
getCountryBlockFromFreeModeError(error) ?? {
500+
countryCode: 'UNKNOWN',
501+
},
498502
)
499503
}
500504
return

cli/src/hooks/use-freebuff-session.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import { logger } from '../utils/logger'
1616
import { saveFreebuffModelPreference } from '../utils/settings'
1717

1818
import type { FreebuffSessionResponse } from '../types/freebuff-session'
19+
import type {
20+
FreebuffCountryBlockReason,
21+
FreebuffIpPrivacySignal,
22+
} from '@codebuff/common/types/freebuff-session'
1923

2024
const POLL_INTERVAL_QUEUED_MS = 5_000
2125
const POLL_INTERVAL_ACTIVE_MS = 30_000
@@ -319,10 +323,30 @@ export function markFreebuffSessionSuperseded(): void {
319323
* Transitioning the session state here unmounts the Chat surface in favor of
320324
* the waiting-room's country_blocked message, so the user can't keep typing
321325
* and sending doomed requests. */
322-
export function markFreebuffSessionCountryBlocked(countryCode: string): void {
326+
export function markFreebuffSessionCountryBlocked(
327+
params:
328+
| string
329+
| {
330+
countryCode: string
331+
countryBlockReason?: string
332+
ipPrivacySignals?: string[]
333+
},
334+
): void {
323335
if (!IS_FREEBUFF) return
336+
const next =
337+
typeof params === 'string'
338+
? { countryCode: params }
339+
: {
340+
countryCode: params.countryCode,
341+
countryBlockReason: params.countryBlockReason as
342+
| FreebuffCountryBlockReason
343+
| undefined,
344+
ipPrivacySignals: params.ipPrivacySignals as
345+
| FreebuffIpPrivacySignal[]
346+
| undefined,
347+
}
324348
controller?.abort()
325-
controller?.apply({ status: 'country_blocked', countryCode })
349+
controller?.apply({ status: 'country_blocked', ...next })
326350
// Best-effort DELETE so we don't hold a waiting-room seat on a session the
327351
// server is already refusing to serve at chat time.
328352
releaseFreebuffSlot().catch(() => {})

cli/src/utils/error-handling.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,32 @@ export const getCountryCodeFromFreeModeError = (
7373
: null
7474
}
7575

76+
export const getCountryBlockFromFreeModeError = (
77+
error: unknown,
78+
): {
79+
countryCode: string
80+
countryBlockReason?: string
81+
ipPrivacySignals?: string[]
82+
} | null => {
83+
if (!isFreeModeUnavailableError(error)) return null
84+
const countryCode = getCountryCodeFromFreeModeError(error) ?? 'UNKNOWN'
85+
const countryBlockReason = (error as { countryBlockReason?: unknown })
86+
.countryBlockReason
87+
const ipPrivacySignals = (error as { ipPrivacySignals?: unknown })
88+
.ipPrivacySignals
89+
90+
return {
91+
countryCode,
92+
countryBlockReason:
93+
typeof countryBlockReason === 'string' ? countryBlockReason : undefined,
94+
ipPrivacySignals: Array.isArray(ipPrivacySignals)
95+
? ipPrivacySignals.filter(
96+
(signal): signal is string => typeof signal === 'string',
97+
)
98+
: undefined,
99+
}
100+
}
101+
76102
/**
77103
* Freebuff waiting-room gate errors returned by /api/v1/chat/completions.
78104
*

common/src/types/freebuff-session.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,23 @@ export interface FreebuffSessionRateLimit {
2121
recentCount: number
2222
}
2323

24+
export type FreebuffCountryBlockReason =
25+
| 'country_not_allowed'
26+
| 'anonymized_or_unknown_country'
27+
| 'anonymous_network'
28+
| 'missing_client_ip'
29+
| 'unresolved_client_ip'
30+
31+
export type FreebuffIpPrivacySignal =
32+
| 'anonymous'
33+
| 'vpn'
34+
| 'proxy'
35+
| 'tor'
36+
| 'relay'
37+
| 'res_proxy'
38+
| 'hosting'
39+
| 'service'
40+
2441
export type FreebuffSessionServerResponse =
2542
| {
2643
/** Waiting room is globally off; free-mode requests flow through
@@ -106,6 +123,8 @@ export type FreebuffSessionServerResponse =
106123
* screen. `countryCode` is the resolved country, or UNKNOWN. */
107124
status: 'country_blocked'
108125
countryCode: string
126+
countryBlockReason?: FreebuffCountryBlockReason
127+
ipPrivacySignals?: FreebuffIpPrivacySignal[]
109128
}
110129
| {
111130
/** User has an active session bound to a different model. Returned

web/src/app/api/v1/chat/completions/__tests__/completions.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
593593
const body = await response.json()
594594
expect(body.error).toBe('free_mode_unavailable')
595595
expect(body.countryCode).toBe('UNKNOWN')
596+
expect(body.countryBlockReason).toBe('missing_client_ip')
596597
})
597598

598599
it('rejects free-mode requests from anonymized Cloudflare country codes', async () => {
@@ -634,6 +635,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
634635
const body = await response.json()
635636
expect(body.error).toBe('free_mode_unavailable')
636637
expect(body.countryCode).toBe('UNKNOWN')
638+
expect(body.countryBlockReason).toBe('anonymized_or_unknown_country')
637639
})
638640

639641
it('lets freebuff use GLM 5.1 through Fireworks availability rules', async () => {

web/src/app/api/v1/chat/completions/_post.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,8 @@ export async function postChatCompletions(params: {
292292
error: 'free_mode_unavailable',
293293
message: 'Free mode is not available in your country.',
294294
countryCode: countryAccess.countryCode ?? 'UNKNOWN',
295+
countryBlockReason: countryAccess.blockReason,
296+
ipPrivacySignals: countryAccess.ipPrivacy?.signals,
295297
},
296298
{ status: 403 },
297299
)

web/src/app/api/v1/freebuff/session/__tests__/session.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ describe('POST /api/v1/freebuff/session', () => {
165165
const body = await resp.json()
166166
expect(body.status).toBe('country_blocked')
167167
expect(body.countryCode).toBe('FR')
168+
expect(body.countryBlockReason).toBe('country_not_allowed')
168169
expect(sessionDeps.rows.size).toBe(0)
169170
})
170171

@@ -178,6 +179,7 @@ describe('POST /api/v1/freebuff/session', () => {
178179
const body = await resp.json()
179180
expect(body.status).toBe('country_blocked')
180181
expect(body.countryCode).toBe('UNKNOWN')
182+
expect(body.countryBlockReason).toBe('missing_client_ip')
181183
expect(sessionDeps.rows.size).toBe(0)
182184
})
183185

@@ -191,6 +193,7 @@ describe('POST /api/v1/freebuff/session', () => {
191193
const body = await resp.json()
192194
expect(body.status).toBe('country_blocked')
193195
expect(body.countryCode).toBe('UNKNOWN')
196+
expect(body.countryBlockReason).toBe('anonymized_or_unknown_country')
194197
expect(sessionDeps.rows.size).toBe(0)
195198
})
196199

@@ -256,6 +259,7 @@ describe('GET /api/v1/freebuff/session', () => {
256259
const body = await resp.json()
257260
expect(body.status).toBe('country_blocked')
258261
expect(body.countryCode).toBe('FR')
262+
expect(body.countryBlockReason).toBe('country_not_allowed')
259263
})
260264

261265
test('returns banned 403 on GET for banned user', async () => {

web/src/app/api/v1/freebuff/session/_handlers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ async function countryBlockedResponse(
3434
{
3535
status: 'country_blocked',
3636
countryCode: countryAccess.countryCode ?? 'UNKNOWN',
37+
countryBlockReason: countryAccess.blockReason,
38+
ipPrivacySignals: countryAccess.ipPrivacy?.signals,
3739
},
3840
{ status: 403 },
3941
)

web/src/server/__tests__/free-mode-country.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,24 @@ describe('free mode country access', () => {
124124
expect(access.ipPrivacy?.signals).toEqual(['res_proxy'])
125125
})
126126

127+
test('allows allowlisted countries when IPinfo only reports hosting or service', async () => {
128+
const access = await getFreeModeCountryAccess(
129+
makeReq({
130+
'cf-ipcountry': 'US',
131+
'x-forwarded-for': '203.0.113.10',
132+
}),
133+
{
134+
ipinfoToken: 'test-token',
135+
lookupIpPrivacy: async () => ({
136+
signals: ['hosting', 'service'],
137+
}),
138+
},
139+
)
140+
expect(access.allowed).toBe(true)
141+
expect(access.blockReason).toBe(null)
142+
expect(access.ipPrivacy?.signals).toEqual(['hosting', 'service'])
143+
})
144+
127145
test('allows allowlisted countries when privacy lookup finds no anonymous signals', async () => {
128146
const access = await getFreeModeCountryAccess(
129147
makeReq({
@@ -204,4 +222,22 @@ describe('free mode country access', () => {
204222
signals: ['anonymous'],
205223
})
206224
})
225+
226+
test('treats is_anonymous as blocking even when service is present', async () => {
227+
const fetch = async () =>
228+
Response.json({
229+
service: 'Privacy Provider',
230+
is_anonymous: true,
231+
})
232+
233+
const privacy = await lookupIpinfoPrivacy({
234+
ip: '198.51.100.44',
235+
token: 'test-token',
236+
fetch: fetch as unknown as typeof globalThis.fetch,
237+
})
238+
239+
expect(privacy).toEqual({
240+
signals: ['service', 'anonymous'],
241+
})
242+
})
207243
})

0 commit comments

Comments
 (0)