diff --git a/README.md b/README.md index 6a91a87a..8b4588e8 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,12 @@ ccs auth create backup --share-context ccs auth create backup2 --context-group sprint-a ``` +Update existing accounts without recreating login: + +1. Run `ccs config` +2. Open `Accounts` +3. Click the pencil icon in Actions and set `isolated` or `shared` mode + Shared mode metadata in `~/.ccs/config.yaml`: ```yaml @@ -300,6 +306,8 @@ accounts: Shared context links project workspace data only. Credentials remain isolated per account. +Technical details: [`docs/session-sharing-technical-analysis.md`](docs/session-sharing-technical-analysis.md) +
## Maintenance diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index 85d1a7ed..b810656f 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -1,6 +1,6 @@ # Dashboard Authentication CLI -Last Updated: 2026-02-24 +Last Updated: 2026-02-26 CLI commands for managing CCS dashboard authentication. @@ -39,6 +39,12 @@ Account context is isolation-first: - normalizes valid shared `context_group` before save - rejects `context_group` when mode is not `shared` +Dashboard accounts context editing: + +- `PUT /api/accounts/:name/context` updates context mode/group for existing auth accounts +- rejects CLIProxy OAuth account keys for this route +- applies normalization/validation rules above + ## Commands ### `ccs config auth setup` diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md new file mode 100644 index 00000000..93c8b466 --- /dev/null +++ b/docs/session-sharing-technical-analysis.md @@ -0,0 +1,70 @@ +# Session Sharing Technical Analysis + +Last Updated: 2026-02-26 + +## Summary + +CCS supports practical cross-account continuity by sharing workspace context files between selected accounts, while keeping credentials isolated per account. + +This is implemented as a context policy per account: + +- `isolated` (default): account keeps its own workspace context +- `shared`: account workspace context is linked to a shared context group + +## Why This Is Safe Enough + +CCS only shares workspace context paths (project/session context files). It does **not** merge or copy authentication credentials between accounts. + +Credential storage remains per account instance. + +## Implementation Model + +Account metadata is stored in `~/.ccs/config.yaml`: + +```yaml +accounts: + work: + created: "2026-02-24T00:00:00.000Z" + last_used: null + context_mode: "shared" + context_group: "team-alpha" +``` + +Rules: + +- `context_mode` must be `isolated` or `shared` +- `context_group` is required when `context_mode=shared` +- group normalization: trim, lowercase, internal spaces -> `-` +- group must start with a letter and only include `[a-zA-Z0-9_-]` +- max length: `64` + +## User Workflows + +### New account with shared context + +```bash +ccs auth create work2 --share-context +ccs auth create backup --context-group sprint-a +``` + +### Existing account + +- Open `ccs config` +- Go to `Accounts` +- Click the pencil icon (`Edit Context`) +- Choose `isolated` or `shared` and set group + +No account recreation required for this workflow. + +## Current Limitations + +- Shared context is local filesystem sharing. It does not bypass remote provider permission models. +- Session continuity still depends on what the upstream tool/provider stores and allows. +- Context sharing should only be enabled for accounts you intentionally trust to share workspace history. + +## Validation Checklist + +- Confirm account row shows `shared ()` in Dashboard Accounts table +- Switch between accounts in the same group and verify workspace continuity +- Run `ccs doctor` if symlink/context health looks inconsistent + diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index 2ed849ee..c94a91c5 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -128,6 +128,9 @@ class AuthCommands { console.log( ` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.` ); + console.log( + ` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.` + ); console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`); console.log( ` ${color('context_group', 'path')} must be non-empty and <= ${MAX_CONTEXT_GROUP_LENGTH} chars in shared mode.` diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 1db7717c..461ea979 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -151,6 +151,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); [ ['ccs auth --help', 'Show account management commands'], ['ccs auth create ', 'Create account profile (supports context sharing flags)'], + ['ccs config', 'Dashboard: Accounts table can edit context mode/group'], ['ccs auth list', 'List all account profiles'], ['ccs auth default ', 'Set default profile'], ['ccs auth reset-default', 'Restore original CCS default'], diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index b6ebc359..81d263b6 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -19,7 +19,11 @@ import { soloAccount, } from '../../cliproxy/account-manager'; import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; -import { resolveAccountContextPolicy } from '../../auth/account-context'; +import { + isValidContextGroupName, + normalizeContextGroupName, + resolveAccountContextPolicy, +} from '../../auth/account-context'; import { buildCliproxyAccountKey, parseCliproxyKey, @@ -149,6 +153,111 @@ router.post('/default', (req: Request, res: Response): void => { } }); +/** + * PUT /api/accounts/:name/context - Update account context mode/group + */ +router.put('/:name/context', async (req: Request, res: Response): Promise => { + try { + const { name } = req.params; + + if (!name) { + res.status(400).json({ error: 'Missing account name' }); + return; + } + + // CLIProxy OAuth accounts do not support local account context metadata. + const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null; + if (cliproxyKey) { + res + .status(400) + .json({ error: `Context mode is not supported for CLIProxy account: ${name}` }); + return; + } + + const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name); + const existsLegacy = registry.hasProfile(name); + if (!existsUnified && !existsLegacy) { + res.status(404).json({ error: `Account not found: ${name}` }); + return; + } + + const mode = req.body?.context_mode; + const rawGroup = req.body?.context_group; + + if (mode !== 'isolated' && mode !== 'shared') { + res.status(400).json({ error: 'Missing or invalid context_mode: expected isolated|shared' }); + return; + } + + if (mode !== 'shared' && rawGroup !== undefined) { + res + .status(400) + .json({ error: 'Invalid payload: context_group requires context_mode=shared' }); + return; + } + + let normalizedGroup: string | undefined; + if (mode === 'shared') { + if (typeof rawGroup !== 'string' || rawGroup.trim().length === 0) { + res + .status(400) + .json({ error: 'Invalid payload: shared context_mode requires non-empty context_group' }); + return; + } + + normalizedGroup = normalizeContextGroupName(rawGroup); + if (!isValidContextGroupName(normalizedGroup)) { + res.status(400).json({ + error: + 'Invalid context_group. Use letters/numbers/dash/underscore, start with a letter, max 64 chars.', + }); + return; + } + } + + const metadata = + mode === 'shared' + ? { + context_mode: 'shared' as const, + context_group: normalizedGroup, + } + : { + context_mode: 'isolated' as const, + }; + const policy = resolveAccountContextPolicy(metadata); + + const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined; + const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined; + + try { + if (existsUnified) { + registry.updateAccountUnified(name, metadata); + } + if (existsLegacy) { + registry.updateProfile(name, metadata); + } + + await instanceMgr.ensureInstance(name, policy); + } catch (error) { + if (existsUnified && previousUnified) { + registry.updateAccountUnified(name, previousUnified); + } + if (existsLegacy && previousLegacy) { + registry.updateProfile(name, previousLegacy); + } + throw error; + } + + res.json({ + name, + context_mode: policy.mode, + context_group: policy.group ?? null, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * DELETE /api/accounts/reset-default - Reset to CCS default */ diff --git a/tests/unit/web-server/account-routes-context.test.ts b/tests/unit/web-server/account-routes-context.test.ts index c3b27999..2456ff37 100644 --- a/tests/unit/web-server/account-routes-context.test.ts +++ b/tests/unit/web-server/account-routes-context.test.ts @@ -18,6 +18,14 @@ async function deletePath(baseUrl: string, routePath: string): Promise return fetch(`${baseUrl}${routePath}`, { method: 'DELETE' }); } +async function putJson(baseUrl: string, routePath: string, body: unknown): Promise { + return fetch(`${baseUrl}${routePath}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + describe('web-server account-routes context normalization', () => { let server: Server; let baseUrl = ''; @@ -27,6 +35,7 @@ describe('web-server account-routes context normalization', () => { beforeAll(async () => { const app = express(); + app.use(express.json()); app.use('/api/accounts', accountRoutes); await new Promise((resolve, reject) => { @@ -173,4 +182,84 @@ describe('web-server account-routes context normalization', () => { InstanceManager.prototype.deleteInstance = originalDeleteInstance; } }); + + it('updates existing account context metadata and normalizes shared group', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + ' context_mode: isolated', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const response = await putJson(baseUrl, '/api/accounts/work/context', { + context_mode: 'shared', + context_group: ' Team Alpha ', + }); + expect(response.status).toBe(200); + const payload = (await response.json()) as { context_mode: string; context_group: string | null }; + expect(payload.context_mode).toBe('shared'); + expect(payload.context_group).toBe('team-alpha'); + + const accountsPayload = await getJson<{ + accounts: Array<{ name: string; context_mode?: string; context_group?: string }>; + }>(baseUrl, '/api/accounts'); + const work = accountsPayload.accounts.find((account) => account.name === 'work'); + expect(work?.context_mode).toBe('shared'); + expect(work?.context_group).toBe('team-alpha'); + }); + + it('rejects shared mode updates without context_group', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const response = await putJson(baseUrl, '/api/accounts/work/context', { + context_mode: 'shared', + }); + expect(response.status).toBe(400); + + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('context_group'); + }); + + it('rejects context updates for CLIProxy account identifiers', async () => { + const response = await putJson(baseUrl, '/api/accounts/gemini:test/context', { + context_mode: 'shared', + context_group: 'default', + }); + expect(response.status).toBe(400); + + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('CLIProxy'); + }); }); diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx index baaddc25..69467192 100644 --- a/ui/src/components/account/accounts-table.tsx +++ b/ui/src/components/account/accounts-table.tsx @@ -24,7 +24,8 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { Check, Trash2, RotateCcw } from 'lucide-react'; +import { Check, Pencil, Trash2, RotateCcw } from 'lucide-react'; +import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog'; import { useSetDefaultAccount, useDeleteAccount, @@ -35,7 +36,6 @@ import type { Account } from '@/lib/api-client'; interface AccountsTableProps { data: Account[]; defaultAccount: string | null; - onRefresh?: () => void; } export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { @@ -43,6 +43,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { const deleteMutation = useDeleteAccount(); const resetDefaultMutation = useResetDefaultAccount(); const [deleteTarget, setDeleteTarget] = useState(null); + const [contextTarget, setContextTarget] = useState(null); const columns: ColumnDef[] = [ { @@ -108,13 +109,26 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { { id: 'actions', header: 'Actions', - size: 180, + size: 220, cell: ({ row }) => { const isDefault = row.original.name === defaultAccount; const isPending = setDefaultMutation.isPending || deleteMutation.isPending; + const isCliproxy = row.original.type === 'cliproxy'; return (
+ {!isCliproxy && ( + + )} + + + + + ); +} diff --git a/ui/src/components/account/index.ts b/ui/src/components/account/index.ts index 0b75ccb8..fe6da6ea 100644 --- a/ui/src/components/account/index.ts +++ b/ui/src/components/account/index.ts @@ -6,6 +6,7 @@ export { AccountsTable } from './accounts-table'; export { AddAccountDialog } from './add-account-dialog'; export { CreateAuthProfileDialog } from './create-auth-profile-dialog'; +export { EditAccountContextDialog } from './edit-account-context-dialog'; // Flow visualization (from subdirectory) export { AccountFlowViz } from './flow-viz'; diff --git a/ui/src/hooks/use-accounts.ts b/ui/src/hooks/use-accounts.ts index b46691af..1e616022 100644 --- a/ui/src/hooks/use-accounts.ts +++ b/ui/src/hooks/use-accounts.ts @@ -1,5 +1,5 @@ /** - * React Query hooks for accounts (profiles.json) + * React Query hooks for account management * Dashboard parity: Full CRUD operations for auth profiles */ @@ -58,3 +58,30 @@ export function useDeleteAccount() { }, }); } + +export function useUpdateAccountContext() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + name, + context_mode, + context_group, + }: { + name: string; + context_mode: 'isolated' | 'shared'; + context_group?: string; + }) => api.accounts.updateContext(name, { context_mode, context_group }), + onSuccess: (_data, vars) => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + const contextSummary = + vars.context_mode === 'shared' + ? `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')})` + : 'isolated'; + toast.success(`Updated "${vars.name}" context to ${contextSummary}`); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 12ec590e..bab3455e 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -457,6 +457,11 @@ export interface Account { context_group?: string; } +export interface UpdateAccountContext { + context_mode: 'isolated' | 'shared'; + context_group?: string; +} + // Unified config types export interface ConfigFormat { format: 'yaml' | 'json' | 'none'; @@ -785,6 +790,11 @@ export const api = { }), resetDefault: () => request('/accounts/reset-default', { method: 'DELETE' }), delete: (name: string) => request(`/accounts/${name}`, { method: 'DELETE' }), + updateContext: (name: string, data: UpdateAccountContext) => + request(`/accounts/${encodeURIComponent(name)}/context`, { + method: 'PUT', + body: JSON.stringify(data), + }), }, // Unified config API config: { diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index bbed9c89..9a47b1df 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'; import { useAccounts } from '@/hooks/use-accounts'; export function AccountsPage() { - const { data, isLoading, refetch } = useAccounts(); + const { data, isLoading } = useAccounts(); const [createDialogOpen, setCreateDialogOpen] = useState(false); return ( @@ -20,7 +20,7 @@ export function AccountsPage() {

Accounts

- Manage multi-account Claude sessions (profiles.json) + Manage multi-account Claude sessions and shared context groups