((resolve) => server.close(() => resolve()));
+ });
+
+ beforeEach(() => {
+ tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-routes-context-'));
+ originalCcsHome = process.env.CCS_HOME;
+ process.env.CCS_HOME = tempHome;
+ fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true });
+ });
+
+ afterEach(() => {
+ if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
+ else delete process.env.CCS_HOME;
+
+ if (tempHome && fs.existsSync(tempHome)) {
+ fs.rmSync(tempHome, { recursive: true, force: true });
+ }
+ });
+
+ it('rejects invalid account context_mode values', async () => {
+ const response = await putJson(baseUrl, '/api/config', {
+ version: 8,
+ accounts: {
+ work: {
+ created: '2026-01-01T00:00:00.000Z',
+ last_used: null,
+ context_mode: 'weird',
+ },
+ },
+ profiles: {},
+ cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
+ });
+
+ expect(response.status).toBe(400);
+ const payload = (await response.json()) as { error: string };
+ expect(payload.error).toContain('context_mode');
+ });
+
+ it('rejects context_group when mode is not shared', async () => {
+ const response = await putJson(baseUrl, '/api/config', {
+ version: 8,
+ accounts: {
+ work: {
+ created: '2026-01-01T00:00:00.000Z',
+ last_used: null,
+ context_mode: 'isolated',
+ context_group: 'sprint-a',
+ },
+ },
+ profiles: {},
+ cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
+ });
+
+ expect(response.status).toBe(400);
+ const payload = (await response.json()) as { error: string };
+ expect(payload.error).toContain('context_group requires context_mode=shared');
+ });
+
+ it('rejects invalid shared context_group names', async () => {
+ const response = await putJson(baseUrl, '/api/config', {
+ version: 8,
+ accounts: {
+ work: {
+ created: '2026-01-01T00:00:00.000Z',
+ last_used: null,
+ context_mode: 'shared',
+ context_group: '###',
+ },
+ },
+ profiles: {},
+ cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
+ });
+
+ expect(response.status).toBe(400);
+ const payload = (await response.json()) as { error: string };
+ expect(payload.error).toContain('context_group');
+ });
+
+ it('rejects whitespace-only shared context_group values', async () => {
+ const response = await putJson(baseUrl, '/api/config', {
+ version: 8,
+ accounts: {
+ work: {
+ created: '2026-01-01T00:00:00.000Z',
+ last_used: null,
+ context_mode: 'shared',
+ context_group: ' ',
+ },
+ },
+ profiles: {},
+ cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
+ });
+
+ expect(response.status).toBe(400);
+ const payload = (await response.json()) as { error: string };
+ expect(payload.error).toContain('requires a non-empty value');
+ });
+
+ it('accepts valid shared context metadata', async () => {
+ const config = createEmptyUnifiedConfig();
+ config.accounts.work = {
+ created: '2026-01-01T00:00:00.000Z',
+ last_used: null,
+ context_mode: 'shared',
+ context_group: 'Sprint-A',
+ };
+ const response = await putJson(baseUrl, '/api/config', config);
+
+ expect(response.status).toBe(200);
+ const payload = (await response.json()) as { success: boolean };
+ expect(payload.success).toBe(true);
+
+ const savedConfig = loadUnifiedConfig();
+ expect(savedConfig?.accounts.work.context_group).toBe('sprint-a');
+ });
+
+ it('returns alreadyMigrated when migration is not needed', async () => {
+ const response = await postJson(baseUrl, '/api/config/migrate');
+
+ expect(response.status).toBe(200);
+ const payload = (await response.json()) as {
+ success: boolean;
+ migratedFiles: string[];
+ warnings: string[];
+ alreadyMigrated?: boolean;
+ };
+ expect(payload.success).toBe(true);
+ expect(payload.migratedFiles).toEqual([]);
+ expect(payload.warnings).toEqual([]);
+ expect(payload.alreadyMigrated).toBe(true);
+ });
+});
diff --git a/ui/src/components/account/create-auth-profile-dialog.tsx b/ui/src/components/account/create-auth-profile-dialog.tsx
index a82dc72f..51b7b997 100644
--- a/ui/src/components/account/create-auth-profile-dialog.tsx
+++ b/ui/src/components/account/create-auth-profile-dialog.tsx
@@ -22,6 +22,8 @@ interface CreateAuthProfileDialogProps {
onClose: () => void;
}
+const MAX_CONTEXT_GROUP_LENGTH = 64;
+
export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) {
const [profileName, setProfileName] = useState('');
const [shareContext, setShareContext] = useState(false);
@@ -32,7 +34,9 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName);
const normalizedGroup = contextGroup.trim().toLowerCase();
const isValidContextGroup =
- normalizedGroup.length === 0 || /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(normalizedGroup);
+ normalizedGroup.length === 0 ||
+ (normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH &&
+ /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(normalizedGroup));
const command =
profileName && isValidName
@@ -119,7 +123,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
{contextGroup.trim().length > 0 && !isValidContextGroup && (
Group must start with a letter and use only letters, numbers, dashes, or
- underscores.
+ underscores (max {MAX_CONTEXT_GROUP_LENGTH} chars).
)}