Skip to content

Commit b4367ac

Browse files
[codex] Block Freebuff VPN and proxy traffic (#709)
Co-authored-by: James Grugett <jahooma@gmail.com>
1 parent dae2e48 commit b4367ac

12 files changed

Lines changed: 590 additions & 55 deletions

File tree

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
FREEBUFF_PREMIUM_SESSION_LIMIT,
3131
} from '@codebuff/common/constants/freebuff-models'
3232
import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session'
33+
import { formatFreebuffHardBlockedPrivacySignals } from '@codebuff/common/util/freebuff-privacy'
3334

3435
import type { FreebuffSessionResponse } from '../types/freebuff-session'
3536
import type { FreebuffIpPrivacySignal } from '@codebuff/common/types/freebuff-session'
@@ -642,7 +643,10 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
642643
{session.countryBlockReason === 'anonymous_network' ? (
643644
<>
644645
We detected{' '}
645-
{formatPrivacySignalList(session.ipPrivacySignals)} traffic
646+
{formatFreebuffHardBlockedPrivacySignals(
647+
session.ipPrivacySignals,
648+
)}{' '}
649+
traffic
646650
{session.countryCode === 'UNKNOWN' ? (
647651
''
648652
) : (
@@ -652,8 +656,8 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
652656
<span fg={theme.foreground}>{session.countryCode}</span>
653657
</>
654658
)}
655-
. Freebuff can't be used from anonymized networks. Press
656-
Ctrl+C to exit.
659+
. Freebuff can't be used from VPN, proxy, or Tor traffic.
660+
Disable it and restart Freebuff to try again.
657661
</>
658662
) : session.countryCode === 'UNKNOWN' ? (
659663
<>

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ import { processBashContext } from '../../utils/bash-context-processor'
1313
import { markRunningAgentsAsCancelled } from '../../utils/block-operations'
1414
import {
1515
getCountryBlockFromFreeModeError,
16+
getFreeModeUnavailableErrorMessage,
1617
getFreebuffGateErrorKind,
1718
getFreebuffRateLimitErrorMessage,
1819
isOutOfCreditsError,
1920
isFreeModeUnavailableError,
2021
OUT_OF_CREDITS_MESSAGE,
21-
FREE_MODE_UNAVAILABLE_MESSAGE,
2222
} from '../../utils/error-handling'
2323
import { formatElapsedTime } from '../../utils/format-elapsed-time'
2424
import { processImagesForMessage } from '../../utils/image-processor'
@@ -399,7 +399,7 @@ export const handleRunCompletion = (params: {
399399
}
400400

401401
if (isFreeModeUnavailableError(output)) {
402-
updater.setError(FREE_MODE_UNAVAILABLE_MESSAGE)
402+
updater.setError(getFreeModeUnavailableErrorMessage(output))
403403
if (IS_FREEBUFF) {
404404
markFreebuffSessionCountryBlocked(
405405
getCountryBlockFromFreeModeError(output) ?? {
@@ -510,7 +510,7 @@ export const handleRunError = (params: {
510510
}
511511

512512
if (isFreeModeUnavailableError(error)) {
513-
updater.setError(FREE_MODE_UNAVAILABLE_MESSAGE)
513+
updater.setError(getFreeModeUnavailableErrorMessage(error))
514514
if (IS_FREEBUFF) {
515515
markFreebuffSessionCountryBlocked(
516516
getCountryBlockFromFreeModeError(error) ?? {

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, test, expect } from 'bun:test'
22

33
import {
44
getFreebuffRateLimitErrorMessage,
5+
getFreeModeUnavailableErrorMessage,
56
isOutOfCreditsError,
67
isFreeModeUnavailableError,
78
getCountryBlockFromFreeModeError,
@@ -81,6 +82,18 @@ describe('error-handling', () => {
8182
expect(isFreeModeUnavailableError(error)).toBe(true)
8283
})
8384

85+
test('returns true for responseBody free_mode_unavailable errors', () => {
86+
expect(
87+
isFreeModeUnavailableError({
88+
statusCode: 403,
89+
responseBody: JSON.stringify({
90+
error: 'free_mode_unavailable',
91+
message: 'Freebuff cannot be used from VPN traffic.',
92+
}),
93+
}),
94+
).toBe(true)
95+
})
96+
8497
test('returns false for 403 without error field', () => {
8598
const error = { statusCode: 403, message: 'Forbidden' }
8699
expect(isFreeModeUnavailableError(error)).toBe(false)
@@ -234,6 +247,24 @@ describe('error-handling', () => {
234247
})
235248
})
236249

250+
test('extracts country block details from responseBody errors', () => {
251+
const error = {
252+
statusCode: 403,
253+
responseBody: JSON.stringify({
254+
error: 'free_mode_unavailable',
255+
countryCode: 'US',
256+
countryBlockReason: 'anonymous_network',
257+
ipPrivacySignals: ['proxy', 'hosting', 123],
258+
}),
259+
}
260+
261+
expect(getCountryBlockFromFreeModeError(error)).toEqual({
262+
countryCode: 'US',
263+
countryBlockReason: 'anonymous_network',
264+
ipPrivacySignals: ['proxy', 'hosting'],
265+
})
266+
})
267+
237268
test('defaults missing country code to UNKNOWN', () => {
238269
const error = {
239270
statusCode: 403,
@@ -265,6 +296,44 @@ describe('error-handling', () => {
265296
})
266297
})
267298

299+
describe('getFreeModeUnavailableErrorMessage', () => {
300+
test('uses a VPN/proxy-specific message for anonymous-network blocks', () => {
301+
expect(
302+
getFreeModeUnavailableErrorMessage({
303+
statusCode: 403,
304+
error: 'free_mode_unavailable',
305+
message: 'Forbidden',
306+
countryBlockReason: 'anonymous_network',
307+
ipPrivacySignals: ['vpn', 'hosting'],
308+
}),
309+
).toContain('VPN')
310+
})
311+
312+
test('uses a VPN/proxy-specific message from responseBody details', () => {
313+
expect(
314+
getFreeModeUnavailableErrorMessage({
315+
statusCode: 403,
316+
message: 'Forbidden',
317+
responseBody: JSON.stringify({
318+
error: 'free_mode_unavailable',
319+
countryBlockReason: 'anonymous_network',
320+
ipPrivacySignals: ['tor'],
321+
}),
322+
}),
323+
).toContain('Tor')
324+
})
325+
326+
test('preserves server message for non-privacy free mode blocks', () => {
327+
expect(
328+
getFreeModeUnavailableErrorMessage({
329+
statusCode: 403,
330+
error: 'free_mode_unavailable',
331+
message: 'Free mode is not available in your country.',
332+
}),
333+
).toBe('Free mode is not available in your country.')
334+
})
335+
})
336+
268337
describe('OUT_OF_CREDITS_MESSAGE', () => {
269338
test('contains usage URL', () => {
270339
expect(OUT_OF_CREDITS_MESSAGE).toContain('/usage')

cli/src/utils/error-handling.ts

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { env } from '@codebuff/common/env'
22
import { extractApiErrorDetails } from '@codebuff/common/util/error'
3+
import { formatFreebuffHardBlockedPrivacySignals } from '@codebuff/common/util/freebuff-privacy'
34

45
import type { ChatMessage } from '../types/chat'
56
import type {
@@ -49,17 +50,11 @@ export const isOutOfCreditsError = (error: unknown): boolean => {
4950
* Standardized on statusCode === 403 + error === 'free_mode_unavailable'.
5051
*/
5152
export const isFreeModeUnavailableError = (error: unknown): boolean => {
52-
if (
53-
error &&
54-
typeof error === 'object' &&
55-
'statusCode' in error &&
56-
(error as { statusCode: unknown }).statusCode === 403 &&
57-
'error' in error &&
58-
(error as { error: unknown }).error === 'free_mode_unavailable'
59-
) {
60-
return true
61-
}
62-
return false
53+
const details = getCliApiErrorDetails(error)
54+
return (
55+
details.statusCode === 403 &&
56+
details.errorCode === 'free_mode_unavailable'
57+
)
6358
}
6459

6560
const getTopLevelApiErrorDetails = (
@@ -68,12 +63,20 @@ const getTopLevelApiErrorDetails = (
6863
statusCode?: number
6964
errorCode?: string
7065
message?: string
66+
countryCode?: string
67+
countryBlockReason?: string
68+
ipPrivacySignals?: string[]
7169
} => {
7270
if (!error || typeof error !== 'object') return {}
7371
const statusCode = (error as { statusCode?: unknown }).statusCode
7472
const status = (error as { status?: unknown }).status
7573
const errorCode = (error as { error?: unknown }).error
7674
const message = (error as { message?: unknown }).message
75+
const countryCode = (error as { countryCode?: unknown }).countryCode
76+
const countryBlockReason = (error as { countryBlockReason?: unknown })
77+
.countryBlockReason
78+
const ipPrivacySignals = (error as { ipPrivacySignals?: unknown })
79+
.ipPrivacySignals
7780
const resolvedStatusCode =
7881
typeof statusCode === 'number'
7982
? statusCode
@@ -85,6 +88,14 @@ const getTopLevelApiErrorDetails = (
8588
...(resolvedStatusCode !== undefined && { statusCode: resolvedStatusCode }),
8689
...(typeof errorCode === 'string' && { errorCode }),
8790
...(typeof message === 'string' && message.length > 0 && { message }),
91+
...(typeof countryCode === 'string' &&
92+
countryCode.length > 0 && { countryCode }),
93+
...(typeof countryBlockReason === 'string' && { countryBlockReason }),
94+
...(Array.isArray(ipPrivacySignals) && {
95+
ipPrivacySignals: ipPrivacySignals.filter(
96+
(signal): signal is string => typeof signal === 'string',
97+
),
98+
}),
8899
}
89100
}
90101

@@ -97,6 +108,10 @@ const getCliApiErrorDetails = (error: unknown) => {
97108
errorCode: topLevel.errorCode ?? parsed.errorCode,
98109
// Prefer responseBody messages over top-level HTTP status text.
99110
message: parsed.message ?? topLevel.message,
111+
countryCode: topLevel.countryCode ?? parsed.countryCode,
112+
countryBlockReason:
113+
topLevel.countryBlockReason ?? parsed.countryBlockReason,
114+
ipPrivacySignals: topLevel.ipPrivacySignals ?? parsed.ipPrivacySignals,
100115
}
101116
}
102117

@@ -119,11 +134,7 @@ export const getCountryBlockFromFreeModeError = (
119134
ipPrivacySignals?: FreebuffIpPrivacySignal[]
120135
} | null => {
121136
if (!isFreeModeUnavailableError(error)) return null
122-
const errorDetails = error as {
123-
countryCode?: unknown
124-
countryBlockReason?: unknown
125-
ipPrivacySignals?: unknown
126-
}
137+
const errorDetails = getCliApiErrorDetails(error)
127138
const countryCode =
128139
typeof errorDetails.countryCode === 'string' &&
129140
errorDetails.countryCode.length > 0
@@ -136,13 +147,23 @@ export const getCountryBlockFromFreeModeError = (
136147
typeof errorDetails.countryBlockReason === 'string'
137148
? (errorDetails.countryBlockReason as FreebuffCountryBlockReason)
138149
: undefined,
139-
ipPrivacySignals: Array.isArray(errorDetails.ipPrivacySignals)
140-
? errorDetails.ipPrivacySignals.filter(
141-
(signal): signal is FreebuffIpPrivacySignal =>
142-
typeof signal === 'string',
143-
)
144-
: undefined,
150+
ipPrivacySignals: errorDetails.ipPrivacySignals as
151+
| FreebuffIpPrivacySignal[]
152+
| undefined,
153+
}
154+
}
155+
156+
export const getFreeModeUnavailableErrorMessage = (
157+
error: unknown,
158+
): string => {
159+
const details = getCliApiErrorDetails(error)
160+
const block = getCountryBlockFromFreeModeError(error)
161+
if (block?.countryBlockReason === 'anonymous_network') {
162+
return `${IS_FREEBUFF ? 'Freebuff' : 'Free mode'} cannot be used from ${formatFreebuffHardBlockedPrivacySignals(
163+
block.ipPrivacySignals,
164+
)} traffic. Please disable it and try again.`
145165
}
166+
return details.message ?? FREE_MODE_UNAVAILABLE_MESSAGE
146167
}
147168

148169
/**

common/src/types/freebuff-session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ export type FreebuffSessionServerResponse =
168168
* CLI stops polling and shows a "not available in your country"
169169
* screen. `countryCode` is the resolved country, or UNKNOWN. */
170170
status: 'country_blocked'
171+
message?: string
171172
countryCode: string
172173
countryBlockReason?: FreebuffCountryBlockReason
173174
ipPrivacySignals?: FreebuffIpPrivacySignal[]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { FreebuffIpPrivacySignal } from '../types/freebuff-session'
2+
3+
export const FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS = [
4+
'vpn',
5+
'proxy',
6+
'tor',
7+
'res_proxy',
8+
] as const satisfies readonly FreebuffIpPrivacySignal[]
9+
10+
type FreebuffHardBlockedPrivacySignal =
11+
(typeof FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS)[number]
12+
13+
const FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_SET =
14+
new Set<FreebuffIpPrivacySignal>(FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS)
15+
16+
const FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_LABELS: Record<
17+
FreebuffHardBlockedPrivacySignal,
18+
string
19+
> = {
20+
vpn: 'VPN',
21+
proxy: 'proxy',
22+
res_proxy: 'proxy',
23+
tor: 'Tor',
24+
}
25+
26+
export function isFreebuffHardBlockedPrivacySignal(
27+
signal: FreebuffIpPrivacySignal,
28+
): signal is FreebuffHardBlockedPrivacySignal {
29+
return FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_SET.has(signal)
30+
}
31+
32+
export function formatFreebuffHardBlockedPrivacySignals(
33+
signals: readonly FreebuffIpPrivacySignal[] | null | undefined,
34+
): string {
35+
const labels = Array.from(
36+
new Set(
37+
(signals ?? []).flatMap((signal): string[] => {
38+
if (!isFreebuffHardBlockedPrivacySignal(signal)) return []
39+
return [FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_LABELS[signal]]
40+
}),
41+
),
42+
)
43+
44+
if (labels.length === 0) return 'VPN, proxy, or Tor'
45+
if (labels.length === 1) return labels[0]
46+
return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}`
47+
}
48+
49+
export function formatFreebuffHardBlockedMessage(
50+
signals: readonly FreebuffIpPrivacySignal[] | null | undefined,
51+
): string {
52+
return `Freebuff cannot be used from ${formatFreebuffHardBlockedPrivacySignals(
53+
signals,
54+
)} traffic. Please disable it and try again.`
55+
}

0 commit comments

Comments
 (0)