diff --git a/apps/web/src/features/accounts/model/accountDetailViewModel.test.ts b/apps/web/src/features/accounts/model/accountDetailViewModel.test.ts
index ac90dec06..59887cf64 100644
--- a/apps/web/src/features/accounts/model/accountDetailViewModel.test.ts
+++ b/apps/web/src/features/accounts/model/accountDetailViewModel.test.ts
@@ -2740,4 +2740,54 @@ describe('accountDetailViewModel', () => {
targetTab: 'quota',
});
});
+
+ it('attaches Devin plan metadata to quota view model when available', () => {
+ const row = makeRow({
+ provider: 'devin',
+ raw: { name: 'devin.json', type: 'devin', authIndex: '0' },
+ });
+ const viewModel = buildAccountDetailViewModel(row, {
+ devinQuota: {
+ status: 'success',
+ windows: [],
+ observedAtMs: 1726000000000,
+ plan: 'Team',
+ planStartMs: 1725000000000,
+ planEndMs: 1727000000000,
+ },
+ });
+
+ expect(viewModel.quota.devinPlan).toEqual({
+ plan: 'Team',
+ planStartMs: 1725000000000,
+ planEndMs: 1727000000000,
+ });
+ });
+
+ it('preserves Devin plan metadata on transient refresh error when plan exists', () => {
+ const row = makeRow({
+ provider: 'devin',
+ raw: { name: 'devin.json', type: 'devin', authIndex: '0' },
+ });
+ const viewModel = buildAccountDetailViewModel(row, {
+ devinQuota: {
+ status: 'error',
+ error: 'temporary failure',
+ errorStatus: 502,
+ failedAtMs: 1726000100000,
+ windows: [],
+ observedAtMs: 1726000000000,
+ plan: 'Pro',
+ planStartMs: 1725000000000,
+ planEndMs: 1727000000000,
+ },
+ });
+
+ expect(viewModel.quota.devinPlan).toEqual({
+ plan: 'Pro',
+ planStartMs: 1725000000000,
+ planEndMs: 1727000000000,
+ });
+ });
});
+
diff --git a/apps/web/src/features/accounts/model/accountDetailViewModel.ts b/apps/web/src/features/accounts/model/accountDetailViewModel.ts
index 7e4035ce4..a400c4112 100644
--- a/apps/web/src/features/accounts/model/accountDetailViewModel.ts
+++ b/apps/web/src/features/accounts/model/accountDetailViewModel.ts
@@ -1,5 +1,5 @@
import type { TFunction } from 'i18next';
-import type { CodexQuotaState, QuotaResetAccuracy, XaiQuotaState } from '@/types';
+import type { CodexQuotaState, DevinQuotaState, QuotaResetAccuracy, XaiQuotaState } from '@/types';
import { getSortedCodexResetCreditExpiries } from '@/components/quota/quotaConfigs';
import type {
AccountActionCandidate,
@@ -323,6 +323,11 @@ export interface AccountDetailViewModel {
cooldown: QuotaCooldownInfo | null;
resetCreditsAvailableCount: number | null;
resetCreditExpiries: AccountDetailResetCreditExpiry[];
+ devinPlan?: {
+ plan: string;
+ planStartMs: number | null;
+ planEndMs: number | null;
+ } | null;
};
auth: {
fields: AccountDetailField[];
@@ -353,6 +358,7 @@ export interface BuildAccountDetailViewModelOptions {
history?: MonitoringAccountHistoryItem | null;
valueRow?: UsageValueRow | null;
codexQuota?: CodexQuotaState | null;
+ devinQuota?: DevinQuotaState | null;
xaiQuota?: XaiQuotaState | null;
diagnosticsSummary?: MonitoringAnalyticsSummary | null;
diagnosticsRecentFailure?: MonitoringAnalyticsRecentFailure | null;
@@ -1490,6 +1496,14 @@ export const buildAccountDetailViewModel = (
resetCreditExpiries: getSortedCodexResetCreditExpiries(
options.codexQuota?.rateLimitResetCredits
).map((item) => ({ id: item.id, expiresAtMs: item.expiresAtMs })),
+ devinPlan:
+ row.provider === 'devin' && options.devinQuota?.plan
+ ? {
+ plan: options.devinQuota.plan,
+ planStartMs: options.devinQuota.planStartMs,
+ planEndMs: options.devinQuota.planEndMs,
+ }
+ : null,
},
auth: {
fields: buildAuthFields(row),
diff --git a/apps/web/src/features/accounts/model/accountOperationalScope.test.ts b/apps/web/src/features/accounts/model/accountOperationalScope.test.ts
index fa33d49da..1c8e3aa33 100644
--- a/apps/web/src/features/accounts/model/accountOperationalScope.test.ts
+++ b/apps/web/src/features/accounts/model/accountOperationalScope.test.ts
@@ -8,6 +8,7 @@ const emptyStores = () => ({
claudeQuota: {},
codexQuota: {},
kimiQuota: {},
+ devinQuota: {},
xaiQuota: {},
});
diff --git a/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.test.ts b/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.test.ts
index a446552cb..ec6e7607c 100644
--- a/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.test.ts
+++ b/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.test.ts
@@ -26,6 +26,7 @@ const emptyStores = (): AccountQuotaStores => ({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
+ devinQuota: {},
kimiQuota: {},
xaiQuota: {},
});
@@ -35,6 +36,8 @@ const t = ((key: string, options?: Record) => {
'antigravity_quota.group_gemini_models': 'Gemini models',
'antigravity_quota.daily_limit': 'Daily limit',
'claude_quota.extra_usage_label': 'Extra Usage',
+ 'devin_quota.daily': 'Daily limit',
+ 'devin_quota.weekly': 'Weekly limit',
'kimi_quota.reset_hint': `resets in ${options?.hint ?? ''}`,
'kimi_quota.weekly_limit': 'Weekly limit',
'xai_quota.weekly_credits': 'Weekly credits',
@@ -54,6 +57,7 @@ const buildRow = (file: AuthFileItem, stores: AccountQuotaStores = emptyStores()
stores.antigravityQuota,
stores.claudeQuota,
stores.codexQuota,
+ stores.devinQuota,
stores.kimiQuota,
stores.xaiQuota,
] as Array>;
@@ -681,6 +685,130 @@ describe('accountQuotaDisplayWindows', () => {
expect(isStandardAccountQuotaListWindow(windows[0])).toBe(true);
});
+ it('builds Devin daily and weekly display windows with exact reset and clamp percent', () => {
+ const stores = {
+ ...emptyStores(),
+ devinQuota: {
+ 'devin.json::d-1': {
+ status: 'success',
+ authFileKey: 'devin.json::d-1',
+ authFileName: 'devin.json',
+ authIndex: 'd-1',
+ authFileIdentityVerified: true,
+ windows: [
+ {
+ id: 'daily',
+ remainingPercent: 0,
+ resetAtMs: Date.parse('2026-09-15T12:00:00Z'),
+ periodHours: 24,
+ },
+ {
+ id: 'weekly',
+ remainingPercent: 75,
+ resetAtMs: Date.parse('2026-09-22T12:00:00Z'),
+ periodHours: 168,
+ },
+ ],
+ plan: 'Pro',
+ planStartMs: null,
+ planEndMs: null,
+ observedAtMs: Date.parse('2026-09-15T10:00:00Z'),
+ },
+ },
+ } satisfies AccountQuotaStores;
+ const row = buildRow({ name: 'devin.json', type: 'devin', authIndex: 'd-1' }, stores);
+
+ const windows = buildAccountQuotaDisplayWindows(row, {
+ stores,
+ translateQuotaWindowLabel,
+ t,
+ });
+
+ expect(windows).toHaveLength(2);
+ expect(windows[0]).toMatchObject({
+ key: 'devin:daily',
+ label: 'Daily limit',
+ kind: 'daily',
+ remainingPercent: 0,
+ usedPercent: 100,
+ resetAtMs: Date.parse('2026-09-15T12:00:00Z'),
+ resetAccuracy: 'exact',
+ limitWindowSeconds: 24 * 3600,
+ source: 'devin',
+ modelScope: { kind: 'all', complete: true },
+ });
+ expect(getAccountQuotaSemanticGroup(windows[0])).toBe('standard');
+
+ expect(windows[1]).toMatchObject({
+ key: 'devin:weekly',
+ label: 'Weekly limit',
+ kind: 'weekly',
+ remainingPercent: 75,
+ usedPercent: 25,
+ resetAtMs: Date.parse('2026-09-22T12:00:00Z'),
+ resetAccuracy: 'exact',
+ limitWindowSeconds: 168 * 3600,
+ source: 'devin',
+ modelScope: { kind: 'all', complete: true },
+ });
+ expect(getAccountQuotaSemanticGroup(windows[1])).toBe('standard');
+ });
+
+ it('preserves Devin daily and weekly windows on transient refresh error when previous windows exist', () => {
+ const stores = {
+ ...emptyStores(),
+ devinQuota: {
+ 'devin.json::d-1': {
+ status: 'error',
+ error: 'temporary failure',
+ errorStatus: 502,
+ failedAtMs: Date.parse('2026-09-15T10:05:00Z'),
+ authFileKey: 'devin.json::d-1',
+ authFileName: 'devin.json',
+ authIndex: 'd-1',
+ authFileIdentityVerified: true,
+ plan: 'Pro',
+ planStartMs: Date.parse('2026-09-01T00:00:00Z'),
+ planEndMs: Date.parse('2026-10-01T00:00:00Z'),
+ windows: [
+ {
+ id: 'daily',
+ remainingPercent: 50,
+ resetAtMs: Date.parse('2026-09-15T12:00:00Z'),
+ periodHours: 24,
+ },
+ {
+ id: 'weekly',
+ remainingPercent: 80,
+ resetAtMs: Date.parse('2026-09-22T12:00:00Z'),
+ periodHours: 168,
+ },
+ ],
+ observedAtMs: Date.parse('2026-09-15T10:00:00Z'),
+ },
+ },
+ } satisfies AccountQuotaStores;
+ const row = buildRow({ name: 'devin.json', type: 'devin', authIndex: 'd-1' }, stores);
+
+ const windows = buildAccountQuotaDisplayWindows(row, {
+ stores,
+ translateQuotaWindowLabel,
+ t,
+ });
+
+ expect(windows).toHaveLength(2);
+ expect(windows[0]).toMatchObject({
+ key: 'devin:daily',
+ label: 'Daily limit',
+ remainingPercent: 50,
+ });
+ expect(windows[1]).toMatchObject({
+ key: 'devin:weekly',
+ label: 'Weekly limit',
+ remainingPercent: 80,
+ });
+ });
+
it('splits xAI billing into monthly and pay-as-you-go windows', () => {
const stores = {
...emptyStores(),
diff --git a/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.ts b/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.ts
index 895c17a3b..6633a11f1 100644
--- a/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.ts
+++ b/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.ts
@@ -43,6 +43,7 @@ export type AccountQuotaWindowSource =
| 'codex'
| 'claude'
| 'antigravity'
+ | 'devin'
| 'kimi'
| 'xai'
| 'summary';
@@ -841,5 +842,51 @@ export const buildAccountQuotaDisplayWindows = (
if (windows.length) return windows;
}
+ if (row.provider === 'devin') {
+ const windows = buildDevinQuotaDisplayWindows(row, options);
+ if (windows.length) return windows;
+ }
+
return buildSummaryQuotaDisplayWindow(row, options);
};
+
+const buildDevinQuotaDisplayWindows = (
+ row: AccountRow,
+ options: BuildAccountQuotaDisplayWindowsOptions
+): AccountQuotaDisplayWindow[] => {
+ const quota = getCredentialScopedQuotaState(options.stores.devinQuota, row.raw);
+ if (!quota || !quota.windows?.length) return [];
+ return quota.windows.map((window) => {
+ const remainingPercent =
+ typeof window.remainingPercent === 'number' && Number.isFinite(window.remainingPercent)
+ ? clampDisplayPercent(window.remainingPercent)
+ : null;
+ const usedPercent =
+ remainingPercent === null ? null : clampDisplayPercent(100 - remainingPercent);
+ const hasReset = isValidQuotaResetAtMs(window.resetAtMs);
+ const resetLabel =
+ hasReset && window.resetAtMs !== null
+ ? formatQuotaResetTime(window.resetAtMs)
+ : '-';
+ const labelKey = window.id === 'daily' ? 'devin_quota.daily' : 'devin_quota.weekly';
+ const label = options.translateQuotaWindowLabel(undefined, labelKey);
+
+ return buildAccountQuotaDisplayWindow({
+ key: `devin:${window.id}`,
+ label,
+ kind: window.id,
+ remainingPercent,
+ usedPercent,
+ resetLabel,
+ resetAtMs: window.resetAtMs,
+ resetAccuracy: hasReset ? 'exact' : 'unknown',
+ limitWindowSeconds: window.periodHours * 3600,
+ source: 'devin',
+ modelScope: { kind: 'all', complete: true },
+ windowMode: 'unknown',
+ observedAtMs: quota.observedAtMs ?? quota.fetchedAtMs ?? null,
+ nowMs: options.nowMs,
+ });
+ });
+};
+
diff --git a/apps/web/src/features/accounts/model/accountQuotaSummary.test.ts b/apps/web/src/features/accounts/model/accountQuotaSummary.test.ts
index af44e0a41..db8f21e93 100644
--- a/apps/web/src/features/accounts/model/accountQuotaSummary.test.ts
+++ b/apps/web/src/features/accounts/model/accountQuotaSummary.test.ts
@@ -13,6 +13,7 @@ const emptyStores = (): AccountQuotaStores => ({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
+ devinQuota: {},
kimiQuota: {},
xaiQuota: {},
});
@@ -471,6 +472,54 @@ describe('resolveAccountQuota', () => {
expect(resolveAccountQuota(file, stores).planType).toBe('Antigravity Future');
});
+
+ it('resolves Devin quota summary choosing the limiting window and preserving live plan', () => {
+ const file = { name: 'devin.json', type: 'devin', authIndex: 'd-1' };
+ const stores = emptyStores();
+ stores.devinQuota['devin.json::d-1'] = {
+ status: 'success',
+ authFileKey: 'devin.json::d-1',
+ authFileName: 'devin.json',
+ authIndex: 'd-1',
+ authFileIdentityVerified: true,
+ windows: [
+ { id: 'daily', remainingPercent: 0, resetAtMs: 1726400000000, periodHours: 24 },
+ { id: 'weekly', remainingPercent: 80, resetAtMs: 1726900000000, periodHours: 168 },
+ ],
+ plan: 'Pro',
+ planStartMs: 1726000000000,
+ planEndMs: 1727000000000,
+ observedAtMs: 1726000000100,
+ fetchedAtMs: 1726000000100,
+ };
+
+ const exhaustedSummary = resolveAccountQuota(file, stores);
+ expect(exhaustedSummary.status).toBe('exhausted');
+ expect(exhaustedSummary.remainingPercent).toBe(0);
+ expect(exhaustedSummary.usedPercent).toBe(100);
+ expect(exhaustedSummary.planType).toBe('Pro');
+ expect(exhaustedSummary.resetAccuracy).toBe('exact');
+
+ // daily = 54, weekly = 77 -> summary = 54
+ stores.devinQuota['devin.json::d-1'].windows[0].remainingPercent = 54;
+ stores.devinQuota['devin.json::d-1'].windows[1].remainingPercent = 77;
+
+ const activeSummary = resolveAccountQuota(file, stores);
+ expect(activeSummary.status).toBe('ok');
+ expect(activeSummary.remainingPercent).toBe(54);
+ expect(activeSummary.usedPercent).toBe(46);
+ expect(activeSummary.planType).toBe('Pro');
+
+ // daily = 80, weekly = 35 -> summary = 35 (limiting window = min(daily, weekly))
+ stores.devinQuota['devin.json::d-1'].windows[0].remainingPercent = 80;
+ stores.devinQuota['devin.json::d-1'].windows[1].remainingPercent = 35;
+
+ const reverseSummary = resolveAccountQuota(file, stores);
+ expect(reverseSummary.status).toBe('ok');
+ expect(reverseSummary.remainingPercent).toBe(35);
+ expect(reverseSummary.usedPercent).toBe(65);
+ expect(reverseSummary.planType).toBe('Pro');
+ });
});
describe('hasConfirmedXaiBillingEntitlement', () => {
diff --git a/apps/web/src/features/accounts/model/accountQuotaSummary.ts b/apps/web/src/features/accounts/model/accountQuotaSummary.ts
index 35f16e076..42f4ad3e7 100644
--- a/apps/web/src/features/accounts/model/accountQuotaSummary.ts
+++ b/apps/web/src/features/accounts/model/accountQuotaSummary.ts
@@ -3,6 +3,7 @@ import type {
AuthFileItem,
ClaudeQuotaState,
CodexQuotaState,
+ DevinQuotaState,
KimiQuotaState,
QuotaResetAccuracy,
XaiBillingSummary,
@@ -74,6 +75,7 @@ export interface AccountQuotaStores {
antigravityQuota: Record;
claudeQuota: Record;
codexQuota: Record;
+ devinQuota: Record;
kimiQuota: Record;
xaiQuota: Record;
}
@@ -1063,6 +1065,24 @@ export const resolveAccountQuota = (
});
}
+ if (provider === 'devin') {
+ const quota = getCredentialScopedQuotaState(stores.devinQuota, file);
+ if (!quota) return emptyQuota(filePlanType);
+ const planType = quota.plan ?? filePlanType;
+ if (quota.status === 'loading') return loadingQuota(planType);
+ if (quota.status === 'error')
+ return quotaFromError(quota.error, planType, quota.errorStatus, quota.failedAtMs);
+ return quotaFromRemainingWindows(
+ quota.windows.map((window) => ({
+ remainingPercent: window.remainingPercent,
+ resetAtMs: window.resetAtMs,
+ resetAccuracy: 'exact',
+ })),
+ planType,
+ { fetchedAtMs: quota.fetchedAtMs }
+ );
+ }
+
return emptyQuota(filePlanType);
};
diff --git a/apps/web/src/features/accounts/model/accountReauth.test.ts b/apps/web/src/features/accounts/model/accountReauth.test.ts
index 0cc033a00..be16acc10 100644
--- a/apps/web/src/features/accounts/model/accountReauth.test.ts
+++ b/apps/web/src/features/accounts/model/accountReauth.test.ts
@@ -19,6 +19,11 @@ describe('accountReauth', () => {
oauthProvider: 'anthropic',
path: '/oauth#oauth-provider-anthropic',
});
+ expect(resolveAccountReauthAction({ name: 'devin.json', type: 'devin' })).toEqual({
+ kind: 'navigate',
+ oauthProvider: 'devin',
+ path: '/oauth#oauth-provider-devin',
+ });
});
it('returns an explicit unsupported action for providers without OAuth login', () => {
diff --git a/apps/web/src/features/accounts/model/accountReauth.ts b/apps/web/src/features/accounts/model/accountReauth.ts
index 5d2a27fd4..55199a745 100644
--- a/apps/web/src/features/accounts/model/accountReauth.ts
+++ b/apps/web/src/features/accounts/model/accountReauth.ts
@@ -13,6 +13,7 @@ const OAUTH_PROVIDER_BY_ACCOUNT_PROVIDER: Record = {
claude: 'anthropic',
kimi: 'kimi',
xai: 'xai',
+ devin: 'devin',
};
export const resolveAccountReauthAction = (file: AuthFileItem): AccountReauthAction => {
diff --git a/apps/web/src/features/accounts/model/accountRows.test.ts b/apps/web/src/features/accounts/model/accountRows.test.ts
index 3a68583f8..91f77b22c 100644
--- a/apps/web/src/features/accounts/model/accountRows.test.ts
+++ b/apps/web/src/features/accounts/model/accountRows.test.ts
@@ -44,6 +44,7 @@ const emptyStores = (): AccountQuotaStores => ({
claudeQuota: {},
codexQuota: {},
kimiQuota: {},
+ devinQuota: {},
xaiQuota: {},
});
diff --git a/apps/web/src/features/accounts/model/usageValueRows.test.ts b/apps/web/src/features/accounts/model/usageValueRows.test.ts
index d1ce4df81..e440f8e94 100644
--- a/apps/web/src/features/accounts/model/usageValueRows.test.ts
+++ b/apps/web/src/features/accounts/model/usageValueRows.test.ts
@@ -19,6 +19,7 @@ const emptyStores = (): AccountQuotaStores => ({
claudeQuota: {},
codexQuota: {},
kimiQuota: {},
+ devinQuota: {},
xaiQuota: {},
});
diff --git a/apps/web/src/features/authFiles/constants.test.ts b/apps/web/src/features/authFiles/constants.test.ts
new file mode 100644
index 000000000..993c3f6d1
--- /dev/null
+++ b/apps/web/src/features/authFiles/constants.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import type { AuthFileType } from '@/types';
+import { getAuthFileIcon, getTypeColor } from './constants';
+
+describe('authFiles constants - devin', () => {
+ it('returns valid distinct light and dark icons for devin', () => {
+ const light = getAuthFileIcon('devin', 'light');
+ const dark = getAuthFileIcon('devin', 'dark');
+
+ expect(light).toBeTruthy();
+ expect(dark).toBeTruthy();
+ expect(light).not.toBe(dark);
+ });
+
+ it('returns correct type colors for devin in light and dark mode', () => {
+ expect(getTypeColor('devin', 'light')).toEqual({
+ bg: '#e8f4ff',
+ text: '#155e9b',
+ });
+ expect(getTypeColor('devin', 'dark')).toEqual({
+ bg: '#123b5d',
+ text: '#8dc9f5',
+ });
+ });
+
+ it('supports devin as AuthFileType', () => {
+ const type: AuthFileType = 'devin';
+ expect(type).toBe('devin');
+ });
+});
diff --git a/apps/web/src/features/authFiles/constants.ts b/apps/web/src/features/authFiles/constants.ts
index 3908bd1c1..134906068 100644
--- a/apps/web/src/features/authFiles/constants.ts
+++ b/apps/web/src/features/authFiles/constants.ts
@@ -2,6 +2,8 @@ import type { TFunction } from 'i18next';
import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconCodex from '@/assets/icons/codex.svg';
+import iconDevin from '@/assets/icons/devin.svg';
+import iconDevinDark from '@/assets/icons/devin-dark.svg';
import iconGemini from '@/assets/icons/gemini.svg';
import iconGrok from '@/assets/icons/grok.svg';
import iconGrokDark from '@/assets/icons/grok-dark.svg';
@@ -24,7 +26,7 @@ export type AuthFileModelItem = {
};
export type AuthFileIconAsset = string | { light: string; dark: string };
-export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'kimi' | 'xai';
+export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'kimi' | 'xai' | 'devin';
export type OAuthConfigLoadState = 'loading' | 'ready' | 'unsupported' | 'error';
export const QUOTA_PROVIDER_TYPES = new Set([
@@ -33,6 +35,7 @@ export const QUOTA_PROVIDER_TYPES = new Set([
'codex',
'kimi',
'xai',
+ 'devin',
]);
export const AUTH_FILE_REFRESH_WARNING_MS = 24 * 60 * 60 * 1000;
@@ -107,6 +110,10 @@ export const TYPE_COLORS: Record = {
light: { bg: '#e4edfd', text: '#2b5fbc' },
dark: { bg: '#1a3d80', text: '#89b3f7' },
},
+ devin: {
+ light: { bg: '#e8f4ff', text: '#155e9b' },
+ dark: { bg: '#123b5d', text: '#8dc9f5' },
+ },
empty: {
light: { bg: '#f5f5f5', text: '#616161' },
dark: { bg: '#424242', text: '#bdbdbd' },
@@ -122,6 +129,7 @@ export const AUTH_FILE_ICONS: Record = {
aistudio: iconGemini,
claude: iconClaude,
codex: iconCodex,
+ devin: { light: iconDevin, dark: iconDevinDark },
gemini: iconGemini,
xai: { light: iconGrok, dark: iconGrokDark },
iflow: iconIflow,
diff --git a/apps/web/src/features/demo/demoFixtures.ts b/apps/web/src/features/demo/demoFixtures.ts
index 7d5e5565a..268a7a3d9 100644
--- a/apps/web/src/features/demo/demoFixtures.ts
+++ b/apps/web/src/features/demo/demoFixtures.ts
@@ -33,6 +33,7 @@ import type {
ClaudeQuotaState,
CodexQuotaState,
CredentialScopedQuotaState,
+ DevinQuotaState,
KimiQuotaState,
XaiQuotaState,
} from '@/types';
@@ -58,6 +59,7 @@ export type DemoQuotaStoreState = {
antigravityQuota: Record;
claudeQuota: Record;
codexQuota: Record;
+ devinQuota: Record;
kimiQuota: Record;
xaiQuota: Record;
};
@@ -6444,6 +6446,7 @@ const getDemoQuotaStoreStateByFileName = (
},
},
},
+ devinQuota: {},
});
const scopeDemoQuotaRecord = (
@@ -6482,6 +6485,7 @@ export const getDemoQuotaStoreState = (
antigravityQuota: scopeDemoQuotaRecord(raw.antigravityQuota, filesByName),
claudeQuota: scopeDemoQuotaRecord(raw.claudeQuota, filesByName),
codexQuota: scopeDemoQuotaRecord(raw.codexQuota, filesByName),
+ devinQuota: scopeDemoQuotaRecord(raw.devinQuota, filesByName),
kimiQuota: scopeDemoQuotaRecord(raw.kimiQuota, filesByName),
xaiQuota: scopeDemoQuotaRecord(raw.xaiQuota, filesByName),
};
diff --git a/apps/web/src/features/monitoring/MonitoringCenterPage.tsx b/apps/web/src/features/monitoring/MonitoringCenterPage.tsx
index 0c34c381d..fec446479 100644
--- a/apps/web/src/features/monitoring/MonitoringCenterPage.tsx
+++ b/apps/web/src/features/monitoring/MonitoringCenterPage.tsx
@@ -80,6 +80,7 @@ import {
ANTIGRAVITY_CONFIG,
CLAUDE_CONFIG,
CODEX_CONFIG,
+ DEVIN_CONFIG,
KIMI_CONFIG,
XAI_CONFIG,
refreshQuotaWithConfig,
@@ -270,6 +271,7 @@ export function MonitoringCenterPage() {
const antigravityQuota = useQuotaStore((state) => state.antigravityQuota);
const claudeQuota = useQuotaStore((state) => state.claudeQuota);
const codexQuota = useQuotaStore((state) => state.codexQuota);
+ const devinQuota = useQuotaStore((state) => state.devinQuota);
const kimiQuota = useQuotaStore((state) => state.kimiQuota);
const xaiQuota = useQuotaStore((state) => state.xaiQuota);
const sharedQuotaStores = useMemo(
@@ -277,14 +279,16 @@ export function MonitoringCenterPage() {
antigravityQuota,
claudeQuota,
codexQuota,
+ devinQuota,
kimiQuota,
xaiQuota,
}),
- [antigravityQuota, claudeQuota, codexQuota, kimiQuota, xaiQuota]
+ [antigravityQuota, claudeQuota, codexQuota, devinQuota, kimiQuota, xaiQuota]
);
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
const setClaudeQuota = useQuotaStore((state) => state.setClaudeQuota);
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
+ const setDevinQuota = useQuotaStore((state) => state.setDevinQuota);
const setKimiQuota = useQuotaStore((state) => state.setKimiQuota);
const setXaiQuota = useQuotaStore((state) => state.setXaiQuota);
const [selectedAccount, setSelectedAccount] = useState(
@@ -1348,6 +1352,12 @@ export function MonitoringCenterPage() {
setKimiQuota,
getCredentialScopedQuotaState(sharedQuotaStores.kimiQuota, target.file)
);
+ case 'devin':
+ return run(
+ DEVIN_CONFIG,
+ setDevinQuota,
+ getCredentialScopedQuotaState(sharedQuotaStores.devinQuota, target.file)
+ );
case 'xai':
return run(
XAI_CONFIG,
@@ -1362,6 +1372,7 @@ export function MonitoringCenterPage() {
setAntigravityQuota,
setClaudeQuota,
setCodexQuota,
+ setDevinQuota,
setKimiQuota,
setXaiQuota,
t,
diff --git a/apps/web/src/features/monitoring/accountOverviewQuotaTargets.test.ts b/apps/web/src/features/monitoring/accountOverviewQuotaTargets.test.ts
index 3fc3e8186..ed86b9877 100644
--- a/apps/web/src/features/monitoring/accountOverviewQuotaTargets.test.ts
+++ b/apps/web/src/features/monitoring/accountOverviewQuotaTargets.test.ts
@@ -273,4 +273,40 @@ describe('accountOverviewQuotaTargets', () => {
]);
expect(result.get('same@example.com')).toBeUndefined();
});
+
+ it('builds Devin quota targets for Devin credentials', () => {
+ const authStateByRowId = new Map([
+ [
+ 'devin@example.com',
+ createAuthState({
+ files: [
+ {
+ name: 'devin.json',
+ type: 'devin',
+ authIndex: 'd-1',
+ label: 'Devin Account',
+ account: 'devin@example.com',
+ },
+ ],
+ enabledState: 'enabled',
+ }),
+ ],
+ ]);
+
+ const result = buildMonitoringAccountQuotaTargetsByRowId(
+ [
+ createAccountRow({
+ id: 'devin@example.com',
+ account: 'devin@example.com',
+ authIndices: ['d-1'],
+ }),
+ ],
+ authStateByRowId
+ );
+
+ expect(result.get('devin@example.com')).toMatchObject([
+ { provider: 'devin', authIndex: 'd-1', fileName: 'devin.json', authLabel: 'Devin Account' },
+ ]);
+ });
});
+
diff --git a/apps/web/src/features/monitoring/accountOverviewQuotaTargets.ts b/apps/web/src/features/monitoring/accountOverviewQuotaTargets.ts
index 2ec0cbea2..daa6e8137 100644
--- a/apps/web/src/features/monitoring/accountOverviewQuotaTargets.ts
+++ b/apps/web/src/features/monitoring/accountOverviewQuotaTargets.ts
@@ -3,6 +3,7 @@ import {
isAntigravityFile,
isClaudeFile,
isCodexFile,
+ isDevinFile,
isDisabledAuthFile,
isKimiFile,
isXaiFile,
@@ -13,7 +14,7 @@ import {
import type { MonitoringAccountAuthState } from './accountOverviewState';
import type { MonitoringAccountRow } from './hooks/useMonitoringData';
-export type MonitoringAccountQuotaProvider = 'antigravity' | 'claude' | 'codex' | 'kimi' | 'xai';
+export type MonitoringAccountQuotaProvider = 'antigravity' | 'claude' | 'codex' | 'kimi' | 'xai' | 'devin';
export type MonitoringAccountQuotaTarget = {
key: string;
@@ -46,6 +47,7 @@ export const resolveMonitoringAccountQuotaProvider = (
if (isCodexFile(file)) return 'codex';
if (isClaudeFile(file)) return 'claude';
if (isAntigravityFile(file)) return 'antigravity';
+ if (isDevinFile(file)) return 'devin';
if (isKimiFile(file)) return 'kimi';
if (isXaiFile(file)) return 'xai';
return null;
diff --git a/apps/web/src/features/monitoring/model/monitoringCenterPageModel.test.ts b/apps/web/src/features/monitoring/model/monitoringCenterPageModel.test.ts
index f12164074..0c02a3be4 100644
--- a/apps/web/src/features/monitoring/model/monitoringCenterPageModel.test.ts
+++ b/apps/web/src/features/monitoring/model/monitoringCenterPageModel.test.ts
@@ -4,6 +4,7 @@ import {
ANTIGRAVITY_CONFIG,
CLAUDE_CONFIG,
CODEX_CONFIG,
+ DEVIN_CONFIG,
KIMI_CONFIG,
XAI_CONFIG,
} from '@/components/quota';
@@ -11,12 +12,18 @@ import {
fetchAntigravityQuota,
fetchClaudeQuota,
fetchCodexQuota,
+ fetchDevinQuota,
fetchKimiQuota,
fetchXaiQuota,
} from '@/utils/quota';
import zhCN from '@/i18n/locales/zh-CN.json';
import zhTW from '@/i18n/locales/zh-TW.json';
-import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState } from '@/types';
+import type {
+ AntigravityQuotaState,
+ ClaudeQuotaState,
+ CodexQuotaState,
+ DevinQuotaState,
+} from '@/types';
import { getQuotaCredentialStoreKey } from '@/utils/quota/credentialScope';
import type { MonitoringAccountQuotaTarget } from '@/features/monitoring/accountOverviewQuotaTargets';
import type {
@@ -55,6 +62,7 @@ vi.mock('@/utils/quota', async (importOriginal) => {
fetchAntigravityQuota: vi.fn(),
fetchClaudeQuota: vi.fn(),
fetchCodexQuota: vi.fn(),
+ fetchDevinQuota: vi.fn(),
fetchKimiQuota: vi.fn(),
fetchXaiQuota: vi.fn(),
};
@@ -74,6 +82,11 @@ const t = ((key: string, options?: Record) => {
'plans.codex.free': 'Free',
'codex_quota.monthly_window': 'Monthly limit',
'codex_quota.window_usage_duration': '{{used}} / {{total}} used',
+ 'devin_quota.title': 'Devin Quota',
+ 'devin_quota.daily': 'Daily limit',
+ 'devin_quota.weekly': 'Weekly limit',
+ 'devin_quota.plan_label': 'Plan',
+ 'devin_quota.empty_data': 'No Devin quota data',
'kimi_quota.title': 'Kimi Quota',
'kimi_quota.empty_data': 'No Kimi quota data',
'xai_quota.title': 'xAI Quota',
@@ -168,6 +181,15 @@ const buildEntryFromMockedProviderFetch = async (
);
break;
}
+ case 'devin': {
+ const data = await fetchDevinQuota(target.file, translate);
+ entry = buildAccountQuotaEntryFromProviderState(
+ target,
+ DEVIN_CONFIG.buildSuccessState(data, target.file),
+ translate
+ );
+ break;
+ }
case 'kimi': {
const data = await fetchKimiQuota(target.file, translate);
entry = buildAccountQuotaEntryFromProviderState(
@@ -195,10 +217,45 @@ const emptyQuotaStores = (): MonitoringQuotaStores => ({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
+ devinQuota: {},
kimiQuota: {},
xaiQuota: {},
});
+const devinState = (
+ file: MonitoringAccountQuotaTarget['file'],
+ dailyPercent: number,
+ weeklyPercent: number,
+ fetchedAtMs = 1_000,
+ overrides: Partial = {}
+): DevinQuotaState => ({
+ status: 'success',
+ authFileKey: getQuotaCredentialStoreKey(file),
+ authFileName: file.name,
+ authIndex: String(file.authIndex ?? file['auth_index'] ?? ''),
+ authFileIdentityVerified: true,
+ fetchedAtMs,
+ windows: [
+ {
+ id: 'daily',
+ remainingPercent: dailyPercent,
+ resetAtMs: 1_700_000_100 * 1000,
+ periodHours: 24,
+ },
+ {
+ id: 'weekly',
+ remainingPercent: weeklyPercent,
+ resetAtMs: 1_700_000_200 * 1000,
+ periodHours: 168,
+ },
+ ],
+ observedAtMs: fetchedAtMs,
+ plan: 'Devin Pro',
+ planStartMs: Date.parse('2026-09-01T00:00:00Z'),
+ planEndMs: Date.parse('2026-10-01T00:00:00Z'),
+ ...overrides,
+});
+
const codexState = (
file: MonitoringAccountQuotaTarget['file'],
usedPercent: number,
@@ -451,6 +508,7 @@ describe('monitoringCenterPageModel account quota', () => {
vi.mocked(fetchAntigravityQuota).mockReset();
vi.mocked(fetchClaudeQuota).mockReset();
vi.mocked(fetchCodexQuota).mockReset();
+ vi.mocked(fetchDevinQuota).mockReset();
vi.mocked(fetchKimiQuota).mockReset();
vi.mocked(fetchXaiQuota).mockReset();
});
@@ -2058,4 +2116,127 @@ describe('monitoringCenterPageModel account quota', () => {
expect(metaLabels.join(' ')).not.toContain('protocol_changed');
expect(metaLabels.join(' ')).not.toContain('HTTP 200');
});
+
+ it('maps Devin daily and weekly quota windows with plan into account quota entries', async () => {
+ vi.mocked(fetchDevinQuota).mockResolvedValue({
+ windows: [
+ {
+ id: 'daily',
+ remainingPercent: 75,
+ resetAtMs: 1_700_000_100 * 1000,
+ periodHours: 24,
+ },
+ {
+ id: 'weekly',
+ remainingPercent: 40,
+ resetAtMs: 1_700_000_200 * 1000,
+ periodHours: 168,
+ },
+ ],
+ observedAtMs: 1_000,
+ plan: 'Team',
+ planStartMs: Date.parse('2026-09-01T00:00:00Z'),
+ planEndMs: Date.parse('2026-10-01T00:00:00Z'),
+ });
+
+ const entry = await buildEntryFromMockedProviderFetch(
+ createTarget({
+ key: 'devin::1::devin.json',
+ provider: 'devin',
+ authIndex: '1',
+ fileName: 'devin.json',
+ file: {
+ name: 'devin.json',
+ type: 'devin',
+ provider: 'devin',
+ authIndex: '1',
+ },
+ }),
+ t
+ );
+
+ expect(entry).toMatchObject({
+ provider: 'devin',
+ providerLabel: 'Devin Quota',
+ metaLabels: ['Devin Quota', 'Plan: Team'],
+ windows: [
+ {
+ id: 'daily',
+ label: 'Daily limit',
+ remainingPercent: 75,
+ resetAtMs: 1_700_000_100 * 1000,
+ resetAccuracy: 'exact',
+ },
+ {
+ id: 'weekly',
+ label: 'Weekly limit',
+ remainingPercent: 40,
+ resetAtMs: 1_700_000_200 * 1000,
+ resetAccuracy: 'exact',
+ },
+ ],
+ });
+ });
+
+ it('reads cached Devin quota from stores without fetching', () => {
+ const file = {
+ name: 'devin-cached.json',
+ type: 'devin',
+ provider: 'devin',
+ authIndex: '1',
+ };
+ const target = createTarget({
+ key: 'devin::1::devin-cached.json',
+ provider: 'devin',
+ authIndex: '1',
+ fileName: file.name,
+ file,
+ });
+ const stores = emptyQuotaStores();
+ stores.devinQuota[getQuotaCredentialStoreKey(file)] = devinState(file, 90, 60, 2_500);
+
+ const entry = buildCachedAccountQuotaEntry(target, stores, t);
+
+ expect(fetchDevinQuota).not.toHaveBeenCalled();
+ expect(entry).toMatchObject({
+ key: target.key,
+ provider: 'devin',
+ fetchedAtMs: 2_500,
+ windows: [
+ { id: 'daily', remainingPercent: 90 },
+ { id: 'weekly', remainingPercent: 60 },
+ ],
+ });
+ });
+
+ it('handles Devin empty_data state gracefully in cached account quota entries', () => {
+ const file = {
+ name: 'devin-empty.json',
+ type: 'devin',
+ provider: 'devin',
+ authIndex: '1',
+ };
+ const target = createTarget({
+ key: 'devin::1::devin-empty.json',
+ provider: 'devin',
+ authIndex: '1',
+ fileName: file.name,
+ file,
+ });
+ const stores = emptyQuotaStores();
+ stores.devinQuota[getQuotaCredentialStoreKey(file)] = devinState(file, 0, 0, 1_000, {
+ status: 'success',
+ windows: [],
+ plan: null,
+ });
+
+ const entry = buildCachedAccountQuotaEntry(target, stores, t);
+
+ expect(entry).toMatchObject({
+ key: target.key,
+ provider: 'devin',
+ windows: [],
+ emptyMessage: 'No Devin quota data',
+ });
+ });
});
diff --git a/apps/web/src/features/monitoring/model/monitoringCenterPageModel.ts b/apps/web/src/features/monitoring/model/monitoringCenterPageModel.ts
index 739349b51..c0c40e595 100644
--- a/apps/web/src/features/monitoring/model/monitoringCenterPageModel.ts
+++ b/apps/web/src/features/monitoring/model/monitoringCenterPageModel.ts
@@ -7,6 +7,8 @@ import type {
ClaudeQuotaWindow,
CodexQuotaState,
CodexQuotaWindow,
+ DevinQuotaState,
+ DevinQuotaWindow,
KimiQuotaState,
KimiQuotaRow,
XaiBillingSummary,
@@ -1292,6 +1294,36 @@ const buildXaiAccountQuotaWindows = (
return windows;
};
+const buildDevinAccountQuotaWindows = (
+ windows: DevinQuotaWindow[] | undefined,
+ t: TFunction
+): AccountQuotaWindow[] =>
+ (windows ?? []).map((window) => {
+ const remainingPercent =
+ typeof window.remainingPercent === 'number' && Number.isFinite(window.remainingPercent)
+ ? Math.max(0, Math.min(100, window.remainingPercent))
+ : null;
+ const hasReset =
+ typeof window.resetAtMs === 'number' &&
+ Number.isFinite(window.resetAtMs) &&
+ window.resetAtMs > 0;
+ const resetLabel =
+ hasReset && window.resetAtMs !== null
+ ? formatQuotaResetTime(window.resetAtMs)
+ : '-';
+ const label = window.id === 'daily' ? t('devin_quota.daily') : t('devin_quota.weekly');
+
+ return {
+ id: window.id,
+ label,
+ remainingPercent,
+ resetLabel,
+ resetAtMs: window.resetAtMs,
+ resetAccuracy: hasReset ? 'exact' : 'unknown',
+ usageLabel: null,
+ };
+ });
+
export const getAccountQuotaProviderLabel = (
provider: MonitoringAccountQuotaProvider,
t: TFunction
@@ -1305,6 +1337,8 @@ export const getAccountQuotaProviderLabel = (
return t('kimi_quota.title');
case 'xai':
return t('xai_quota.title');
+ case 'devin':
+ return t('devin_quota.title');
case 'codex':
default:
return t('codex_quota.title');
@@ -1321,6 +1355,8 @@ const getAccountQuotaEmptyMessage = (provider: MonitoringAccountQuotaProvider, t
return t('kimi_quota.empty_data');
case 'xai':
return t('xai_quota.empty_data');
+ case 'devin':
+ return t('devin_quota.empty_data');
case 'codex':
default:
return t('codex_quota.empty_windows');
@@ -1349,6 +1385,7 @@ export type MonitoringQuotaStores = {
antigravityQuota: Record;
claudeQuota: Record;
codexQuota: Record;
+ devinQuota: Record;
kimiQuota: Record;
xaiQuota: Record;
};
@@ -1357,6 +1394,7 @@ export type MonitoringProviderQuotaState =
| AntigravityQuotaState
| ClaudeQuotaState
| CodexQuotaState
+ | DevinQuotaState
| KimiQuotaState
| XaiQuotaState;
@@ -1470,6 +1508,26 @@ export const buildAccountQuotaEntryFromProviderState = (
quota
);
}
+ case 'devin': {
+ const quota = state as DevinQuotaState;
+ const planType = quota.plan ?? target.planType;
+ const metaLabels: string[] = [];
+ if (quota.plan) {
+ metaLabels.push(`${t('devin_quota.plan_label')}: ${quota.plan}`);
+ }
+ return applyProviderQuotaStateMetadata(
+ {
+ ...buildBaseAccountQuotaEntry(
+ { ...target, planType },
+ t,
+ metaLabels
+ ),
+ planType,
+ windows: buildDevinAccountQuotaWindows(quota.windows, t),
+ },
+ quota
+ );
+ }
case 'codex':
default: {
const quota = state as CodexQuotaState;
@@ -1516,6 +1574,12 @@ export const buildCachedAccountQuotaEntry = (
getCredentialScopedQuotaState(stores.codexQuota, target.file),
t
);
+ case 'devin':
+ return buildAccountQuotaEntryFromProviderState(
+ target,
+ getCredentialScopedQuotaState(stores.devinQuota, target.file),
+ t
+ );
case 'kimi':
return buildAccountQuotaEntryFromProviderState(
target,
diff --git a/apps/web/src/features/monitoring/model/sourceDisplay.test.ts b/apps/web/src/features/monitoring/model/sourceDisplay.test.ts
index 2a94893e2..7dbaa63c5 100644
--- a/apps/web/src/features/monitoring/model/sourceDisplay.test.ts
+++ b/apps/web/src/features/monitoring/model/sourceDisplay.test.ts
@@ -21,6 +21,8 @@ describe('isGenericMonitoringProviderLabel', () => {
expect(isGenericMonitoringProviderLabel('x-ai')).toBe(true);
expect(isGenericMonitoringProviderLabel('grok')).toBe(true);
expect(isGenericMonitoringProviderLabel('antigravity')).toBe(true);
+ expect(isGenericMonitoringProviderLabel('devin')).toBe(true);
+ expect(isGenericMonitoringProviderLabel('Devin')).toBe(true);
expect(isGenericMonitoringProviderLabel('anyrouter.top #1')).toBe(false);
});
});
@@ -112,6 +114,45 @@ describe('buildMonitoringSourceDisplay', () => {
expect(display.meta).toBe('codex');
});
+ it('keeps generic devin provider labels secondary to the account identity', () => {
+ const authMetaMap = new Map([
+ [
+ 'devin-1',
+ {
+ authIndex: 'devin-1',
+ label: 'devin',
+ account: 'user@example.com',
+ provider: 'devin',
+ status: 'active',
+ disabled: false,
+ unavailable: false,
+ runtimeOnly: false,
+ planType: '-',
+ updatedAt: '',
+ },
+ ],
+ ]);
+
+ const display = buildMonitoringSourceDisplay(
+ {
+ authIndex: 'devin-1',
+ accountSnapshot: 'user@example.com',
+ authLabelSnapshot: 'devin',
+ authProviderSnapshot: 'devin',
+ channel: 'devin',
+ },
+ {
+ authMetaMap,
+ channelByAuthIndex: new Map(),
+ }
+ );
+
+ expect(display.primary).toBe('use***@example.com');
+ expect(display.meta).toBe('devin');
+ expect(display.accountMasked).toBe('use***@example.com');
+ expect(display.provider).toBe('devin');
+ });
+
it('still prefers non-generic channel names over the account identity', () => {
const display = buildMonitoringSourceDisplay(
{
diff --git a/apps/web/src/features/monitoring/model/sourceDisplay.ts b/apps/web/src/features/monitoring/model/sourceDisplay.ts
index 086190800..b3efbb062 100644
--- a/apps/web/src/features/monitoring/model/sourceDisplay.ts
+++ b/apps/web/src/features/monitoring/model/sourceDisplay.ts
@@ -15,6 +15,7 @@ const GENERIC_PROVIDER_LABELS = new Set([
'xai',
'x-ai',
'grok',
+ 'devin',
]);
const hasReadableValue = (value: string | null | undefined) => {
diff --git a/apps/web/src/features/oauth/OAuthPage.devin.test.tsx b/apps/web/src/features/oauth/OAuthPage.devin.test.tsx
new file mode 100644
index 000000000..c7b515d36
--- /dev/null
+++ b/apps/web/src/features/oauth/OAuthPage.devin.test.tsx
@@ -0,0 +1,590 @@
+import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { OAuthPage } from './OAuthPage';
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const { mocks } = vi.hoisted(() => ({
+ mocks: {
+ apiBase: 'http://cpa-a.local:8317',
+ managementKey: 'key-a',
+ startAuth: vi.fn(),
+ getAuthStatus: vi.fn(),
+ submitCallback: vi.fn(),
+ cancelSession: vi.fn(),
+ authFilesList: vi.fn(async () => ({ files: [] })),
+ pluginList: vi.fn(async () => ({ plugins: [] })),
+ vertexImport: vi.fn(),
+ showNotification: vi.fn(),
+ navigate: vi.fn(),
+ recordMutationMarker: vi.fn(),
+ publishMutationRevision: vi.fn(),
+ intervalCallbacks: [] as Array<() => void | Promise>,
+ },
+}));
+
+vi.mock('react-i18next', () => ({
+ initReactI18next: { type: '3rdParty', init: () => undefined },
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+vi.mock('react-router-dom', () => ({
+ useLocation: () => ({ hash: '' }),
+ useNavigate: () => mocks.navigate,
+}));
+
+vi.mock('@/stores', () => {
+ const readAuthState = () => ({
+ apiBase: mocks.apiBase,
+ managementKey: mocks.managementKey,
+ connectionStatus: 'connected' as const,
+ supportsPlugin: false,
+ });
+ return {
+ publishAccountCredentialMutationRevision: mocks.publishMutationRevision,
+ useAuthStore: Object.assign(
+ (selector: (state: Record) => unknown) => selector(readAuthState()),
+ { getState: readAuthState }
+ ),
+ useNotificationStore: (
+ selector?: (state: { showNotification: typeof mocks.showNotification }) => unknown
+ ) => {
+ const state = { showNotification: mocks.showNotification };
+ return selector ? selector(state) : state;
+ },
+ useThemeStore: (selector: (state: { resolvedTheme: 'light' }) => unknown) =>
+ selector({ resolvedTheme: 'light' }),
+ };
+});
+
+vi.mock('@/services/api', () => ({
+ oauthApi: {
+ startAuth: mocks.startAuth,
+ getAuthStatus: mocks.getAuthStatus,
+ submitCallback: mocks.submitCallback,
+ cancelSession: mocks.cancelSession,
+ },
+ authFilesApi: {
+ list: mocks.authFilesList,
+ },
+ pluginsApi: {
+ list: mocks.pluginList,
+ },
+}));
+
+vi.mock('@/services/api/vertex', () => ({
+ vertexApi: {
+ importCredential: mocks.vertexImport,
+ },
+}));
+
+vi.mock('@/features/monitoring/codexInspection', () => ({
+ createCodexInspectionConnectionFingerprint: (apiBase: string, managementKey: string) =>
+ apiBase && managementKey ? `${apiBase}:${managementKey}` : null,
+}));
+
+vi.mock('@/features/accounts/model/accountCredentialMutationMarker', () => ({
+ createAccountCredentialMutationBaseline: (_files: unknown[], provider: string) => ({
+ provider,
+ credentials: [],
+ }),
+ recordAccountCredentialMutationMarker: mocks.recordMutationMarker,
+}));
+
+vi.mock('@/utils/clipboard', () => ({
+ copyToClipboard: vi.fn(async () => true),
+}));
+
+vi.mock('@/features/plugins/pluginResources', () => ({
+ getPluginTitle: (plugin: { id: string }) => plugin.id,
+ resolvePluginAssetURL: () => '',
+}));
+
+const textContent = (node: ReactTestInstance): string =>
+ node.children.map((child) => (typeof child === 'string' ? child : textContent(child))).join('');
+
+const treeText = (renderer: ReactTestRenderer): string =>
+ renderer.root.children
+ .map((child) => (typeof child === 'string' ? child : textContent(child)))
+ .join('');
+
+const getDevinCard = (renderer: ReactTestRenderer): ReactTestInstance => {
+ const card = renderer.root.find((node) => node.props?.id === 'oauth-provider-devin');
+ if (!card) throw new Error('Devin provider card not found');
+ return card;
+};
+
+const findDevinButton = (renderer: ReactTestRenderer, text: string): ReactTestInstance => {
+ const card = getDevinCard(renderer);
+ const button = card
+ .findAllByType('button')
+ .find((candidate) => textContent(candidate) === text);
+ if (!button) throw new Error(`Button not found in Devin card: ${text}`);
+ return button;
+};
+
+const queryDevinButton = (renderer: ReactTestRenderer, text: string): ReactTestInstance | undefined => {
+ const card = getDevinCard(renderer);
+ return card
+ .findAllByType('button')
+ .find((candidate) => textContent(candidate) === text);
+};
+
+const mountedRenderers = new Set();
+
+const renderOAuthPage = async (): Promise => {
+ let renderer!: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ await Promise.resolve();
+ });
+ mountedRenderers.add(renderer);
+ return renderer;
+};
+
+const findDevinLoginButton = (renderer: ReactTestRenderer): ReactTestInstance => {
+ const button =
+ queryDevinButton(renderer, 'auth_login.devin_oauth_button') ||
+ queryDevinButton(renderer, 'auth_login.login_another_account');
+ if (!button) throw new Error('Devin login button not found');
+ return button;
+};
+
+const startDevinAuth = (renderer: ReactTestRenderer): Promise => {
+ const button = findDevinLoginButton(renderer);
+ return Promise.resolve(button.props.onClick());
+};
+
+const submitDevinCallback = async (renderer: ReactTestRenderer, callbackUrl: string) => {
+ const card = getDevinCard(renderer);
+ const callbackInput = card
+ .findAllByType('input')
+ .find((input) => input.props.placeholder === 'auth_login.devin_callback_placeholder');
+ if (!callbackInput) throw new Error('Devin callback input not found');
+
+ await act(async () => {
+ callbackInput.props.onChange({ target: { value: callbackUrl } });
+ await Promise.resolve();
+ });
+
+ let callbackPromise!: Promise;
+ act(() => {
+ callbackPromise = Promise.resolve(
+ findDevinButton(renderer, 'auth_login.oauth_callback_button').props.onClick()
+ );
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+ return callbackPromise;
+};
+
+const cancelDevinAuth = (renderer: ReactTestRenderer): Promise => {
+ const button = findDevinButton(renderer, 'auth_login.devin_oauth_cancel');
+ return Promise.resolve(button.props.onClick());
+};
+
+describe('OAuthPage Devin OAuth lifecycle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.apiBase = 'http://cpa-a.local:8317';
+ mocks.managementKey = 'key-a';
+ mocks.intervalCallbacks = [];
+ mocks.authFilesList.mockReset();
+ mocks.authFilesList.mockResolvedValue({ files: [] });
+ vi.stubGlobal('window', {
+ requestAnimationFrame: vi.fn(() => 1),
+ cancelAnimationFrame: vi.fn(),
+ setInterval: vi.fn((callback: () => void | Promise) => {
+ mocks.intervalCallbacks.push(callback);
+ return mocks.intervalCallbacks.length;
+ }),
+ clearInterval: vi.fn(),
+ setTimeout: vi.fn(() => 100),
+ clearTimeout: vi.fn(),
+ open: vi.fn(),
+ });
+ });
+
+ afterEach(async () => {
+ await act(async () => {
+ mountedRenderers.forEach((renderer) => renderer.unmount());
+ });
+ mountedRenderers.clear();
+ vi.unstubAllGlobals();
+ });
+
+ it('renders Devin card, starts auth, and begins polling', async () => {
+ mocks.startAuth.mockResolvedValue({
+ url: 'https://auth.example/devin?state=devin-state-1',
+ state: 'devin-state-1',
+ });
+ mocks.getAuthStatus.mockResolvedValue({ status: 'wait' });
+
+ const renderer = await renderOAuthPage();
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ expect(mocks.startAuth).toHaveBeenCalledWith('devin', {
+ apiBase: 'http://cpa-a.local:8317',
+ managementKey: 'key-a',
+ });
+
+ expect(treeText(renderer)).toContain('https://auth.example/devin?state=devin-state-1');
+ expect(treeText(renderer)).toContain('auth_login.devin_oauth_status_waiting');
+ expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeDefined();
+
+ // Verify polling callback is registered and triggers getAuthStatus
+ expect(mocks.intervalCallbacks).toHaveLength(1);
+ await act(async () => {
+ await mocks.intervalCallbacks[0]?.();
+ });
+ expect(mocks.getAuthStatus).toHaveBeenCalledWith('devin-state-1', {
+ apiBase: 'http://cpa-a.local:8317',
+ managementKey: 'key-a',
+ });
+ });
+
+ it('prevents duplicate auth start when Devin session is already pending', async () => {
+ mocks.startAuth.mockResolvedValue({
+ url: 'https://auth.example/devin?state=devin-state-1',
+ state: 'devin-state-1',
+ });
+
+ const renderer = await renderOAuthPage();
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+ expect(mocks.startAuth).toHaveBeenCalledTimes(1);
+
+ const loginButton = findDevinButton(renderer, 'auth_login.devin_oauth_button');
+ expect(loginButton.props.disabled).toBe(true);
+
+ // Clicking again while pending state exists should not trigger another startAuth
+ await act(async () => {
+ await loginButton.props.onClick();
+ });
+ expect(mocks.startAuth).toHaveBeenCalledTimes(1);
+ });
+
+ it('submits valid Devin callback URL untouched and records mutation marker on completion', async () => {
+ mocks.startAuth.mockResolvedValue({
+ url: 'https://auth.example/devin?state=devin-state-1',
+ state: 'devin-state-1',
+ });
+ mocks.submitCallback.mockResolvedValue({ status: 'ok' });
+ mocks.getAuthStatus.mockResolvedValue({ status: 'ok' });
+
+ const renderer = await renderOAuthPage();
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ const fullCallbackUrl = 'http://127.0.0.1:8317/callback?code=devin_code_xyz&state=devin-state-1';
+ const callbackPromise = await submitDevinCallback(renderer, fullCallbackUrl);
+ await act(async () => {
+ await callbackPromise;
+ });
+
+ // submitCallback must receive the complete URL with host, port, path, and parameters intact
+ expect(mocks.submitCallback).toHaveBeenCalledWith(
+ 'devin',
+ fullCallbackUrl,
+ {
+ apiBase: 'http://cpa-a.local:8317',
+ managementKey: 'key-a',
+ }
+ );
+
+ expect(mocks.recordMutationMarker).toHaveBeenCalledWith({
+ connectionFingerprint: 'http://cpa-a.local:8317:key-a',
+ provider: 'devin',
+ baseline: { provider: 'devin', credentials: [] },
+ requireObservedMutation: true,
+ });
+ expect(mocks.showNotification).toHaveBeenCalledWith(
+ 'auth_login.oauth_callback_success',
+ 'success'
+ );
+ });
+
+ it('blocks submission and shows warning when callback state does not match expected state', async () => {
+ mocks.startAuth.mockResolvedValue({
+ url: 'https://auth.example/devin?state=devin-state-1',
+ state: 'devin-state-1',
+ });
+
+ const renderer = await renderOAuthPage();
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ const mismatchedUrl = 'http://127.0.0.1:8317/callback?code=devin_code_xyz&state=wrong-state';
+ await submitDevinCallback(renderer, mismatchedUrl);
+
+ expect(mocks.submitCallback).not.toHaveBeenCalled();
+ expect(mocks.showNotification).toHaveBeenCalledWith(
+ 'auth_login.devin_callback_state_mismatch',
+ 'warning'
+ );
+
+ // Input retains user value
+ const input = renderer.root
+ .findAllByType('input')
+ .find((candidate) => candidate.props.placeholder === 'auth_login.devin_callback_placeholder');
+ expect(input?.props.value).toBe(mismatchedUrl);
+ });
+
+ it('blocks submission and shows warning when callback URL is malformed or missing code', async () => {
+ mocks.startAuth.mockResolvedValue({
+ url: 'https://auth.example/devin?state=devin-state-1',
+ state: 'devin-state-1',
+ });
+
+ const renderer = await renderOAuthPage();
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ await submitDevinCallback(renderer, 'not-a-valid-url');
+
+ expect(mocks.submitCallback).not.toHaveBeenCalled();
+ expect(mocks.showNotification).toHaveBeenCalledWith(
+ 'auth_login.devin_callback_invalid',
+ 'warning'
+ );
+ });
+
+ it('cancels pending Devin auth session successfully when cancelled: true', async () => {
+ mocks.startAuth.mockResolvedValue({
+ url: 'https://auth.example/devin?state=devin-state-1',
+ state: 'devin-state-1',
+ });
+ mocks.cancelSession.mockResolvedValue({ status: 'ok', cancelled: true });
+
+ const renderer = await renderOAuthPage();
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeDefined();
+
+ await act(async () => {
+ await cancelDevinAuth(renderer);
+ });
+
+ expect(mocks.cancelSession).toHaveBeenCalledWith('devin-state-1', {
+ apiBase: 'http://cpa-a.local:8317',
+ managementKey: 'key-a',
+ });
+
+ // Session reset: cancel button disappears, URL box removed, start button re-enabled
+ expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeUndefined();
+ expect(treeText(renderer)).not.toContain('https://auth.example/devin');
+ const startButton = findDevinButton(renderer, 'auth_login.devin_oauth_button');
+ expect(startButton.props.disabled).toBeFalsy();
+ expect(mocks.showNotification).toHaveBeenCalledWith(
+ 'auth_login.devin_oauth_cancelled',
+ 'success'
+ );
+ });
+
+ it('handles cancel race condition when cancelled: false and session already completed', async () => {
+ mocks.startAuth.mockResolvedValue({
+ url: 'https://auth.example/devin?state=devin-state-1',
+ state: 'devin-state-1',
+ });
+ mocks.cancelSession.mockResolvedValue({ status: 'ok', cancelled: false });
+ mocks.getAuthStatus.mockResolvedValue({ status: 'ok' });
+
+ const renderer = await renderOAuthPage();
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ await act(async () => {
+ await cancelDevinAuth(renderer);
+ });
+
+ expect(mocks.cancelSession).toHaveBeenCalledWith('devin-state-1', {
+ apiBase: 'http://cpa-a.local:8317',
+ managementKey: 'key-a',
+ });
+ expect(mocks.getAuthStatus).toHaveBeenCalledWith('devin-state-1', {
+ apiBase: 'http://cpa-a.local:8317',
+ managementKey: 'key-a',
+ });
+ expect(mocks.recordMutationMarker).toHaveBeenCalledWith({
+ connectionFingerprint: 'http://cpa-a.local:8317:key-a',
+ provider: 'devin',
+ baseline: { provider: 'devin', credentials: [] },
+ requireObservedMutation: true,
+ });
+ expect(mocks.showNotification).toHaveBeenCalledWith(
+ 'auth_login.devin_oauth_status_success',
+ 'success'
+ );
+ });
+
+ it('handles cancel failure: preserves state, shows cancel error, and resumes polling', async () => {
+ mocks.startAuth.mockResolvedValue({
+ url: 'https://auth.example/devin?state=devin-state-1',
+ state: 'devin-state-1',
+ });
+ mocks.cancelSession.mockRejectedValue(new Error('Network error on cancel'));
+
+ const renderer = await renderOAuthPage();
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ await act(async () => {
+ await cancelDevinAuth(renderer);
+ });
+
+ expect(mocks.showNotification).toHaveBeenCalledWith(
+ 'Network error on cancel',
+ 'error'
+ );
+ expect(treeText(renderer)).toContain('Network error on cancel');
+ // State and Cancel button preserved
+ expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeDefined();
+ expect(treeText(renderer)).toContain('https://auth.example/devin');
+ });
+
+ it('clears cancelError when attempt finishes successfully and does not leak to a new Devin login', async () => {
+ mocks.startAuth
+ .mockResolvedValueOnce({
+ url: 'https://auth.example/devin?state=devin-state-1',
+ state: 'devin-state-1',
+ })
+ .mockResolvedValueOnce({
+ url: 'https://auth.example/devin?state=devin-state-2',
+ state: 'devin-state-2',
+ });
+ mocks.cancelSession.mockRejectedValueOnce(new Error('Network error on cancel'));
+ mocks.getAuthStatus.mockResolvedValueOnce({ status: 'ok' });
+
+ const renderer = await renderOAuthPage();
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ // Cancel fails, preserves pending state and shows cancelError
+ await act(async () => {
+ await cancelDevinAuth(renderer);
+ });
+ expect(treeText(renderer)).toContain('Network error on cancel');
+ expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeDefined();
+
+ // Subsequent polling succeeds and finishes the attempt
+ const latestPollingCallback = mocks.intervalCallbacks[mocks.intervalCallbacks.length - 1];
+ await act(async () => {
+ await latestPollingCallback?.();
+ });
+
+ // Attempt completes successfully: cancelError cleared
+ expect(treeText(renderer)).not.toContain('Network error on cancel');
+
+ // Start a new Devin login attempt
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ // Old cancel error must not leak into the new login attempt
+ expect(treeText(renderer)).not.toContain('Network error on cancel');
+ expect(treeText(renderer)).toContain('https://auth.example/devin?state=devin-state-2');
+ });
+
+ it('clears cancelError when CPA returns error status and does not leak to a new Devin login', async () => {
+ mocks.startAuth
+ .mockResolvedValueOnce({
+ url: 'https://auth.example/devin?state=devin-state-1',
+ state: 'devin-state-1',
+ })
+ .mockResolvedValueOnce({
+ url: 'https://auth.example/devin?state=devin-state-2',
+ state: 'devin-state-2',
+ });
+ mocks.cancelSession.mockRejectedValueOnce(new Error('Network error on cancel'));
+ mocks.getAuthStatus.mockResolvedValueOnce({ status: 'error', error: 'session expired' });
+
+ const renderer = await renderOAuthPage();
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ // Cancel fails, cancelError is displayed
+ await act(async () => {
+ await cancelDevinAuth(renderer);
+ });
+ expect(treeText(renderer)).toContain('Network error on cancel');
+
+ // Polling receives CPA error status
+ const latestPollingCallback = mocks.intervalCallbacks[mocks.intervalCallbacks.length - 1];
+ await act(async () => {
+ await latestPollingCallback?.();
+ });
+
+ // Error status clears cancelError and resets session
+ expect(treeText(renderer)).not.toContain('Network error on cancel');
+ expect(treeText(renderer)).toContain('session expired');
+
+ // Start a new Devin login attempt
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ // Old cancel error must not leak into the new login attempt
+ expect(treeText(renderer)).not.toContain('Network error on cancel');
+ expect(treeText(renderer)).toContain('https://auth.example/devin?state=devin-state-2');
+ });
+
+ it('preserves state and leaves cancel button visible when polling encounters network error', async () => {
+ mocks.startAuth.mockResolvedValue({
+ url: 'https://auth.example/devin?state=devin-state-1',
+ state: 'devin-state-1',
+ });
+ mocks.getAuthStatus.mockRejectedValue(new Error('Connection reset'));
+
+ const renderer = await renderOAuthPage();
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ // Fire polling interval
+ await act(async () => {
+ await mocks.intervalCallbacks[0]?.();
+ });
+
+ // Error status shown, but state and cancel button remain accessible
+ expect(treeText(renderer)).toContain('auth_login.devin_oauth_status_error');
+ expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeDefined();
+ expect(treeText(renderer)).toContain('https://auth.example/devin');
+ });
+
+ it('resets Devin state back to idle when CPA explicitly returns error status', async () => {
+ mocks.startAuth.mockResolvedValue({
+ url: 'https://auth.example/devin?state=devin-state-1',
+ state: 'devin-state-1',
+ });
+ mocks.getAuthStatus.mockResolvedValue({ status: 'error', error: 'session expired' });
+
+ const renderer = await renderOAuthPage();
+ await act(async () => {
+ await startDevinAuth(renderer);
+ });
+
+ // Fire polling interval
+ await act(async () => {
+ await mocks.intervalCallbacks[0]?.();
+ });
+
+ // Error status handled: state cleared, cancel button gone, start button re-enabled
+ expect(queryDevinButton(renderer, 'auth_login.devin_oauth_cancel')).toBeUndefined();
+ expect(treeText(renderer)).not.toContain('https://auth.example/devin');
+ const startButton = findDevinButton(renderer, 'auth_login.devin_oauth_button');
+ expect(startButton.props.disabled).toBeFalsy();
+ });
+});
diff --git a/apps/web/src/features/oauth/OAuthPage.lifecycle.test.tsx b/apps/web/src/features/oauth/OAuthPage.lifecycle.test.tsx
index 0fadfd574..22ed8b97a 100644
--- a/apps/web/src/features/oauth/OAuthPage.lifecycle.test.tsx
+++ b/apps/web/src/features/oauth/OAuthPage.lifecycle.test.tsx
@@ -11,6 +11,7 @@ const { mocks } = vi.hoisted(() => ({
startAuth: vi.fn(),
getAuthStatus: vi.fn(),
submitCallback: vi.fn(),
+ cancelSession: vi.fn(),
authFilesList: vi.fn(async () => ({ files: [] })),
pluginList: vi.fn(async () => ({ plugins: [] })),
vertexImport: vi.fn(),
@@ -61,6 +62,7 @@ vi.mock('@/services/api', () => ({
startAuth: mocks.startAuth,
getAuthStatus: mocks.getAuthStatus,
submitCallback: mocks.submitCallback,
+ cancelSession: mocks.cancelSession,
},
authFilesApi: {
list: mocks.authFilesList,
@@ -146,6 +148,16 @@ const startCodexAuth = (renderer: ReactTestRenderer): Promise => {
return promise;
};
+const startDevinAuth = (renderer: ReactTestRenderer): Promise => {
+ let promise!: Promise;
+ act(() => {
+ promise = Promise.resolve(
+ findButton(renderer, 'auth_login.devin_oauth_button').props.onClick()
+ );
+ });
+ return promise;
+};
+
describe('OAuthPage connection lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -417,4 +429,35 @@ describe('OAuthPage connection lifecycle', () => {
});
expect(mocks.recordMutationMarker).toHaveBeenCalledTimes(1);
});
+
+ it('ignores a late Devin polling response after the CPA connection changes', async () => {
+ const polling = deferred<{ status: 'ok' }>();
+ mocks.startAuth.mockResolvedValue({ url: 'https://auth.example/devin', state: 'state-devin-a' });
+ mocks.getAuthStatus.mockReturnValue(polling.promise);
+ const renderer = await renderOAuthPage();
+ const authPromise = startDevinAuth(renderer);
+ await act(async () => {
+ await authPromise;
+ });
+
+ let pollingPromise!: Promise;
+ await act(async () => {
+ pollingPromise = Promise.resolve(mocks.intervalCallbacks[0]?.());
+ await Promise.resolve();
+ });
+
+ mocks.apiBase = 'http://cpa-b.local:8317';
+ mocks.managementKey = 'key-b';
+ await act(async () => {
+ renderer.update();
+ polling.resolve({ status: 'ok' });
+ await pollingPromise;
+ });
+
+ expect(mocks.recordMutationMarker).not.toHaveBeenCalled();
+ expect(mocks.showNotification).not.toHaveBeenCalledWith(
+ 'auth_login.devin_oauth_status_success',
+ 'success'
+ );
+ });
});
diff --git a/apps/web/src/features/oauth/OAuthPage.test.tsx b/apps/web/src/features/oauth/OAuthPage.test.tsx
index e3830b07c..9bac2b3dd 100644
--- a/apps/web/src/features/oauth/OAuthPage.test.tsx
+++ b/apps/web/src/features/oauth/OAuthPage.test.tsx
@@ -275,7 +275,7 @@ describe('OAuthPage request lifecycle', () => {
});
});
-const builtInProviderIds = new Set(['codex', 'anthropic', 'antigravity', 'kimi', 'xai']);
+const builtInProviderIds = new Set(['codex', 'anthropic', 'antigravity', 'kimi', 'xai', 'devin']);
describe('plugin OAuth provider helpers', () => {
it('uses explicit plugin OAuth provider ids when present', () => {
@@ -296,6 +296,16 @@ describe('plugin OAuth provider helpers', () => {
builtInProviderIds
)
).toBe(false);
+ expect(
+ shouldShowPluginOAuthProvider(
+ {
+ id: 'custom-plugin',
+ oauthProvider: 'devin',
+ supportsOAuth: true,
+ },
+ builtInProviderIds
+ )
+ ).toBe(false);
expect(
shouldShowPluginOAuthProvider(
{
diff --git a/apps/web/src/features/oauth/OAuthPage.tsx b/apps/web/src/features/oauth/OAuthPage.tsx
index 9f686866c..0802ec6d6 100644
--- a/apps/web/src/features/oauth/OAuthPage.tsx
+++ b/apps/web/src/features/oauth/OAuthPage.tsx
@@ -48,6 +48,7 @@ import {
type OAuthProviderAttempt,
type OAuthPollingScope,
} from './oauthProviderHelpers';
+import { validateDevinCallback } from './devinOAuth';
import styles from './OAuthPage.module.scss';
import iconCodex from '@/assets/icons/codex.svg';
import iconClaude from '@/assets/icons/claude.svg';
@@ -57,6 +58,8 @@ import iconKimiDark from '@/assets/icons/kimi-dark.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import iconGrok from '@/assets/icons/grok.svg';
import iconGrokDark from '@/assets/icons/grok-dark.svg';
+import iconDevin from '@/assets/icons/devin.svg';
+import iconDevinDark from '@/assets/icons/devin-dark.svg';
interface ProviderState {
url?: string;
@@ -64,6 +67,8 @@ interface ProviderState {
status?: 'idle' | 'waiting' | 'success' | 'error';
error?: string;
polling?: boolean;
+ cancelling?: boolean;
+ cancelError?: string;
callbackUrl?: string;
callbackSubmitting?: boolean;
callbackStatus?: 'success' | 'error';
@@ -160,11 +165,24 @@ const BUILT_IN_PROVIDERS: BuiltInProviderDefinition[] = [
urlLabelKey: 'auth_login.xai_oauth_url_label',
icon: { light: iconGrok, dark: iconGrokDark },
},
+ {
+ id: 'devin',
+ titleKey: 'auth_login.devin_oauth_title',
+ hintKey: 'auth_login.devin_oauth_hint',
+ urlLabelKey: 'auth_login.devin_oauth_url_label',
+ icon: { light: iconDevin, dark: iconDevinDark },
+ },
];
const BUILT_IN_PROVIDER_IDS = new Set(BUILT_IN_PROVIDERS.map((provider) => provider.id));
-const CALLBACK_SUPPORTED = new Set(['codex', 'anthropic', 'antigravity', 'xai']);
+const CALLBACK_SUPPORTED = new Set([
+ 'codex',
+ 'anthropic',
+ 'antigravity',
+ 'xai',
+ 'devin',
+]);
const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback';
const SUCCESS_RESET_DELAY_MS = 5000;
const getProviderI18nPrefix = (provider: BuiltInOAuthProvider) => provider.replace('-', '_');
@@ -242,6 +260,7 @@ const resolveCallbackUrl = (
input: string,
state?: string
): string | null => {
+ if (provider === 'devin') return input.trim();
if (provider !== 'xai') return input.trim();
return buildXaiCallbackUrl(input, state);
};
@@ -550,6 +569,12 @@ export function OAuthPage() {
status: 'success',
error: undefined,
polling: false,
+ ...(provider === 'devin'
+ ? {
+ cancelling: false,
+ cancelError: undefined,
+ }
+ : {}),
callbackUrl: '',
callbackSubmitting: false,
callbackStatus: undefined,
@@ -580,6 +605,15 @@ export function OAuthPage() {
delete callbackAttemptVersions.current[provider];
clearPollingTimer(provider);
updateProviderState(provider, {
+ ...(provider === 'devin'
+ ? {
+ url: undefined,
+ state: undefined,
+ callbackUrl: '',
+ cancelling: false,
+ cancelError: undefined,
+ }
+ : {}),
status: 'error',
error: response.error,
polling: false,
@@ -638,6 +672,15 @@ export function OAuthPage() {
stopAttempt();
return;
}
+ if (provider === 'devin') {
+ updateProviderState(provider, {
+ status: 'error',
+ error: `${getErrorMessage(err) || ''} ${t('auth_login.devin_oauth_retry_hint')}`.trim(),
+ polling: false,
+ });
+ stopAttempt();
+ return;
+ }
finishProviderAttempt(provider, attempt);
updateProviderState(provider, {
status: 'error',
@@ -653,6 +696,9 @@ export function OAuthPage() {
};
const startAuth = async (provider: OAuthProvider) => {
+ if (provider === 'devin' && states[provider]?.state) {
+ return;
+ }
clearProviderTimers(provider);
delete callbackAttemptVersions.current[provider];
delete providerCredentialBaselines.current[provider];
@@ -663,6 +709,8 @@ export function OAuthPage() {
status: 'waiting',
polling: true,
error: undefined,
+ cancelling: false,
+ cancelError: undefined,
callbackSubmitting: false,
callbackStatus: undefined,
callbackError: undefined,
@@ -718,6 +766,73 @@ export function OAuthPage() {
}
};
+ const cancelAuth = async (provider: OAuthProvider) => {
+ if (provider !== 'devin') return;
+ const providerState = states[provider];
+ const state = providerState?.state;
+ if (!state) return;
+
+ const previousVersion = providerAttemptVersions.current[provider];
+ const previousBaseline = providerCredentialBaselines.current[provider];
+
+ clearPollingTimer(provider);
+ delete callbackAttemptVersions.current[provider];
+
+ const attempt = beginProviderAttempt(provider);
+
+ if (previousBaseline && previousBaseline.version === previousVersion) {
+ attempt.credentialBaseline = previousBaseline.baseline;
+ providerCredentialBaselines.current[provider] = {
+ version: attempt.version,
+ baseline: previousBaseline.baseline,
+ };
+ }
+
+ updateProviderState(provider, {
+ cancelling: true,
+ cancelError: undefined,
+ });
+
+ try {
+ const res = await oauthApi.cancelSession(state, attempt.requestScope);
+ if (!isProviderAttemptCurrent(provider, attempt)) return;
+
+ if (res.cancelled) {
+ resetProviderAttempt(provider);
+ showNotification(t('auth_login.devin_oauth_cancelled'), 'success');
+ return;
+ }
+
+ updateProviderState(provider, { cancelling: false });
+ const statusRes = await oauthApi.getAuthStatus(state, attempt.requestScope);
+ if (!isProviderAttemptCurrent(provider, attempt)) return;
+
+ const result = handleProviderAuthStatus(
+ provider,
+ statusRes,
+ attempt,
+ getProviderActionText(provider, 'oauth_status_success')
+ );
+ if (result === 'waiting') {
+ updateProviderState(provider, { polling: true });
+ startPolling(provider, state, attempt);
+ }
+ } catch (err: unknown) {
+ if (!isProviderAttemptCurrent(provider, attempt)) return;
+ const message = getErrorMessage(err);
+ const cancelErrorMessage =
+ message || t('auth_login.devin_oauth_cancel_error');
+ updateProviderState(provider, {
+ cancelling: false,
+ cancelError: cancelErrorMessage,
+ status: 'waiting',
+ polling: true,
+ });
+ showNotification(cancelErrorMessage, 'error');
+ startPolling(provider, state, attempt);
+ }
+ };
+
const copyLink = async (url?: string) => {
if (!url) return;
const copied = await copyToClipboard(url);
@@ -743,6 +858,18 @@ export function OAuthPage() {
}
const state = providerState?.state;
const providerVersion = providerAttemptVersions.current[provider];
+ if (provider === 'devin') {
+ const validation = validateDevinCallback(callbackInput, state);
+ if (!validation.valid) {
+ const errorMsg = t(validation.errorKey || 'auth_login.devin_callback_invalid');
+ updateProviderState(provider, {
+ callbackStatus: 'error',
+ callbackError: errorMsg,
+ });
+ showNotification(errorMsg, 'warning');
+ return;
+ }
+ }
const redirectUrl = resolveCallbackUrl(provider, callbackInput, state);
if (!redirectUrl) {
showNotification(
@@ -963,7 +1090,11 @@ export function OAuthPage() {
}
extra={
-
+ {provider.id === 'devin' && Boolean(state.state) && (
+ cancelAuth(provider.id)}
+ loading={state.cancelling}
+ disabled={state.cancelling}
+ >
+ {t('auth_login.devin_oauth_cancel')}
+
+ )}
+ {state.cancelError && (
+
+ {state.cancelError}
+
+ )}
)}
{canSubmitCallback && (
updateProviderState(provider.id, {
callbackUrl: e.target.value,
@@ -1010,9 +1165,11 @@ export function OAuthPage() {
})
}
placeholder={t(
- provider.id === 'xai'
- ? 'auth_login.xai_callback_placeholder'
- : 'auth_login.oauth_callback_placeholder'
+ provider.id === 'devin'
+ ? 'auth_login.devin_callback_placeholder'
+ : provider.id === 'xai'
+ ? 'auth_login.xai_callback_placeholder'
+ : 'auth_login.oauth_callback_placeholder'
)}
/>
@@ -1021,6 +1178,10 @@ export function OAuthPage() {
size="sm"
onClick={() => submitCallback(provider.id)}
loading={state.callbackSubmitting}
+ disabled={
+ provider.id === 'devin' &&
+ (state.cancelling === true || state.status !== 'waiting')
+ }
>
{t('auth_login.oauth_callback_button')}
diff --git a/apps/web/src/features/oauth/devinOAuth.test.ts b/apps/web/src/features/oauth/devinOAuth.test.ts
new file mode 100644
index 000000000..9afa9cac0
--- /dev/null
+++ b/apps/web/src/features/oauth/devinOAuth.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from 'vitest';
+import { validateDevinCallback } from './devinOAuth';
+
+describe('validateDevinCallback', () => {
+ it('accepts standard loopback /callback with code and matching state', () => {
+ const result = validateDevinCallback(
+ 'http://127.0.0.1:8317/callback?code=abc&state=expected',
+ 'expected'
+ );
+ expect(result).toEqual({ valid: true });
+ });
+
+ it('accepts alternative path /devin/callback with code and matching state', () => {
+ const result = validateDevinCallback(
+ 'http://127.0.0.1:8317/devin/callback?code=abc&state=expected',
+ 'expected'
+ );
+ expect(result).toEqual({ valid: true });
+ });
+
+ it('accepts custom domain or forwarded https URL', () => {
+ const result = validateDevinCallback(
+ 'https://proxy.example:9443/custom/callback?code=abc&state=expected',
+ 'expected'
+ );
+ expect(result).toEqual({ valid: true });
+ });
+
+ it('accepts callback with error or error_description instead of code', () => {
+ expect(
+ validateDevinCallback(
+ 'http://127.0.0.1:8317/callback?error=access_denied&state=expected',
+ 'expected'
+ )
+ ).toEqual({ valid: true });
+
+ expect(
+ validateDevinCallback(
+ 'http://127.0.0.1:8317/callback?error_description=user_cancelled&state=expected',
+ 'expected'
+ )
+ ).toEqual({ valid: true });
+ });
+
+ it('rejects invalid or relative URLs', () => {
+ expect(validateDevinCallback('not-a-url', 'expected')).toEqual({
+ valid: false,
+ errorKey: 'auth_login.devin_callback_invalid',
+ });
+ expect(validateDevinCallback('/callback?code=123&state=expected', 'expected')).toEqual({
+ valid: false,
+ errorKey: 'auth_login.devin_callback_invalid',
+ });
+ expect(
+ validateDevinCallback('javascript:alert(1)?code=123&state=expected', 'expected')
+ ).toEqual({
+ valid: false,
+ errorKey: 'auth_login.devin_callback_invalid',
+ });
+ });
+
+ it('rejects missing or empty state', () => {
+ expect(
+ validateDevinCallback('http://127.0.0.1:8317/callback?code=abc', 'expected')
+ ).toEqual({
+ valid: false,
+ errorKey: 'auth_login.devin_callback_invalid',
+ });
+ expect(
+ validateDevinCallback('http://127.0.0.1:8317/callback?code=abc&state=', 'expected')
+ ).toEqual({
+ valid: false,
+ errorKey: 'auth_login.devin_callback_invalid',
+ });
+ });
+
+ it('rejects duplicate state parameters', () => {
+ expect(
+ validateDevinCallback(
+ 'http://127.0.0.1:8317/callback?code=abc&state=expected&state=another',
+ 'expected'
+ )
+ ).toEqual({
+ valid: false,
+ errorKey: 'auth_login.devin_callback_invalid',
+ });
+ });
+
+ it('rejects state mismatch when expected state is provided', () => {
+ expect(
+ validateDevinCallback(
+ 'http://127.0.0.1:8317/callback?code=abc&state=wrong-state',
+ 'expected'
+ )
+ ).toEqual({
+ valid: false,
+ errorKey: 'auth_login.devin_callback_state_mismatch',
+ });
+ });
+
+ it('rejects callback when expected state is undefined or empty', () => {
+ expect(
+ validateDevinCallback(
+ 'http://127.0.0.1:8317/callback?code=abc&state=state-a',
+ undefined
+ )
+ ).toEqual({
+ valid: false,
+ errorKey: 'auth_login.devin_callback_state_mismatch',
+ });
+ expect(
+ validateDevinCallback(
+ 'http://127.0.0.1:8317/callback?code=abc&state=state-a',
+ ' '
+ )
+ ).toEqual({
+ valid: false,
+ errorKey: 'auth_login.devin_callback_state_mismatch',
+ });
+ });
+
+ it('rejects callback missing code, error, and error_description', () => {
+ expect(
+ validateDevinCallback('http://127.0.0.1:8317/callback?state=expected', 'expected')
+ ).toEqual({
+ valid: false,
+ errorKey: 'auth_login.devin_callback_invalid',
+ });
+ });
+});
diff --git a/apps/web/src/features/oauth/devinOAuth.ts b/apps/web/src/features/oauth/devinOAuth.ts
new file mode 100644
index 000000000..7d6f4bfb9
--- /dev/null
+++ b/apps/web/src/features/oauth/devinOAuth.ts
@@ -0,0 +1,63 @@
+export interface DevinCallbackValidationResult {
+ valid: boolean;
+ errorKey?: string;
+}
+
+/**
+ * Validates a user-provided Devin callback URL without modifying it.
+ *
+ * Rules:
+ * 1. Must parse with URL()
+ * 2. Protocol must be http: or https:
+ * 3. Exactly one non-empty state parameter
+ * 4. At least one of code, error, or error_description present
+ * 5. State must match expectedState when provided
+ * 6. Does NOT restrict hostname, port, or pathname
+ */
+export function validateDevinCallback(
+ input: string,
+ expectedState?: string
+): DevinCallbackValidationResult {
+ const trimmed = input.trim();
+ if (!trimmed) {
+ return { valid: false, errorKey: 'auth_login.devin_callback_invalid' };
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(trimmed);
+ } catch {
+ return { valid: false, errorKey: 'auth_login.devin_callback_invalid' };
+ }
+
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ return { valid: false, errorKey: 'auth_login.devin_callback_invalid' };
+ }
+
+ const allStates = parsed.searchParams.getAll('state');
+ if (allStates.length !== 1) {
+ return { valid: false, errorKey: 'auth_login.devin_callback_invalid' };
+ }
+
+ const callbackState = allStates[0].trim();
+ if (!callbackState) {
+ return { valid: false, errorKey: 'auth_login.devin_callback_invalid' };
+ }
+
+ const code = parsed.searchParams.get('code')?.trim();
+ const error = parsed.searchParams.get('error')?.trim();
+ const errorDescription = parsed.searchParams.get('error_description')?.trim();
+
+ if (!code && !error && !errorDescription) {
+ return { valid: false, errorKey: 'auth_login.devin_callback_invalid' };
+ }
+
+ if (!expectedState?.trim() || callbackState !== expectedState.trim()) {
+ return {
+ valid: false,
+ errorKey: 'auth_login.devin_callback_state_mismatch',
+ };
+ }
+
+ return { valid: true };
+}
diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx
index 163f64b6e..e7df6672d 100644
--- a/apps/web/src/features/system/SystemPage.tsx
+++ b/apps/web/src/features/system/SystemPage.tsx
@@ -25,6 +25,8 @@ import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH, STORAGE_KEY_QUOTA_CACHE } from '@/utils/constants';
import iconGemini from '@/assets/icons/gemini.svg';
import iconClaude from '@/assets/icons/claude.svg';
+import iconDevinLight from '@/assets/icons/devin.svg';
+import iconDevinDark from '@/assets/icons/devin-dark.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import iconQwen from '@/assets/icons/qwen.svg';
@@ -40,6 +42,7 @@ import { useManagerUpdates } from './ManagerUpdates';
import styles from './SystemPage.module.scss';
const MODEL_CATEGORY_ICONS: Record
= {
+ devin: { light: iconDevinLight, dark: iconDevinDark },
gpt: { light: iconOpenaiLight, dark: iconOpenaiDark },
claude: iconClaude,
gemini: iconGemini,
diff --git a/apps/web/src/hooks/useVisualConfig.test.ts b/apps/web/src/hooks/useVisualConfig.test.ts
index f55519dc1..2103d1ff2 100644
--- a/apps/web/src/hooks/useVisualConfig.test.ts
+++ b/apps/web/src/hooks/useVisualConfig.test.ts
@@ -689,4 +689,247 @@ describe('useVisualConfig', () => {
harness.unmount();
});
+
+ describe('devin sensitive words', () => {
+ it('parses devin.sensitive-words with trimming, filtering empty items, and preserving order', () => {
+ const harness = mountUseVisualConfig();
+ const yaml = [
+ 'devin:',
+ ' sensitive-words:',
+ ' - " forbidden-token "',
+ ' - ""',
+ ' - " "',
+ ' - "system prompt leak"',
+ ' - "secret-key"',
+ '',
+ ].join('\n');
+
+ act(() => {
+ expect(harness.getCurrent().loadVisualValuesFromYaml(yaml).ok).toBe(true);
+ });
+
+ expect(harness.getCurrent().visualValues.devinSensitiveWords).toEqual([
+ 'forbidden-token',
+ 'system prompt leak',
+ 'secret-key',
+ ]);
+
+ // Verify non-canonical keys are ignored
+ const nonCanonicalYaml = [
+ 'devin:',
+ ' sensitiveWords:',
+ ' - "bad1"',
+ 'devin-sensitive-words:',
+ ' - "bad2"',
+ '',
+ ].join('\n');
+
+ act(() => {
+ expect(harness.getCurrent().loadVisualValuesFromYaml(nonCanonicalYaml).ok).toBe(true);
+ });
+ expect(harness.getCurrent().visualValues.devinSensitiveWords).toEqual([]);
+
+ harness.unmount();
+ });
+
+ it('canonically writes devin.sensitive-words into yaml', () => {
+ const harness = mountUseVisualConfig();
+ const initialYaml = ['port: 8080', ''].join('\n');
+
+ act(() => {
+ expect(harness.getCurrent().loadVisualValuesFromYaml(initialYaml).ok).toBe(true);
+ harness.getCurrent().setVisualValues({
+ devinSensitiveWords: ['word1', 'word2'],
+ });
+ });
+
+ const resultYaml = harness.getCurrent().applyVisualChangesToYaml(initialYaml);
+ const parsed = parseYaml(resultYaml) as Record;
+ expect(parsed.devin).toEqual({
+ 'sensitive-words': ['word1', 'word2'],
+ });
+
+ harness.unmount();
+ });
+
+ it('canonically writes devin.sensitive-words trimming items and dropping empty strings', () => {
+ const harness = mountUseVisualConfig();
+ const initialYaml = ['port: 8080', ''].join('\n');
+
+ act(() => {
+ expect(harness.getCurrent().loadVisualValuesFromYaml(initialYaml).ok).toBe(true);
+ harness.getCurrent().setVisualValues({
+ devinSensitiveWords: [' API ', '', 'Claude Code'],
+ });
+ });
+
+ const resultYaml = harness.getCurrent().applyVisualChangesToYaml(initialYaml);
+ const parsed = parseYaml(resultYaml) as Record;
+ expect(parsed.devin).toEqual({
+ 'sensitive-words': ['API', 'Claude Code'],
+ });
+
+ harness.unmount();
+ });
+
+ it('removes the devin map completely when clearing sensitive words and no other fields exist', () => {
+ const harness = mountUseVisualConfig();
+ const yaml = [
+ 'devin:',
+ ' sensitive-words:',
+ ' - secret',
+ 'port: 8080',
+ '',
+ ].join('\n');
+
+ act(() => {
+ expect(harness.getCurrent().loadVisualValuesFromYaml(yaml).ok).toBe(true);
+ harness.getCurrent().setVisualValues({
+ devinSensitiveWords: [],
+ });
+ });
+
+ const resultYaml = harness.getCurrent().applyVisualChangesToYaml(yaml);
+ const parsed = parseYaml(resultYaml) as Record;
+ expect(parsed.devin).toBeUndefined();
+ expect(parsed.port).toBe(8080);
+
+ harness.unmount();
+ });
+
+ it('preserves unknown future sibling properties under devin when editing sensitive words', () => {
+ const harness = mountUseVisualConfig();
+ const yaml = [
+ 'devin:',
+ ' sensitive-words:',
+ ' - old-secret',
+ ' future-option: true',
+ ' nested-config:',
+ ' feature-flag: enabled',
+ 'port: 8080',
+ '',
+ ].join('\n');
+
+ act(() => {
+ expect(harness.getCurrent().loadVisualValuesFromYaml(yaml).ok).toBe(true);
+ harness.getCurrent().setVisualValues({
+ devinSensitiveWords: ['new-secret'],
+ });
+ });
+
+ const resultYaml = harness.getCurrent().applyVisualChangesToYaml(yaml);
+ const parsed = parseYaml(resultYaml) as Record;
+ expect(parsed.devin).toEqual({
+ 'sensitive-words': ['new-secret'],
+ 'future-option': true,
+ 'nested-config': {
+ 'feature-flag': 'enabled',
+ },
+ });
+
+ harness.unmount();
+ });
+
+ it('preserves future sibling properties when clearing devin.sensitive-words', () => {
+ const harness = mountUseVisualConfig();
+ const yaml = [
+ 'devin:',
+ ' sensitive-words:',
+ ' - secret',
+ ' future-option: "keep-me"',
+ 'port: 8080',
+ '',
+ ].join('\n');
+
+ act(() => {
+ expect(harness.getCurrent().loadVisualValuesFromYaml(yaml).ok).toBe(true);
+ harness.getCurrent().setVisualValues({
+ devinSensitiveWords: [],
+ });
+ });
+
+ const resultYaml = harness.getCurrent().applyVisualChangesToYaml(yaml);
+ const parsed = parseYaml(resultYaml) as Record;
+ expect(parsed.devin).toEqual({
+ 'future-option': 'keep-me',
+ });
+ expect(parsed.port).toBe(8080);
+
+ harness.unmount();
+ });
+
+ it('does not touch or modify the devin subtree on unrelated visual edits', () => {
+ const harness = mountUseVisualConfig();
+ const yaml = [
+ '# Custom devin comment',
+ 'devin:',
+ ' sensitive-words:',
+ ' - do-not-touch',
+ ' custom-flag: 123',
+ 'port: 8080',
+ '',
+ ].join('\n');
+
+ act(() => {
+ expect(harness.getCurrent().loadVisualValuesFromYaml(yaml).ok).toBe(true);
+ harness.getCurrent().setVisualValues({
+ port: '9090',
+ });
+ });
+
+ const resultYaml = harness.getCurrent().applyVisualChangesToYaml(yaml);
+ expect(resultYaml).toContain('# Custom devin comment');
+ expect(resultYaml).toContain('custom-flag: 123');
+ const parsed = parseYaml(resultYaml) as Record;
+ expect(parsed.port).toBe(9090);
+ expect(parsed.devin).toEqual({
+ 'sensitive-words': ['do-not-touch'],
+ 'custom-flag': 123,
+ });
+
+ harness.unmount();
+ });
+
+ it('tracks the dirty lifecycle accurately for devinSensitiveWords', () => {
+ const harness = mountUseVisualConfig();
+ const yaml = [
+ 'devin:',
+ ' sensitive-words:',
+ ' - foo',
+ ' - bar',
+ '',
+ ].join('\n');
+
+ act(() => {
+ expect(harness.getCurrent().loadVisualValuesFromYaml(yaml).ok).toBe(true);
+ });
+ expect(harness.getCurrent().visualDirty).toBe(false);
+
+ // Setting to identical values does not mark dirty
+ act(() => {
+ harness.getCurrent().setVisualValues({
+ devinSensitiveWords: ['foo', 'bar'],
+ });
+ });
+ expect(harness.getCurrent().visualDirty).toBe(false);
+
+ // Editing marks dirty
+ act(() => {
+ harness.getCurrent().setVisualValues({
+ devinSensitiveWords: ['foo', 'bar', 'baz'],
+ });
+ });
+ expect(harness.getCurrent().visualDirty).toBe(true);
+
+ // Reverting clears dirty
+ act(() => {
+ harness.getCurrent().setVisualValues({
+ devinSensitiveWords: ['foo', 'bar'],
+ });
+ });
+ expect(harness.getCurrent().visualDirty).toBe(false);
+
+ harness.unmount();
+ });
+ });
});
diff --git a/apps/web/src/hooks/useVisualConfig.ts b/apps/web/src/hooks/useVisualConfig.ts
index 3d9eadbb0..27fc2b1a0 100644
--- a/apps/web/src/hooks/useVisualConfig.ts
+++ b/apps/web/src/hooks/useVisualConfig.ts
@@ -644,6 +644,12 @@ function getNextDirtyFields(
arePayloadFilterRulesEqual(nextValues.payloadFilterRules, baselineValues.payloadFilterRules)
);
}
+ if (Object.prototype.hasOwnProperty.call(patch, 'devinSensitiveWords')) {
+ updateDirty(
+ 'devinSensitiveWords',
+ areStringArraysEqual(nextValues.devinSensitiveWords, baselineValues.devinSensitiveWords)
+ );
+ }
if (patch.streaming) {
const streamingPatch = patch.streaming;
if (Object.prototype.hasOwnProperty.call(streamingPatch, 'keepaliveSeconds')) {
@@ -770,6 +776,7 @@ export function useVisualConfig() {
const claudeHeaderDefaults = asRecord(parsed['claude-header-defaults']);
const codexHeaderDefaults = asRecord(parsed['codex-header-defaults']);
const codex = asRecord(parsed.codex);
+ const devin = asRecord(parsed.devin);
const newValues: VisualConfigValues = {
host: typeof parsed.host === 'string' ? parsed.host : '',
@@ -874,6 +881,7 @@ export function useVisualConfig() {
? codexHeaderDefaults['beta-features']
: '',
codexIdentityConfuse: Boolean(codex?.['identity-confuse'] ?? codex?.identityConfuse),
+ devinSensitiveWords: parseStringList(devin?.['sensitive-words']),
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? false),
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? false),
@@ -1225,6 +1233,17 @@ export function useVisualConfig() {
deleteIfMapEmpty(doc, ['codex']);
}
+ if (isDirty('devinSensitiveWords')) {
+ const devinSensitiveWords = serializeStringListForYaml(values.devinSensitiveWords);
+ if (devinSensitiveWords.length > 0) {
+ ensureMapInDoc(doc, ['devin']);
+ doc.setIn(['devin', 'sensitive-words'], devinSensitiveWords);
+ } else if (docHas(doc, ['devin', 'sensitive-words'])) {
+ doc.deleteIn(['devin', 'sensitive-words']);
+ }
+ deleteIfMapEmpty(doc, ['devin']);
+ }
+
const writeQuotaSwitchProject = isDirty('quotaSwitchProject');
const writeQuotaSwitchPreviewModel = isDirty('quotaSwitchPreviewModel');
const writeQuotaAntigravityCredits = isDirty('quotaAntigravityCredits');
diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json
index be6e43978..efa2d1217 100644
--- a/apps/web/src/i18n/locales/en.json
+++ b/apps/web/src/i18n/locales/en.json
@@ -1638,6 +1638,7 @@
"filter_codex": "Codex",
"filter_antigravity": "Antigravity",
"filter_xai": "xAI",
+ "filter_devin": "Devin",
"filter_iflow": "iFlow",
"filter_vertex": "Vertex",
"filter_empty": "Empty",
@@ -1933,6 +1934,26 @@
"usage_amount": "{{remaining}} / {{limit}} remaining",
"reset_unknown": "reset unknown"
},
+ "devin_quota": {
+ "title": "Devin Quota",
+ "empty_title": "No Devin Auth Files",
+ "empty_desc": "Upload or log in with Devin to view remaining quota.",
+ "idle": "Click here to refresh quota",
+ "loading": "Loading quota...",
+ "load_failed": "Failed to load quota: {{message}}",
+ "missing_auth_index": "Auth file missing auth_index",
+ "missing_identity": "Devin credential is missing its file name or auth_index",
+ "empty_data": "No quota data available",
+ "unavailable": "Quota unavailable",
+ "reset_unknown": "Reset time unknown",
+ "refresh_button": "Refresh Quota",
+ "fetch_all": "Fetch All",
+ "daily": "Daily limit",
+ "weekly": "Weekly limit",
+ "plan_label": "Plan",
+ "plan_start": "Plan start",
+ "plan_end": "Plan end"
+ },
"vertex_import": {
"title": "Vertex JSON Login",
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-.json using the same rules as the CLI vertex-import helper.",
@@ -2161,6 +2182,25 @@
"xai_callback_hint": "Grok may only show a code on the page. Paste that code directly and the app will submit it as http://127.0.0.1:56121/callback?... automatically.",
"xai_callback_required": "Please paste the code shown by Grok or the full callback URL first.",
"xai_callback_state_missing": "Missing the state for this xAI login. Start xAI login again, then submit the code.",
+ "devin_oauth_title": "Devin",
+ "devin_oauth_button": "Start Devin Login",
+ "devin_oauth_hint": "Sign in to Devin / Cognition through browser OAuth to save an authentication file. Requires CLI Proxy API v7.3.1 or later; complete authorization within five minutes.",
+ "devin_oauth_url_label": "Authorization URL:",
+ "devin_copy_link": "Copy Link",
+ "devin_open_link": "Open Link",
+ "devin_oauth_status_waiting": "Waiting for Devin authentication...",
+ "devin_oauth_status_success": "Devin authentication successful!",
+ "devin_oauth_status_error": "Devin authentication failed:",
+ "devin_oauth_start_error": "Failed to start Devin OAuth:",
+ "devin_oauth_cancel": "Cancel Devin Login",
+ "devin_oauth_cancelled": "Devin login cancelled.",
+ "devin_oauth_cancel_error": "Failed to cancel Devin login:",
+ "devin_oauth_retry_hint": "The server may still be waiting for authorization. Cancel this login before starting again.",
+ "devin_callback_label": "Callback URL",
+ "devin_callback_hint": "Remote server: copy the complete final callback URL (e.g. /callback?...) from the browser address bar, even if the loopback page cannot be reached. Keep its port, http/https, code and state unchanged. Submit the URL here, then wait for authentication to finish.",
+ "devin_callback_placeholder": "http://127.0.0.1:/callback?code=...&state=...",
+ "devin_callback_invalid": "Paste the complete http/https callback URL containing state and an authorization code or error.",
+ "devin_callback_state_mismatch": "This callback does not belong to the current Devin login. Use the final URL from this attempt; do not reuse an old callback.",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "Start Qwen Login",
"qwen_oauth_hint": "Log in to Qwen through device authorization, then automatically obtain and save the login file.",
@@ -3546,7 +3586,13 @@
"antigravity_signature_cache": "Enable Antigravity Signature Cache",
"antigravity_signature_cache_desc": "Prefer and validate cached thinking-block signatures",
"antigravity_signature_strict": "Strict Bypass Signature Validation",
- "antigravity_signature_strict_desc": "Only applies when the signature cache is disabled; validates the full Claude protobuf tree"
+ "antigravity_signature_strict_desc": "Only applies when the signature cache is disabled; validates the full Claude protobuf tree",
+ "devin_title": "Devin",
+ "devin_sensitive_words": "Sensitive Words",
+ "devin_sensitive_words_desc": "Configure sensitive words for Devin system prompts. Matching words will be obfuscated using zero-width characters before being sent to Devin.",
+ "devin_sensitive_words_label": "Sensitive Words",
+ "devin_sensitive_words_placeholder": "Enter a sensitive word or phrase",
+ "devin_sensitive_words_hint": "One word or phrase per item. Matches in Devin system prompts are obfuscated with zero-width characters. Empty entries are removed when saving."
},
"headers": {
"title": "Header Defaults",
diff --git a/apps/web/src/i18n/locales/ru.json b/apps/web/src/i18n/locales/ru.json
index 1e4b6565b..12e22c4e2 100644
--- a/apps/web/src/i18n/locales/ru.json
+++ b/apps/web/src/i18n/locales/ru.json
@@ -1642,6 +1642,7 @@
"filter_codex": "Codex",
"filter_antigravity": "Antigravity",
"filter_xai": "xAI",
+ "filter_devin": "Devin",
"filter_iflow": "iFlow",
"filter_vertex": "Vertex",
"filter_empty": "Пусто",
@@ -1935,6 +1936,26 @@
"usage_amount": "Осталось {{remaining}} / {{limit}}",
"reset_unknown": "сброс неизвестен"
},
+ "devin_quota": {
+ "title": "Квота Devin",
+ "empty_title": "Файлы авторизации Devin отсутствуют",
+ "empty_desc": "Войдите или загрузите учётные данные Devin, чтобы просмотреть квоту.",
+ "idle": "Не загружено. Нажмите \"Обновить квоту\".",
+ "loading": "Загрузка квоты...",
+ "load_failed": "Не удалось загрузить квоту: {{message}}",
+ "missing_auth_index": "В файле авторизации отсутствует auth_index",
+ "missing_identity": "В учётных данных Devin отсутствует имя файла или auth_index",
+ "empty_data": "Данные по квоте отсутствуют",
+ "unavailable": "Квота недоступна",
+ "reset_unknown": "Время сброса неизвестно",
+ "refresh_button": "Обновить квоту",
+ "fetch_all": "Получить все",
+ "daily": "Дневной лимит",
+ "weekly": "Недельный лимит",
+ "plan_label": "Тариф",
+ "plan_start": "Начало тарифа",
+ "plan_end": "Окончание тарифа"
+ },
"vertex_import": {
"title": "Вход с Vertex JSON",
"description": "Загрузите JSON ключа сервисного аккаунта Google, чтобы сохранить его как auth-dir/vertex-.json по тем же правилам, что и помощник CLI vertex-import.",
@@ -2163,6 +2184,25 @@
"xai_callback_hint": "Grok иногда показывает только код на странице. Вставьте этот код напрямую, приложение автоматически отправит его как http://127.0.0.1:56121/callback?...",
"xai_callback_required": "Сначала вставьте код со страницы Grok или полный callback URL.",
"xai_callback_state_missing": "Отсутствует state для этого входа xAI. Запустите вход xAI заново и затем отправьте код.",
+ "devin_oauth_title": "Devin",
+ "devin_oauth_button": "Войти в Devin",
+ "devin_oauth_hint": "Войдите в Devin / Cognition через OAuth в браузере для сохранения файла аутентификации. Требуется CLI Proxy API v7.3.1 или новее; завершите авторизацию в течение пяти минут.",
+ "devin_oauth_url_label": "URL авторизации:",
+ "devin_copy_link": "Скопировать ссылку",
+ "devin_open_link": "Открыть ссылку",
+ "devin_oauth_status_waiting": "Ожидание аутентификации Devin...",
+ "devin_oauth_status_success": "Аутентификация Devin успешна!",
+ "devin_oauth_status_error": "Ошибка аутентификации Devin:",
+ "devin_oauth_start_error": "Не удалось запустить Devin OAuth:",
+ "devin_oauth_cancel": "Отменить вход в Devin",
+ "devin_oauth_cancelled": "Вход в Devin отменён.",
+ "devin_oauth_cancel_error": "Не удалось отменить вход в Devin:",
+ "devin_oauth_retry_hint": "Сервер может всё ещё ожидать авторизацию. Отмените эту попытку входа перед новой.",
+ "devin_callback_label": "URL обратного вызова",
+ "devin_callback_hint": "Удалённый сервер: скопируйте полный конечный URL обратного вызова (например /callback?...) из адресной строки, даже если локальный адрес недоступен. Сохраните порт, http/https, code и state без изменений. Отправьте URL здесь и дождитесь завершения аутентификации.",
+ "devin_callback_placeholder": "http://127.0.0.1:<порт-сервера>/callback?code=...&state=...",
+ "devin_callback_invalid": "Вставьте полный URL обратного вызова http/https со state и кодом авторизации или ошибкой.",
+ "devin_callback_state_mismatch": "Этот обратный вызов не относится к текущему входу в Devin. Используйте конечный URL этой попытки, а не старый URL.",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "Начать вход Qwen",
"qwen_oauth_hint": "Войдите в Qwen через авторизацию устройства, затем файл входа будет получен и сохранён автоматически.",
@@ -3548,7 +3588,13 @@
"antigravity_signature_cache": "Включить кэш подписей Antigravity",
"antigravity_signature_cache_desc": "Предпочитать и проверять кэшированные подписи thinking-блоков",
"antigravity_signature_strict": "Строгая проверка bypass-подписи",
- "antigravity_signature_strict_desc": "Применяется только при отключённом кэше подписей; проверяет полное дерево Claude protobuf"
+ "antigravity_signature_strict_desc": "Применяется только при отключённом кэше подписей; проверяет полное дерево Claude protobuf",
+ "devin_title": "Devin",
+ "devin_sensitive_words": "Список чувствительных слов",
+ "devin_sensitive_words_desc": "Настройка чувствительных слов для системных промптов Devin. Совпадающие слова обфусцируются с помощью символов нулевой ширины перед отправкой в Devin.",
+ "devin_sensitive_words_label": "Чувствительные слова",
+ "devin_sensitive_words_placeholder": "Введите чувствительное слово или фразу",
+ "devin_sensitive_words_hint": "По одному слову или фразе на строку. Совпадения в системных промптах Devin обфусцируются символами нулевой ширины. Пустые записи удаляются при сохранении."
},
"headers": {
"title": "Заголовки по умолчанию",
diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json
index 3630c56b9..71f28fce3 100644
--- a/apps/web/src/i18n/locales/zh-CN.json
+++ b/apps/web/src/i18n/locales/zh-CN.json
@@ -1636,6 +1636,7 @@
"filter_codex": "Codex",
"filter_antigravity": "Antigravity",
"filter_xai": "xAI",
+ "filter_devin": "Devin",
"filter_iflow": "iFlow",
"filter_vertex": "Vertex",
"filter_empty": "空文件",
@@ -1931,6 +1932,26 @@
"usage_amount": "剩余 {{remaining}} / {{limit}}",
"reset_unknown": "重置时间未知"
},
+ "devin_quota": {
+ "title": "Devin 额度",
+ "empty_title": "暂无 Devin 凭证",
+ "empty_desc": "登录或导入 Devin 凭证后即可查看额度。",
+ "idle": "点击此处刷新额度",
+ "loading": "正在加载额度...",
+ "load_failed": "额度获取失败:{{message}}",
+ "missing_auth_index": "认证文件缺少 auth_index",
+ "missing_identity": "Devin 凭证缺少文件名或 auth_index",
+ "empty_data": "暂无额度数据",
+ "unavailable": "额度不可用",
+ "reset_unknown": "重置时间未知",
+ "refresh_button": "刷新额度",
+ "fetch_all": "获取全部",
+ "daily": "每日限额",
+ "weekly": "每周限额",
+ "plan_label": "套餐",
+ "plan_start": "套餐生效时间",
+ "plan_end": "套餐到期时间"
+ },
"vertex_import": {
"title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-.json。",
@@ -2159,6 +2180,25 @@
"xai_callback_hint": "Grok 有时只在页面显示 code。可直接粘贴 code,系统会自动拼接为 http://127.0.0.1:56121/callback?... 后提交。",
"xai_callback_required": "请先粘贴 Grok 页面显示的 code 或完整回调 URL。",
"xai_callback_state_missing": "缺少本次 xAI 登录的 state,请重新开始 xAI 登录后再提交 code。",
+ "devin_oauth_title": "Devin",
+ "devin_oauth_button": "开始 Devin 登录",
+ "devin_oauth_hint": "通过浏览器 OAuth 登录 Devin / Cognition,自动保存认证文件。需要 CLI Proxy API v7.3.1 或更高版本,请在五分钟内完成授权。",
+ "devin_oauth_url_label": "授权链接:",
+ "devin_copy_link": "复制链接",
+ "devin_open_link": "打开链接",
+ "devin_oauth_status_waiting": "等待 Devin 认证中...",
+ "devin_oauth_status_success": "Devin 认证成功!",
+ "devin_oauth_status_error": "Devin 认证失败:",
+ "devin_oauth_start_error": "启动 Devin OAuth 失败:",
+ "devin_oauth_cancel": "取消 Devin 登录",
+ "devin_oauth_cancelled": "已取消 Devin 登录。",
+ "devin_oauth_cancel_error": "取消 Devin 登录失败:",
+ "devin_oauth_retry_hint": "服务器可能仍在等待授权。请先取消本次登录,再重新开始。",
+ "devin_callback_label": "回调 URL",
+ "devin_callback_hint": "远程服务器:即使浏览器无法打开回环地址,也请从地址栏复制最终的完整回调 URL(如 /callback?...),保留原有端口、http/https、code 和 state。提交后请继续等待认证完成。",
+ "devin_callback_placeholder": "http://127.0.0.1:<服务器端口>/callback?code=...&state=...",
+ "devin_callback_invalid": "请粘贴完整的 http/https 回调 URL,包含 state 和授权码或错误信息。",
+ "devin_callback_state_mismatch": "此回调不属于当前 Devin 登录。请使用本次授权的最终 URL,不要复用旧回调。",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "开始 Qwen 登录",
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
@@ -3544,7 +3584,13 @@
"antigravity_signature_cache": "启用 Antigravity 签名缓存",
"antigravity_signature_cache_desc": "优先使用并校验缓存的 thinking block 签名",
"antigravity_signature_strict": "严格校验旁路签名",
- "antigravity_signature_strict_desc": "仅在关闭签名缓存时生效,按完整 Claude protobuf 树校验"
+ "antigravity_signature_strict_desc": "仅在关闭签名缓存时生效,按完整 Claude protobuf 树校验",
+ "devin_title": "Devin",
+ "devin_sensitive_words": "敏感词列表",
+ "devin_sensitive_words_desc": "配置 Devin 专用的系统提示词敏感词列表。命中的敏感词会在发送至 Devin 时通过零宽字符混淆。",
+ "devin_sensitive_words_label": "敏感词",
+ "devin_sensitive_words_placeholder": "输入敏感词或短语",
+ "devin_sensitive_words_hint": "每项一个词或短语。命中 Devin 系统提示词内容时将通过零宽字符混淆,保存时自动移除空白项。"
},
"headers": {
"title": "Header 默认值",
diff --git a/apps/web/src/i18n/locales/zh-TW.json b/apps/web/src/i18n/locales/zh-TW.json
index b1dc0bb10..db043eedc 100644
--- a/apps/web/src/i18n/locales/zh-TW.json
+++ b/apps/web/src/i18n/locales/zh-TW.json
@@ -1636,6 +1636,7 @@
"filter_codex": "Codex",
"filter_antigravity": "Antigravity",
"filter_xai": "xAI",
+ "filter_devin": "Devin",
"filter_iflow": "iFlow",
"filter_vertex": "Vertex",
"filter_empty": "空檔案",
@@ -1931,6 +1932,26 @@
"usage_amount": "剩餘 {{remaining}} / {{limit}}",
"reset_unknown": "重置時間未知"
},
+ "devin_quota": {
+ "title": "Devin 配額",
+ "empty_title": "暫無 Devin 憑證",
+ "empty_desc": "登入或匯入 Devin 憑證後即可查看配額。",
+ "idle": "點擊此處重新整理配額",
+ "loading": "正在載入配額...",
+ "load_failed": "配額取得失敗:{{message}}",
+ "missing_auth_index": "驗證檔案缺少 auth_index",
+ "missing_identity": "Devin 憑證缺少檔案名稱或 auth_index",
+ "empty_data": "暫無配額資料",
+ "unavailable": "配額不可用",
+ "reset_unknown": "重置時間未知",
+ "refresh_button": "重新整理配額",
+ "fetch_all": "取得全部",
+ "daily": "每日限額",
+ "weekly": "每週限額",
+ "plan_label": "方案",
+ "plan_start": "方案生效時間",
+ "plan_end": "方案到期時間"
+ },
"vertex_import": {
"title": "Vertex JSON 登入",
"description": "上傳 Google 服務帳號 JSON,使用 CLI vertex-import 同步規則寫入 auth-dir/vertex-.json。",
@@ -2159,6 +2180,25 @@
"xai_callback_hint": "Grok 有時只在頁面顯示 code。可直接貼上 code,系統會自動拼接為 http://127.0.0.1:56121/callback?... 後提交。",
"xai_callback_required": "請先貼上 Grok 頁面顯示的 code 或完整回調 URL。",
"xai_callback_state_missing": "缺少本次 xAI 登入的 state,請重新開始 xAI 登入後再提交 code。",
+ "devin_oauth_title": "Devin",
+ "devin_oauth_button": "開始 Devin 登入",
+ "devin_oauth_hint": "透過瀏覽器 OAuth 登入 Devin / Cognition,自動儲存認證檔案。需要 CLI Proxy API v7.3.1 或更新版本,請在五分鐘內完成授權。",
+ "devin_oauth_url_label": "授權連結:",
+ "devin_copy_link": "複製連結",
+ "devin_open_link": "開啟連結",
+ "devin_oauth_status_waiting": "等待 Devin 認證中...",
+ "devin_oauth_status_success": "Devin 認證成功!",
+ "devin_oauth_status_error": "Devin 認證失敗:",
+ "devin_oauth_start_error": "啟動 Devin OAuth 失敗:",
+ "devin_oauth_cancel": "取消 Devin 登入",
+ "devin_oauth_cancelled": "已取消 Devin 登入。",
+ "devin_oauth_cancel_error": "取消 Devin 登入失敗:",
+ "devin_oauth_retry_hint": "伺服器可能仍在等待授權。請先取消本次登入,再重新開始。",
+ "devin_callback_label": "回調 URL",
+ "devin_callback_hint": "遠端伺服器:即使瀏覽器無法開啟迴路位址,也請從網址列複製最終的完整回呼 URL(如 /callback?...),保留原有連接埠、http/https、code 和 state。提交後請繼續等待認證完成。",
+ "devin_callback_placeholder": "http://127.0.0.1:<伺服器連接埠>/callback?code=...&state=...",
+ "devin_callback_invalid": "請貼上完整的 http/https 回呼 URL,包含 state 和授權碼或錯誤資訊。",
+ "devin_callback_state_mismatch": "此回呼不屬於目前的 Devin 登入。請使用本次授權的最終 URL,不要重複使用舊回呼。",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "開始 Qwen 登入",
"qwen_oauth_hint": "透過裝置授權流程登入 Qwen 服務,自動取得並儲存驗證檔案。",
@@ -3544,7 +3584,13 @@
"antigravity_signature_cache": "啟用 Antigravity 簽名快取",
"antigravity_signature_cache_desc": "優先使用並校驗快取的 thinking block 簽名",
"antigravity_signature_strict": "嚴格校驗旁路簽名",
- "antigravity_signature_strict_desc": "僅在關閉簽名快取時生效,按完整 Claude protobuf 樹校驗"
+ "antigravity_signature_strict_desc": "僅在關閉簽名快取時生效,按完整 Claude protobuf 樹校驗",
+ "devin_title": "Devin",
+ "devin_sensitive_words": "敏感詞列表",
+ "devin_sensitive_words_desc": "設定 Devin 專用的系統提示詞敏感詞列表。命中的敏感詞會在發送至 Devin 時透過零寬字元混淆。",
+ "devin_sensitive_words_label": "敏感詞",
+ "devin_sensitive_words_placeholder": "輸入敏感詞或短語",
+ "devin_sensitive_words_hint": "每項一個詞或短語。命中 Devin 系統提示詞內容時將透過零寬字元混淆,儲存時自動移除空白項。"
},
"headers": {
"title": "Header 預設值",
diff --git a/apps/web/src/services/api/oauth.test.ts b/apps/web/src/services/api/oauth.test.ts
index 19e0f1d0d..a77ecf8a9 100644
--- a/apps/web/src/services/api/oauth.test.ts
+++ b/apps/web/src/services/api/oauth.test.ts
@@ -4,6 +4,7 @@ const { mocks } = vi.hoisted(() => ({
mocks: {
get: vi.fn(),
post: vi.fn(),
+ delete: vi.fn(),
},
}));
@@ -11,6 +12,7 @@ vi.mock('./client', () => ({
apiClient: {
get: mocks.get,
post: mocks.post,
+ delete: mocks.delete,
},
createScopedApiRequestConfig: (scope: { apiBase: string; managementKey: string }) => ({
baseURL: `${scope.apiBase.replace(/\/+$/, '')}/v0/management`,
@@ -24,6 +26,7 @@ import { oauthApi } from './oauth';
beforeEach(() => {
mocks.get.mockReset();
mocks.post.mockReset();
+ mocks.delete.mockReset();
});
describe('oauthApi', () => {
@@ -83,4 +86,35 @@ describe('oauthApi', () => {
scopedConfig
);
});
+
+ it('starts Devin OAuth with is_webui flag', async () => {
+ mocks.get.mockResolvedValue({ url: 'https://auth.example/devin', state: 'state-devin-1' });
+
+ await oauthApi.startAuth('devin');
+
+ expect(mocks.get).toHaveBeenCalledWith('/devin-auth-url', {
+ params: { is_webui: true },
+ });
+ });
+
+ it('cancels an active OAuth session using DELETE /oauth-session with captured scope', async () => {
+ const requestScope = {
+ apiBase: 'http://cpa.example:8317',
+ managementKey: 'cpa-key-1',
+ };
+ const scopedConfig = {
+ baseURL: 'http://cpa.example:8317/v0/management',
+ headers: { Authorization: 'Bearer cpa-key-1' },
+ cpampScopedRequest: true,
+ };
+ mocks.delete.mockResolvedValue({ status: 'ok', cancelled: true });
+
+ const result = await oauthApi.cancelSession('state-devin-1', requestScope);
+
+ expect(mocks.delete).toHaveBeenCalledWith('/oauth-session', {
+ ...scopedConfig,
+ params: { state: 'state-devin-1' },
+ });
+ expect(result).toEqual({ status: 'ok', cancelled: true });
+ });
});
diff --git a/apps/web/src/services/api/oauth.ts b/apps/web/src/services/api/oauth.ts
index 620dfc33c..341aa4585 100644
--- a/apps/web/src/services/api/oauth.ts
+++ b/apps/web/src/services/api/oauth.ts
@@ -4,7 +4,13 @@
import { apiClient, createScopedApiRequestConfig, type ApiClientRequestScope } from './client';
-export type BuiltInOAuthProvider = 'codex' | 'anthropic' | 'antigravity' | 'kimi' | 'xai';
+export type BuiltInOAuthProvider =
+ | 'codex'
+ | 'anthropic'
+ | 'antigravity'
+ | 'kimi'
+ | 'xai'
+ | 'devin';
export type OAuthProvider = BuiltInOAuthProvider | (string & {});
export interface OAuthStartResponse {
@@ -16,7 +22,12 @@ export interface OAuthCallbackResponse {
status: 'ok';
}
-const WEBUI_SUPPORTED: string[] = ['codex', 'anthropic', 'antigravity', 'xai'];
+export interface OAuthCancelResponse {
+ status: 'ok';
+ cancelled: boolean;
+}
+
+const WEBUI_SUPPORTED: string[] = ['codex', 'anthropic', 'antigravity', 'xai', 'devin'];
export const oauthApi = {
startAuth: (provider: OAuthProvider, requestScope?: ApiClientRequestScope) => {
@@ -50,4 +61,10 @@ export const oauthApi = {
requestScope ? createScopedApiRequestConfig(requestScope) : undefined
);
},
+
+ cancelSession: (state: string, requestScope?: ApiClientRequestScope) =>
+ apiClient.delete('/oauth-session', {
+ ...(requestScope ? createScopedApiRequestConfig(requestScope) : {}),
+ params: { state },
+ }),
};
diff --git a/apps/web/src/stores/useQuotaStore.test.ts b/apps/web/src/stores/useQuotaStore.test.ts
index 2d9f5f007..0e511f92e 100644
--- a/apps/web/src/stores/useQuotaStore.test.ts
+++ b/apps/web/src/stores/useQuotaStore.test.ts
@@ -3,6 +3,7 @@ import type {
AntigravityQuotaState,
ClaudeQuotaState,
CodexQuotaState,
+ DevinQuotaState,
KimiQuotaState,
XaiQuotaState,
} from '@/types';
@@ -38,6 +39,7 @@ const readPersistedQuotaState = async () => {
antigravityQuota?: Record;
claudeQuota?: Record;
codexQuota?: Record;
+ devinQuota?: Record;
kimiQuota?: Record;
xaiQuota?: Record;
};
@@ -177,6 +179,38 @@ describe('useQuotaStore persistence', () => {
},
xaiLoading: { status: 'loading', billing: null },
});
+ useQuotaStore.getState().setDevinQuota({
+ devinSuccess: {
+ status: 'success',
+ windows: [],
+ observedAtMs: null,
+ plan: null,
+ planStartMs: null,
+ planEndMs: null,
+ authFileKey: 'devinSuccess',
+ authFileIdentityVerified: true,
+ },
+ devinError: {
+ status: 'error',
+ windows: [],
+ observedAtMs: null,
+ plan: null,
+ planStartMs: null,
+ planEndMs: null,
+ error: 'devin failed',
+ errorStatus: 500,
+ authFileKey: 'devinError',
+ authFileIdentityVerified: true,
+ },
+ devinLoading: {
+ status: 'loading',
+ windows: [],
+ observedAtMs: null,
+ plan: null,
+ planStartMs: null,
+ planEndMs: null,
+ },
+ });
const persisted = await readPersistedQuotaState();
@@ -187,6 +221,7 @@ describe('useQuotaStore persistence', () => {
]);
expect(Object.keys(persisted.kimiQuota ?? {})).toEqual(['kimiSuccess', 'kimiError']);
expect(Object.keys(persisted.xaiQuota ?? {})).toEqual(['xaiSuccess', 'xaiError']);
+ expect(Object.keys(persisted.devinQuota ?? {})).toEqual(['devinSuccess', 'devinError']);
});
it('drops legacy and unverified quota cache entries while canonicalizing verified keys', async () => {
@@ -233,6 +268,30 @@ describe('useQuotaStore persistence', () => {
authFileIdentityVerified: true,
},
});
+ useQuotaStore.getState().setDevinQuota({
+ devinSuccess: {
+ status: 'success',
+ windows: [],
+ observedAtMs: 1_000,
+ plan: 'Team',
+ planStartMs: null,
+ planEndMs: null,
+ authFileKey: 'devinSuccess',
+ authFileIdentityVerified: true,
+ },
+ devinError: {
+ status: 'error',
+ windows: [],
+ observedAtMs: null,
+ plan: null,
+ planStartMs: null,
+ planEndMs: null,
+ error: 'devin failed',
+ errorStatus: 502,
+ authFileKey: 'devinError',
+ authFileIdentityVerified: true,
+ },
+ });
vi.resetModules();
const { useQuotaStore: hydratedQuotaStore } = await import('./useQuotaStore');
@@ -244,6 +303,15 @@ describe('useQuotaStore persistence', () => {
expect(hydratedQuotaStore.getState().claudeQuota.claudeSuccess).toMatchObject({
status: 'success',
});
+ expect(hydratedQuotaStore.getState().devinQuota.devinSuccess).toMatchObject({
+ status: 'success',
+ plan: 'Team',
+ });
+ expect(hydratedQuotaStore.getState().devinQuota.devinError).toMatchObject({
+ status: 'error',
+ error: 'devin failed',
+ errorStatus: 502,
+ });
});
it('clears quota state and persisted quota cache together', async () => {
@@ -266,6 +334,7 @@ describe('useQuotaStore persistence', () => {
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
+ devinQuota: {},
kimiQuota: {},
xaiQuota: {},
});
diff --git a/apps/web/src/stores/useQuotaStore.ts b/apps/web/src/stores/useQuotaStore.ts
index 20567842a..929a6e3b6 100644
--- a/apps/web/src/stores/useQuotaStore.ts
+++ b/apps/web/src/stores/useQuotaStore.ts
@@ -9,6 +9,7 @@ import type {
ClaudeQuotaState,
CodexQuotaState,
CredentialScopedQuotaState,
+ DevinQuotaState,
KimiQuotaState,
XaiQuotaState,
} from '@/types';
@@ -23,11 +24,13 @@ interface QuotaStoreState {
antigravityQuota: Record;
claudeQuota: Record;
codexQuota: Record;
+ devinQuota: Record;
kimiQuota: Record;
xaiQuota: Record;
setAntigravityQuota: (updater: QuotaUpdater>) => void;
setClaudeQuota: (updater: QuotaUpdater>) => void;
setCodexQuota: (updater: QuotaUpdater>) => void;
+ setDevinQuota: (updater: QuotaUpdater>) => void;
setKimiQuota: (updater: QuotaUpdater>) => void;
setXaiQuota: (updater: QuotaUpdater>) => void;
activateQuotaCacheScope: (scope: string) => void;
@@ -45,6 +48,7 @@ const emptyQuotaState = {
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
+ devinQuota: {},
kimiQuota: {},
xaiQuota: {},
};
@@ -115,6 +119,10 @@ export const useQuotaStore = create()(
set((state) => ({
codexQuota: resolveUpdater(updater, state.codexQuota),
})),
+ setDevinQuota: (updater) =>
+ set((state) => ({
+ devinQuota: resolveUpdater(updater, state.devinQuota),
+ })),
setKimiQuota: (updater) =>
set((state) => ({
kimiQuota: resolveUpdater(updater, state.kimiQuota),
@@ -153,6 +161,7 @@ export const useQuotaStore = create()(
antigravityQuota: filterPersistableQuotaStates(state.antigravityQuota),
claudeQuota: filterPersistableQuotaStates(state.claudeQuota),
codexQuota: filterPersistableCodexQuota(state.codexQuota),
+ devinQuota: filterPersistableQuotaStates(state.devinQuota),
kimiQuota: filterPersistableQuotaStates(state.kimiQuota),
xaiQuota: filterPersistableQuotaStates(state.xaiQuota),
}),
@@ -164,6 +173,7 @@ export const useQuotaStore = create()(
antigravityQuota: filterPersistableQuotaStates(persisted?.antigravityQuota),
claudeQuota: filterPersistableQuotaStates(persisted?.claudeQuota),
codexQuota: filterPersistableCodexQuota(persisted?.codexQuota),
+ devinQuota: filterPersistableQuotaStates(persisted?.devinQuota),
kimiQuota: filterPersistableQuotaStates(persisted?.kimiQuota),
xaiQuota: filterPersistableQuotaStates(persisted?.xaiQuota),
};
diff --git a/apps/web/src/types/authFile.ts b/apps/web/src/types/authFile.ts
index e85ef05b4..3be76b640 100644
--- a/apps/web/src/types/authFile.ts
+++ b/apps/web/src/types/authFile.ts
@@ -16,6 +16,7 @@ export type AuthFileType =
| 'xai'
| 'iflow'
| 'vertex'
+ | 'devin'
| 'empty'
| 'unknown';
diff --git a/apps/web/src/types/quota.ts b/apps/web/src/types/quota.ts
index 76ba5a77e..f7df2ca35 100644
--- a/apps/web/src/types/quota.ts
+++ b/apps/web/src/types/quota.ts
@@ -614,3 +614,28 @@ export interface XaiQuotaState extends CredentialScopedQuotaState {
error?: string;
errorStatus?: number;
}
+
+export interface DevinQuotaWindow {
+ id: 'daily' | 'weekly';
+ label?: string;
+ remainingPercent: number | null;
+ resetAtMs: number | null;
+ periodHours: number;
+}
+
+export interface DevinQuotaData {
+ windows: DevinQuotaWindow[];
+ observedAtMs: number | null;
+ plan: string | null;
+ planStartMs: number | null;
+ planEndMs: number | null;
+}
+
+export interface DevinQuotaState
+ extends CredentialScopedQuotaState,
+ DevinQuotaData {
+ status: 'idle' | 'loading' | 'success' | 'error';
+ error?: string;
+ errorStatus?: number;
+}
+
diff --git a/apps/web/src/types/visualConfig.ts b/apps/web/src/types/visualConfig.ts
index 5c0082552..8d99cf3fe 100644
--- a/apps/web/src/types/visualConfig.ts
+++ b/apps/web/src/types/visualConfig.ts
@@ -150,6 +150,7 @@ export type VisualConfigValues = {
codexHeaderUserAgent: string;
codexHeaderBetaFeatures: string;
codexIdentityConfuse: boolean;
+ devinSensitiveWords: string[];
payloadDefaultRules: PayloadRule[];
payloadDefaultRawRules: PayloadRule[];
payloadOverrideRules: PayloadRule[];
@@ -225,6 +226,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
codexHeaderUserAgent: '',
codexHeaderBetaFeatures: '',
codexIdentityConfuse: false,
+ devinSensitiveWords: [],
payloadDefaultRules: [],
payloadDefaultRawRules: [],
payloadOverrideRules: [],
diff --git a/apps/web/src/utils/models.test.ts b/apps/web/src/utils/models.test.ts
new file mode 100644
index 000000000..23bcd51dc
--- /dev/null
+++ b/apps/web/src/utils/models.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest';
+import { classifyModels } from './models';
+
+describe('classifyModels', () => {
+ it('classifies devin/* models into Devin group without altering model names', () => {
+ const input = [
+ { name: 'devin/gpt-5' },
+ { name: 'devin/claude-sonnet' },
+ { name: 'devin/gemini-3-8-flash' },
+ { name: 'devin/grok-4-6' },
+ { name: 'devin/deepseek-v4-1-flash' },
+
+ { name: 'gpt-5' },
+ { name: 'claude-sonnet' },
+ { name: 'gemini-3-8-flash' },
+ { name: 'grok-4-6' },
+ { name: 'deepseek-v4-1-flash' },
+ ];
+
+ const groups = classifyModels(input);
+
+ const devinGroup = groups.find((g) => g.id === 'devin');
+ expect(devinGroup).toBeDefined();
+ expect(devinGroup?.items.map((m) => m.name)).toEqual([
+ 'devin/gpt-5',
+ 'devin/claude-sonnet',
+ 'devin/gemini-3-8-flash',
+ 'devin/grok-4-6',
+ 'devin/deepseek-v4-1-flash',
+ ]);
+
+ const gptGroup = groups.find((g) => g.id === 'gpt');
+ expect(gptGroup).toBeDefined();
+ expect(gptGroup?.items.map((m) => m.name)).toEqual(['gpt-5']);
+
+ const claudeGroup = groups.find((g) => g.id === 'claude');
+ expect(claudeGroup).toBeDefined();
+ expect(claudeGroup?.items.map((m) => m.name)).toEqual(['claude-sonnet']);
+
+ const geminiGroup = groups.find((g) => g.id === 'gemini');
+ expect(geminiGroup).toBeDefined();
+ expect(geminiGroup?.items.map((m) => m.name)).toEqual(['gemini-3-8-flash']);
+
+ const grokGroup = groups.find((g) => g.id === 'grok');
+ expect(grokGroup).toBeDefined();
+ expect(grokGroup?.items.map((m) => m.name)).toEqual(['grok-4-6']);
+
+ const deepseekGroup = groups.find((g) => g.id === 'deepseek');
+ expect(deepseekGroup).toBeDefined();
+ expect(deepseekGroup?.items.map((m) => m.name)).toEqual(['deepseek-v4-1-flash']);
+
+ devinGroup?.items.forEach((item) => {
+ expect(item.name.startsWith('devin/')).toBe(true);
+ });
+ });
+
+ it('keeps devin namespace check strictly on name and does not classify by alias alone', () => {
+ const input = [
+ { name: 'custom-model', alias: 'devin/something' },
+ ];
+ const groups = classifyModels(input);
+ const devinGroup = groups.find((g) => g.id === 'devin');
+ expect(devinGroup).toBeUndefined();
+ });
+});
diff --git a/apps/web/src/utils/models.ts b/apps/web/src/utils/models.ts
index 5049fa0e6..855101e16 100644
--- a/apps/web/src/utils/models.ts
+++ b/apps/web/src/utils/models.ts
@@ -10,6 +10,7 @@ export interface ModelInfo {
}
const MODEL_CATEGORIES = [
+ { id: 'devin', label: 'Devin', patterns: [] },
{ id: 'gpt', label: 'GPT', patterns: [/gpt/i, /\bo\d\b/i, /\bo\d+\.?/i, /\bchatgpt/i] },
{ id: 'claude', label: 'Claude', patterns: [/claude/i] },
{ id: 'gemini', label: 'Gemini', patterns: [/gemini/i, /\bgai\b/i] },
@@ -103,7 +104,9 @@ export function classifyModels(models: ModelInfo[] = [], { otherLabel = 'Other'
const name = (model?.name || '').toString();
const alias = (model?.alias || '').toString();
const haystack = `${name} ${alias}`.toLowerCase();
- const matchedId = matchCategory(haystack);
+ const matchedId = /^devin\//i.test(name)
+ ? 'devin'
+ : matchCategory(haystack);
const target = matchedId ? groups.find((group) => group.id === matchedId) : null;
if (target) {
diff --git a/apps/web/src/utils/quota/constants.ts b/apps/web/src/utils/quota/constants.ts
index b91272d84..080b8e1bc 100644
--- a/apps/web/src/utils/quota/constants.ts
+++ b/apps/web/src/utils/quota/constants.ts
@@ -38,6 +38,10 @@ export const TYPE_COLORS: Record = {
light: { bg: '#f3f4f6', text: '#111827', border: '1px solid #d1d5db' },
dark: { bg: '#111827', text: '#f9fafb', border: '1px solid #374151' },
},
+ devin: {
+ light: { bg: '#e8f4ff', text: '#155e9b' },
+ dark: { bg: '#123b5d', text: '#8dc9f5' },
+ },
iflow: {
light: { bg: '#f5e3fc', text: '#9025c8' },
dark: { bg: '#521490', text: '#d49cf5' },
@@ -218,3 +222,13 @@ export const XAI_REQUEST_HEADERS = {
accept: '*/*',
'user-agent': XAI_GROK_USER_AGENT,
};
+
+// Devin API configuration
+export const DEVIN_GET_USER_STATUS_URL =
+ 'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus';
+
+export const DEVIN_REQUEST_HEADERS = {
+ 'Content-Type': 'application/json',
+ 'Connect-Protocol-Version': '1',
+};
+
diff --git a/apps/web/src/utils/quota/credentialScope.test.ts b/apps/web/src/utils/quota/credentialScope.test.ts
index df8073afe..5d19b5a4a 100644
--- a/apps/web/src/utils/quota/credentialScope.test.ts
+++ b/apps/web/src/utils/quota/credentialScope.test.ts
@@ -25,6 +25,36 @@ describe('quota credential scope', () => {
expect(buildQuotaCredentialIdentity(first).authFileIdentityVerified).toBe(true);
});
+ it('identifies Devin credentials with stable credential store keys', () => {
+ const devinFirst = {
+ name: 'devin-account.json',
+ provider: 'devin',
+ authIndex: 'auth-0',
+ };
+ const devinSecond = {
+ name: 'devin-account.json',
+ provider: 'devin',
+ authIndex: 'auth-1',
+ };
+ const devinSame = {
+ name: 'devin-account.json',
+ provider: 'devin',
+ authIndex: 'auth-0',
+ };
+
+ expect(getQuotaCredentialStoreKey(devinFirst)).toBe('devin-account.json::auth-0');
+ expect(getQuotaCredentialStoreKey(devinSame)).toBe(getQuotaCredentialStoreKey(devinFirst));
+ expect(getQuotaCredentialStoreKey(devinSecond)).not.toBe(
+ getQuotaCredentialStoreKey(devinFirst)
+ );
+ expect(getQuotaCredentialStoreKey(devinSecond)).toBe('devin-account.json::auth-1');
+
+ const identity = buildQuotaCredentialIdentity(devinFirst);
+ expect(identity.authFileIdentityVerified).toBe(true);
+ expect(identity.authIndex).toBe('auth-0');
+ expect(identity.authFileName).toBe('devin-account.json');
+ });
+
it('marks filename-only identities as unverified', () => {
expect(
buildQuotaCredentialIdentity({ name: 'legacy.json', provider: 'kimi' })
diff --git a/apps/web/src/utils/quota/devinQuota.test.ts b/apps/web/src/utils/quota/devinQuota.test.ts
new file mode 100644
index 000000000..fab4c5a2b
--- /dev/null
+++ b/apps/web/src/utils/quota/devinQuota.test.ts
@@ -0,0 +1,217 @@
+import { describe, expect, it } from 'vitest';
+import {
+ normalizeIsoTimestampMs,
+ normalizePlanName,
+ normalizeQuotaPercent,
+ normalizeUnixSecondsToMs,
+ parseDevinQuotaPayload,
+} from './devinQuota';
+
+describe('devinQuota normalizers', () => {
+ it('normalizes quota percent correctly', () => {
+ expect(normalizeQuotaPercent(54)).toBe(54);
+ expect(normalizeQuotaPercent('54')).toBe(54);
+ expect(normalizeQuotaPercent(0)).toBe(0);
+ expect(normalizeQuotaPercent('0')).toBe(0);
+ expect(normalizeQuotaPercent(100)).toBe(100);
+ expect(normalizeQuotaPercent('100')).toBe(100);
+ expect(normalizeQuotaPercent(0.5)).toBe(0.5);
+ expect(normalizeQuotaPercent('0.5')).toBe(0.5);
+
+ // Invalid percent inputs return null
+ expect(normalizeQuotaPercent('54%')).toBeNull();
+ expect(normalizeQuotaPercent(-1)).toBeNull();
+ expect(normalizeQuotaPercent('-1')).toBeNull();
+ expect(normalizeQuotaPercent(101)).toBeNull();
+ expect(normalizeQuotaPercent('101')).toBeNull();
+ expect(normalizeQuotaPercent(NaN)).toBeNull();
+ expect(normalizeQuotaPercent(Infinity)).toBeNull();
+ expect(normalizeQuotaPercent(-Infinity)).toBeNull();
+ expect(normalizeQuotaPercent(true)).toBeNull();
+ expect(normalizeQuotaPercent(false)).toBeNull();
+ expect(normalizeQuotaPercent({})).toBeNull();
+ expect(normalizeQuotaPercent([])).toBeNull();
+ expect(normalizeQuotaPercent(null)).toBeNull();
+ expect(normalizeQuotaPercent(undefined)).toBeNull();
+ expect(normalizeQuotaPercent('')).toBeNull();
+ expect(normalizeQuotaPercent(' ')).toBeNull();
+ });
+
+ it('normalizes unix seconds to epoch ms', () => {
+ expect(normalizeUnixSecondsToMs(1726000000)).toBe(1726000000000);
+ expect(normalizeUnixSecondsToMs('1726000000')).toBe(1726000000000);
+ expect(normalizeUnixSecondsToMs(0)).toBeNull();
+ expect(normalizeUnixSecondsToMs(-1)).toBeNull();
+ expect(normalizeUnixSecondsToMs(1.5)).toBeNull();
+ expect(normalizeUnixSecondsToMs('1.5')).toBeNull();
+ expect(normalizeUnixSecondsToMs(Infinity)).toBeNull();
+ expect(normalizeUnixSecondsToMs(NaN)).toBeNull();
+ expect(normalizeUnixSecondsToMs(1e20)).toBeNull();
+ expect(normalizeUnixSecondsToMs('abc')).toBeNull();
+ expect(normalizeUnixSecondsToMs('')).toBeNull();
+ expect(normalizeUnixSecondsToMs(null)).toBeNull();
+ expect(normalizeUnixSecondsToMs(undefined)).toBeNull();
+ });
+
+ it('normalizes ISO timestamp to ms and rejects Go/protobuf zero-time', () => {
+ expect(normalizeIsoTimestampMs('2026-09-15T10:00:00Z')).toBe(
+ Date.parse('2026-09-15T10:00:00Z')
+ );
+ expect(normalizeIsoTimestampMs('0001-01-01T00:00:00Z')).toBeNull();
+ expect(normalizeIsoTimestampMs('invalid-date')).toBeNull();
+ expect(normalizeIsoTimestampMs('')).toBeNull();
+ expect(normalizeIsoTimestampMs(null)).toBeNull();
+ expect(normalizeIsoTimestampMs(undefined)).toBeNull();
+ });
+
+ it('normalizes plan name preserving case and trimming', () => {
+ expect(normalizePlanName(' Pro ')).toBe('Pro');
+ expect(normalizePlanName('Team')).toBe('Team');
+ expect(normalizePlanName('pro')).toBe('pro');
+ expect(normalizePlanName('')).toBeNull();
+ expect(normalizePlanName(' ')).toBeNull();
+ expect(normalizePlanName(null)).toBeNull();
+ });
+});
+
+describe('parseDevinQuotaPayload', () => {
+ it('parses valid Connect JSON response into daily and weekly windows', () => {
+ const payload = {
+ userStatus: {
+ planStatus: {
+ planInfo: { planName: ' Devin Pro ' },
+ planStart: '2026-09-01T00:00:00Z',
+ planEnd: '2026-10-01T00:00:00Z',
+ dailyQuotaRemainingPercent: '54',
+ weeklyQuotaRemainingPercent: 80,
+ dailyQuotaResetAtUnix: 1726400000,
+ weeklyQuotaResetAtUnix: '1726900000',
+ },
+ // Sensitive fields that must NOT enter quota data
+ email: 'user@example.com',
+ userName: 'devin-user',
+ userId: 'u_12345',
+ teamId: 't_67890',
+ orgId: 'org_abc',
+ orgName: 'Acme Corp',
+ sessionToken: 'secret-session-token',
+ signals: { abuse: false },
+ metadata: { apiKey: 'sk-devin-secret' },
+ },
+ };
+
+ const parsed = parseDevinQuotaPayload(payload, { observedAtMs: 123456789 });
+ expect(parsed).not.toBeNull();
+ expect(parsed?.observedAtMs).toBe(123456789);
+ expect(parsed?.plan).toBe('Devin Pro');
+ expect(parsed?.planStartMs).toBe(Date.parse('2026-09-01T00:00:00Z'));
+ expect(parsed?.planEndMs).toBe(Date.parse('2026-10-01T00:00:00Z'));
+
+ expect(parsed?.windows).toEqual([
+ {
+ id: 'daily',
+ remainingPercent: 54,
+ resetAtMs: 1726400000000,
+ periodHours: 24,
+ },
+ {
+ id: 'weekly',
+ remainingPercent: 80,
+ resetAtMs: 1726900000000,
+ periodHours: 168,
+ },
+ ]);
+
+ // Verify sensitive data leak prevention
+ const rawResult = parsed as unknown as Record;
+ expect(rawResult['email']).toBeUndefined();
+ expect(rawResult['userName']).toBeUndefined();
+ expect(rawResult['userId']).toBeUndefined();
+ expect(rawResult['teamId']).toBeUndefined();
+ expect(rawResult['orgId']).toBeUndefined();
+ expect(rawResult['orgName']).toBeUndefined();
+ expect(rawResult['sessionToken']).toBeUndefined();
+ expect(rawResult['signals']).toBeUndefined();
+ expect(rawResult['metadata']).toBeUndefined();
+ });
+
+ it('correctly handles 0 remaining percent as genuine quota exhaustion', () => {
+ const payload = {
+ userStatus: {
+ planStatus: {
+ dailyQuotaRemainingPercent: 0,
+ weeklyQuotaRemainingPercent: '0',
+ dailyQuotaResetAtUnix: 1726400000,
+ weeklyQuotaResetAtUnix: 1726900000,
+ },
+ },
+ };
+
+ const parsed = parseDevinQuotaPayload(payload);
+ expect(parsed?.windows[0].remainingPercent).toBe(0);
+ expect(parsed?.windows[1].remainingPercent).toBe(0);
+ });
+
+ it('parses JSON string payload', () => {
+ const jsonStr = JSON.stringify({
+ userStatus: {
+ planStatus: {
+ dailyQuotaRemainingPercent: 100,
+ dailyQuotaResetAtUnix: 1726400000,
+ },
+ },
+ });
+
+ const parsed = parseDevinQuotaPayload(jsonStr);
+ expect(parsed).not.toBeNull();
+ expect(parsed?.windows[0].remainingPercent).toBe(100);
+ expect(parsed?.windows[1].remainingPercent).toBeNull();
+ });
+
+ it('returns null (empty_data) when plan-only or no quota observations exist', () => {
+ // Only plan name, no remaining percent or reset unix
+ const planOnly = {
+ userStatus: {
+ planStatus: {
+ planInfo: { planName: 'Pro' },
+ planStart: '2026-09-01T00:00:00Z',
+ planEnd: '2026-10-01T00:00:00Z',
+ },
+ },
+ };
+ expect(parseDevinQuotaPayload(planOnly)).toBeNull();
+
+ // Invalid values across all quota fields
+ const invalidValues = {
+ userStatus: {
+ planStatus: {
+ dailyQuotaRemainingPercent: 'invalid%',
+ weeklyQuotaRemainingPercent: -5,
+ dailyQuotaResetAtUnix: 'bad',
+ weeklyQuotaResetAtUnix: 0,
+ },
+ },
+ };
+ expect(parseDevinQuotaPayload(invalidValues)).toBeNull();
+
+ // Malformed JSON string
+ expect(parseDevinQuotaPayload('not a json')).toBeNull();
+
+ // Empty object or non-object
+ expect(parseDevinQuotaPayload({})).toBeNull();
+ expect(parseDevinQuotaPayload(null)).toBeNull();
+ expect(parseDevinQuotaPayload(undefined)).toBeNull();
+ });
+
+ it('rejects payloads where only invalid reset timestamp exists without valid observation', () => {
+ const invalidResetOnly = {
+ userStatus: {
+ planStatus: {
+ dailyQuotaResetAtUnix: 1.5,
+ weeklyQuotaResetAtUnix: 'bad',
+ },
+ },
+ };
+ expect(parseDevinQuotaPayload(invalidResetOnly)).toBeNull();
+ });
+});
diff --git a/apps/web/src/utils/quota/devinQuota.ts b/apps/web/src/utils/quota/devinQuota.ts
new file mode 100644
index 000000000..ac1aaa4ae
--- /dev/null
+++ b/apps/web/src/utils/quota/devinQuota.ts
@@ -0,0 +1,140 @@
+/**
+ * Pure parsers and normalizers for Devin Quota responses.
+ */
+
+import type { DevinQuotaData, DevinQuotaWindow } from '@/types';
+
+export const normalizeQuotaPercent = (value: unknown): number | null => {
+ if (value === null || value === undefined) return null;
+ if (typeof value === 'boolean') return null;
+
+ let num: number;
+ if (typeof value === 'number') {
+ num = value;
+ } else if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return null;
+ num = Number(trimmed);
+ } else {
+ return null;
+ }
+
+ if (!Number.isFinite(num)) return null;
+ if (num < 0 || num > 100) return null;
+ return num;
+};
+
+export const normalizeUnixSecondsToMs = (value: unknown): number | null => {
+ if (typeof value !== 'number' && typeof value !== 'string') return null;
+ if (typeof value === 'string' && !/^\d+$/.test(value)) return null;
+
+ const seconds = Number(value);
+ const ms = seconds * 1000;
+
+ if (
+ Number.isSafeInteger(seconds) &&
+ seconds > 0 &&
+ Number.isFinite(new Date(ms).getTime())
+ ) {
+ return ms;
+ }
+ return null;
+};
+
+export const normalizeIsoTimestampMs = (value: unknown): number | null => {
+ if (typeof value !== 'string') return null;
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) return null;
+
+ const ms = Date.parse(value);
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
+};
+
+export const normalizePlanName = (value: unknown): string | null => {
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+};
+
+export interface ParseDevinQuotaOptions {
+ observedAtMs?: number;
+}
+
+export const parseDevinQuotaPayload = (
+ rawPayload: unknown,
+ options: ParseDevinQuotaOptions = {}
+): DevinQuotaData | null => {
+ let body = rawPayload;
+ if (typeof body === 'string') {
+ try {
+ body = JSON.parse(body);
+ } catch {
+ return null;
+ }
+ }
+
+ if (!body || typeof body !== 'object') {
+ return null;
+ }
+
+ const root = body as Record;
+ const userStatus =
+ root.userStatus && typeof root.userStatus === 'object'
+ ? (root.userStatus as Record)
+ : null;
+ const planStatus =
+ userStatus?.planStatus && typeof userStatus.planStatus === 'object'
+ ? (userStatus.planStatus as Record)
+ : null;
+
+ if (!planStatus) {
+ return null;
+ }
+
+ const planInfo =
+ planStatus.planInfo && typeof planStatus.planInfo === 'object'
+ ? (planStatus.planInfo as Record)
+ : null;
+
+ const plan = normalizePlanName(planInfo?.planName);
+ const planStartMs = normalizeIsoTimestampMs(planStatus.planStart);
+ const planEndMs = normalizeIsoTimestampMs(planStatus.planEnd);
+
+ const dailyRemaining = normalizeQuotaPercent(planStatus.dailyQuotaRemainingPercent);
+ const weeklyRemaining = normalizeQuotaPercent(planStatus.weeklyQuotaRemainingPercent);
+
+ const dailyReset = normalizeUnixSecondsToMs(planStatus.dailyQuotaResetAtUnix);
+ const weeklyReset = normalizeUnixSecondsToMs(planStatus.weeklyQuotaResetAtUnix);
+
+ const hasObservation =
+ dailyRemaining !== null ||
+ dailyReset !== null ||
+ weeklyRemaining !== null ||
+ weeklyReset !== null;
+
+ if (!hasObservation) {
+ return null;
+ }
+
+ const dailyWindow: DevinQuotaWindow = {
+ id: 'daily',
+ remainingPercent: dailyRemaining,
+ resetAtMs: dailyReset,
+ periodHours: 24,
+ };
+
+ const weeklyWindow: DevinQuotaWindow = {
+ id: 'weekly',
+ remainingPercent: weeklyRemaining,
+ resetAtMs: weeklyReset,
+ periodHours: 168,
+ };
+
+ return {
+ windows: [dailyWindow, weeklyWindow],
+ observedAtMs: options.observedAtMs ?? Date.now(),
+ plan,
+ planStartMs,
+ planEndMs,
+ };
+};
diff --git a/apps/web/src/utils/quota/index.ts b/apps/web/src/utils/quota/index.ts
index fa573ed74..f8603b5ef 100644
--- a/apps/web/src/utils/quota/index.ts
+++ b/apps/web/src/utils/quota/index.ts
@@ -12,3 +12,5 @@ export * from './codexQuota';
export * from './codexRequestHeaders';
export * from './resetCredits';
export * from './providerRequests';
+export * from './devinQuota';
+
diff --git a/apps/web/src/utils/quota/providerRequests.test.ts b/apps/web/src/utils/quota/providerRequests.test.ts
index db48f1393..35ddfd1a2 100644
--- a/apps/web/src/utils/quota/providerRequests.test.ts
+++ b/apps/web/src/utils/quota/providerRequests.test.ts
@@ -51,6 +51,7 @@ import {
fetchCodexQuota,
fetchCodexQuotaSummary,
fetchCodexResetCredits,
+ fetchDevinQuota,
fetchKimiQuota,
mergeXaiBillingSummaries,
probeXaiBilling,
@@ -3831,3 +3832,114 @@ describe('CODEX_REQUEST_HEADERS', () => {
);
});
});
+
+describe('fetchDevinQuota', () => {
+ it('sends POST request to GetUserStatus with exact contract and no Authorization headers', async () => {
+ mocks.request.mockResolvedValueOnce({
+ statusCode: 200,
+ hasStatusCode: true,
+ header: {},
+ bodyText: '',
+ body: {
+ userStatus: {
+ planStatus: {
+ planInfo: { planName: 'Pro' },
+ dailyQuotaRemainingPercent: 54,
+ dailyQuotaResetAtUnix: 1726400000,
+ },
+ },
+ },
+ });
+
+ const file = { name: 'devin.json', type: 'devin', authIndex: 'devin-001' };
+ const requestScope = { apiBase: 'https://cpa.example.com', managementKey: 'test-key' };
+ const result = await fetchDevinQuota(file, t, requestScope);
+
+ expect(mocks.request).toHaveBeenCalledTimes(1);
+ const [payload, config] = mocks.request.mock.calls[0];
+
+ expect(payload).toEqual({
+ authIndex: 'devin-001',
+ method: 'POST',
+ url: 'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus',
+ header: {
+ 'Content-Type': 'application/json',
+ 'Connect-Protocol-Version': '1',
+ },
+ data: JSON.stringify({
+ metadata: {
+ ideName: 'chisel',
+ ideVersion: '3000.10.21',
+ apiKey: '$TOKEN$',
+ locale: 'en',
+ os: 'darwin',
+ extensionVersion: '3000.10.21',
+ clientName: 'chisel',
+ },
+ }),
+ });
+
+ // Ensure NO Authorization or X-Api-Key headers exist on the proxied upstream request
+ expect(payload.header['Authorization']).toBeUndefined();
+ expect(payload.header['X-Api-Key']).toBeUndefined();
+
+ // Verify requestScope is captured in axios config for CPA management call
+ expect(config?.baseURL).toBe('https://cpa.example.com/v0/management');
+ expect(config?.headers?.['Authorization']).toBe('Bearer test-key');
+ expect(config?.cpampScopedRequest).toBe(true);
+
+ expect(result.plan).toBe('Pro');
+ expect(result.windows[0].remainingPercent).toBe(54);
+ expect(result.windows[0].resetAtMs).toBe(1726400000000);
+ });
+
+ it('rejects without network request when identity is missing', async () => {
+ // Missing authIndex
+ await expect(
+ fetchDevinQuota({ name: 'devin.json', type: 'devin' }, t)
+ ).rejects.toThrow('devin_quota.missing_identity');
+
+ // Missing fileName
+ await expect(
+ fetchDevinQuota({ name: '', type: 'devin', authIndex: 'devin-1' }, t)
+ ).rejects.toThrow('devin_quota.missing_identity');
+
+ expect(mocks.request).not.toHaveBeenCalled();
+ });
+
+ it('preserves error status codes like 429 and 401', async () => {
+ mocks.request.mockResolvedValueOnce({
+ statusCode: 429,
+ hasStatusCode: true,
+ header: {},
+ bodyText: 'Rate limit exceeded',
+ });
+
+ const promise = fetchDevinQuota(
+ { name: 'devin.json', type: 'devin', authIndex: 'devin-1' },
+ t
+ );
+ await expect(promise).rejects.toMatchObject({ status: 429 });
+ });
+
+ it('throws empty_data on 200 response without quota observations', async () => {
+ mocks.request.mockResolvedValueOnce({
+ statusCode: 200,
+ hasStatusCode: true,
+ header: {},
+ bodyText: '',
+ body: {
+ userStatus: {
+ planStatus: {
+ planInfo: { planName: 'Pro' },
+ },
+ },
+ },
+ });
+
+ await expect(
+ fetchDevinQuota({ name: 'devin.json', type: 'devin', authIndex: 'devin-1' }, t)
+ ).rejects.toThrow('devin_quota.empty_data');
+ });
+});
+
diff --git a/apps/web/src/utils/quota/providerRequests.ts b/apps/web/src/utils/quota/providerRequests.ts
index bd4517e75..e7b64794d 100644
--- a/apps/web/src/utils/quota/providerRequests.ts
+++ b/apps/web/src/utils/quota/providerRequests.ts
@@ -12,6 +12,7 @@ import type {
CodexRateLimitResetCredit,
CodexQuotaWindow,
CodexUsagePayload,
+ DevinQuotaData,
KimiQuotaRow,
KimiUsagePayload,
XaiBillingConfig,
@@ -42,6 +43,8 @@ import {
CLAUDE_USAGE_WINDOW_KEYS,
CODEX_RATE_LIMIT_RESET_CREDITS_URL,
CODEX_USAGE_URL,
+ DEVIN_GET_USER_STATUS_URL,
+ DEVIN_REQUEST_HEADERS,
KIMI_REQUEST_HEADERS,
KIMI_USAGE_URL,
XAI_BILLING_MONTHLY_URL,
@@ -55,6 +58,7 @@ import {
XAI_OFFICIAL_API_ME_URL,
XAI_REQUEST_HEADERS,
} from './constants';
+import { parseDevinQuotaPayload } from './devinQuota';
import { buildAntigravityQuotaGroups, buildKimiQuotaRows } from './builders';
import {
createStatusError,
@@ -2046,3 +2050,53 @@ export const fetchXaiQuota = async (
}),
};
});
+
+export const fetchDevinQuota = async (
+ file: AuthFileItem,
+ t: TFunction,
+ requestScope?: ApiClientRequestScope
+): Promise => {
+ const fileName = typeof file?.name === 'string' ? file.name.trim() : '';
+ const rawAuthIndex = file['auth_index'] ?? file.authIndex;
+ const authIndex = normalizeAuthIndex(rawAuthIndex);
+
+ if (!fileName || !authIndex) {
+ throw new Error(t('devin_quota.missing_identity'));
+ }
+
+ const result = await apiCallApi.request(
+ {
+ authIndex,
+ method: 'POST',
+ url: DEVIN_GET_USER_STATUS_URL,
+ header: { ...DEVIN_REQUEST_HEADERS },
+ data: JSON.stringify({
+ metadata: {
+ ideName: 'chisel',
+ ideVersion: '3000.10.21',
+ apiKey: '$TOKEN$',
+ locale: 'en',
+ os: 'darwin',
+ extensionVersion: '3000.10.21',
+ clientName: 'chisel',
+ },
+ }),
+ },
+ requestScope ? createScopedApiRequestConfig(requestScope) : undefined
+ );
+
+ if (result.statusCode < 200 || result.statusCode >= 300) {
+ throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
+ }
+
+ const quotaData = parseDevinQuotaPayload(result.body ?? result.bodyText, {
+ observedAtMs: Date.now(),
+ });
+
+ if (!quotaData) {
+ throw new Error(t('devin_quota.empty_data'));
+ }
+
+ return quotaData;
+};
+
diff --git a/apps/web/src/utils/quota/validators.ts b/apps/web/src/utils/quota/validators.ts
index 953170282..9f87a3d7c 100644
--- a/apps/web/src/utils/quota/validators.ts
+++ b/apps/web/src/utils/quota/validators.ts
@@ -44,6 +44,10 @@ export function isXaiFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'xai';
}
+export function isDevinFile(file: AuthFileItem): boolean {
+ return resolveAuthProvider(file) === 'devin';
+}
+
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
const raw = file['runtime_only'] ?? file.runtimeOnly;
if (typeof raw === 'boolean') return raw;