fix(auth): harden shared-context isolation and config safety

This commit is contained in:
Tam Nhu Tran
2026-02-25 04:19:28 +07:00
parent 0b070a3f34
commit 0309630193
18 changed files with 695 additions and 197 deletions
+32 -4
View File
@@ -59,7 +59,7 @@ Want to run the dashboard in Docker? See `docker/README.md`.
The dashboard provides visual management for all account types: The dashboard provides visual management for all account types:
- **Claude Accounts**: Create isolated instances (work, personal, client) - **Claude Accounts**: Isolation-first by default (work, personal, client), with explicit shared context opt-in
- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot - **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot
- **API Profiles**: Configure GLM, Kimi with your keys - **API Profiles**: Configure GLM, Kimi with your keys
- **Health Monitor**: Real-time status across all profiles - **Health Monitor**: Real-time status across all profiles
@@ -221,17 +221,45 @@ ccs work "implement feature" # Terminal 1
ccs "review code" # Terminal 2 (personal account) ccs "review code" # Terminal 2 (personal account)
``` ```
Need continuity between two accounts for the same project? Opt in to shared context: #### Account Context Modes (Isolation-First)
Account profiles are isolated by default.
| Mode | Default | Requirements |
|------|---------|--------------|
| `isolated` | Yes | No `context_group` required |
| `shared` | No (explicit opt-in) | Valid non-empty `context_group` |
Opt in to shared context when needed:
```bash ```bash
# Share context with default group # Share context with default group
ccs auth create backup --share-context ccs auth create backup --share-context
# Or isolate by named group (only accounts in this group share context) # Share context only within named group
ccs auth create backup2 --context-group sprint-a ccs auth create backup2 --context-group sprint-a
``` ```
Isolation remains the default. Shared context only links project workspace data; credentials stay per-account. Shared mode metadata in `~/.ccs/config.yaml`:
```yaml
accounts:
work:
created: "2026-02-24T00:00:00.000Z"
last_used: null
context_mode: "shared"
context_group: "team-alpha"
```
`context_group` rules:
- lowercase letters, numbers, `_`, `-`
- must start with a letter
- max length `64`
- non-empty after normalization
- normalized by trim + lowercase + whitespace collapse (`" Team Alpha "` -> `"team-alpha"`)
Shared context links project workspace data only. Credentials remain isolated per account.
<br> <br>
+15 -2
View File
@@ -1,8 +1,8 @@
# CCS Codebase Summary # CCS Codebase Summary
Last Updated: 2026-02-04 Last Updated: 2026-02-24
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, and v7.34 Image Analysis Hook. Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening.
## Repository Structure ## Repository Structure
@@ -201,6 +201,19 @@ src/
| Services | `web-server/`, `api/` | HTTP server, API services | | Services | `web-server/`, `api/` | HTTP server, API services |
| Utilities | `utils/`, `management/` | Helpers, diagnostics | | Utilities | `utils/`, `management/` | Helpers, diagnostics |
### Account Context Metadata Flow
- Source fields: `accounts.<name>.context_mode` and `accounts.<name>.context_group` in `~/.ccs/config.yaml`.
- Runtime policy resolver: `src/auth/account-context.ts`.
- Metadata storage normalization: `src/auth/profile-registry.ts`.
- API write validation: `PUT /api/config` in `src/web-server/routes/config-routes.ts`.
- Rules:
- mode is isolation-first (`isolated` default, `shared` opt-in)
- shared mode requires non-empty valid `context_group`
- `context_group` is normalized (trim + lowercase + whitespace collapse to `-`)
- API route rejects `context_group` when mode is not `shared`
- registry normalization drops malformed persisted `context_group` values
### Target Adapter Module ### Target Adapter Module
The targets module provides an extensible interface for dispatching profiles to different CLI implementations. The targets module provides an extensible interface for dispatching profiles to different CLI implementations.
+34 -1
View File
@@ -1,6 +1,6 @@
# Dashboard Authentication CLI # Dashboard Authentication CLI
Last Updated: 2026-02-04 Last Updated: 2026-02-24
CLI commands for managing CCS dashboard authentication. CLI commands for managing CCS dashboard authentication.
@@ -10,6 +10,35 @@ The CCS dashboard (`ccs config`) can be protected with username/password authent
Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it. Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it.
## Account Context Modes (Related Feature)
Dashboard auth and account context metadata are separate:
- `dashboard_auth`: protects dashboard access with username/password
- `accounts.<name>.context_mode/context_group`: controls isolated vs shared account context
Account context is isolation-first:
| Mode | Default | Requirement |
|------|---------|-------------|
| `isolated` | Yes | No `context_group` required |
| `shared` | No (opt-in) | Valid non-empty `context_group` |
`context_group` normalization and validation:
- trim + lowercase + collapse internal whitespace to `-`
- allowed characters: lowercase letters, numbers, `_`, `-`
- must start with a letter
- max length: 64
- shared mode requires non-empty value after normalization
`PUT /api/config` behavior for account context:
- rejects invalid unified payloads
- rejects explicit `context_mode: shared` with invalid/empty `context_group`
- normalizes valid shared `context_group` before save
- rejects `context_group` when mode is not `shared`
## Commands ## Commands
### `ccs config auth setup` ### `ccs config auth setup`
@@ -162,6 +191,10 @@ export CCS_DASHBOARD_PASSWORD_HASH='$2b$10$...'
Check `session_timeout_hours` in config. Default is 24 hours. Check `session_timeout_hours` in config. Default is 24 hours.
### "Invalid ... context_group ..."
This error comes from `PUT /api/config` when an account explicitly sets shared mode with an invalid group. Use a canonical group value (for example: `team-alpha`).
## See Also ## See Also
- [Dashboard Auth Feature](https://ccs.kaitran.ca/features/dashboard-auth) - Full documentation - [Dashboard Auth Feature](https://ccs.kaitran.ca/features/dashboard-auth) - Full documentation
+1 -1
View File
@@ -38,7 +38,7 @@ const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
* Normalize context group names so paths and config stay consistent. * Normalize context group names so paths and config stay consistent.
*/ */
export function normalizeContextGroupName(value: string): string { export function normalizeContextGroupName(value: string): string {
return value.trim().toLowerCase(); return value.trim().toLowerCase().replace(/\s+/g, '-');
} }
/** /**
+5
View File
@@ -14,6 +14,7 @@
import ProfileRegistry from './profile-registry'; import ProfileRegistry from './profile-registry';
import { InstanceManager } from '../management/instance-manager'; import { InstanceManager } from '../management/instance-manager';
import { initUI, header, subheader, color, dim, warn, fail } from '../utils/ui'; import { initUI, header, subheader, color, dim, warn, fail } from '../utils/ui';
import { MAX_CONTEXT_GROUP_LENGTH } from './account-context';
import packageJson from '../../package.json'; import packageJson from '../../package.json';
// Import command handlers from modular structure // Import command handlers from modular structure
@@ -127,6 +128,10 @@ class AuthCommands {
console.log( console.log(
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.` ` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
); );
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.`
);
console.log(''); console.log('');
} }
+57
View File
@@ -0,0 +1,57 @@
const AMBIENT_PROVIDER_PREFIXES = [
'ANTHROPIC_',
'OPENAI_',
'GOOGLE_',
'GEMINI_',
'MINIMAX_',
'QWEN_',
'DEEPSEEK_',
'KIMI_',
'AZURE_',
'OLLAMA_',
'OPENROUTER_',
'XAI_',
'MISTRAL_',
'COHERE_',
'PERPLEXITY_',
'TOGETHER_',
'FIREWORKS_',
];
const AMBIENT_PROVIDER_EXACT_KEYS = new Set([
'OPENROUTER_API_KEY',
'OPENROUTER_KEY',
'XAI_API_KEY',
'MISTRAL_API_KEY',
'COHERE_API_KEY',
]);
const AMBIENT_PROVIDER_SUFFIXES = [
'_API_KEY',
'_AUTH_TOKEN',
'_ACCESS_TOKEN',
'_SECRET_KEY',
'_API_TOKEN',
'_BEARER_TOKEN',
'_SESSION_TOKEN',
];
export function stripAmbientProviderCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const sanitized: NodeJS.ProcessEnv = { ...env };
for (const envKey of Object.keys(sanitized)) {
const normalizedKey = envKey.toUpperCase();
if (normalizedKey === 'CLAUDE_CONFIG_DIR') {
continue;
}
if (
AMBIENT_PROVIDER_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix)) ||
AMBIENT_PROVIDER_EXACT_KEYS.has(normalizedKey) ||
AMBIENT_PROVIDER_SUFFIXES.some((suffix) => normalizedKey.endsWith(suffix))
) {
delete sanitized[envKey];
}
}
return sanitized;
}
+26 -60
View File
@@ -20,52 +20,12 @@ import {
import { exitWithError } from '../../errors'; import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes'; import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, parseArgs } from './types'; import { CommandContext, parseArgs } from './types';
import { stripAmbientProviderCredentials } from './create-command-env';
function sanitizeProfileNameForInstance(name: string): string { function sanitizeProfileNameForInstance(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
} }
const AMBIENT_PROVIDER_PREFIXES = [
'ANTHROPIC_',
'OPENAI_',
'GOOGLE_',
'GEMINI_',
'MINIMAX_',
'QWEN_',
'DEEPSEEK_',
'KIMI_',
'AZURE_',
'OLLAMA_',
];
const AMBIENT_PROVIDER_EXACT_KEYS = new Set([
'OPENROUTER_API_KEY',
'OPENROUTER_KEY',
'XAI_API_KEY',
'MISTRAL_API_KEY',
'COHERE_API_KEY',
]);
const AMBIENT_PROVIDER_SUFFIXES = ['_API_KEY', '_AUTH_TOKEN', '_ACCESS_TOKEN', '_SECRET_KEY'];
function stripAmbientProviderCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const sanitized: NodeJS.ProcessEnv = { ...env };
for (const envKey of Object.keys(sanitized)) {
if (envKey === 'CLAUDE_CONFIG_DIR') {
continue;
}
if (
AMBIENT_PROVIDER_PREFIXES.some((prefix) => envKey.startsWith(prefix)) ||
AMBIENT_PROVIDER_EXACT_KEYS.has(envKey) ||
AMBIENT_PROVIDER_SUFFIXES.some((suffix) => envKey.endsWith(suffix))
) {
delete sanitized[envKey];
}
}
return sanitized;
}
/** /**
* Handle the create command * Handle the create command
*/ */
@@ -74,9 +34,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
const { profileName, force, shareContext, contextGroup, unknownFlags } = parseArgs(args); const { profileName, force, shareContext, contextGroup, unknownFlags } = parseArgs(args);
if (unknownFlags && unknownFlags.length > 0) { if (unknownFlags && unknownFlags.length > 0) {
const unknownList = unknownFlags.join(', '); const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
console.log(fail(`Unknown option(s): ${unknownList}`)); console.log(fail(`Unknown option(s): ${unknownList}`));
console.log(''); console.log('');
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', 'command')}`
);
console.log(`Help: ${color('ccs auth --help', 'command')}`);
console.log('');
exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR); exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR);
} }
@@ -135,15 +100,17 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
const contextPolicy = resolvedContext.policy; const contextPolicy = resolvedContext.policy;
const contextMetadata = policyToAccountContextMetadata(contextPolicy); const contextMetadata = policyToAccountContextMetadata(contextPolicy);
const useUnifiedConfig = isUnifiedMode(); const useUnifiedConfig = isUnifiedMode();
const createdProfile = useUnifiedConfig ? !existsUnified : !existsLegacy; const profileExistedBeforeCreate = existsLegacy || existsUnified;
const previousLegacyProfile: ProfileMetadata | undefined = const createdUnifiedProfile = useUnifiedConfig && !existsUnified;
!useUnifiedConfig && existsLegacy ? ctx.registry.getProfile(profileName) : undefined; const createdLegacyProfile = !useUnifiedConfig && !existsLegacy;
const previousUnifiedProfile = const previousLegacyProfile: ProfileMetadata | undefined = existsLegacy
useUnifiedConfig && existsUnified ? ctx.registry.getProfile(profileName)
? ctx.registry.getAllAccountsUnified()[profileName] : undefined;
: undefined; const previousUnifiedProfile = existsUnified
? ctx.registry.getAllAccountsUnified()[profileName]
: undefined;
const previousContextPolicy = const previousContextPolicy =
!createdProfile && (previousUnifiedProfile || previousLegacyProfile) profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile)
? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile) ? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile)
: undefined; : undefined;
@@ -160,22 +127,21 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
const rollbackMetadata = (): void => { const rollbackMetadata = (): void => {
try { try {
if (useUnifiedConfig) { if (useUnifiedConfig) {
if (createdProfile) { if (createdUnifiedProfile) {
if (ctx.registry.hasAccountUnified(profileName)) { if (ctx.registry.hasAccountUnified(profileName)) {
ctx.registry.removeAccountUnified(profileName); ctx.registry.removeAccountUnified(profileName);
} }
} else if (previousUnifiedProfile) { } else if (previousUnifiedProfile) {
ctx.registry.updateAccountUnified(profileName, previousUnifiedProfile); ctx.registry.updateAccountUnified(profileName, previousUnifiedProfile);
} }
return; } else {
} if (createdLegacyProfile) {
if (ctx.registry.hasProfile(profileName)) {
if (createdProfile) { ctx.registry.deleteProfile(profileName);
if (ctx.registry.hasProfile(profileName)) { }
ctx.registry.deleteProfile(profileName); } else if (previousLegacyProfile) {
ctx.registry.updateProfile(profileName, previousLegacyProfile);
} }
} else if (previousLegacyProfile) {
ctx.registry.updateProfile(profileName, previousLegacyProfile);
} }
} catch { } catch {
// Best-effort rollback to avoid leaving stale accounts after failed login. // Best-effort rollback to avoid leaving stale accounts after failed login.
@@ -190,7 +156,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
rollbackMetadata(); rollbackMetadata();
if (createdProfile) { if (!profileExistedBeforeCreate) {
try { try {
ctx.instanceMgr.deleteInstance(profileName); ctx.instanceMgr.deleteInstance(profileName);
} catch { } catch {
+28 -4
View File
@@ -8,6 +8,7 @@ import {
} from '../config/unified-config-loader'; } from '../config/unified-config-loader';
import type { AccountConfig } from '../config/unified-config-types'; import type { AccountConfig } from '../config/unified-config-types';
import { getCcsDir } from '../utils/config-manager'; import { getCcsDir } from '../utils/config-manager';
import { isValidContextGroupName, normalizeContextGroupName } from './account-context';
/** /**
* Profile Registry (Simplified) * Profile Registry (Simplified)
@@ -51,13 +52,31 @@ export class ProfileRegistry {
this.profilesPath = path.join(getCcsDir(), 'profiles.json'); this.profilesPath = path.join(getCcsDir(), 'profiles.json');
} }
private normalizeContextGroupValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = normalizeContextGroupName(value);
if (normalized.length === 0 || !isValidContextGroupName(normalized)) {
return undefined;
}
return normalized;
}
private normalizeLegacyProfileMetadata(metadata: ProfileMetadata): ProfileMetadata { private normalizeLegacyProfileMetadata(metadata: ProfileMetadata): ProfileMetadata {
const normalized: ProfileMetadata = { ...metadata }; const normalized: ProfileMetadata = { ...metadata };
if (normalized.context_mode !== 'shared') { if (normalized.context_mode !== 'shared') {
delete normalized.context_group; delete normalized.context_group;
} else if (!normalized.context_group || normalized.context_group.trim().length === 0) { } else {
delete normalized.context_group; const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
if (normalizedGroup) {
normalized.context_group = normalizedGroup;
} else {
delete normalized.context_group;
}
} }
return normalized; return normalized;
@@ -68,8 +87,13 @@ export class ProfileRegistry {
if (normalized.context_mode !== 'shared') { if (normalized.context_mode !== 'shared') {
delete normalized.context_group; delete normalized.context_group;
} else if (!normalized.context_group || normalized.context_group.trim().length === 0) { } else {
delete normalized.context_group; const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
if (normalizedGroup) {
normalized.context_group = normalizedGroup;
} else {
delete normalized.context_group;
}
} }
return normalized; return normalized;
+4 -98
View File
@@ -8,8 +8,8 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { createHash } from 'crypto';
import SharedManager from './shared-manager'; import SharedManager from './shared-manager';
import ProfileContextSyncLock from './profile-context-sync-lock';
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context'; import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
import { getCcsDir } from '../utils/config-manager'; import { getCcsDir } from '../utils/config-manager';
@@ -18,13 +18,13 @@ import { getCcsDir } from '../utils/config-manager';
*/ */
class InstanceManager { class InstanceManager {
private readonly instancesDir: string; private readonly instancesDir: string;
private readonly locksDir: string;
private readonly sharedManager: SharedManager; private readonly sharedManager: SharedManager;
private readonly contextSyncLock: ProfileContextSyncLock;
constructor() { constructor() {
this.instancesDir = path.join(getCcsDir(), 'instances'); this.instancesDir = path.join(getCcsDir(), 'instances');
this.locksDir = path.join(this.instancesDir, '.locks');
this.sharedManager = new SharedManager(); this.sharedManager = new SharedManager();
this.contextSyncLock = new ProfileContextSyncLock(this.instancesDir);
} }
/** /**
@@ -37,7 +37,7 @@ class InstanceManager {
const instancePath = this.getInstancePath(profileName); const instancePath = this.getInstancePath(profileName);
// Serialize context sync operations per profile across processes. // Serialize context sync operations per profile across processes.
await this.withContextSyncLock(profileName, async () => { await this.contextSyncLock.withLock(profileName, async () => {
// Lazy initialization // Lazy initialization
if (!fs.existsSync(instancePath)) { if (!fs.existsSync(instancePath)) {
this.initializeInstance(profileName, instancePath); this.initializeInstance(profileName, instancePath);
@@ -201,100 +201,6 @@ class InstanceManager {
// Replace unsafe characters with dash // Replace unsafe characters with dash
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
} }
private getContextSyncLockPath(profileName: string): string {
const safeName = this.sanitizeName(profileName);
// Keep lock filenames deterministic while preventing normalized-name collisions.
const profileHash = createHash('sha1').update(profileName).digest('hex').slice(0, 8);
return path.join(this.locksDir, `${safeName}-${profileHash}.lock`);
}
private isProcessAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EPERM') {
return true;
}
return false;
}
}
private tryRemoveDeadOwnerLock(lockPath: string): boolean {
try {
const lockContent = fs.readFileSync(lockPath, 'utf8').trim();
const lockPid = Number.parseInt(lockContent, 10);
if (!this.isProcessAlive(lockPid)) {
fs.unlinkSync(lockPath);
return true;
}
} catch {
// Best-effort stale lock cleanup.
}
return false;
}
private async withContextSyncLock<T>(
profileName: string,
callback: () => Promise<T>
): Promise<T> {
const lockPath = this.getContextSyncLockPath(profileName);
const retryDelayMs = 50;
const staleLockMs = 30000;
const timeoutMs = staleLockMs + 5000;
const start = Date.now();
fs.mkdirSync(this.locksDir, { recursive: true, mode: 0o700 });
while (true) {
try {
const fd = fs.openSync(lockPath, 'wx', 0o600);
fs.writeFileSync(fd, `${process.pid}`);
fs.closeSync(fd);
break;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== 'EEXIST') {
throw error;
}
try {
const lockStats = fs.statSync(lockPath);
if (Date.now() - lockStats.mtimeMs > staleLockMs) {
fs.unlinkSync(lockPath);
continue;
}
if (this.tryRemoveDeadOwnerLock(lockPath)) {
continue;
}
} catch {
// Best-effort stale lock cleanup.
}
if (Date.now() - start > timeoutMs) {
throw new Error(`Timed out waiting for profile context lock: ${profileName}`);
}
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
}
try {
return await callback();
} finally {
try {
fs.unlinkSync(lockPath);
} catch {
// Best-effort cleanup.
}
}
}
} }
export { InstanceManager }; export { InstanceManager };
+177
View File
@@ -0,0 +1,177 @@
import * as fs from 'fs';
import * as path from 'path';
import { createHash } from 'crypto';
interface ContextSyncLockPayload {
version: 1;
pid: number;
nonce: string;
acquiredAtMs: number;
}
interface ContextSyncLockSnapshot {
raw: string;
owner: { pid: number; nonce?: string } | null;
}
class ProfileContextSyncLock {
private readonly locksDir: string;
constructor(instancesDir: string) {
this.locksDir = path.join(instancesDir, '.locks');
}
private sanitizeName(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
}
private getLockPath(profileName: string): string {
const safeName = this.sanitizeName(profileName);
const profileHash = createHash('sha1').update(profileName).digest('hex').slice(0, 8);
return path.join(this.locksDir, `${safeName}-${profileHash}.lock`);
}
private isProcessAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EPERM') {
return true;
}
return false;
}
}
private parseContextSyncLock(raw: string): { pid: number; nonce?: string } | null {
const trimmed = raw.trim();
if (trimmed.length === 0) {
return null;
}
try {
const parsed = JSON.parse(trimmed) as Partial<ContextSyncLockPayload>;
if (typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0) {
const nonce =
typeof parsed.nonce === 'string' && parsed.nonce.length > 0 ? parsed.nonce : undefined;
return { pid: parsed.pid, nonce };
}
} catch {
const legacyPid = Number.parseInt(trimmed, 10);
if (Number.isInteger(legacyPid) && legacyPid > 0) {
return { pid: legacyPid };
}
}
return null;
}
private readContextSyncLockSnapshot(lockPath: string): ContextSyncLockSnapshot | null {
try {
const raw = fs.readFileSync(lockPath, 'utf8');
return {
raw,
owner: this.parseContextSyncLock(raw),
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
return null;
}
}
private tryRemoveLockIfUnchanged(lockPath: string, expectedRaw: string): boolean {
try {
const currentRaw = fs.readFileSync(lockPath, 'utf8');
if (currentRaw !== expectedRaw) {
return false;
}
fs.unlinkSync(lockPath);
return true;
} catch {
return false;
}
}
private tryRemoveDeadOwnerLock(lockPath: string, snapshot: ContextSyncLockSnapshot): boolean {
if (!snapshot.owner || this.isProcessAlive(snapshot.owner.pid)) {
return false;
}
return this.tryRemoveLockIfUnchanged(lockPath, snapshot.raw);
}
async withLock<T>(profileName: string, callback: () => Promise<T>): Promise<T> {
const lockPath = this.getLockPath(profileName);
const retryDelayMs = 50;
const staleLockMs = 30000;
const timeoutMs = staleLockMs + 5000;
const start = Date.now();
const ownerPayload: ContextSyncLockPayload = {
version: 1,
pid: process.pid,
nonce: createHash('sha1')
.update(`${process.pid}:${Date.now()}:${Math.random()}`)
.digest('hex')
.slice(0, 16),
acquiredAtMs: Date.now(),
};
const ownerPayloadRaw = JSON.stringify(ownerPayload);
fs.mkdirSync(this.locksDir, { recursive: true, mode: 0o700 });
while (true) {
try {
const fd = fs.openSync(lockPath, 'wx', 0o600);
fs.writeFileSync(fd, ownerPayloadRaw, 'utf8');
fs.closeSync(fd);
break;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== 'EEXIST') {
throw error;
}
const lockSnapshot = this.readContextSyncLockSnapshot(lockPath);
if (lockSnapshot) {
if (this.tryRemoveDeadOwnerLock(lockPath, lockSnapshot)) {
continue;
}
// For malformed lock payloads, fall back to age-based stale cleanup.
if (!lockSnapshot.owner) {
try {
const lockStats = fs.statSync(lockPath);
if (Date.now() - lockStats.mtimeMs > staleLockMs) {
if (this.tryRemoveLockIfUnchanged(lockPath, lockSnapshot.raw)) {
continue;
}
}
} catch {
// Best-effort stale lock cleanup.
}
}
}
if (Date.now() - start > timeoutMs) {
throw new Error(`Timed out waiting for profile context lock: ${profileName}`);
}
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
}
try {
return await callback();
} finally {
this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw);
}
}
}
export default ProfileContextSyncLock;
+45 -13
View File
@@ -63,8 +63,14 @@ class SharedManager {
const resolvedTarget = path.resolve(path.dirname(target), targetLink); const resolvedTarget = path.resolve(path.dirname(target), targetLink);
// Check if target points back to our shared dir or link path // Check if target points back to our shared dir or link path
const sharedDir = path.join(getCcsDir(), 'shared'); const sharedDir = this.resolveCanonicalPath(path.join(getCcsDir(), 'shared'));
if (resolvedTarget.startsWith(sharedDir) || resolvedTarget === linkPath) { const canonicalResolvedTarget = this.resolveCanonicalPath(resolvedTarget);
const canonicalLinkPath = this.resolveCanonicalPath(linkPath);
if (
this.isPathWithinDirectory(canonicalResolvedTarget, sharedDir) ||
canonicalResolvedTarget === canonicalLinkPath
) {
console.log(warn(`Circular symlink detected: ${target}${resolvedTarget}`)); console.log(warn(`Circular symlink detected: ${target}${resolvedTarget}`));
return true; return true;
} }
@@ -692,17 +698,17 @@ class SharedManager {
* Guard project merge operations to known CCS-managed roots only. * Guard project merge operations to known CCS-managed roots only.
*/ */
private isSafeProjectsMergeSource(sourcePath: string, instanceName: string): boolean { private isSafeProjectsMergeSource(sourcePath: string, instanceName: string): boolean {
const resolvedSource = path.resolve(sourcePath); const resolvedSource = this.resolveCanonicalPath(sourcePath);
const sharedContextRoot = path.resolve(path.join(this.sharedDir, 'context-groups')); const sharedContextRoot = this.resolveCanonicalPath(
const instanceProjectsRoot = path.resolve( path.join(this.sharedDir, 'context-groups')
);
const instanceProjectsRoot = this.resolveCanonicalPath(
path.join(this.instancesDir, instanceName, 'projects') path.join(this.instancesDir, instanceName, 'projects')
); );
const isWithin = (child: string, root: string): boolean =>
child === root || child.startsWith(`${root}${path.sep}`);
return ( return (
isWithin(resolvedSource, sharedContextRoot) || isWithin(resolvedSource, instanceProjectsRoot) this.isPathWithinDirectory(resolvedSource, sharedContextRoot) ||
this.isPathWithinDirectory(resolvedSource, instanceProjectsRoot)
); );
} }
@@ -736,7 +742,7 @@ class SharedManager {
projectsPath: string, projectsPath: string,
instanceName: string instanceName: string
): Promise<void> { ): Promise<void> {
const sharedMemoryRoot = path.resolve(path.join(this.sharedDir, 'memory')); const sharedMemoryRoot = this.resolveCanonicalPath(path.join(this.sharedDir, 'memory'));
let projectEntries: fs.Dirent[] = []; let projectEntries: fs.Dirent[] = [];
try { try {
@@ -763,15 +769,20 @@ class SharedManager {
continue; continue;
} }
if (!path.resolve(memoryTarget).startsWith(sharedMemoryRoot)) { const canonicalMemoryTarget = this.resolveCanonicalPath(memoryTarget);
if (!this.isPathWithinDirectory(canonicalMemoryTarget, sharedMemoryRoot)) {
continue; continue;
} }
await fs.promises.unlink(memoryPath); await fs.promises.unlink(memoryPath);
await this.ensureDirectory(memoryPath); await this.ensureDirectory(memoryPath);
if (await this.pathExists(memoryTarget)) { if (await this.pathExists(canonicalMemoryTarget)) {
await this.mergeDirectoryWithConflictCopies(memoryTarget, memoryPath, instanceName); await this.mergeDirectoryWithConflictCopies(
canonicalMemoryTarget,
memoryPath,
instanceName
);
} }
} }
} }
@@ -879,6 +890,27 @@ class SharedManager {
return candidate; return candidate;
} }
private resolveCanonicalPath(targetPath: string): string {
try {
return fs.realpathSync.native(targetPath);
} catch {
return path.resolve(targetPath);
}
}
private isPathWithinDirectory(candidatePath: string, rootPath: string): boolean {
const normalizeForCompare = (inputPath: string): string => {
const resolved = path.resolve(inputPath);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
};
const normalizedCandidate = normalizeForCompare(candidatePath);
const normalizedRoot = normalizeForCompare(rootPath);
const relative = path.relative(normalizedRoot, normalizedCandidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
private async pathExists(targetPath: string): Promise<boolean> { private async pathExists(targetPath: string): Promise<boolean> {
try { try {
await fs.promises.access(targetPath); await fs.promises.access(targetPath);
+11 -12
View File
@@ -206,25 +206,24 @@ router.delete('/:name', (req: Request, res: Response): void => {
return; return;
} }
// Delete from appropriate config (unified and/or legacy) const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name);
let deleted = false; const existsLegacy = registry.hasProfile(name);
if (isUnifiedMode() && registry.hasAccountUnified(name)) {
registry.removeAccountUnified(name);
deleted = true;
}
if (registry.hasProfile(name)) {
registry.deleteProfile(name);
deleted = true;
}
if (!deleted) { if (!existsUnified && !existsLegacy) {
res.status(404).json({ error: `Account not found: ${name}` }); res.status(404).json({ error: `Account not found: ${name}` });
return; return;
} }
// Keep API delete behavior aligned with CLI remove command. // Match CLI remove ordering: delete instance first, metadata second.
instanceMgr.deleteInstance(name); instanceMgr.deleteInstance(name);
if (existsUnified) {
registry.removeAccountUnified(name);
}
if (existsLegacy) {
registry.deleteProfile(name);
}
res.json({ success: true, deleted: name }); res.json({ success: true, deleted: name });
} catch (error) { } catch (error) {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });
+20 -2
View File
@@ -22,7 +22,7 @@ import { isValidContextGroupName, normalizeContextGroupName } from '../../auth/a
const router = Router(); const router = Router();
function validateAccountContextMetadata(config: unknown): string | null { function validateAndNormalizeAccountContextMetadata(config: unknown): string | null {
if (typeof config !== 'object' || config === null) { if (typeof config !== 'object' || config === null) {
return 'Invalid config payload'; return 'Invalid config payload';
} }
@@ -63,6 +63,15 @@ function validateAccountContextMetadata(config: unknown): string | null {
if (!isValidContextGroupName(normalizedGroup)) { if (!isValidContextGroupName(normalizedGroup)) {
return `Invalid config.accounts.${accountName}.context_group`; return `Invalid config.accounts.${accountName}.context_group`;
} }
account.context_group = normalizedGroup;
}
if (mode === 'shared' && typeof group === 'string' && group.trim().length === 0) {
return `Invalid config.accounts.${accountName}.context_group: shared mode requires a non-empty value`;
}
if (mode === 'isolated' && group !== undefined) {
delete account.context_group;
} }
} }
@@ -130,7 +139,7 @@ router.put('/', (req: Request, res: Response): void => {
return; return;
} }
const accountContextError = validateAccountContextMetadata(config); const accountContextError = validateAndNormalizeAccountContextMetadata(config);
if (accountContextError) { if (accountContextError) {
res.status(400).json({ error: accountContextError }); res.status(400).json({ error: accountContextError });
return; return;
@@ -150,6 +159,15 @@ router.put('/', (req: Request, res: Response): void => {
router.post('/migrate', async (req: Request, res: Response): Promise<void> => { router.post('/migrate', async (req: Request, res: Response): Promise<void> => {
try { try {
const dryRun = req.query.dryRun === 'true'; const dryRun = req.query.dryRun === 'true';
if (!needsMigration()) {
res.json({
success: true,
migratedFiles: [],
warnings: [],
alreadyMigrated: true,
});
return;
}
const result = await migrate(dryRun); const result = await migrate(dryRun);
res.json(result); res.json(result);
} catch (error) { } catch (error) {
+11
View File
@@ -40,4 +40,15 @@ describe('account context helpers', () => {
expect(resolved.mode).toBe('shared'); expect(resolved.mode).toBe('shared');
expect(resolved.group).toBe('sprint-a'); expect(resolved.group).toBe('sprint-a');
}); });
it('normalizes whitespace in explicit shared context group names', () => {
const result = resolveCreateAccountContext({
shareContext: false,
contextGroup: ' Team Alpha ',
});
expect(result.error).toBeUndefined();
expect(result.policy.mode).toBe('shared');
expect(result.policy.group).toBe('team-alpha');
});
}); });
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import ProfileRegistry from '../../../src/auth/profile-registry';
describe('profile-registry context normalization', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let originalUnifiedMode: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-registry-context-'));
originalCcsHome = process.env.CCS_HOME;
originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalUnifiedMode !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode;
else delete process.env.CCS_UNIFIED_CONFIG;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('drops non-string legacy context_group values without throwing', () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'profiles.json'),
JSON.stringify(
{
version: '2.0.0',
default: null,
profiles: {
work: {
type: 'account',
created: '2026-02-24T00:00:00.000Z',
last_used: null,
context_mode: 'shared',
context_group: { invalid: true },
},
},
},
null,
2
),
'utf8'
);
const registry = new ProfileRegistry();
const profile = registry.getProfile('work');
expect(profile.context_mode).toBe('shared');
expect(profile.context_group).toBeUndefined();
});
it('drops non-string unified context_group values without throwing', () => {
process.env.CCS_UNIFIED_CONFIG = '1';
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-24T00:00:00.000Z"',
' last_used: null',
' context_mode: shared',
' context_group: 123',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const registry = new ProfileRegistry();
const accounts = registry.getAllAccountsUnified();
expect(accounts.work.context_mode).toBe('shared');
expect(accounts.work.context_group).toBeUndefined();
});
});
+44
View File
@@ -13,6 +13,12 @@ function getTestCcsDir(): string {
return path.join(path.resolve(process.env.CCS_HOME), '.ccs'); return path.join(path.resolve(process.env.CCS_HOME), '.ccs');
} }
function createDirectorySymlink(targetPath: string, linkPath: string): void {
const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir';
const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath;
fs.symlinkSync(linkTarget, linkPath, symlinkType);
}
describe('SharedManager context policy', () => { describe('SharedManager context policy', () => {
let tempRoot = ''; let tempRoot = '';
let originalHome: string | undefined; let originalHome: string | undefined;
@@ -134,4 +140,42 @@ describe('SharedManager context policy', () => {
expect(stats.isDirectory() || stats.isSymbolicLink()).toBe(true); expect(stats.isDirectory() || stats.isSymbolicLink()).toBe(true);
}); });
it('skips merge when projects symlink target is outside canonical CCS roots', async () => {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
const projectsPath = path.join(instancePath, 'projects');
const unsafeProjectsPath = path.join(ccsDir, 'shared', 'context-groups-evil', 'projects');
const unsafeFile = path.join(unsafeProjectsPath, '-tmp-project', 'notes.md');
fs.mkdirSync(path.dirname(unsafeFile), { recursive: true });
fs.writeFileSync(unsafeFile, 'unsafe source', 'utf8');
fs.mkdirSync(instancePath, { recursive: true });
createDirectorySymlink(unsafeProjectsPath, projectsPath);
const manager = new SharedManager();
await manager.syncProjectContext(instancePath, { mode: 'isolated' });
expect(fs.lstatSync(projectsPath).isDirectory()).toBe(true);
expect(fs.existsSync(path.join(projectsPath, '-tmp-project', 'notes.md'))).toBe(false);
});
it('does not detach project memory symlink from lookalike shared path prefixes', async () => {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
const projectPath = path.join(instancePath, 'projects', '-tmp-project');
const memoryPath = path.join(projectPath, 'memory');
const unsafeMemoryTarget = path.join(ccsDir, 'shared', 'memory-evil', '-tmp-project');
const unsafeMemoryFile = path.join(unsafeMemoryTarget, 'MEMORY.md');
fs.mkdirSync(path.dirname(unsafeMemoryFile), { recursive: true });
fs.writeFileSync(unsafeMemoryFile, 'unsafe memory', 'utf8');
fs.mkdirSync(projectPath, { recursive: true });
createDirectorySymlink(unsafeMemoryTarget, memoryPath);
const manager = new SharedManager();
await manager.syncProjectContext(instancePath, { mode: 'isolated' });
expect(fs.lstatSync(memoryPath).isSymbolicLink()).toBe(true);
});
}); });
@@ -5,6 +5,8 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import type { Server } from 'http'; import type { Server } from 'http';
import accountRoutes from '../../../src/web-server/routes/account-routes'; import accountRoutes from '../../../src/web-server/routes/account-routes';
import ProfileRegistry from '../../../src/auth/profile-registry';
import { InstanceManager } from '../../../src/management/instance-manager';
async function getJson<T>(baseUrl: string, routePath: string): Promise<T> { async function getJson<T>(baseUrl: string, routePath: string): Promise<T> {
const response = await fetch(`${baseUrl}${routePath}`); const response = await fetch(`${baseUrl}${routePath}`);
@@ -12,6 +14,10 @@ async function getJson<T>(baseUrl: string, routePath: string): Promise<T> {
return (await response.json()) as T; return (await response.json()) as T;
} }
async function deletePath(baseUrl: string, routePath: string): Promise<Response> {
return fetch(`${baseUrl}${routePath}`, { method: 'DELETE' });
}
describe('web-server account-routes context normalization', () => { describe('web-server account-routes context normalization', () => {
let server: Server; let server: Server;
let baseUrl = ''; let baseUrl = '';
@@ -130,4 +136,41 @@ describe('web-server account-routes context normalization', () => {
expect(work?.context_mode).toBe('shared'); expect(work?.context_mode).toBe('shared');
expect(work?.context_group).toBe('default'); expect(work?.context_group).toBe('default');
}); });
it('does not delete metadata when instance deletion fails', 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: shared',
' context_group: sprint-a',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const registry = new ProfileRegistry();
const originalDeleteInstance = InstanceManager.prototype.deleteInstance;
InstanceManager.prototype.deleteInstance = () => {
throw new Error('simulated instance delete failure');
};
try {
const response = await deletePath(baseUrl, '/api/accounts/work');
expect(response.status).toBe(500);
expect(registry.hasAccountUnified('work')).toBe(true);
} finally {
InstanceManager.prototype.deleteInstance = originalDeleteInstance;
}
});
}); });
@@ -6,6 +6,7 @@ import * as path from 'path';
import type { Server } from 'http'; import type { Server } from 'http';
import configRoutes from '../../../src/web-server/routes/config-routes'; import configRoutes from '../../../src/web-server/routes/config-routes';
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
import { loadUnifiedConfig } from '../../../src/config/unified-config-loader';
async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> { async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
return fetch(`${baseUrl}${routePath}`, { return fetch(`${baseUrl}${routePath}`, {
@@ -17,6 +18,16 @@ async function putJson(baseUrl: string, routePath: string, body: unknown): Promi
}); });
} }
async function postJson(baseUrl: string, routePath: string, body?: unknown): Promise<Response> {
return fetch(`${baseUrl}${routePath}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
});
}
describe('web-server config-routes account context validation', () => { describe('web-server config-routes account context validation', () => {
let server: Server; let server: Server;
let baseUrl = ''; let baseUrl = '';
@@ -124,6 +135,26 @@ describe('web-server config-routes account context validation', () => {
expect(payload.error).toContain('context_group'); 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 () => { it('accepts valid shared context metadata', async () => {
const config = createEmptyUnifiedConfig(); const config = createEmptyUnifiedConfig();
config.accounts.work = { config.accounts.work = {
@@ -137,5 +168,24 @@ describe('web-server config-routes account context validation', () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
const payload = (await response.json()) as { success: boolean }; const payload = (await response.json()) as { success: boolean };
expect(payload.success).toBe(true); 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);
}); });
}); });