Skip to content

Commit 1b922dc

Browse files
authored
Fix freebuff VPN block messaging (#555)
1 parent 18b0f12 commit 1b922dc

18 files changed

Lines changed: 495 additions & 98 deletions

File tree

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { exitFreebuffCleanly } from '../utils/freebuff-exit'
1717
import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'
1818

1919
import type { FreebuffSessionResponse } from '../types/freebuff-session'
20+
import type { FreebuffIpPrivacySignal } from '@codebuff/common/types/freebuff-session'
2021

2122
interface WaitingRoomScreenProps {
2223
session: FreebuffSessionResponse | null
@@ -55,6 +56,35 @@ const formatRetryAfter = (ms: number): string => {
5556
return rem === 0 ? `${hours}h` : `${hours}h ${rem}m`
5657
}
5758

59+
const PRIVACY_SIGNAL_LABELS: Partial<Record<FreebuffIpPrivacySignal, string>> =
60+
{
61+
anonymous: 'anonymized network',
62+
proxy: 'proxy',
63+
relay: 'relay',
64+
res_proxy: 'residential proxy',
65+
tor: 'Tor',
66+
vpn: 'VPN',
67+
}
68+
69+
const formatPrivacySignalList = (
70+
signals: FreebuffIpPrivacySignal[] | undefined,
71+
): string => {
72+
const labels = Array.from(
73+
new Set(
74+
signals
75+
?.map((signal) => PRIVACY_SIGNAL_LABELS[signal])
76+
.filter((label): label is string => Boolean(label)) ?? [],
77+
),
78+
)
79+
80+
if (labels.length === 0) {
81+
return 'VPN, Tor, proxy, relay, or anonymized network'
82+
}
83+
if (labels.length === 1) return labels[0]
84+
if (labels.length === 2) return `${labels[0]} or ${labels[1]}`
85+
return `${labels.slice(0, -1).join(', ')}, or ${labels[labels.length - 1]}`
86+
}
87+
5888
export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
5989
session,
6090
error,
@@ -263,7 +293,23 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
263293
⚠ Free mode isn't available in your region
264294
</text>
265295
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
266-
{session.countryCode === 'UNKNOWN' ? (
296+
{session.countryBlockReason === 'anonymous_network' ? (
297+
<>
298+
We detected{' '}
299+
{formatPrivacySignalList(session.ipPrivacySignals)} traffic
300+
{session.countryCode === 'UNKNOWN' ? (
301+
''
302+
) : (
303+
<>
304+
{' '}
305+
from{' '}
306+
<span fg={theme.foreground}>{session.countryCode}</span>
307+
</>
308+
)}
309+
. Freebuff can't be used from anonymized networks. Press
310+
Ctrl+C to exit.
311+
</>
312+
) : session.countryCode === 'UNKNOWN' ? (
267313
<>
268314
We couldn't verify an eligible location for this request.
269315
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: 10 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,14 @@ 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(params: {
327+
countryCode: string
328+
countryBlockReason?: FreebuffCountryBlockReason
329+
ipPrivacySignals?: FreebuffIpPrivacySignal[]
330+
}): void {
323331
if (!IS_FREEBUFF) return
324332
controller?.abort()
325-
controller?.apply({ status: 'country_blocked', countryCode })
333+
controller?.apply({ status: 'country_blocked', ...params })
326334
// Best-effort DELETE so we don't hold a waiting-room seat on a session the
327335
// server is already refusing to serve at chat time.
328336
releaseFreebuffSlot().catch(() => {})

cli/src/utils/__tests__/error-handling.test.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, test, expect } from 'bun:test'
33
import {
44
isOutOfCreditsError,
55
isFreeModeUnavailableError,
6+
getCountryBlockFromFreeModeError,
67
OUT_OF_CREDITS_MESSAGE,
78
FREE_MODE_UNAVAILABLE_MESSAGE,
89
createErrorMessage,
@@ -70,7 +71,11 @@ describe('error-handling', () => {
7071

7172
describe('isFreeModeUnavailableError', () => {
7273
test('returns true for error with statusCode 403 and error free_mode_unavailable', () => {
73-
const error = { statusCode: 403, error: 'free_mode_unavailable', message: 'Free mode is not available in your country.' }
74+
const error = {
75+
statusCode: 403,
76+
error: 'free_mode_unavailable',
77+
message: 'Free mode is not available in your country.',
78+
}
7479
expect(isFreeModeUnavailableError(error)).toBe(true)
7580
})
7681

@@ -80,12 +85,20 @@ describe('error-handling', () => {
8085
})
8186

8287
test('returns false for 403 with different error code', () => {
83-
const error = { statusCode: 403, error: 'account_suspended', message: 'Suspended' }
88+
const error = {
89+
statusCode: 403,
90+
error: 'account_suspended',
91+
message: 'Suspended',
92+
}
8493
expect(isFreeModeUnavailableError(error)).toBe(false)
8594
})
8695

8796
test('returns false for non-403 status with free_mode_unavailable error', () => {
88-
const error = { statusCode: 400, error: 'free_mode_unavailable', message: 'Bad request' }
97+
const error = {
98+
statusCode: 400,
99+
error: 'free_mode_unavailable',
100+
message: 'Bad request',
101+
}
89102
expect(isFreeModeUnavailableError(error)).toBe(false)
90103
})
91104

@@ -102,9 +115,51 @@ describe('error-handling', () => {
102115
})
103116
})
104117

118+
describe('getCountryBlockFromFreeModeError', () => {
119+
test('extracts country block details from free-mode unavailable errors', () => {
120+
const error = {
121+
statusCode: 403,
122+
error: 'free_mode_unavailable',
123+
countryCode: 'US',
124+
countryBlockReason: 'anonymous_network',
125+
ipPrivacySignals: ['vpn', 'hosting', 123],
126+
}
127+
128+
expect(getCountryBlockFromFreeModeError(error)).toEqual({
129+
countryCode: 'US',
130+
countryBlockReason: 'anonymous_network',
131+
ipPrivacySignals: ['vpn', 'hosting'],
132+
})
133+
})
134+
135+
test('defaults missing country code to UNKNOWN', () => {
136+
const error = {
137+
statusCode: 403,
138+
error: 'free_mode_unavailable',
139+
}
140+
141+
expect(getCountryBlockFromFreeModeError(error)).toEqual({
142+
countryCode: 'UNKNOWN',
143+
countryBlockReason: undefined,
144+
ipPrivacySignals: undefined,
145+
})
146+
})
147+
148+
test('returns null for non-free-mode errors', () => {
149+
expect(
150+
getCountryBlockFromFreeModeError({
151+
statusCode: 403,
152+
error: 'account_suspended',
153+
}),
154+
).toBe(null)
155+
})
156+
})
157+
105158
describe('FREE_MODE_UNAVAILABLE_MESSAGE', () => {
106159
test('mentions unavailability in country', () => {
107-
expect(FREE_MODE_UNAVAILABLE_MESSAGE.toLowerCase()).toContain('not available in your country')
160+
expect(FREE_MODE_UNAVAILABLE_MESSAGE.toLowerCase()).toContain(
161+
'not available in your country',
162+
)
108163
})
109164
})
110165

cli/src/utils/error-handling.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { env } from '@codebuff/common/env'
22

33
import type { ChatMessage } from '../types/chat'
4+
import type {
5+
FreebuffCountryBlockReason,
6+
FreebuffIpPrivacySignal,
7+
} from '@codebuff/common/types/freebuff-session'
48

59
import { IS_FREEBUFF } from './constants'
610

@@ -57,20 +61,38 @@ export const isFreeModeUnavailableError = (error: unknown): boolean => {
5761
return false
5862
}
5963

60-
/**
61-
* Extract the detected countryCode off a free_mode_unavailable error, if the
62-
* server included one. Used to populate the country_blocked screen after the
63-
* chat-completions gate rejects a user whose session-level country check did
64-
* not catch the request first.
65-
*/
66-
export const getCountryCodeFromFreeModeError = (
64+
export const getCountryBlockFromFreeModeError = (
6765
error: unknown,
68-
): string | null => {
66+
): {
67+
countryCode: string
68+
countryBlockReason?: FreebuffCountryBlockReason
69+
ipPrivacySignals?: FreebuffIpPrivacySignal[]
70+
} | null => {
6971
if (!isFreeModeUnavailableError(error)) return null
70-
const candidate = (error as { countryCode?: unknown }).countryCode
71-
return typeof candidate === 'string' && candidate.length > 0
72-
? candidate
73-
: null
72+
const errorDetails = error as {
73+
countryCode?: unknown
74+
countryBlockReason?: unknown
75+
ipPrivacySignals?: unknown
76+
}
77+
const countryCode =
78+
typeof errorDetails.countryCode === 'string' &&
79+
errorDetails.countryCode.length > 0
80+
? errorDetails.countryCode
81+
: 'UNKNOWN'
82+
83+
return {
84+
countryCode,
85+
countryBlockReason:
86+
typeof errorDetails.countryBlockReason === 'string'
87+
? (errorDetails.countryBlockReason as FreebuffCountryBlockReason)
88+
: undefined,
89+
ipPrivacySignals: Array.isArray(errorDetails.ipPrivacySignals)
90+
? errorDetails.ipPrivacySignals.filter(
91+
(signal): signal is FreebuffIpPrivacySignal =>
92+
typeof signal === 'string',
93+
)
94+
: undefined,
95+
}
7496
}
7597

7698
/**

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

common/src/types/session-state.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ export const AgentOutputSchema = z.discriminatedUnion('type', [
6969
message: z.string(),
7070
statusCode: z.number().optional(),
7171
error: z.string().optional(),
72+
countryCode: z.string().optional(),
73+
countryBlockReason: z.string().optional(),
74+
ipPrivacySignals: z.array(z.string()).optional(),
7275
}),
7376
])
7477
export type AgentOutput = z.infer<typeof AgentOutputSchema>

common/src/util/error.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,18 +198,56 @@ export function unwrapPromptResult<T>(result: PromptResult<T>): T {
198198
export function parseApiErrorResponseBody(responseBody: unknown): {
199199
errorCode?: string
200200
message?: string
201+
countryCode?: string
202+
countryBlockReason?: string
203+
ipPrivacySignals?: string[]
201204
} {
202205
if (typeof responseBody !== 'string') return {}
203206
try {
204207
const parsed: unknown = JSON.parse(responseBody)
205208
if (!parsed || typeof parsed !== 'object') return {}
206-
const result: { errorCode?: string; message?: string } = {}
207-
if ('error' in parsed && typeof (parsed as { error: unknown }).error === 'string') {
209+
const result: {
210+
errorCode?: string
211+
message?: string
212+
countryCode?: string
213+
countryBlockReason?: string
214+
ipPrivacySignals?: string[]
215+
} = {}
216+
if (
217+
'error' in parsed &&
218+
typeof (parsed as { error: unknown }).error === 'string'
219+
) {
208220
result.errorCode = (parsed as { error: string }).error
209221
}
210-
if ('message' in parsed && typeof (parsed as { message: unknown }).message === 'string') {
222+
if (
223+
'message' in parsed &&
224+
typeof (parsed as { message: unknown }).message === 'string'
225+
) {
211226
result.message = (parsed as { message: string }).message
212227
}
228+
if (
229+
'countryCode' in parsed &&
230+
typeof (parsed as { countryCode: unknown }).countryCode === 'string'
231+
) {
232+
result.countryCode = (parsed as { countryCode: string }).countryCode
233+
}
234+
if (
235+
'countryBlockReason' in parsed &&
236+
typeof (parsed as { countryBlockReason: unknown }).countryBlockReason ===
237+
'string'
238+
) {
239+
result.countryBlockReason = (
240+
parsed as { countryBlockReason: string }
241+
).countryBlockReason
242+
}
243+
if ('ipPrivacySignals' in parsed) {
244+
const signals = (parsed as { ipPrivacySignals: unknown }).ipPrivacySignals
245+
if (Array.isArray(signals)) {
246+
result.ipPrivacySignals = signals.filter(
247+
(signal): signal is string => typeof signal === 'string',
248+
)
249+
}
250+
}
213251
return result
214252
} catch {
215253
return {}

0 commit comments

Comments
 (0)