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
10 changes: 7 additions & 3 deletions cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
FREEBUFF_PREMIUM_SESSION_LIMIT,
} from '@codebuff/common/constants/freebuff-models'
import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session'
import { formatFreebuffHardBlockedPrivacySignals } from '@codebuff/common/util/freebuff-privacy'

import type { FreebuffSessionResponse } from '../types/freebuff-session'
import type { FreebuffIpPrivacySignal } from '@codebuff/common/types/freebuff-session'
Expand Down Expand Up @@ -642,7 +643,10 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
{session.countryBlockReason === 'anonymous_network' ? (
<>
We detected{' '}
{formatPrivacySignalList(session.ipPrivacySignals)} traffic
{formatFreebuffHardBlockedPrivacySignals(
session.ipPrivacySignals,
)}{' '}
traffic
{session.countryCode === 'UNKNOWN' ? (
''
) : (
Expand All @@ -652,8 +656,8 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
<span fg={theme.foreground}>{session.countryCode}</span>
</>
)}
. Freebuff can't be used from anonymized networks. Press
Ctrl+C to exit.
. Freebuff can't be used from VPN, proxy, or Tor traffic.
Disable it and restart Freebuff to try again.
</>
) : session.countryCode === 'UNKNOWN' ? (
<>
Expand Down
6 changes: 3 additions & 3 deletions cli/src/hooks/helpers/send-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ import { processBashContext } from '../../utils/bash-context-processor'
import { markRunningAgentsAsCancelled } from '../../utils/block-operations'
import {
getCountryBlockFromFreeModeError,
getFreeModeUnavailableErrorMessage,
getFreebuffGateErrorKind,
getFreebuffRateLimitErrorMessage,
isOutOfCreditsError,
isFreeModeUnavailableError,
OUT_OF_CREDITS_MESSAGE,
FREE_MODE_UNAVAILABLE_MESSAGE,
} from '../../utils/error-handling'
import { formatElapsedTime } from '../../utils/format-elapsed-time'
import { processImagesForMessage } from '../../utils/image-processor'
Expand Down Expand Up @@ -399,7 +399,7 @@ export const handleRunCompletion = (params: {
}

if (isFreeModeUnavailableError(output)) {
updater.setError(FREE_MODE_UNAVAILABLE_MESSAGE)
updater.setError(getFreeModeUnavailableErrorMessage(output))
if (IS_FREEBUFF) {
markFreebuffSessionCountryBlocked(
getCountryBlockFromFreeModeError(output) ?? {
Expand Down Expand Up @@ -510,7 +510,7 @@ export const handleRunError = (params: {
}

if (isFreeModeUnavailableError(error)) {
updater.setError(FREE_MODE_UNAVAILABLE_MESSAGE)
updater.setError(getFreeModeUnavailableErrorMessage(error))
if (IS_FREEBUFF) {
markFreebuffSessionCountryBlocked(
getCountryBlockFromFreeModeError(error) ?? {
Expand Down
69 changes: 69 additions & 0 deletions cli/src/utils/__tests__/error-handling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, test, expect } from 'bun:test'

import {
getFreebuffRateLimitErrorMessage,
getFreeModeUnavailableErrorMessage,
isOutOfCreditsError,
isFreeModeUnavailableError,
getCountryBlockFromFreeModeError,
Expand Down Expand Up @@ -81,6 +82,18 @@ describe('error-handling', () => {
expect(isFreeModeUnavailableError(error)).toBe(true)
})

test('returns true for responseBody free_mode_unavailable errors', () => {
expect(
isFreeModeUnavailableError({
statusCode: 403,
responseBody: JSON.stringify({
error: 'free_mode_unavailable',
message: 'Freebuff cannot be used from VPN traffic.',
}),
}),
).toBe(true)
})

test('returns false for 403 without error field', () => {
const error = { statusCode: 403, message: 'Forbidden' }
expect(isFreeModeUnavailableError(error)).toBe(false)
Expand Down Expand Up @@ -234,6 +247,24 @@ describe('error-handling', () => {
})
})

test('extracts country block details from responseBody errors', () => {
const error = {
statusCode: 403,
responseBody: JSON.stringify({
error: 'free_mode_unavailable',
countryCode: 'US',
countryBlockReason: 'anonymous_network',
ipPrivacySignals: ['proxy', 'hosting', 123],
}),
}

expect(getCountryBlockFromFreeModeError(error)).toEqual({
countryCode: 'US',
countryBlockReason: 'anonymous_network',
ipPrivacySignals: ['proxy', 'hosting'],
})
})

test('defaults missing country code to UNKNOWN', () => {
const error = {
statusCode: 403,
Expand Down Expand Up @@ -265,6 +296,44 @@ describe('error-handling', () => {
})
})

describe('getFreeModeUnavailableErrorMessage', () => {
test('uses a VPN/proxy-specific message for anonymous-network blocks', () => {
expect(
getFreeModeUnavailableErrorMessage({
statusCode: 403,
error: 'free_mode_unavailable',
message: 'Forbidden',
countryBlockReason: 'anonymous_network',
ipPrivacySignals: ['vpn', 'hosting'],
}),
).toContain('VPN')
})

test('uses a VPN/proxy-specific message from responseBody details', () => {
expect(
getFreeModeUnavailableErrorMessage({
statusCode: 403,
message: 'Forbidden',
responseBody: JSON.stringify({
error: 'free_mode_unavailable',
countryBlockReason: 'anonymous_network',
ipPrivacySignals: ['tor'],
}),
}),
).toContain('Tor')
})

test('preserves server message for non-privacy free mode blocks', () => {
expect(
getFreeModeUnavailableErrorMessage({
statusCode: 403,
error: 'free_mode_unavailable',
message: 'Free mode is not available in your country.',
}),
).toBe('Free mode is not available in your country.')
})
})

describe('OUT_OF_CREDITS_MESSAGE', () => {
test('contains usage URL', () => {
expect(OUT_OF_CREDITS_MESSAGE).toContain('/usage')
Expand Down
65 changes: 43 additions & 22 deletions cli/src/utils/error-handling.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { env } from '@codebuff/common/env'
import { extractApiErrorDetails } from '@codebuff/common/util/error'
import { formatFreebuffHardBlockedPrivacySignals } from '@codebuff/common/util/freebuff-privacy'

import type { ChatMessage } from '../types/chat'
import type {
Expand Down Expand Up @@ -49,17 +50,11 @@ export const isOutOfCreditsError = (error: unknown): boolean => {
* Standardized on statusCode === 403 + error === 'free_mode_unavailable'.
*/
export const isFreeModeUnavailableError = (error: unknown): boolean => {
if (
error &&
typeof error === 'object' &&
'statusCode' in error &&
(error as { statusCode: unknown }).statusCode === 403 &&
'error' in error &&
(error as { error: unknown }).error === 'free_mode_unavailable'
) {
return true
}
return false
const details = getCliApiErrorDetails(error)
return (
details.statusCode === 403 &&
details.errorCode === 'free_mode_unavailable'
)
}

const getTopLevelApiErrorDetails = (
Expand All @@ -68,12 +63,20 @@ const getTopLevelApiErrorDetails = (
statusCode?: number
errorCode?: string
message?: string
countryCode?: string
countryBlockReason?: string
ipPrivacySignals?: string[]
} => {
if (!error || typeof error !== 'object') return {}
const statusCode = (error as { statusCode?: unknown }).statusCode
const status = (error as { status?: unknown }).status
const errorCode = (error as { error?: unknown }).error
const message = (error as { message?: unknown }).message
const countryCode = (error as { countryCode?: unknown }).countryCode
const countryBlockReason = (error as { countryBlockReason?: unknown })
.countryBlockReason
const ipPrivacySignals = (error as { ipPrivacySignals?: unknown })
.ipPrivacySignals
const resolvedStatusCode =
typeof statusCode === 'number'
? statusCode
Expand All @@ -85,6 +88,14 @@ const getTopLevelApiErrorDetails = (
...(resolvedStatusCode !== undefined && { statusCode: resolvedStatusCode }),
...(typeof errorCode === 'string' && { errorCode }),
...(typeof message === 'string' && message.length > 0 && { message }),
...(typeof countryCode === 'string' &&
countryCode.length > 0 && { countryCode }),
...(typeof countryBlockReason === 'string' && { countryBlockReason }),
...(Array.isArray(ipPrivacySignals) && {
ipPrivacySignals: ipPrivacySignals.filter(
(signal): signal is string => typeof signal === 'string',
),
}),
}
}

Expand All @@ -97,6 +108,10 @@ const getCliApiErrorDetails = (error: unknown) => {
errorCode: topLevel.errorCode ?? parsed.errorCode,
// Prefer responseBody messages over top-level HTTP status text.
message: parsed.message ?? topLevel.message,
countryCode: topLevel.countryCode ?? parsed.countryCode,
countryBlockReason:
topLevel.countryBlockReason ?? parsed.countryBlockReason,
ipPrivacySignals: topLevel.ipPrivacySignals ?? parsed.ipPrivacySignals,
}
}

Expand All @@ -119,11 +134,7 @@ export const getCountryBlockFromFreeModeError = (
ipPrivacySignals?: FreebuffIpPrivacySignal[]
} | null => {
if (!isFreeModeUnavailableError(error)) return null
const errorDetails = error as {
countryCode?: unknown
countryBlockReason?: unknown
ipPrivacySignals?: unknown
}
const errorDetails = getCliApiErrorDetails(error)
const countryCode =
typeof errorDetails.countryCode === 'string' &&
errorDetails.countryCode.length > 0
Expand All @@ -136,13 +147,23 @@ export const getCountryBlockFromFreeModeError = (
typeof errorDetails.countryBlockReason === 'string'
? (errorDetails.countryBlockReason as FreebuffCountryBlockReason)
: undefined,
ipPrivacySignals: Array.isArray(errorDetails.ipPrivacySignals)
? errorDetails.ipPrivacySignals.filter(
(signal): signal is FreebuffIpPrivacySignal =>
typeof signal === 'string',
)
: undefined,
ipPrivacySignals: errorDetails.ipPrivacySignals as
| FreebuffIpPrivacySignal[]
| undefined,
}
}

export const getFreeModeUnavailableErrorMessage = (
error: unknown,
): string => {
const details = getCliApiErrorDetails(error)
const block = getCountryBlockFromFreeModeError(error)
if (block?.countryBlockReason === 'anonymous_network') {
return `${IS_FREEBUFF ? 'Freebuff' : 'Free mode'} cannot be used from ${formatFreebuffHardBlockedPrivacySignals(
block.ipPrivacySignals,
)} traffic. Please disable it and try again.`
}
return details.message ?? FREE_MODE_UNAVAILABLE_MESSAGE
}

/**
Expand Down
1 change: 1 addition & 0 deletions common/src/types/freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export type FreebuffSessionServerResponse =
* CLI stops polling and shows a "not available in your country"
* screen. `countryCode` is the resolved country, or UNKNOWN. */
status: 'country_blocked'
message?: string
countryCode: string
countryBlockReason?: FreebuffCountryBlockReason
ipPrivacySignals?: FreebuffIpPrivacySignal[]
Expand Down
55 changes: 55 additions & 0 deletions common/src/util/freebuff-privacy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { FreebuffIpPrivacySignal } from '../types/freebuff-session'

export const FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS = [
'vpn',
'proxy',
'tor',
'res_proxy',
] as const satisfies readonly FreebuffIpPrivacySignal[]

type FreebuffHardBlockedPrivacySignal =
(typeof FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS)[number]

const FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_SET =
new Set<FreebuffIpPrivacySignal>(FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS)

const FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_LABELS: Record<
FreebuffHardBlockedPrivacySignal,
string
> = {
vpn: 'VPN',
proxy: 'proxy',
res_proxy: 'proxy',
tor: 'Tor',
}

export function isFreebuffHardBlockedPrivacySignal(
signal: FreebuffIpPrivacySignal,
): signal is FreebuffHardBlockedPrivacySignal {
return FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_SET.has(signal)
}

export function formatFreebuffHardBlockedPrivacySignals(
signals: readonly FreebuffIpPrivacySignal[] | null | undefined,
): string {
const labels = Array.from(
new Set(
(signals ?? []).flatMap((signal): string[] => {
if (!isFreebuffHardBlockedPrivacySignal(signal)) return []
return [FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_LABELS[signal]]
}),
),
)

if (labels.length === 0) return 'VPN, proxy, or Tor'
if (labels.length === 1) return labels[0]
return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}`
}

export function formatFreebuffHardBlockedMessage(
signals: readonly FreebuffIpPrivacySignal[] | null | undefined,
): string {
return `Freebuff cannot be used from ${formatFreebuffHardBlockedPrivacySignals(
signals,
)} traffic. Please disable it and try again.`
}
Loading
Loading