diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76254243..e4041af2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,5 +30,5 @@ jobs: - name: Build package run: bun run build:all - - name: Validate (typecheck + lint + tests) + - name: Validate (typecheck + lint + format + maintainability [warn on PR] + tests) run: bun run validate diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index c32b9f91..35db2a30 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -42,7 +42,7 @@ jobs: - name: Build run: bun run build:all - - name: Validate (typecheck + lint + tests) + - name: Validate (typecheck + lint + format + maintainability [strict] + tests) run: bun run validate - name: Release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04485546..1d91bfe3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: - name: Build package run: bun run build:all - - name: Validate (typecheck + lint + tests) + - name: Validate (typecheck + lint + format + maintainability [strict] + tests) run: bun run validate - name: Release diff --git a/CLAUDE.md b/CLAUDE.md index 80f46d55..8c5be4d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ CLI wrapper for instant switching between multiple provider accounts and alterna | Mistake | Consequence | Correct Action | |---------|-------------|----------------| | Running `validate` without `format` first | format:check fails | Run `bun run format` BEFORE validate | +| Assuming maintainability check is always strict | PR/feature branches run warning mode by default | Use `bun run maintainability:check:strict` before merge when touching debt-sensitive code | | Using `chore:` for dev→main PR | No npm release triggered | Use `feat:` or `fix:` prefix | | Committing directly to `main` or `dev` | Bypasses CI/review | Always use PRs | | Manual version bump or git tag | Conflicts with semantic-release | Let CI handle versioning | @@ -59,7 +60,7 @@ Quality gates MUST pass before pushing. **Both projects have identical workflow. # Main project (from repo root) bun run format # Step 1: Fix formatting bun run lint:fix # Step 2: Fix lint issues -bun run validate # Step 3: Full test gate (must pass) +bun run validate # Step 3: Full gate (typecheck + lint + format + maintainability + tests) bun run validate:ci-parity # Step 4: CI parity gate (build + validate + base branch check) # UI project (if UI changed) @@ -78,7 +79,7 @@ bun run validate # Step 3: Final check (must pass) | Project | Command | Runs | |---------|---------|------| -| Main | `bun run validate` | typecheck + lint:fix + format:check + test:all | +| Main | `bun run validate` | typecheck + lint:fix + format:check + maintainability:check + test:all | | UI | `bun run validate` | typecheck + lint:fix + format:check | ### ESLint Rules (ALL errors) @@ -105,10 +106,31 @@ bun run validate # Step 3: Final check (must pass) ### Automatic Enforcement - `prepublishOnly` / `prepack` runs `build:all` + `validate` + `sync-version.js` -- CI/CD runs `bun run validate` on every PR +- CI/CD runs `bun run validate` on every PR (maintainability is warning mode on PR events) - husky `pre-commit` runs quick lint/type/format checks - husky `pre-push` runs `bun run validate:ci-parity` to block CI drift before push +### Maintainability Baseline Gate + +- Baseline file: `docs/metrics/maintainability-baseline.json` +- Metric collector/check script: `scripts/maintainability-baseline.js` +- Branch-aware gate wrapper: `scripts/maintainability-check.js` +- Enforcement path: `bun run maintainability:check` (included in `bun run validate`) +- Gate modes: + - `strict`: protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) and equivalent CI refs + - `warn`: pull request CI and non-protected local branches (non-blocking for parallel PR workflow) + - override commands: + - `bun run maintainability:check:strict` + - `bun run maintainability:check:warn` +- Gated metrics (must not increase vs baseline): + - `processExitReferenceCount` + - `synchronousFsApiReferenceCount` + - `largeFileCountOver350Loc` +- Baseline update policy: + 1. Prefer reducing the metric and keeping the baseline unchanged. + 2. On protected-branch integration (strict mode), if increase is intentional and accepted, run `bun run maintainability:baseline`. + 3. Commit both the code change and `docs/metrics/maintainability-baseline.json`, and state reason in PR description. + ## Critical Constraints (NEVER VIOLATE) 1. **NO EMOJIS in CLI output** - Terminal output uses ASCII only: [OK], [!], [X], [i] @@ -359,6 +381,8 @@ rm -rf ~/.ccs # Clean environment - [ ] `bun run validate` — all checks pass - [ ] `bun run validate:ci-parity` — CI parity passed (also enforced by pre-push hook) - [ ] `cd ui && bun run format && bun run validate` — if UI changed +- [ ] If touching debt-sensitive code, run `bun run maintainability:check:strict` before opening/merging PR +- [ ] If strict mode fails and increase is intentional: `bun run maintainability:baseline` and commit `docs/metrics/maintainability-baseline.json` **Code:** - [ ] Conventional commit format (`feat:`, `fix:`, etc.) diff --git a/README.md b/README.md index 9c173863..d626d812 100644 --- a/README.md +++ b/README.md @@ -53,15 +53,19 @@ ccs config # Opens http://localhost:3000 ``` +Dashboard updates hub: `http://localhost:3000/updates` + Want to run the dashboard in Docker? See `docker/README.md`. ### 3. Configure Your Accounts 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 - **API Profiles**: Configure GLM, Kimi with your keys +- **Factory Droid**: Track Droid install location and BYOK settings health +- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations) - **Health Monitor**: Real-time status across all profiles **Analytics Dashboard** @@ -154,6 +158,56 @@ ccsd glm Need additional alias names? Set `CCS_DROID_ALIASES` as a comma-separated list (for example: `CCS_DROID_ALIASES=ccs-droid,mydroid`). +For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hint +(`CCS_DROID_PROVIDER`) using one of: +`anthropic`, `openai`, or `generic-chat-completion-api`. +If the hint is missing, CCS resolves provider from base URL/model at runtime. + +CCS also persists Droid's active model selector in `~/.factory/settings.json` +(`model: custom:`). This avoids passing `-m` argv in interactive mode, +which Droid treats as queued prompt text. + +CCS supports structural Droid command passthrough after profile selection: + +```bash +ccsd codex exec --skip-permissions-unsafe "fix failing tests" +ccsd codex --skip-permissions-unsafe "fix failing tests" # auto-routed to: droid exec ... +ccsd codex -m custom:gpt-5.3-codex "fix failing tests" # short exec flags auto-routed too +``` + +If you pass exec-only flags without a prompt (for example `--skip-permissions-unsafe`), +Droid `exec` will return its native "No prompt provided" usage guidance. + +If multiple reasoning flags are provided in Droid exec mode, CCS keeps the first +flag and warns about duplicates. + +Dashboard parity: `ccs config` -> `Factory Droid` + +### Per-Profile Target Defaults + +You can pin a default target (`claude` or `droid`) per profile: + +```bash +# API profile defaults to Droid +ccs api create myglm --preset glm --target droid + +# CLIProxy variant defaults to Droid +ccs cliproxy create mycodex --provider codex --target droid +``` + +Built-in CLIProxy providers also work with Droid alias/target override: + +```bash +ccsd codex +ccsd agy +ccs codex --target droid +ccsd codex exec --auto high "triage this bug report" +``` + +Dashboard parity: +- `ccs config` -> `API Profiles` -> set **Default Target** +- `ccs config` -> `CLIProxy` -> create/edit variant -> set **Default Target** + ### Kiro Auth Methods `ccs kiro --auth` defaults to AWS Builder ID Device OAuth (best support for AWS org accounts). @@ -221,17 +275,66 @@ ccs work "implement feature" # Terminal 1 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` | + +Shared mode continuity depth: + +- `standard` (default): shares project workspace context only +- `deeper` (advanced opt-in): additionally syncs `session-env`, `file-history`, `shell-snapshots`, `todos` + +Opt in to shared context when needed: ```bash # Share context with default group 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 + +# Advanced deeper continuity mode (requires shared mode) +ccs auth create backup3 --context-group sprint-a --deeper-continuity ``` -Isolation remains the default. Shared context only links project workspace data; credentials stay per-account. +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 + continuity depth + +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" + continuity_mode: "standard" +``` + +`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 with `standard` depth links project workspace data. `deeper` depth links additional continuity artifacts. Credentials remain isolated per account. + +Alternative path for lower manual switching: + +- Use CLIProxy Claude pool (`ccs cliproxy auth claude`) and manage pool behavior in `ccs config` -> `CLIProxy Plus`. + +Technical details: [`docs/session-sharing-technical-analysis.md`](docs/session-sharing-technical-analysis.md)
diff --git a/config/base-iflow.settings.json b/config/base-iflow.settings.json index 2fc56b47..4d9c3ba2 100644 --- a/config/base-iflow.settings.json +++ b/config/base-iflow.settings.json @@ -2,9 +2,9 @@ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/iflow", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", - "ANTHROPIC_MODEL": "deepseek-v3.2", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-k2-thinking", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v3.2", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "minimax-m2" + "ANTHROPIC_MODEL": "qwen3-coder-plus", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3-coder-plus", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "qwen3-coder-plus", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qwen3-coder-plus" } } diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 972b56c6..ae78f67a 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,8 +1,8 @@ # 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 @@ -201,6 +201,20 @@ src/ | Services | `web-server/`, `api/` | HTTP server, API services | | Utilities | `utils/`, `management/` | Helpers, diagnostics | +### Account Context Metadata Flow + +- Source fields: `accounts..context_mode`, `accounts..context_group`, `accounts..continuity_mode` 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` + - shared mode continuity depth is `standard` by default, optional `deeper` + - `context_group` is normalized (trim + lowercase + whitespace collapse to `-`) + - API route rejects `context_group`/`continuity_mode` when mode is not `shared` + - registry normalization drops malformed persisted `context_group` values + ### Target Adapter Module The targets module provides an extensible interface for dispatching profiles to different CLI implementations. diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index 60c442e4..1449e0ec 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -1,6 +1,6 @@ # Dashboard Authentication CLI -Last Updated: 2026-02-04 +Last Updated: 2026-02-26 CLI commands for managing CCS dashboard authentication. @@ -10,6 +10,50 @@ 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. +## Account Context Modes (Related Feature) + +Dashboard auth and account context metadata are separate: + +- `dashboard_auth`: protects dashboard access with username/password +- `accounts..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` | + +Shared continuity depth: + +- `standard` (default): shares project workspace context only +- `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos` + +`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 +- `continuity_mode` is only valid when mode is `shared` + +`PUT /api/config` behavior for account context: + +- rejects invalid unified payloads +- rejects explicit `context_mode: shared` with invalid/empty `context_group` +- rejects invalid `continuity_mode` values +- normalizes valid shared `context_group` before save +- defaults missing shared `continuity_mode` to `standard` +- rejects `context_group` when mode is not `shared` +- rejects `continuity_mode` when mode is not `shared` + +Dashboard accounts context editing: + +- `PUT /api/accounts/:name/context` updates context mode/group/continuity for existing auth accounts +- rejects CLIProxy OAuth account keys for this route +- applies normalization/validation rules above + ## Commands ### `ccs config auth setup` @@ -162,6 +206,10 @@ export CCS_DASHBOARD_PASSWORD_HASH='$2b$10$...' 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 - [Dashboard Auth Feature](https://ccs.kaitran.ca/features/dashboard-auth) - Full documentation diff --git a/docs/metrics/maintainability-baseline.json b/docs/metrics/maintainability-baseline.json index 734ab29f..773fb0b9 100644 --- a/docs/metrics/maintainability-baseline.json +++ b/docs/metrics/maintainability-baseline.json @@ -1,9 +1,9 @@ { "sourceDirectory": "src", "largeFileThresholdLoc": 350, - "typeScriptFileCount": 355, - "locInSrc": 71353, + "typeScriptFileCount": 385, + "locInSrc": 81556, "processExitReferenceCount": 185, - "synchronousFsApiReferenceCount": 879, - "largeFileCountOver350Loc": 56 + "synchronousFsApiReferenceCount": 892, + "largeFileCountOver350Loc": 58 } diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 07598a41..2ef26085 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -203,15 +203,21 @@ All criteria achieved: ## Maintainability Gate (Issue #539 Foundation) - Baseline metrics artifact: `docs/metrics/maintainability-baseline.json` +- Branch-aware gate wrapper: `scripts/maintainability-check.js` - Generate or refresh baseline: - `bun run maintainability:baseline` - `npm run maintainability:baseline` - Run regression check gate: - `bun run maintainability:check` - `npm run maintainability:check` + - `bun run maintainability:check:strict` (force strict locally) The baseline/check scripts enumerate git-tracked files under `src` for deterministic results and fail fast if git file listing is unavailable. +Default gate behavior: +- strict mode on protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) +- warning mode on PR CI and non-protected branches (parallel PR friendly) + The check mode supports a maintainability regression gate that blocks increases in: - `process.exit` references - synchronous fs API references diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md new file mode 100644 index 00000000..9e4b9a68 --- /dev/null +++ b/docs/session-sharing-technical-analysis.md @@ -0,0 +1,89 @@ +# 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` + `standard` (default): account workspace context is linked to a shared context group +- `shared` + `deeper` (advanced opt-in): account also shares continuity artifacts + +## 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" + continuity_mode: "deeper" +``` + +Rules: + +- `context_mode` must be `isolated` or `shared` +- `context_group` is required when `context_mode=shared` +- `continuity_mode` is valid only when `context_mode=shared` (`standard` or `deeper`) +- group normalization: trim, lowercase, internal spaces -> `-` +- group must start with a letter and only include `[a-zA-Z0-9_-]` +- max length: `64` + +Deeper continuity links these directories per context group: + +- `session-env` +- `file-history` +- `shell-snapshots` +- `todos` + +`.anthropic` and account credentials remain isolated. + +## User Workflows + +### New account with shared context + +```bash +ccs auth create work2 --share-context +ccs auth create backup --context-group sprint-a +ccs auth create backup2 --context-group sprint-a --deeper-continuity +``` + +### Existing account + +- Open `ccs config` +- Go to `Accounts` +- Click the pencil icon (`Edit History Sync`) +- Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity + +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. + +## Alternative: CLIProxy Claude Pool + +For users who prefer lower manual account switching, use CLIProxy Claude pool instead: + +- Authenticate pool accounts via `ccs cliproxy auth claude` +- Manage account pool behavior in `ccs config` -> `CLIProxy Plus` + +## 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/docs/websearch.md b/docs/websearch.md index 06e93141..c1b44841 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -1,6 +1,6 @@ # WebSearch Configuration Guide -Last Updated: 2026-02-04 +Last Updated: 2026-02-26 CCS provides automatic web search capability for all profiles, including third-party providers that cannot access Anthropic's native WebSearch API. @@ -19,7 +19,7 @@ Third-party profiles (OAuth and API-based) cannot use Anthropic's WebSearch beca CCS solves this with a hybrid fallback approach: -1. **Gemini CLI Transformer** (Primary) - Uses `gemini -p` with `google_web_search` tool +1. **Gemini CLI Transformer** (Primary) - Uses positional Gemini prompt mode (with legacy `-p` fallback) and `google_web_search` tool 2. **MCP Fallback Chain** (Secondary) - MCP-based web search servers ## Architecture @@ -53,7 +53,7 @@ The **ultimate solution** for third-party WebSearch. Uses `gemini` CLI with OAut ### How It Works 1. A PreToolUse hook intercepts WebSearch tool calls -2. Executes `gemini -p` with explicit google_web_search instruction +2. Executes `gemini ""` (positional mode) with explicit google_web_search instruction 3. Returns search results directly to Claude via the hook's deny reason 4. Claude receives full search results and continues the conversation diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 2df55450..8d8a99ab 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -282,6 +282,61 @@ async function processHook() { /** * Execute search via Gemini CLI */ +function shouldRetryGeminiWithLegacyPrompt(errorMessage) { + const lowerError = (errorMessage || '').toLowerCase(); + + return ( + lowerError.includes('unknown option') || + lowerError.includes('unknown argument') || + lowerError.includes('unrecognized option') || + lowerError.includes('usage: gemini') || + lowerError.includes('use --prompt') || + lowerError.includes('using the --prompt option') + ); +} + +function runGeminiCommand(args, timeoutMs) { + const spawnResult = spawnSync('gemini', args, { + encoding: 'utf8', + timeout: timeoutMs, + maxBuffer: 1024 * 1024 * 2, + stdio: ['pipe', 'pipe', 'pipe'], + shell: isWindows, + }); + + if (spawnResult.error) { + if (spawnResult.error.code === 'ENOENT') { + return { success: false, error: 'Gemini CLI not installed' }; + } + throw spawnResult.error; + } + + if (spawnResult.status !== 0) { + const stderr = (spawnResult.stderr || '').trim(); + return { + success: false, + error: stderr || `Gemini CLI exited with code ${spawnResult.status}`, + }; + } + + const result = (spawnResult.stdout || '').trim(); + + if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) { + return { success: false, error: 'Empty or too short response from Gemini' }; + } + + const lowerResult = result.toLowerCase(); + if ( + lowerResult.includes('error:') || + lowerResult.includes('failed to') || + lowerResult.includes('authentication required') + ) { + return { success: false, error: `Gemini returned error: ${result.substring(0, 100)}` }; + } + + return { success: true, content: result }; +} + function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { try { const timeoutMs = timeoutSec * 1000; @@ -290,54 +345,31 @@ function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { // Allow model override via env var const model = process.env.CCS_WEBSEARCH_GEMINI_MODEL || config.model; + const baseArgs = ['--model', model, '--yolo']; + const positionalArgs = [...baseArgs, prompt]; if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Executing: gemini --model ${model} --yolo "..."`); + } + + // Current Gemini CLI prefers positional prompts and deprecates -p/--prompt. + // Retry once with -p for legacy CLIs that still require it. + const positionalResult = runGeminiCommand(positionalArgs, timeoutMs); + if (positionalResult.success) { + return positionalResult; + } + + if (!shouldRetryGeminiWithLegacyPrompt(positionalResult.error)) { + return positionalResult; + } + + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Positional Gemini prompt failed; retrying with -p for legacy CLI'); console.error(`[CCS Hook] Executing: gemini --model ${model} --yolo -p "..."`); } - const spawnResult = spawnSync( - 'gemini', - ['--model', model, '--yolo', '-p', prompt], - { - encoding: 'utf8', - timeout: timeoutMs, - maxBuffer: 1024 * 1024 * 2, - stdio: ['pipe', 'pipe', 'pipe'], - shell: isWindows, - } - ); - - if (spawnResult.error) { - if (spawnResult.error.code === 'ENOENT') { - return { success: false, error: 'Gemini CLI not installed' }; - } - throw spawnResult.error; - } - - if (spawnResult.status !== 0) { - const stderr = (spawnResult.stderr || '').trim(); - return { - success: false, - error: stderr || `Gemini CLI exited with code ${spawnResult.status}`, - }; - } - - const result = (spawnResult.stdout || '').trim(); - - if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) { - return { success: false, error: 'Empty or too short response from Gemini' }; - } - - const lowerResult = result.toLowerCase(); - if ( - lowerResult.includes('error:') || - lowerResult.includes('failed to') || - lowerResult.includes('authentication required') - ) { - return { success: false, error: `Gemini returned error: ${result.substring(0, 100)}` }; - } - - return { success: true, content: result }; + const legacyPromptArgs = [...baseArgs, '-p', prompt]; + return runGeminiCommand(legacyPromptArgs, timeoutMs); } catch (err) { if (err.killed) { return { success: false, error: 'Gemini CLI timed out' }; @@ -362,17 +394,13 @@ function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { console.error(`[CCS Hook] Executing: opencode run --model ${model} "..."`); } - const spawnResult = spawnSync( - 'opencode', - ['run', prompt, '--model', model], - { - encoding: 'utf8', - timeout: timeoutMs, - maxBuffer: 1024 * 1024 * 2, - stdio: ['pipe', 'pipe', 'pipe'], - shell: isWindows, - } - ); + const spawnResult = spawnSync('opencode', ['run', prompt, '--model', model], { + encoding: 'utf8', + timeout: timeoutMs, + maxBuffer: 1024 * 1024 * 2, + stdio: ['pipe', 'pipe', 'pipe'], + shell: isWindows, + }); if (spawnResult.error) { if (spawnResult.error.code === 'ENOENT') { @@ -599,9 +627,7 @@ function outputNoToolsMessage(query) { * Output all providers failed message */ function outputAllFailedMessage(query, errors) { - const errorDetails = errors - .map((e) => ` - ${e.provider}: ${e.error}`) - .join('\n'); + const errorDetails = errors.map((e) => ` - ${e.provider}: ${e.error}`).join('\n'); const message = [ '[WebSearch - All Providers Failed]', diff --git a/package.json b/package.json index 855675b7..bb5309e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.50.0", + "version": "7.50.0-dev.11", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", @@ -69,7 +69,9 @@ "validate:ci-parity": "bash scripts/ci-parity-gate.sh", "verify:bundle": "node scripts/verify-bundle.js", "maintainability:baseline": "node scripts/maintainability-baseline.js --out docs/metrics/maintainability-baseline.json", - "maintainability:check": "node scripts/maintainability-baseline.js --check docs/metrics/maintainability-baseline.json", + "maintainability:check": "node scripts/maintainability-check.js", + "maintainability:check:strict": "node scripts/maintainability-check.js --strict", + "maintainability:check:warn": "node scripts/maintainability-check.js --warn", "test": "bun run build && bun run test:all", "test:ci": "bun run test:all", "test:all": "bun test tests/unit tests/integration tests/npm", diff --git a/scripts/maintainability-check.js b/scripts/maintainability-check.js new file mode 100644 index 00000000..8971ef84 --- /dev/null +++ b/scripts/maintainability-check.js @@ -0,0 +1,163 @@ +#!/usr/bin/env node + +const { execFileSync, spawnSync } = require('child_process'); +const path = require('path'); + +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const BASELINE_FILE = path.join('docs', 'metrics', 'maintainability-baseline.json'); +const BASELINE_SCRIPT = path.join(PROJECT_ROOT, 'scripts', 'maintainability-baseline.js'); + +const PROTECTED_BRANCHES = new Set(['main', 'dev']); +const HOTFIX_PREFIXES = ['hotfix/', 'kai/hotfix-']; + +function hasFlag(name) { + return process.argv.slice(2).includes(name); +} + +function detectBranchName() { + try { + return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +function isProtectedBranch(branchName) { + if (!branchName) { + return false; + } + + if (PROTECTED_BRANCHES.has(branchName)) { + return true; + } + + return HOTFIX_PREFIXES.some(prefix => branchName.startsWith(prefix)); +} + +function detectMode() { + if (hasFlag('--strict')) { + return 'strict'; + } + + if (hasFlag('--warn')) { + return 'warn'; + } + + if (hasFlag('--off')) { + return 'off'; + } + + const explicitMode = (process.env.CCS_MAINTAINABILITY_MODE || '').toLowerCase().trim(); + if (explicitMode === 'strict' || explicitMode === 'warn' || explicitMode === 'off') { + return explicitMode; + } + + const eventName = process.env.GITHUB_EVENT_NAME || ''; + if (eventName === 'pull_request' || eventName === 'pull_request_target') { + return 'warn'; + } + + const gitHubRef = process.env.GITHUB_REF || ''; + if (gitHubRef.startsWith('refs/heads/')) { + const branchFromRef = gitHubRef.slice('refs/heads/'.length); + if (isProtectedBranch(branchFromRef)) { + return 'strict'; + } + } + + return isProtectedBranch(detectBranchName()) ? 'strict' : 'warn'; +} + +function runBaselineCheck() { + return spawnSync('node', [BASELINE_SCRIPT, '--check', BASELINE_FILE], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function writeStreams(result) { + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } +} + +function tryParseJson(stdout) { + if (!stdout) { + return null; + } + + try { + return JSON.parse(stdout); + } catch { + return null; + } +} + +function formatViolations(violations) { + if (!Array.isArray(violations) || violations.length === 0) { + return []; + } + + return violations.map(violation => { + if (!violation || typeof violation !== 'object') { + return '- unknown violation'; + } + + const metric = violation.metric || 'unknown'; + const baseline = typeof violation.baseline === 'number' ? violation.baseline : 'n/a'; + const current = typeof violation.current === 'number' ? violation.current : 'n/a'; + return `- ${metric}: baseline=${baseline}, current=${current}`; + }); +} + +function main() { + const mode = detectMode(); + + if (mode === 'off') { + console.log('[i] Maintainability gate disabled (mode=off).'); + process.exit(0); + } + + const result = runBaselineCheck(); + if (mode === 'strict') { + writeStreams(result); + process.exit(result.status === null ? 1 : result.status); + } + + if (result.status === 0) { + writeStreams(result); + process.exit(0); + } + + const parsed = tryParseJson(result.stdout); + const branchName = detectBranchName(); + + console.log('[!] Maintainability regression detected (warning-only mode).'); + if (branchName) { + console.log(`[i] Branch: ${branchName}`); + } + + if (parsed && Array.isArray(parsed.violations) && parsed.violations.length > 0) { + console.log('[i] Violations:'); + for (const line of formatViolations(parsed.violations)) { + console.log(line); + } + } else { + writeStreams(result); + } + + console.log('[i] This is non-blocking on PR/feature branches to support parallel workflow.'); + console.log( + '[i] Use strict mode when needed: bun run maintainability:check:strict' + ); +} + +main(); diff --git a/src/api/services/index.ts b/src/api/services/index.ts index bdb07b20..57cceba4 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -15,6 +15,7 @@ export { type ApiListResult, type CreateApiProfileResult, type RemoveApiProfileResult, + type UpdateApiProfileTargetResult, } from './profile-types'; // Profile read operations @@ -27,7 +28,7 @@ export { } from './profile-reader'; // Profile write operations -export { createApiProfile, removeApiProfile } from './profile-writer'; +export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer'; // OpenRouter catalog and picker export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog'; diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index f31d4986..190426da 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -9,8 +9,18 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir, loadConfigSafe } from '../../utils/config-manager'; import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; +import type { TargetType } from '../../targets/target-adapter'; import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; +const VALID_TARGETS: ReadonlySet = new Set(['claude', 'droid']); + +function sanitizeTarget(target: unknown): TargetType { + if (typeof target === 'string' && VALID_TARGETS.has(target as TargetType)) { + return target as TargetType; + } + return 'claude'; +} + /** * Check if API profile exists in config */ @@ -68,6 +78,7 @@ export function listApiProfiles(): ApiListResult { settingsPath: profile.settings || 'config.yaml', isConfigured: isApiProfileConfigured(name), configSource: 'unified', + target: sanitizeTarget(profile.target), }); } // CLIProxy variants @@ -80,10 +91,13 @@ export function listApiProfiles(): ApiListResult { name, provider, settings: variant?.settings || '-', + target: sanitizeTarget(variant?.target), }); } } else { const config = loadConfigSafe(); + const legacyTargetMap = (config as { profile_targets?: Record }) + .profile_targets; for (const [name, settingsPath] of Object.entries(config.profiles)) { // Skip 'default' profile - it's the user's native Claude settings if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) { @@ -94,16 +108,18 @@ export function listApiProfiles(): ApiListResult { settingsPath: settingsPath as string, isConfigured: isApiProfileConfigured(name), configSource: 'legacy', + target: sanitizeTarget(legacyTargetMap?.[name]), }); } // CLIProxy variants if (config.cliproxy) { for (const [name, v] of Object.entries(config.cliproxy)) { - const variant = v as { provider: string; settings: string }; + const variant = v as { provider: string; settings: string; target?: unknown }; variants.push({ name, provider: variant.provider, settings: variant.settings, + target: sanitizeTarget(variant.target), }); } } diff --git a/src/api/services/profile-types.ts b/src/api/services/profile-types.ts index 75a0b76b..62da4667 100644 --- a/src/api/services/profile-types.ts +++ b/src/api/services/profile-types.ts @@ -4,6 +4,8 @@ * Shared type definitions for API profile services. */ +import type { TargetType } from '../../targets/target-adapter'; + /** Model mapping for API profiles */ export interface ModelMapping { default: string; @@ -18,6 +20,7 @@ export interface ApiProfileInfo { settingsPath: string; isConfigured: boolean; configSource: 'unified' | 'legacy'; + target: TargetType; } /** CLIProxy variant info */ @@ -25,6 +28,7 @@ export interface CliproxyVariantInfo { name: string; provider: string; settings: string; + target: TargetType; } /** Result from list operation */ @@ -45,3 +49,10 @@ export interface RemoveApiProfileResult { success: boolean; error?: string; } + +/** Result from updating API profile target */ +export interface UpdateApiProfileTargetResult { + success: boolean; + target?: TargetType; + error?: string; +} diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index ebac9446..6e21c2e4 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -12,7 +12,14 @@ import { isUnifiedMode, } from '../../config/unified-config-loader'; import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; -import type { ModelMapping, CreateApiProfileResult, RemoveApiProfileResult } from './profile-types'; +import type { TargetType } from '../../targets/target-adapter'; +import { resolveDroidProvider } from '../../targets/droid-provider'; +import type { + ModelMapping, + CreateApiProfileResult, + RemoveApiProfileResult, + UpdateApiProfileTargetResult, +} from './profile-types'; /** Check if URL is an OpenRouter endpoint */ function isOpenRouterUrl(baseUrl: string): boolean { @@ -24,10 +31,16 @@ function createSettingsFile( name: string, baseUrl: string, apiKey: string, - models: ModelMapping + models: ModelMapping, + provider?: string ): string { const ccsDir = getCcsDir(); const settingsPath = path.join(ccsDir, `${name}.settings.json`); + const droidProvider = resolveDroidProvider({ + provider, + baseUrl, + model: models.default, + }); const settings = { env: { @@ -37,6 +50,7 @@ function createSettingsFile( ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + CCS_DROID_PROVIDER: droidProvider, // OpenRouter requires explicitly blanking the API key to prevent conflicts ...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }), }, @@ -51,11 +65,15 @@ function createSettingsFile( } /** Update config.json with new API profile (legacy format) */ -function updateLegacyConfig(name: string): void { +function updateLegacyConfig(name: string, target: TargetType = 'claude'): void { const configPath = getConfigPath(); const ccsDir = getCcsDir(); - let config: { profiles: Record; cliproxy?: Record }; + let config: { + profiles: Record; + cliproxy?: Record; + profile_targets?: Record; + }; try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { @@ -64,6 +82,12 @@ function updateLegacyConfig(name: string): void { const relativePath = `~/.ccs/${name}.settings.json`; config.profiles[name] = relativePath; + config.profile_targets = config.profile_targets || {}; + if (target === 'claude') { + delete config.profile_targets[name]; + } else { + config.profile_targets[name] = target; + } if (!fs.existsSync(ccsDir)) { fs.mkdirSync(ccsDir, { recursive: true }); @@ -80,11 +104,18 @@ function createApiProfileUnified( name: string, baseUrl: string, apiKey: string, - models: ModelMapping + models: ModelMapping, + target: TargetType = 'claude', + provider?: string ): void { const ccsDir = getCcsDir(); const settingsFile = `${name}.settings.json`; const settingsPath = path.join(ccsDir, settingsFile); + const droidProvider = resolveDroidProvider({ + provider, + baseUrl, + model: models.default, + }); const settings = { env: { @@ -94,6 +125,7 @@ function createApiProfileUnified( ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + CCS_DROID_PROVIDER: droidProvider, // OpenRouter requires explicitly blanking the API key to prevent conflicts ...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }), }, @@ -112,6 +144,7 @@ function createApiProfileUnified( config.profiles[name] = { type: 'api', settings: `~/.ccs/${settingsFile}`, + ...(target !== 'claude' && { target }), }; saveUnifiedConfig(config); } @@ -121,16 +154,18 @@ export function createApiProfile( name: string, baseUrl: string, apiKey: string, - models: ModelMapping + models: ModelMapping, + target: TargetType = 'claude', + provider?: string ): CreateApiProfileResult { try { const settingsFile = `~/.ccs/${name}.settings.json`; if (isUnifiedMode()) { - createApiProfileUnified(name, baseUrl, apiKey, models); + createApiProfileUnified(name, baseUrl, apiKey, models, target, provider); } else { - createSettingsFile(name, baseUrl, apiKey, models); - updateLegacyConfig(name); + createSettingsFile(name, baseUrl, apiKey, models, provider); + updateLegacyConfig(name, target); } return { success: true, settingsFile }; @@ -143,6 +178,63 @@ export function createApiProfile( } } +/** + * Update API profile target (claude/droid). + * Persists to config.yaml in unified mode and config.json profile_targets in legacy mode. + */ +export function updateApiProfileTarget( + name: string, + target: TargetType +): UpdateApiProfileTargetResult { + try { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + if (!config.profiles[name]) { + return { success: false, error: `API profile not found: ${name}` }; + } + + if (target === 'claude') { + delete config.profiles[name].target; + } else { + config.profiles[name].target = target; + } + saveUnifiedConfig(config); + return { success: true, target }; + } + + const configPath = getConfigPath(); + let config: { + profiles: Record; + cliproxy?: Record; + profile_targets?: Record; + }; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + config = { profiles: {} }; + } + + if (!config.profiles[name]) { + return { success: false, error: `API profile not found: ${name}` }; + } + + config.profile_targets = config.profile_targets || {}; + if (target === 'claude') { + delete config.profile_targets[name]; + } else { + config.profile_targets[name] = target; + } + + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); + + return { success: true, target }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} + /** Remove API profile from unified config */ function removeApiProfileUnified(name: string): void { const config = loadOrCreateUnifiedConfig(); @@ -175,6 +267,12 @@ function removeApiProfileUnified(name: string): void { function removeApiProfileLegacy(name: string): void { const config = loadConfigSafe(); delete config.profiles[name]; + if (config.profile_targets) { + delete config.profile_targets[name]; + if (Object.keys(config.profile_targets).length === 0) { + delete config.profile_targets; + } + } const configPath = getConfigPath(); const tempPath = configPath + '.tmp'; diff --git a/src/auth/account-context.ts b/src/auth/account-context.ts index 93cb29be..a8c28b50 100644 --- a/src/auth/account-context.ts +++ b/src/auth/account-context.ts @@ -6,20 +6,24 @@ */ export type AccountContextMode = 'isolated' | 'shared'; +export type AccountContinuityMode = 'standard' | 'deeper'; export interface AccountContextMetadata { context_mode?: AccountContextMode; context_group?: string; + continuity_mode?: AccountContinuityMode; } export interface AccountContextPolicy { mode: AccountContextMode; group?: string; + continuityMode?: AccountContinuityMode; } export interface CreateAccountContextInput { shareContext: boolean; contextGroup?: string; + deeperContinuity?: boolean; } export interface ResolvedCreateAccountContext { @@ -29,6 +33,9 @@ export interface ResolvedCreateAccountContext { export const DEFAULT_ACCOUNT_CONTEXT_MODE: AccountContextMode = 'isolated'; export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default'; +export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard'; +export const MAX_CONTEXT_GROUP_LENGTH = 64; +export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; @@ -36,14 +43,21 @@ const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; * Normalize context group names so paths and config stay consistent. */ export function normalizeContextGroupName(value: string): string { - return value.trim().toLowerCase(); + return value.trim().toLowerCase().replace(/\s+/g, '-'); } /** * Validate context group naming constraints. */ export function isValidContextGroupName(value: string): boolean { - return CONTEXT_GROUP_PATTERN.test(value); + return value.length <= MAX_CONTEXT_GROUP_LENGTH && CONTEXT_GROUP_PATTERN.test(value); +} + +/** + * Validate account profile naming constraints. + */ +export function isValidAccountProfileName(value: string): boolean { + return ACCOUNT_PROFILE_NAME_PATTERN.test(value); } /** @@ -57,11 +71,22 @@ export function isAccountContextMetadata(value: unknown): value is AccountContex const candidate = value as Record; const mode = candidate['context_mode']; const group = candidate['context_group']; + const continuity = candidate['continuity_mode']; const modeValid = mode === undefined || mode === 'isolated' || mode === 'shared'; const groupValid = group === undefined || typeof group === 'string'; + const continuityValid = + continuity === undefined || continuity === 'standard' || continuity === 'deeper'; - return modeValid && groupValid; + if (!modeValid || !groupValid || !continuityValid) { + return false; + } + + if (mode !== 'shared' && continuity !== undefined) { + return false; + } + + return true; } /** @@ -71,6 +96,15 @@ export function resolveCreateAccountContext( input: CreateAccountContextInput ): ResolvedCreateAccountContext { const hasGroupFlag = input.contextGroup !== undefined; + const continuityMode: AccountContinuityMode = input.deeperContinuity ? 'deeper' : 'standard'; + + if (input.deeperContinuity && !input.shareContext && !hasGroupFlag) { + return { + policy: { mode: 'isolated' }, + error: + 'Advanced deeper continuity requires shared context (--share-context or --context-group).', + }; + } if (hasGroupFlag) { if (!input.contextGroup || input.contextGroup.trim().length === 0) { @@ -84,8 +118,7 @@ export function resolveCreateAccountContext( if (!isValidContextGroupName(normalizedGroup)) { return { policy: { mode: 'isolated' }, - error: - 'Invalid context group. Use letters/numbers/dash/underscore and start with a letter.', + error: `Invalid context group. Use letters/numbers/dash/underscore, start with a letter, max ${MAX_CONTEXT_GROUP_LENGTH} chars.`, }; } @@ -93,6 +126,7 @@ export function resolveCreateAccountContext( policy: { mode: 'shared', group: normalizedGroup, + continuityMode, }, }; } @@ -102,6 +136,7 @@ export function resolveCreateAccountContext( policy: { mode: 'shared', group: DEFAULT_ACCOUNT_CONTEXT_GROUP, + continuityMode, }, }; } @@ -120,15 +155,21 @@ export function resolveAccountContextPolicy( const mode: AccountContextMode = metadata?.context_mode === 'shared' ? 'shared' : 'isolated'; if (mode === 'shared') { + const continuityMode: AccountContinuityMode = + metadata?.continuity_mode === 'deeper' ? 'deeper' : 'standard'; const rawGroup = metadata?.context_group; if (rawGroup && rawGroup.trim().length > 0) { const normalized = normalizeContextGroupName(rawGroup); if (isValidContextGroupName(normalized)) { - return { mode: 'shared', group: normalized }; + return { mode: 'shared', group: normalized, continuityMode }; } } - return { mode: 'shared', group: DEFAULT_ACCOUNT_CONTEXT_GROUP }; + return { + mode: 'shared', + group: DEFAULT_ACCOUNT_CONTEXT_GROUP, + continuityMode, + }; } return { mode: 'isolated' }; @@ -144,6 +185,8 @@ export function policyToAccountContextMetadata( return { context_mode: 'shared', context_group: policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP, + continuity_mode: + policy.continuityMode === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE, }; } @@ -157,7 +200,8 @@ export function policyToAccountContextMetadata( */ export function formatAccountContextPolicy(policy: AccountContextPolicy): string { if (policy.mode === 'shared') { - return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP})`; + const continuity = policy.continuityMode === 'deeper' ? 'deeper continuity' : 'standard'; + return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP}, ${continuity})`; } return 'isolated'; diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index e94c47f8..86a3c710 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -14,6 +14,7 @@ import ProfileRegistry from './profile-registry'; import { InstanceManager } from '../management/instance-manager'; 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 command handlers from modular structure @@ -85,6 +86,11 @@ class AuthCommands { console.log(` ${dim('# Share context only within a specific group')}`); console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`); console.log(''); + console.log(` ${dim('# Advanced: deeper shared continuity for session history artifacts')}`); + console.log( + ` ${color('ccs auth create backup --context-group sprint-a --deeper-continuity', 'command')}` + ); + console.log(''); console.log(` ${dim('# Set work as default')}`); console.log(` ${color('ccs auth default work', 'command')}`); console.log(''); @@ -107,6 +113,9 @@ class AuthCommands { console.log( ` ${color('--context-group ', 'command')} Share context only within a named group` ); + console.log( + ` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts` + ); console.log( ` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)` ); @@ -127,6 +136,16 @@ class AuthCommands { console.log( ` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.` ); + console.log( + ` ${color('--deeper-continuity', 'command')} requires shared mode and syncs session-env/file-history/todos/shell-snapshots.` + ); + 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.` + ); console.log(''); } diff --git a/src/auth/commands/create-command-env.ts b/src/auth/commands/create-command-env.ts new file mode 100644 index 00000000..630e9a0e --- /dev/null +++ b/src/auth/commands/create-command-env.ts @@ -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; +} diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index 84c04518..93550a2e 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -9,27 +9,48 @@ import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../.. import { getClaudeCliInfo } from '../../utils/claude-detector'; import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor'; import { isUnifiedMode } from '../../config/unified-config-loader'; +import { ProfileMetadata } from '../../types'; import { resolveCreateAccountContext, policyToAccountContextMetadata, formatAccountContextPolicy, + isValidAccountProfileName, + resolveAccountContextPolicy, } from '../account-context'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, parseArgs } from './types'; +import { stripAmbientProviderCredentials } from './create-command-env'; + +function sanitizeProfileNameForInstance(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); +} /** * Handle the create command */ export async function handleCreate(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName, force, shareContext, contextGroup } = parseArgs(args); + const { profileName, force, shareContext, contextGroup, deeperContinuity, unknownFlags } = + parseArgs(args); + + if (unknownFlags && unknownFlags.length > 0) { + const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', '); + console.log(fail(`Unknown option(s): ${unknownList}`)); + console.log(''); + console.log( + `Usage: ${color('ccs auth create [--force] [--share-context] [--context-group ] [--deeper-continuity]', 'command')}` + ); + console.log(`Help: ${color('ccs auth --help', 'command')}`); + console.log(''); + exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR); + } if (!profileName) { console.log(fail('Profile name is required')); console.log(''); console.log( - `Usage: ${color('ccs auth create [--force] [--share-context] [--context-group ]', 'command')}` + `Usage: ${color('ccs auth create [--force] [--share-context] [--context-group ] [--deeper-continuity]', 'command')}` ); console.log(''); console.log('Example:'); @@ -37,6 +58,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); } + if (!isValidAccountProfileName(profileName)) { + const error = + 'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.'; + console.log(fail(error)); + console.log(''); + exitWithError(error, ExitCode.PROFILE_ERROR); + } + // Check if profile already exists (check both legacy and unified) const existsLegacy = ctx.registry.hasProfile(profileName); const existsUnified = ctx.registry.hasAccountUnified(profileName); @@ -46,9 +75,22 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR); } + const normalizedName = sanitizeProfileNameForInstance(profileName); + const collidingName = Object.keys(ctx.registry.getAllProfilesMerged()).find( + (name) => name !== profileName && sanitizeProfileNameForInstance(name) === normalizedName + ); + + if (collidingName) { + const error = `Profile "${profileName}" conflicts with existing profile "${collidingName}" on filesystem.`; + console.log(fail(error)); + console.log(''); + exitWithError(error, ExitCode.PROFILE_ERROR); + } + const resolvedContext = resolveCreateAccountContext({ shareContext: !!shareContext, contextGroup, + deeperContinuity: !!deeperContinuity, }); if (resolvedContext.error) { @@ -59,6 +101,80 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise const contextPolicy = resolvedContext.policy; const contextMetadata = policyToAccountContextMetadata(contextPolicy); + const useUnifiedConfig = isUnifiedMode(); + const profileExistedBeforeCreate = existsLegacy || existsUnified; + const createdUnifiedProfile = useUnifiedConfig && !existsUnified; + const createdLegacyProfile = !useUnifiedConfig && !existsLegacy; + const previousLegacyProfile: ProfileMetadata | undefined = existsLegacy + ? ctx.registry.getProfile(profileName) + : undefined; + const previousUnifiedProfile = existsUnified + ? ctx.registry.getAllAccountsUnified()[profileName] + : undefined; + const previousContextPolicy = + profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile) + ? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile) + : undefined; + + const claudeInfo = getClaudeCliInfo(); + if (!claudeInfo) { + console.log(fail('Claude CLI not found')); + console.log(''); + console.log('Please install Claude CLI first:'); + console.log(` ${color('https://claude.ai/download', 'path')}`); + exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR); + } + + let rollbackCompleted = false; + const rollbackMetadata = (): void => { + try { + if (useUnifiedConfig) { + if (createdUnifiedProfile) { + if (ctx.registry.hasAccountUnified(profileName)) { + ctx.registry.removeAccountUnified(profileName); + } + } else if (previousUnifiedProfile) { + ctx.registry.updateAccountUnified(profileName, previousUnifiedProfile); + } + } else { + if (createdLegacyProfile) { + if (ctx.registry.hasProfile(profileName)) { + ctx.registry.deleteProfile(profileName); + } + } else if (previousLegacyProfile) { + ctx.registry.updateProfile(profileName, previousLegacyProfile); + } + } + } catch { + // Best-effort rollback to avoid leaving stale accounts after failed login. + } + }; + + const rollbackFailedCreate = async (): Promise => { + if (rollbackCompleted) { + return; + } + rollbackCompleted = true; + + rollbackMetadata(); + + if (!profileExistedBeforeCreate) { + try { + ctx.instanceMgr.deleteInstance(profileName); + } catch { + // Best-effort cleanup. + } + return; + } + + if (previousContextPolicy) { + try { + await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy); + } catch { + // Best-effort rollback for context mode/group. + } + } + }; try { // Create instance directory @@ -66,7 +182,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy); // Create/update profile entry based on config mode - if (isUnifiedMode()) { + if (useUnifiedConfig) { // Use unified config (config.yaml) if (existsUnified) { ctx.registry.updateAccountUnified(profileName, { @@ -96,43 +212,49 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise console.log(info(`Instance directory: ${instancePath}`)); console.log(''); - console.log(warn('Starting Claude in isolated instance...')); + const launchDescription = + contextPolicy.mode === 'shared' + ? contextPolicy.continuityMode === 'deeper' + ? `Starting Claude with shared context group "${contextPolicy.group || 'default'}" (deeper continuity)...` + : `Starting Claude with shared context group "${contextPolicy.group || 'default'}"...` + : 'Starting Claude in isolated instance...'; + console.log(warn(launchDescription)); console.log(warn('You will be prompted to login with your account.')); console.log(''); - // Detect Claude CLI - const claudeInfo = getClaudeCliInfo(); - if (!claudeInfo) { - console.log(fail('Claude CLI not found')); - console.log(''); - console.log('Please install Claude CLI first:'); - console.log(` ${color('https://claude.ai/download', 'path')}`); - exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR); - } - const { path: claudeCli, needsShell } = claudeInfo; - const childEnv = stripClaudeCodeEnv({ ...process.env, CLAUDE_CONFIG_DIR: instancePath }); + const childEnv = stripAmbientProviderCredentials( + stripClaudeCodeEnv({ ...process.env, CLAUDE_CONFIG_DIR: instancePath }) + ); // Execute Claude in isolated instance (will auto-prompt for login if no credentials) // On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly let child: ChildProcess; - if (needsShell) { - const cmdString = escapeShellArg(claudeCli); - child = spawn(cmdString, { - stdio: 'inherit', - windowsHide: true, - shell: true, - env: childEnv, - }); - } else { - child = spawn(claudeCli, [], { - stdio: 'inherit', - windowsHide: true, - env: childEnv, - }); + try { + if (needsShell) { + const cmdString = escapeShellArg(claudeCli); + child = spawn(cmdString, { + stdio: 'inherit', + windowsHide: true, + shell: true, + env: childEnv, + }); + } else { + child = spawn(claudeCli, [], { + stdio: 'inherit', + windowsHide: true, + env: childEnv, + }); + } + } catch (error) { + await rollbackFailedCreate(); + exitWithError( + `Failed to execute Claude CLI: ${(error as Error).message}`, + ExitCode.BINARY_ERROR + ); } - child.on('exit', (code: number | null) => { + child.on('exit', async (code: number | null) => { if (code === 0) { console.log(''); console.log( @@ -162,6 +284,8 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise console.log(''); process.exit(0); } else { + await rollbackFailedCreate(); + console.log(''); console.log(fail('Login failed or cancelled')); console.log(''); @@ -172,10 +296,12 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise } }); - child.on('error', (err: Error) => { + child.on('error', async (err: Error) => { + await rollbackFailedCreate(); exitWithError(`Failed to execute Claude CLI: ${err.message}`, ExitCode.BINARY_ERROR); }); } catch (error) { + await rollbackFailedCreate(); exitWithError(`Failed to create profile: ${(error as Error).message}`, ExitCode.GENERAL_ERROR); } } diff --git a/src/auth/commands/list-command.ts b/src/auth/commands/list-command.ts index a60dbff3..f25ab770 100644 --- a/src/auth/commands/list-command.ts +++ b/src/auth/commands/list-command.ts @@ -30,6 +30,9 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise(); + const knownBooleanFlags = new Set([ + '--force', + '--verbose', + '--json', + '--yes', + '-y', + '--share-context', + '--deeper-continuity', + ]); + const knownValueFlags = new Set(['--context-group']); for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -83,6 +97,18 @@ export function parseArgs(args: string[]): AuthCommandArgs { } if (arg.startsWith('-')) { + const normalizedFlag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg; + const isKnownFlag = + knownBooleanFlags.has(normalizedFlag) || knownValueFlags.has(normalizedFlag); + if (!isKnownFlag) { + unknownFlags.add(normalizedFlag); + // Best effort: unknown flags often take a value token. + // Skip one following non-flag token to avoid mis-parsing profile name. + const next = args[i + 1]; + if (!arg.includes('=') && next && !next.startsWith('-')) { + i++; + } + } continue; } @@ -98,6 +124,8 @@ export function parseArgs(args: string[]): AuthCommandArgs { json: args.includes('--json'), yes: args.includes('--yes') || args.includes('-y'), shareContext: args.includes('--share-context'), + deeperContinuity: args.includes('--deeper-continuity'), contextGroup, + unknownFlags: [...unknownFlags], }; } diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index a705200b..bc745b0a 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -202,6 +202,7 @@ class ProfileDetector { last_used: account.last_used, context_mode: account.context_mode, context_group: account.context_group, + continuity_mode: account.continuity_mode, }, }; } @@ -309,12 +310,15 @@ class ProfileDetector { // Priority 2: Check user-defined CLIProxy variants (config.cliproxy section) const config = this.readConfig(); + const legacyTargetMap = (config as { profile_targets?: Record }) + .profile_targets; if (config.cliproxy && config.cliproxy[profileName]) { const variant = config.cliproxy[profileName]; return { type: 'cliproxy', name: profileName, + target: variant.target, provider: variant.provider as CLIProxyProfileName, settingsPath: variant.settings, port: variant.port, @@ -333,6 +337,7 @@ class ProfileDetector { type: 'settings', name: profileName, settingsPath: config.profiles[candidate], + target: legacyTargetMap?.[candidate], message: viaLegacyAlias ? `Using legacy API profile "${candidate}" for "${profileName}".` : undefined, @@ -392,6 +397,8 @@ class ProfileDetector { // Check if settings-based default exists const config = this.readConfig(); + const legacyTargetMap = (config as { profile_targets?: Record }) + .profile_targets; if (config.profiles && config.profiles['default']) { const settingsPath = config.profiles['default']; @@ -409,6 +416,7 @@ class ProfileDetector { type: 'settings', name: 'default', settingsPath, + target: legacyTargetMap?.['default'], }; } diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index e6d7beda..7ee4db37 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -8,6 +8,7 @@ import { } from '../config/unified-config-loader'; import type { AccountConfig } from '../config/unified-config-types'; import { getCcsDir } from '../utils/config-manager'; +import { isValidContextGroupName, normalizeContextGroupName } from './account-context'; /** * Profile Registry (Simplified) @@ -22,6 +23,7 @@ import { getCcsDir } from '../utils/config-manager'; * last_used: // Last usage time * context_mode?: 'isolated' | 'shared' // Workspace context policy * context_group?: // Shared context group when mode=shared + * continuity_mode?: 'standard' | 'deeper' // Shared continuity depth * } * * Removed fields from v2.x: @@ -42,6 +44,7 @@ interface CreateMetadata { last_used?: string | null; context_mode?: 'isolated' | 'shared'; context_group?: string; + continuity_mode?: 'standard' | 'deeper'; } export class ProfileRegistry { @@ -51,13 +54,34 @@ export class ProfileRegistry { 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 { const normalized: ProfileMetadata = { ...metadata }; if (normalized.context_mode !== 'shared') { delete normalized.context_group; - } else if (!normalized.context_group || normalized.context_group.trim().length === 0) { - delete normalized.context_group; + delete normalized.continuity_mode; + } else { + const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group); + if (normalizedGroup) { + normalized.context_group = normalizedGroup; + } else { + delete normalized.context_group; + } + + normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard'; } return normalized; @@ -68,8 +92,16 @@ export class ProfileRegistry { if (normalized.context_mode !== 'shared') { delete normalized.context_group; - } else if (!normalized.context_group || normalized.context_group.trim().length === 0) { - delete normalized.context_group; + delete normalized.continuity_mode; + } else { + const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group); + if (normalizedGroup) { + normalized.context_group = normalizedGroup; + } else { + delete normalized.context_group; + } + + normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard'; } return normalized; @@ -140,6 +172,7 @@ export class ProfileRegistry { last_used: metadata.last_used || null, context_mode: metadata.context_mode, context_group: metadata.context_group, + continuity_mode: metadata.continuity_mode, }); // Note: No longer auto-set as default @@ -159,7 +192,7 @@ export class ProfileRegistry { throw new Error(`Profile not found: ${name}`); } - return data.profiles[name]; + return this.normalizeLegacyProfileMetadata(data.profiles[name]); } /** @@ -215,7 +248,11 @@ export class ProfileRegistry { */ getAllProfiles(): Record { const data = this._read(); - return data.profiles; + const normalized: Record = {}; + for (const [name, profile] of Object.entries(data.profiles)) { + normalized[name] = this.normalizeLegacyProfileMetadata(profile); + } + return normalized; } /** @@ -283,6 +320,7 @@ export class ProfileRegistry { last_used: null, context_mode: metadata.context_mode, context_group: metadata.context_group, + continuity_mode: metadata.continuity_mode, }); saveUnifiedConfig(config); } @@ -357,7 +395,11 @@ export class ProfileRegistry { getAllAccountsUnified(): Record { if (!isUnifiedMode()) return {}; const config = loadOrCreateUnifiedConfig(); - return config.accounts; + const normalized: Record = {}; + for (const [name, account] of Object.entries(config.accounts)) { + normalized[name] = this.normalizeUnifiedAccountConfig(account); + } + return normalized; } /** @@ -406,6 +448,7 @@ export class ProfileRegistry { last_used: account.last_used, context_mode: account.context_mode, context_group: account.context_group, + continuity_mode: account.continuity_mode, }; } diff --git a/src/ccs.ts b/src/ccs.ts index 20f69d54..0d735973 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -12,7 +12,14 @@ import { import { expandPath } from './utils/helpers'; import { validateGlmKey, validateMiniMaxKey } from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; -import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; +import { + execClaudeWithCLIProxy, + CLIProxyProvider, + ensureCliproxyService, + isAuthenticated, +} from './cliproxy'; +import { getEffectiveEnvVars, getCompositeEnvVars } from './cliproxy/config/env-builder'; +import { CLIPROXY_DEFAULT_PORT } from './cliproxy/config/port-manager'; import { ensureMcpWebSearch, displayWebSearchStatus, @@ -47,9 +54,15 @@ import { ClaudeAdapter, DroidAdapter, pruneOrphanedModels, + resolveDroidProvider, type TargetCredentials, } from './targets'; import { resolveTargetType, stripTargetFlag } from './targets/target-resolver'; +import { + DroidReasoningFlagError, + resolveDroidReasoningRuntime, +} from './targets/droid-reasoning-runtime'; +import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; // Version and Update check utilities import { getVersion } from './utils/version'; @@ -694,13 +707,60 @@ async function main(): Promise { if (resolvedTarget === 'droid') { try { const allProfiles = detector.getAllProfiles(); - const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9_-]+$/.test(name)); + const activeProfiles = allProfiles.settings.filter((name) => + /^[a-zA-Z0-9._-]+$/.test(name) + ); await pruneOrphanedModels(activeProfiles); } catch (error) { console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`)); } } + let targetRemainingArgs = remainingArgs; + let droidReasoningOverride: string | number | undefined; + if (resolvedTarget === 'droid') { + try { + const droidRoute = routeDroidCommandArgs(remainingArgs); + targetRemainingArgs = droidRoute.argsForDroid; + + if (droidRoute.mode === 'interactive') { + const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING); + targetRemainingArgs = runtime.argsWithoutReasoningFlags; + droidReasoningOverride = runtime.reasoningOverride; + + if (runtime.duplicateDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` + ) + ); + } + } else { + if (droidRoute.duplicateReasoningDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || ''}` + ) + ); + } + if (droidRoute.autoPrependedExec && process.stdout.isTTY) { + console.error( + info('Detected Droid exec-only flags. Routing as: droid exec [prompt]') + ); + } + } + } catch (error) { + if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) { + console.error(fail(error.message)); + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); + console.error(' Codex alias: --effort medium|high|xhigh'); + console.error(' Droid exec: --reasoning-effort high'); + process.exit(1); + } + throw error; + } + } + // Special case: headless delegation (-p/--prompt) // Keep existing behavior for Claude targets only; non-claude targets must continue // through normal adapter dispatch logic. @@ -724,9 +784,145 @@ async function main(): Promise { const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles const variantPort = profileInfo.port; // variant-specific port for isolation + const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT; + + if (resolvedTarget !== 'claude') { + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exitCode = 1; + return; + } + if (!adapter.supportsProfileType('cliproxy')) { + console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`)); + process.exitCode = 1; + return; + } + + // Keep CLIProxy management/auth flags on Claude flow only. + const unsupportedCliproxyFlags = [ + '--auth', + '--logout', + '--accounts', + '--add', + '--use', + '--config', + '--headless', + '--paste-callback', + '--port-forward', + '--nickname', + '--kiro-auth-method', + '--backend', + '--proxy-host', + '--proxy-port', + '--proxy-protocol', + '--proxy-auth-token', + '--proxy-timeout', + '--local-proxy', + '--remote-only', + '--no-fallback', + '--allow-self-signed', + '--1m', + '--no-1m', + ]; + const providedUnsupportedFlag = unsupportedCliproxyFlags.find( + (flag) => + targetRemainingArgs.includes(flag) || + targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`)) + ); + if (providedUnsupportedFlag) { + console.error( + fail( + `${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target` + ) + ); + console.error( + info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`) + ); + process.exitCode = 1; + return; + } + + // For Droid execution path, require existing OAuth auth and running local proxy. + if (profileInfo.isComposite && profileInfo.compositeTiers) { + const compositeProviders = [ + ...new Set(Object.values(profileInfo.compositeTiers).map((tier) => tier.provider)), + ] as CLIProxyProvider[]; + const missingProvider = compositeProviders.find((p) => !isAuthenticated(p)); + if (missingProvider) { + console.error( + fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`) + ); + console.error(info(`Authenticate first: ccs ${missingProvider} --auth`)); + process.exitCode = 1; + return; + } + } else if (!isAuthenticated(provider)) { + console.error(fail(`No OAuth authentication found for provider: ${provider}`)); + console.error(info(`Authenticate first: ccs ${provider} --auth`)); + process.exitCode = 1; + return; + } + + const ensureServiceResult = await ensureCliproxyService( + cliproxyPort, + targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v') + ); + if (!ensureServiceResult.started) { + console.error( + fail(ensureServiceResult.error || 'Failed to start local CLIProxy service') + ); + process.exitCode = 1; + return; + } + + const envVars = + profileInfo.isComposite && profileInfo.compositeTiers && profileInfo.compositeDefaultTier + ? getCompositeEnvVars( + profileInfo.compositeTiers, + profileInfo.compositeDefaultTier, + cliproxyPort, + customSettingsPath + ) + : getEffectiveEnvVars(provider, cliproxyPort, customSettingsPath); + + const creds: TargetCredentials = { + profile: profileInfo.name, + baseUrl: envVars['ANTHROPIC_BASE_URL'] || '', + apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '', + model: envVars['ANTHROPIC_MODEL'] || undefined, + provider: resolveDroidProvider({ + provider: envVars['CCS_DROID_PROVIDER'] || envVars['DROID_PROVIDER'], + baseUrl: envVars['ANTHROPIC_BASE_URL'], + model: envVars['ANTHROPIC_MODEL'], + }), + reasoningOverride: droidReasoningOverride, + envVars, + }; + + if (!creds.baseUrl || !creds.apiKey) { + console.error( + fail( + `Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)` + ) + ); + console.error( + info('Reconfigure with: ccs config > CLIProxy, or run ccs --config') + ); + process.exitCode = 1; + return; + } + + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs); + const targetEnv = adapter.buildEnv(creds, profileInfo.type); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); + return; + } + await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath, - port: variantPort, + port: cliproxyPort, isComposite: profileInfo.isComposite, compositeTiers: profileInfo.compositeTiers, compositeDefaultTier: profileInfo.compositeDefaultTier, @@ -869,9 +1065,15 @@ async function main(): Promise { baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '', apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || '', model: settingsEnv['ANTHROPIC_MODEL'], + provider: resolveDroidProvider({ + provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'], + baseUrl: settingsEnv['ANTHROPIC_BASE_URL'], + model: settingsEnv['ANTHROPIC_MODEL'], + }), + reasoningOverride: droidReasoningOverride, }; await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs); const targetEnv = adapter.buildEnv(creds, profileInfo.type); adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; @@ -935,6 +1137,12 @@ async function main(): Promise { baseUrl: process.env['ANTHROPIC_BASE_URL'] || '', apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '', model: process.env['ANTHROPIC_MODEL'], + provider: resolveDroidProvider({ + provider: process.env['CCS_DROID_PROVIDER'] || process.env['DROID_PROVIDER'], + baseUrl: process.env['ANTHROPIC_BASE_URL'], + model: process.env['ANTHROPIC_MODEL'], + }), + reasoningOverride: droidReasoningOverride, }; if (!creds.baseUrl || !creds.apiKey) { console.error( @@ -946,7 +1154,7 @@ async function main(): Promise { process.exit(1); } await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs('default', remainingArgs); + const targetArgs = adapter.buildArgs('default', targetRemainingArgs); const targetEnv = adapter.buildEnv(creds, 'default'); adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; diff --git a/src/cliproxy/account-safety.ts b/src/cliproxy/account-safety.ts index 6c7e049d..f8f61e0c 100644 --- a/src/cliproxy/account-safety.ts +++ b/src/cliproxy/account-safety.ts @@ -234,11 +234,17 @@ export function warnOAuthBanRisk(provider: CLIProxyProvider): void { if (!isBanWarningProvider(provider) || shownBanWarnings.has(provider)) return; shownBanWarnings.add(provider); + const isAgy = provider === 'agy'; console.error(''); console.error(warn('Account safety warning (#509 - read before continuing)')); console.error( ' Known risk: one Google account shared by "ccs gemini" + "ccs agy" can be disabled/banned.' ); + if (isAgy) { + console.error( + ' Antigravity-specific warning: OAuth usage can still trigger suspension/ban patterns.' + ); + } console.error( ' This risk applies whether auth was done from CLI or from "ccs config" dashboard.' ); diff --git a/src/cliproxy/antigravity-responsibility.ts b/src/cliproxy/antigravity-responsibility.ts new file mode 100644 index 00000000..c16722cd --- /dev/null +++ b/src/cliproxy/antigravity-responsibility.ts @@ -0,0 +1,225 @@ +/** + * Antigravity OAuth Responsibility Gate + * + * Enforces explicit user acknowledgement for Antigravity OAuth usage. + * This is used by: + * - CLI OAuth flow (`ccs agy --auth`) + * - CLI runtime flow (`ccs agy`) + * - Dashboard auth endpoints (server-side payload validation) + */ + +import { createInterface, Interface } from 'readline'; +import { fail, info, ok, warn } from '../utils/ui'; +import { getCliproxySafetyConfig } from '../config/unified-config-loader'; + +export const ANTIGRAVITY_RISK_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/509'; +export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v2'; +export const RISK_ACK_PHRASE = 'I ACCEPT RISK'; +export const ANTIGRAVITY_ACK_PHRASE = RISK_ACK_PHRASE; +export const ANTIGRAVITY_ACCEPT_RISK_FLAGS = ['--accept-agr-risk', '--accept-antigravity-risk']; + +type AgyRiskContext = 'oauth' | 'run'; + +export interface AntigravityRiskAcknowledgement { + version: string; + reviewedIssue509: boolean; + understandsBanRisk: boolean; + acceptsFullResponsibility: boolean; + typedPhrase: string; +} + +interface ValidationResult { + valid: boolean; + error?: string; +} + +interface EnsureCliRiskOptions { + context: AgyRiskContext; + acceptedByFlag?: boolean; +} + +function normalizePhrase(value: string): string { + return value.trim().replace(/\s+/g, ' ').toUpperCase(); +} + +function isTruthyEnv(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes'; +} + +export function isAntigravityResponsibilityBypassEnabled(): boolean { + if (isTruthyEnv(process.env.CCS_ACCEPT_AGY_RISK)) { + return true; + } + + try { + const safety = getCliproxySafetyConfig(); + return safety.antigravity_ack_bypass === true; + } catch { + return false; + } +} + +function askQuestion(rl: Interface, prompt: string): Promise { + return new Promise((resolve) => { + let settled = false; + + const onClose = () => { + if (!settled) { + settled = true; + resolve(null); + } + }; + + rl.once('close', onClose); + rl.question(prompt, (answer) => { + if (settled) return; + settled = true; + rl.removeListener('close', onClose); + resolve(answer.trim()); + }); + }); +} + +async function askYesNoStep(rl: Interface, step: string, message: string): Promise { + while (true) { + const answer = await askQuestion( + rl, + `[?] ${step}\n ${message}\n Type YES to continue (NO to cancel): ` + ); + + if (answer === null) return false; + const normalized = answer.toUpperCase(); + if (normalized === 'YES') return true; + if (normalized === 'NO' || normalized === 'N' || normalized === '') return false; + console.error(warn('Please type YES or NO.')); + } +} + +async function askResponsibilityPhrase(rl: Interface): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + const answer = await askQuestion( + rl, + `[?] Step 4/4\n Type exactly "${ANTIGRAVITY_ACK_PHRASE}": ` + ); + if (answer === null || answer === '') return false; + + if (normalizePhrase(answer) === ANTIGRAVITY_ACK_PHRASE) { + return true; + } + console.error(warn('Phrase mismatch. Try again.')); + } + return false; +} + +function printResponsibilityHeader(context: AgyRiskContext): void { + const contextLine = + context === 'oauth' + ? 'You are starting Antigravity OAuth account authorization.' + : 'You are starting a live Antigravity CLI session (ccs agy).'; + + console.error(''); + console.error('╔══════════════════════════════════════════════════════════════════════╗'); + console.error('║ Antigravity Responsibility Confirmation (Mandatory) ║'); + console.error('╚══════════════════════════════════════════════════════════════════════╝'); + console.error(` ${contextLine}`); + console.error(' Antigravity has active ban/suspension patterns for risky OAuth usage.'); + console.error(` Policy issue: ${ANTIGRAVITY_RISK_ISSUE_URL}`); + console.error(''); +} + +export function hasAntigravityRiskAcceptanceFlag(args: string[]): boolean { + return args.some((arg) => ANTIGRAVITY_ACCEPT_RISK_FLAGS.includes(arg)); +} + +export function validateAntigravityRiskAcknowledgement(payload: unknown): ValidationResult { + if (!payload || typeof payload !== 'object') { + return { + valid: false, + error: 'Antigravity OAuth requires a full responsibility acknowledgement payload.', + }; + } + + const data = payload as Partial; + + if (data.version !== ANTIGRAVITY_ACK_VERSION) { + return { + valid: false, + error: 'Antigravity acknowledgement version mismatch. Re-open add account and try again.', + }; + } + + if (!data.reviewedIssue509 || !data.understandsBanRisk || !data.acceptsFullResponsibility) { + return { + valid: false, + error: 'Complete all Antigravity responsibility checklist steps before authenticating.', + }; + } + + if ( + typeof data.typedPhrase !== 'string' || + normalizePhrase(data.typedPhrase) !== ANTIGRAVITY_ACK_PHRASE + ) { + return { + valid: false, + error: `Type exact acknowledgement phrase: "${ANTIGRAVITY_ACK_PHRASE}".`, + }; + } + + return { valid: true }; +} + +export async function ensureCliAntigravityResponsibility( + options: EnsureCliRiskOptions +): Promise { + if (options.acceptedByFlag || isAntigravityResponsibilityBypassEnabled()) { + return true; + } + + if (!process.stdin.isTTY || !process.stderr.isTTY) { + console.error(fail('Antigravity responsibility acknowledgement required.')); + console.error(' Re-run interactively and complete the 4-step confirmation.'); + console.error(' Non-interactive override: --accept-agr-risk'); + return false; + } + + printResponsibilityHeader(options.context); + + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + + try { + const step1 = await askYesNoStep( + rl, + 'Step 1/4', + 'I reviewed issue #509 and understand AGY OAuth can trigger bans/suspensions.' + ); + if (!step1) return false; + + const step2 = await askYesNoStep( + rl, + 'Step 2/4', + 'I understand this OAuth operation is my own decision and I choose to continue.' + ); + if (!step2) return false; + + const step3 = await askYesNoStep( + rl, + 'Step 3/4', + 'I accept that CCS provides no responsibility coverage for account loss, bans, or suspension.' + ); + if (!step3) return false; + + const step4 = await askResponsibilityPhrase(rl); + if (!step4) return false; + + console.error(ok('Antigravity responsibility acknowledgement accepted for this command.')); + console.error(info('Proceeding with Antigravity flow...')); + return true; + } finally { + rl.close(); + } +} diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 92080547..9d1cf401 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -273,6 +273,8 @@ export interface OAuthOptions { account?: string; add?: boolean; nickname?: string; + /** If true, caller explicitly accepts Antigravity OAuth risk for this command/session. */ + acceptAgyRisk?: boolean; /** Kiro auth method override (CLI + Dashboard parity). */ kiroMethod?: KiroAuthMethod; /** If true, triggered from Web UI (enables project selection prompt) */ diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index f05316d2..f4a2d3b2 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -50,6 +50,7 @@ import { warnOAuthBanRisk, warnPossible403Ban, } from '../account-safety'; +import { ensureCliAntigravityResponsibility } from '../antigravity-responsibility'; /** * Prompt user to add another account @@ -429,10 +430,29 @@ export async function triggerOAuth( const oauthConfig = getOAuthConfig(provider); warnOAuthBanRisk(provider); const { verbose = false, add = false, fromUI = false, noIncognito = true } = options; + const acceptAgyRisk = options.acceptAgyRisk === true; let { nickname } = options; const resolvedKiroMethod = provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD; + if (provider === 'agy') { + if (fromUI && !acceptAgyRisk) { + console.log(fail('Antigravity OAuth blocked: responsibility acknowledgement is missing.')); + return null; + } + + if (!fromUI) { + const acknowledged = await ensureCliAntigravityResponsibility({ + context: 'oauth', + acceptedByFlag: acceptAgyRisk, + }); + if (!acknowledged) { + console.log(info('Cancelled')); + return null; + } + } + } + // Check for existing accounts const existingAccounts = getProviderAccounts(provider); diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 5347962d..f90a0691 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -37,6 +37,8 @@ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; /** Replacement prefix matching actual upstream model names */ const UPSTREAM_MODEL_PREFIX = 'claude-'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +const IFLOW_PLACEHOLDER_MODEL = 'iflow-default'; +const IFLOW_DEFAULT_MODEL = 'qwen3-coder-plus'; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; const REQUIRED_PROVIDER_ENV_KEYS = [ 'ANTHROPIC_BASE_URL', @@ -136,6 +138,61 @@ function migrateCodexEffortSuffixes( return migrated; } +/** + * Migrate legacy iFlow placeholder model IDs to a real default model. + * Example: iflow-default -> qwen3-coder-plus + */ +function migrateIFlowPlaceholderModel( + settingsPath: string, + provider: CLIProxyProvider, + settings: ProviderSettings +): boolean { + if (provider !== 'iflow') return false; + if (!settings.env || typeof settings.env !== 'object') return false; + + let migrated = false; + const normalize = (value: string): string => value.trim().toLowerCase(); + const replaceIfPlaceholder = (value: string): string => + normalize(value) === IFLOW_PLACEHOLDER_MODEL ? IFLOW_DEFAULT_MODEL : value; + + for (const key of MODEL_ENV_VAR_KEYS) { + const value = settings.env[key]; + if (typeof value !== 'string') continue; + const replaced = replaceIfPlaceholder(value); + if (replaced !== value) { + settings.env[key] = replaced; + migrated = true; + } + } + + if (Array.isArray(settings.presets)) { + for (const preset of settings.presets) { + if (!preset || typeof preset !== 'object') continue; + const presetRecord = preset as Record; + + for (const key of PRESET_MODEL_KEYS) { + const value = presetRecord[key]; + if (typeof value !== 'string') continue; + const replaced = replaceIfPlaceholder(value); + if (replaced !== value) { + presetRecord[key] = replaced; + migrated = true; + } + } + } + } + + if (migrated) { + try { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 }); + } catch { + // Best-effort migration — don't block startup if write fails + } + } + + return migrated; +} + /** Remote proxy configuration for URL rewriting */ export interface RemoteProxyRewriteConfig { host: string; @@ -355,6 +412,8 @@ export function getEffectiveEnvVars( migrateDeprecatedModelNames(expandedPath, settings); // Migrate codex effort suffixes to canonical IDs if present migrateCodexEffortSuffixes(expandedPath, provider, settings); + // Migrate legacy iFlow placeholders to supported model IDs + migrateIFlowPlaceholderModel(expandedPath, provider, settings); // Custom variant settings found - merge with global env envVars = { ...globalEnv, ...settings.env }; // Ensure required vars are present (fall back to defaults if missing) @@ -388,6 +447,8 @@ export function getEffectiveEnvVars( migrateDeprecatedModelNames(settingsPath, settings); // Migrate codex effort suffixes to canonical IDs if present migrateCodexEffortSuffixes(settingsPath, provider, settings); + // Migrate legacy iFlow placeholders to supported model IDs + migrateIFlowPlaceholderModel(settingsPath, provider, settings); // User override found - merge with global env envVars = { ...globalEnv, ...settings.env }; // Ensure required vars are present (fall back to defaults if missing) @@ -525,6 +586,7 @@ export function getRemoteEnvVars( if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(expandedPath, settings); migrateCodexEffortSuffixes(expandedPath, provider, settings); + migrateIFlowPlaceholderModel(expandedPath, provider, settings); userEnvVars = settings.env as Record; } } catch { @@ -544,6 +606,7 @@ export function getRemoteEnvVars( if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(settingsPath, settings); migrateCodexEffortSuffixes(settingsPath, provider, settings); + migrateIFlowPlaceholderModel(settingsPath, provider, settings); userEnvVars = settings.env as Record; } } catch { diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 27e649d4..0b88f6f0 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -31,8 +31,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v7: Added fork:true for Claude model aliases (keep both upstream and alias names) * v8: Added Gemini 3.1 preview aliases for provider routing compatibility * v9: Added resilient alias compatibility expansion and cache-assisted alias enrichment + * v10: Migrated deprecated gemini-claude-* aliases to upstream claude-* aliases */ -export const CLIPROXY_CONFIG_VERSION = 9; +export const CLIPROXY_CONFIG_VERSION = 10; interface OAuthModelAliasEntry { name: string; @@ -41,6 +42,8 @@ interface OAuthModelAliasEntry { } const GEMINI_MINOR_COMPAT_RANGE = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const; +const DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX = 'gemini-claude-'; +const UPSTREAM_CLAUDE_ALIAS_PREFIX = 'claude-'; /** * Default Antigravity oauth-model-alias entries. @@ -54,12 +57,12 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [ { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview' }, { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview-customtools' }, { name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' }, - { name: 'claude-sonnet-4-6', alias: 'gemini-claude-sonnet-4-6', fork: true }, - { name: 'claude-sonnet-4-6-thinking', alias: 'gemini-claude-sonnet-4-6-thinking', fork: true }, - { name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5', fork: true }, - { name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking', fork: true }, - { name: 'claude-opus-4-5-thinking', alias: 'gemini-claude-opus-4-5-thinking', fork: true }, - { name: 'claude-opus-4-6-thinking', alias: 'gemini-claude-opus-4-6-thinking', fork: true }, + { name: 'claude-sonnet-4-6', alias: 'claude-sonnet-4-6', fork: true }, + { name: 'claude-sonnet-4-6-thinking', alias: 'claude-sonnet-4-6-thinking', fork: true }, + { name: 'claude-sonnet-4-5', alias: 'claude-sonnet-4-5', fork: true }, + { name: 'claude-sonnet-4-5-thinking', alias: 'claude-sonnet-4-5-thinking', fork: true }, + { name: 'claude-opus-4-5-thinking', alias: 'claude-opus-4-5-thinking', fork: true }, + { name: 'claude-opus-4-6-thinking', alias: 'claude-opus-4-6-thinking', fork: true }, ]; /** @@ -101,6 +104,16 @@ function sanitizeYamlScalar(rawValue: string): string { return trimmed; } +function normalizeAntigravityAlias(rawAlias: string): string { + const normalized = sanitizeYamlScalar(rawAlias); + if (normalized.toLowerCase().startsWith(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX)) { + return ( + UPSTREAM_CLAUDE_ALIAS_PREFIX + normalized.slice(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX.length) + ); + } + return normalized; +} + function addAliasEntry( entries: OAuthModelAliasEntry[], indexByKey: Map, @@ -108,7 +121,7 @@ function addAliasEntry( ): void { const normalized: OAuthModelAliasEntry = { name: sanitizeYamlScalar(entry.name), - alias: sanitizeYamlScalar(entry.alias), + alias: normalizeAntigravityAlias(entry.alias), fork: entry.fork || undefined, }; if (!normalized.name || !normalized.alias) return; diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 220ccdd2..ed24cd88 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -72,6 +72,11 @@ import { enforceProviderIsolation, restoreAutoPausedAccounts, } from '../account-safety'; +import { + ensureCliAntigravityResponsibility, + hasAntigravityRiskAcceptanceFlag, + ANTIGRAVITY_ACCEPT_RISK_FLAGS, +} from '../antigravity-responsibility'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; import { buildThinkingStartupStatus, @@ -286,6 +291,7 @@ export async function execClaudeWithCLIProxy( const addAccount = argsWithoutProxy.includes('--add'); const showAccounts = argsWithoutProxy.includes('--accounts'); const forceImport = argsWithoutProxy.includes('--import'); + const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(argsWithoutProxy); const incognitoFlag = argsWithoutProxy.includes('--incognito'); const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito'); @@ -523,6 +529,38 @@ export async function execClaudeWithCLIProxy( log(`Using remote proxy authentication (skipping local OAuth)`); } + if (provider === 'agy' && forceAuth && skipLocalAuth) { + const acknowledged = await ensureCliAntigravityResponsibility({ + context: 'oauth', + acceptedByFlag: acceptAgyRisk, + }); + if (!acknowledged) { + throw new Error( + `Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` + ); + } + console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.')); + return; + } + + if (provider === 'agy' && !forceAuth) { + const requiresAuthNow = providerConfig.requiresOAuth && !isAuthenticated(provider); + if (skipLocalAuth || !requiresAuthNow) { + const acknowledged = await ensureCliAntigravityResponsibility({ + context: 'run', + acceptedByFlag: acceptAgyRisk, + }); + if (!acknowledged) { + console.error( + fail( + `Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` + ) + ); + process.exit(1); + } + } + } + if (providerConfig.requiresOAuth && !skipLocalAuth) { log(`Checking authentication for ${provider}`); @@ -536,6 +574,7 @@ export async function execClaudeWithCLIProxy( const authSuccess = await triggerOAuth(p, { verbose, add: addAccount, + ...(acceptAgyRisk ? { acceptAgyRisk: true } : {}), ...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}), ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), @@ -577,6 +616,7 @@ export async function execClaudeWithCLIProxy( const authSuccess = await triggerOAuth(provider, { verbose, add: addAccount, + ...(acceptAgyRisk ? { acceptAgyRisk: true } : {}), ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), @@ -922,6 +962,8 @@ export async function execClaudeWithCLIProxy( '--incognito', '--no-incognito', '--import', + '--accept-agr-risk', + '--accept-antigravity-risk', '--settings', ...PROXY_CLI_FLAGS, ]; diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts index 1b1b3f15..e8179b51 100644 --- a/src/cliproxy/services/variant-config-adapter.ts +++ b/src/cliproxy/services/variant-config-adapter.ts @@ -1,12 +1,7 @@ -/** - * CLIProxy Variant Config Adapters - * - * Handles reading/writing variant config in both unified and legacy formats. - */ - import * as fs from 'fs'; import { getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { CLIProxyProvider } from '../types'; +import type { TargetType } from '../../targets/target-adapter'; import { CLIProxyVariantConfig, CompositeVariantConfig, @@ -20,19 +15,15 @@ import { } from '../../config/unified-config-loader'; import { CLIPROXY_DEFAULT_PORT } from '../config-generator'; -/** First port for variant profiles (8318 = default + 1) */ export const VARIANT_PORT_BASE = CLIPROXY_DEFAULT_PORT + 1; - -/** Maximum port offset for variants (100 ports: 8318-8417) */ export const VARIANT_PORT_MAX_OFFSET = 100; - -/** Variant configuration structure */ export interface VariantConfig { provider: string; settings?: string; account?: string; model?: string; port?: number; + target?: TargetType; /** Composite variant fields */ type?: 'composite'; default_tier?: 'opus' | 'sonnet' | 'haiku'; @@ -45,9 +36,6 @@ export interface VariantConfig { hasFallback?: boolean; } -/** - * Check if variant exists in config - */ export function variantExistsInConfig(name: string): boolean { try { if (isUnifiedMode()) { @@ -61,10 +49,6 @@ export function variantExistsInConfig(name: string): boolean { } } -/** - * Get next available port for a new variant. - * Scans existing variants, returns first unused port starting from VARIANT_PORT_BASE. - */ export function getNextAvailablePort(): number { const variants = listVariantsFromConfig(); const usedPorts = new Set(); @@ -89,9 +73,6 @@ export function getNextAvailablePort(): number { ); } -/** - * List variants from config - */ export function listVariantsFromConfig(): Record { try { if (isUnifiedMode()) { @@ -139,6 +120,7 @@ export function listVariantsFromConfig(): Record { provider: defaultTierConfig.provider, settings: composite.settings, port: composite.port, + target: composite.target || 'claude', type: 'composite', default_tier: composite.default_tier, tiers: normalizedTiers, @@ -151,6 +133,7 @@ export function listVariantsFromConfig(): Record { settings: single.settings, account: single.account, port: single.port, + target: single.target || 'claude', }; } } catch (error) { @@ -171,12 +154,14 @@ export function listVariantsFromConfig(): Record { settings: string; account?: string; port?: number; + target?: TargetType; }; result[name] = { provider: v.provider, settings: v.settings, account: v.account, port: v.port, + target: v.target || 'claude', }; } return result; @@ -185,9 +170,6 @@ export function listVariantsFromConfig(): Record { } } -/** - * Save composite variant to unified config - */ export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void { const unifiedConfig = loadOrCreateUnifiedConfig(); @@ -206,15 +188,13 @@ export function saveCompositeVariantUnified(name: string, config: CompositeVaria saveUnifiedConfig(unifiedConfig); } -/** - * Save variant to unified config - */ export function saveVariantUnified( name: string, provider: CLIProxyProvider, settingsPath: string, account?: string, - port?: number + port?: number, + target: TargetType = 'claude' ): void { const config = loadOrCreateUnifiedConfig(); @@ -234,20 +214,19 @@ export function saveVariantUnified( account, settings: settingsPath, port, + ...(target !== 'claude' && { target }), }; saveUnifiedConfig(config); } -/** - * Save variant to legacy JSON config - */ export function saveVariantLegacy( name: string, provider: string, settingsPath: string, account?: string, - port?: number + port?: number, + target: TargetType = 'claude' ): void { const configPath = getConfigPath(); @@ -262,7 +241,13 @@ export function saveVariantLegacy( config.cliproxy = {}; } - const variantConfig: { provider: string; settings: string; account?: string; port?: number } = { + const variantConfig: { + provider: string; + settings: string; + account?: string; + port?: number; + target?: TargetType; + } = { provider, settings: settingsPath, }; @@ -272,6 +257,9 @@ export function saveVariantLegacy( if (port) { variantConfig.port = port; } + if (target !== 'claude') { + variantConfig.target = target; + } config.cliproxy[name] = variantConfig; const tempPath = configPath + '.tmp'; @@ -279,9 +267,6 @@ export function saveVariantLegacy( fs.renameSync(tempPath, configPath); } -/** - * Remove variant from unified config - */ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null { const config = loadOrCreateUnifiedConfig(); @@ -299,6 +284,7 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu provider: composite.tiers[composite.default_tier].provider, settings: composite.settings, port: composite.port, + target: composite.target || 'claude', type: 'composite', default_tier: composite.default_tier, tiers: composite.tiers, @@ -309,12 +295,10 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu provider: singleVariant.provider, settings: singleVariant.settings, port: singleVariant.port, + target: singleVariant.target || 'claude', }; } -/** - * Remove variant from legacy JSON config - */ export function removeVariantFromLegacyConfig(name: string): VariantConfig | null { const configPath = getConfigPath(); @@ -329,7 +313,12 @@ export function removeVariantFromLegacyConfig(name: string): VariantConfig | nul return null; } - const variant = config.cliproxy[name] as { provider: string; settings: string; port?: number }; + const variant = config.cliproxy[name] as { + provider: string; + settings: string; + port?: number; + target?: TargetType; + }; delete config.cliproxy[name]; if (Object.keys(config.cliproxy).length === 0) { diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index a3471c13..fbb917a6 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import { CLIProxyProfileName } from '../../auth/profile-detector'; import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types'; import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types'; +import type { TargetType } from '../../targets/target-adapter'; import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { DEFAULT_BACKEND } from '../platform-detector'; @@ -110,7 +111,8 @@ export function createVariant( name: string, provider: CLIProxyProfileName, model: string, - account?: string + account?: string, + target: TargetType = 'claude' ): VariantOperationResult { try { // Validate provider/backend compatibility (block kiro/ghcp on original backend) @@ -131,17 +133,25 @@ export function createVariant( provider as CLIProxyProvider, getRelativeSettingsPath(provider, name), account, - port + port, + target ); } else { settingsPath = createSettingsFile(name, provider, model, port); - saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account, port); + saveVariantLegacy( + name, + provider, + `~/.ccs/${path.basename(settingsPath)}`, + account, + port, + target + ); } return { success: true, settingsPath, - variant: { provider, model, account, port }, + variant: { provider, model, account, port, target }, }; } catch (error) { return { @@ -208,6 +218,7 @@ export interface UpdateVariantOptions { provider?: CLIProxyProfileName; account?: string; model?: string; + target?: TargetType; } /** @@ -233,6 +244,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari const providerChanged = updates.provider !== undefined && updates.provider !== existing.provider; + const existingTarget = existing.target || 'claude'; + const targetChanged = updates.target !== undefined && updates.target !== existingTarget; const hasModelUpdate = updates.model !== undefined && updates.model.trim().length > 0; if (providerChanged && !hasModelUpdate) { @@ -257,8 +270,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari } } - // Update config entry if provider or account changed - if (updates.provider !== undefined || updates.account !== undefined) { + // Update config entry if provider/account/target changed + if (updates.provider !== undefined || updates.account !== undefined || targetChanged) { const newProvider = updates.provider ?? existing.provider; // Validate provider/backend compatibility on provider change @@ -269,6 +282,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari } } const newAccount = updates.account !== undefined ? updates.account : existing.account; + const newTarget = updates.target ?? existingTarget; if (isUnifiedMode()) { saveVariantUnified( @@ -276,7 +290,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari newProvider as CLIProxyProvider, existing.settings || '', newAccount || undefined, - existing.port + existing.port, + newTarget ); } else { saveVariantLegacy( @@ -284,7 +299,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari newProvider, existing.settings || '', newAccount || undefined, - existing.port + existing.port, + newTarget ); } } @@ -297,6 +313,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari account: updates.account !== undefined ? updates.account : existing.account, port: existing.port, settings: existing.settings, + target: updates.target ?? existingTarget, }, }; } catch (error) { @@ -308,6 +325,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari export interface CreateCompositeVariantOptions { name: string; defaultTier: 'opus' | 'sonnet' | 'haiku'; + target?: TargetType; tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; @@ -329,7 +347,7 @@ export function createCompositeVariant( } try { - const { name, defaultTier, tiers } = options; + const { name, defaultTier, tiers, target = 'claude' } = options; const validationError = validateCompositeTiers(tiers, { defaultTier, @@ -361,6 +379,7 @@ export function createCompositeVariant( tiers, settings: settingsPath, port, + ...(target !== 'claude' && { target }), }; saveCompositeVariantUnified(name, compositeConfig); @@ -373,6 +392,7 @@ export function createCompositeVariant( default_tier: defaultTier, tiers, port, + target, }, }; } catch (error) { @@ -384,6 +404,7 @@ export function createCompositeVariant( export interface UpdateCompositeVariantOptions { defaultTier?: 'opus' | 'sonnet' | 'haiku'; tiers?: Partial>; + target?: TargetType; } /** @@ -418,6 +439,8 @@ export function updateCompositeVariant( }; const newDefaultTier = updates.defaultTier ?? existing.default_tier ?? 'sonnet'; + const existingTarget = existing.target || 'claude'; + const newTarget = updates.target ?? existingTarget; const validationError = validateCompositeTiers(mergedTiers, { defaultTier: newDefaultTier, requireAllTiers: true, @@ -455,6 +478,7 @@ export function updateCompositeVariant( tiers: mergedTiers, settings: settingsRef, port: existing.port, + ...(newTarget !== 'claude' && { target: newTarget }), }; saveCompositeVariantUnified(name, compositeConfig); @@ -468,6 +492,7 @@ export function updateCompositeVariant( tiers: mergedTiers, port: existing.port, settings: settingsRef, + target: newTarget, }, }; } catch (error) { diff --git a/src/cliproxy/sync/profile-mapper.ts b/src/cliproxy/sync/profile-mapper.ts index d9b8b995..40583fa4 100644 --- a/src/cliproxy/sync/profile-mapper.ts +++ b/src/cliproxy/sync/profile-mapper.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; import { listApiProfiles, isApiProfileConfigured } from '../../api/services/profile-reader'; import type { ClaudeKey } from '../management-api-types'; @@ -31,6 +32,15 @@ interface SettingsJson { env?: Record; } +function resolveProfileSettingsPath(settingsPath: string): string { + const normalized = settingsPath.replace(/\\/g, '/'); + if (normalized.startsWith('~/.ccs/')) { + return path.join(getCcsDir(), normalized.slice('~/.ccs/'.length)); + } + + return expandPath(settingsPath); +} + /** * Load syncable API profiles from CCS config. * Filters to only configured profiles (with real API keys). @@ -45,9 +55,14 @@ export function loadSyncableProfiles(): SyncableProfile[] { continue; } + // Local CLIProxy sync writes Claude-compatible entries only. + // Profiles pinned to non-claude targets are intentionally skipped. + if (profile.target !== 'claude') { + continue; + } + // Load settings.json for env vars - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile.name}.settings.json`); + const settingsPath = resolveProfileSettingsPath(profile.settingsPath); let env: Record | undefined; try { diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 495617d0..cdbb2f43 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -43,6 +43,7 @@ import { type ProviderPreset, } from '../api/services'; import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync'; +import type { TargetType } from '../targets/target-adapter'; import { extractOption, hasAnyFlag } from './arg-extractor'; interface ApiCommandArgs { @@ -51,13 +52,14 @@ interface ApiCommandArgs { apiKey?: string; model?: string; preset?: string; + target?: TargetType; force?: boolean; yes?: boolean; errors: string[]; } const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const; -const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset'] as const; +const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset', '--target'] as const; const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS]; const API_VALUE_FLAG_SET = new Set(API_VALUE_FLAGS); @@ -130,6 +132,14 @@ function extractPositionalArgs(args: string[]): string[] { return positionals; } +function parseTargetValue(value: string): TargetType | null { + const normalized = value.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + return null; +} + /** Parse command line arguments for api commands */ export function parseApiCommandArgs(args: string[]): ApiCommandArgs { const result: ApiCommandArgs = { @@ -184,6 +194,22 @@ export function parseApiCommandArgs(args: string[]): ApiCommandArgs { } ); + remaining = applyRepeatedOption( + remaining, + ['--target'], + (value) => { + const target = parseTargetValue(value); + if (!target) { + result.errors.push(`Invalid --target value "${value}". Use: claude or droid`); + return; + } + result.target = target; + }, + () => { + result.errors.push('Missing value for --target'); + } + ); + const positionalArgs = extractPositionalArgs(remaining); result.name = positionalArgs[0]; return result; @@ -383,12 +409,23 @@ async function handleCreate(args: string[]): Promise { sonnet: sonnetModel, haiku: haikuModel, }; + let resolvedTarget: TargetType = parsedArgs.target || 'claude'; + + if (!parsedArgs.target && !parsedArgs.yes) { + const useDroidByDefault = await InteractivePrompt.confirm( + 'Set default target to Factory Droid for this profile?', + { default: false } + ); + if (useDroidByDefault) { + resolvedTarget = 'droid'; + } + } // Create profile console.log(''); console.log(info('Creating API profile...')); - const result = createApiProfile(name, baseUrl, apiKey, models); + const result = createApiProfile(name, baseUrl, apiKey, models, resolvedTarget); if (!result.success) { console.log(fail(`Failed to create API profile: ${result.error}`)); @@ -411,7 +448,8 @@ async function handleCreate(args: string[]): Promise { `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + `Settings: ${result.settingsFile}\n` + `Base URL: ${baseUrl}\n` + - `Model: ${model}`; + `Model: ${model}\n` + + `Target: ${resolvedTarget}`; if (hasCustomMapping) { infoMsg += @@ -424,7 +462,24 @@ async function handleCreate(args: string[]): Promise { console.log(infoBox(infoMsg, 'API Profile Created')); console.log(''); console.log(header('Usage')); - console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + if (resolvedTarget === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } console.log(''); console.log(header('Edit Settings')); console.log(` ${dim('To modify env vars later:')}`); @@ -453,13 +508,13 @@ async function handleList(): Promise { // Build table data const rows: string[][] = profiles.map((p) => { const status = p.isConfigured ? color('[OK]', 'success') : color('[!]', 'warning'); - return [p.name, p.settingsPath, status]; + return [p.name, p.target, p.settingsPath, status]; }); - const colWidths = isUsingUnifiedConfig() ? [15, 20, 10] : [15, 35, 10]; + const colWidths = isUsingUnifiedConfig() ? [15, 10, 20, 10] : [15, 10, 35, 10]; console.log( table(rows, { - head: ['API', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'], + head: ['API', 'Target', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'], colWidths, }) ); @@ -468,11 +523,11 @@ async function handleList(): Promise { // Show CLIProxy variants if any if (variants.length > 0) { console.log(subheader('CLIProxy Variants')); - const cliproxyRows = variants.map((v) => [v.name, v.provider, v.settings]); + const cliproxyRows = variants.map((v) => [v.name, v.provider, v.target, v.settings]); console.log( table(cliproxyRows, { - head: ['Variant', 'Provider', 'Settings'], - colWidths: [15, 15, 30], + head: ['Variant', 'Provider', 'Target', 'Settings'], + colWidths: [15, 12, 10, 28], }) ); console.log(''); @@ -582,6 +637,9 @@ async function showHelp(): Promise { console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); console.log(` ${color('--model ', 'command')} Default model (create)`); + console.log( + ` ${color('--target ', 'command')} Default target: claude or droid (create)` + ); console.log(` ${color('--force', 'command')} Overwrite existing (create)`); console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`); console.log(''); @@ -605,6 +663,7 @@ async function showHelp(): Promise { console.log(''); console.log(` ${dim('# Create with name')}`); console.log(` ${color('ccs api create myapi', 'command')}`); + console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`); console.log(''); console.log(` ${dim('# Remove API profile')}`); console.log(` ${color('ccs api remove myapi', 'command')}`); diff --git a/src/commands/cliproxy/auth-subcommand.ts b/src/commands/cliproxy/auth-subcommand.ts index 76ab6730..b856f278 100644 --- a/src/commands/cliproxy/auth-subcommand.ts +++ b/src/commands/cliproxy/auth-subcommand.ts @@ -45,10 +45,13 @@ export async function handleList(): Promise { const variant = variants[name]; const providerDisplay = variant.type === 'composite' ? 'composite' : variant.provider; const portStr = variant.port ? String(variant.port) : '-'; - return [name, providerDisplay, portStr, variant.settings || '-']; + return [name, providerDisplay, variant.target || 'claude', portStr, variant.settings || '-']; }); console.log( - table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] }) + table(rows, { + head: ['Variant', 'Provider', 'Target', 'Port', 'Settings'], + colWidths: [15, 12, 10, 8, 24], + }) ); console.log(''); console.log(dim(`Total: ${variantNames.length} custom variant(s)`)); diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 01d5bd13..cc3e60a2 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -81,6 +81,7 @@ export async function showHelp(): Promise { 'Options:', [ ['--backend ', 'Use specific backend: original | plus (default: from config)'], + ['--target ', 'Default target for created/edited variants: claude | droid'], ['--verbose, -v', 'Show detailed quota fetch diagnostics'], ], ], diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index fa395445..b3a00056 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -12,6 +12,7 @@ import { triggerOAuth } from '../../cliproxy/auth/oauth-handler'; import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog'; import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types'; +import type { TargetType } from '../../targets/target-adapter'; import { isUnifiedMode } from '../../config/unified-config-loader'; import { initUI, header, color, ok, fail, warn, info, infoBox, dim } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; @@ -32,28 +33,66 @@ interface CliproxyProfileArgs { provider?: CLIProxyProfileName; model?: string; account?: string; + target?: TargetType; force?: boolean; yes?: boolean; composite?: boolean; + errors: string[]; } -function parseProfileArgs(args: string[]): CliproxyProfileArgs { - const result: CliproxyProfileArgs = {}; +function parseTargetValue(rawValue: string): TargetType | null { + const normalized = rawValue.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + return null; +} + +export function parseProfileArgs(args: string[]): CliproxyProfileArgs { + const result: CliproxyProfileArgs = { errors: [] }; + let parseOptions = true; + for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === '--provider' && args[i + 1]) { + if (parseOptions && arg === '--') { + parseOptions = false; + continue; + } + + if (parseOptions && arg === '--provider' && args[i + 1]) { result.provider = args[++i] as CLIProxyProfileName; - } else if (arg === '--model' && args[i + 1]) { + } else if (parseOptions && arg === '--model' && args[i + 1]) { result.model = args[++i]; - } else if (arg === '--account' && args[i + 1]) { + } else if (parseOptions && arg === '--account' && args[i + 1]) { result.account = args[++i]; - } else if (arg === '--force') { + } else if (parseOptions && arg === '--target') { + const rawValue = args[i + 1]; + if (!rawValue || rawValue.startsWith('-')) { + result.errors.push('Missing value for --target'); + } else { + i += 1; + const parsedTarget = parseTargetValue(rawValue); + if (!parsedTarget) { + result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`); + } else { + result.target = parsedTarget; + } + } + } else if (parseOptions && arg.startsWith('--target=')) { + const rawValue = arg.slice('--target='.length); + const parsedTarget = parseTargetValue(rawValue); + if (!parsedTarget) { + result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`); + } else { + result.target = parsedTarget; + } + } else if (parseOptions && arg === '--force') { result.force = true; - } else if (arg === '--yes' || arg === '-y') { + } else if (parseOptions && (arg === '--yes' || arg === '-y')) { result.yes = true; - } else if (arg === '--composite') { + } else if (parseOptions && arg === '--composite') { result.composite = true; - } else if (!arg.startsWith('-') && !result.name) { + } else if ((!parseOptions || !arg.startsWith('-')) && !result.name) { result.name = arg; } } @@ -145,6 +184,11 @@ export async function handleCreate( ): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exitCode = 1; + return; + } console.log(header(`Create ${getBackendLabel(backend)} Variant`)); console.log(''); @@ -168,6 +212,17 @@ export async function handleCreate( process.exit(1); } + let resolvedTarget: TargetType = parsedArgs.target || 'claude'; + if (!parsedArgs.target && !parsedArgs.yes) { + const useDroidByDefault = await InteractivePrompt.confirm( + 'Set default target to Factory Droid for this variant?', + { default: false } + ); + if (useDroidByDefault) { + resolvedTarget = 'droid'; + } + } + // Composite mode: select provider+model per tier if (parsedArgs.composite) { console.log(info('Composite variant — select provider and model for each tier')); @@ -203,6 +258,7 @@ export async function handleCreate( const result = createCompositeVariant({ name, defaultTier, + target: resolvedTarget, tiers: { opus, sonnet, haiku }, }); @@ -217,7 +273,8 @@ export async function handleCreate( ? `Opus: ${tiers.opus.provider} / ${tiers.opus.model}\n` + `Sonnet: ${tiers.sonnet.provider} / ${tiers.sonnet.model}\n` + `Haiku: ${tiers.haiku.provider} / ${tiers.haiku.model}\n` + - `Default: ${defaultTier}` + `Default: ${defaultTier}\n` + + `Target: ${resolvedTarget}` : ''; const portInfo = result.variant?.port ? `\nPort: ${result.variant.port}` : ''; console.log( @@ -228,7 +285,24 @@ export async function handleCreate( ); console.log(''); console.log(header('Usage')); - console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + if (resolvedTarget === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } console.log(''); return; } @@ -350,7 +424,7 @@ export async function handleCreate( // Create variant console.log(''); console.log(info(`Creating ${getBackendLabel(backend)} variant...`)); - const result = createVariant(name, provider, model, account); + const result = createVariant(name, provider, model, account, resolvedTarget); if (!result.success) { console.log(fail(`Failed to create variant: ${result.error}`)); @@ -367,13 +441,30 @@ export async function handleCreate( const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : ''; console.log( infoBox( - `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`, + `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`, configType ) ); console.log(''); console.log(header('Usage')); - console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + if (resolvedTarget === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } console.log(''); console.log(dim('To change model later:')); console.log(` ${color(`ccs ${name} --config`, 'command')}`); @@ -383,6 +474,11 @@ export async function handleCreate( export async function handleRemove(args: string[]): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exitCode = 1; + return; + } const variants = listVariants(); const variantNames = Object.keys(variants); @@ -435,6 +531,7 @@ export async function handleRemove(args: string[]): Promise { if (variant.port) { console.log(` Port: ${variant.port}`); } + console.log(` Target: ${variant.target || 'claude'}`); console.log(` Settings: ${variant.settings || '-'}`); console.log(''); @@ -461,6 +558,11 @@ export async function handleEdit( ): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exitCode = 1; + return; + } const variants = listVariants(); const variantNames = Object.keys(variants); @@ -501,12 +603,14 @@ export async function handleEdit( // If not composite, use existing updateVariant() flow (interactive prompts) if (variant.type !== 'composite') { + const currentTarget: TargetType = variant.target || 'claude'; console.log(header(`Edit Variant: ${name}`)); console.log(''); console.log(`Current provider: ${variant.provider}`); if (variant.model) { console.log(`Current model: ${variant.model}`); } + console.log(`Current target: ${currentTarget}`); console.log(''); const changeProvider = await InteractivePrompt.confirm('Change provider?', { default: false }); @@ -552,6 +656,22 @@ export async function handleEdit( } } + let newTarget: TargetType | undefined = parsedArgs.target; + if (!parsedArgs.target) { + const changeTarget = await InteractivePrompt.confirm('Change default target?', { + default: false, + }); + if (changeTarget) { + const targetOptions = [ + { id: 'claude', label: 'Claude Code' }, + { id: 'droid', label: 'Factory Droid' }, + ]; + newTarget = (await InteractivePrompt.selectFromList('Select target:', targetOptions, { + defaultIndex: currentTarget === 'droid' ? 1 : 0, + })) as TargetType; + } + } + console.log(''); console.log(info(`Updating ${getBackendLabel(backend)} variant...`)); // Use existing updateVariant from variant-service for single-provider variants @@ -559,6 +679,7 @@ export async function handleEdit( const result = updateVariant(name, { provider: newProvider, model: changeModel ? newModel : undefined, + target: newTarget, }); if (!result.success) { @@ -566,13 +687,35 @@ export async function handleEdit( process.exit(1); } + const resolvedTarget = result.variant?.target || currentTarget; console.log(''); console.log(ok(`Variant updated: ${name}`)); console.log(''); + console.log(header('Usage')); + if (resolvedTarget === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } + console.log(''); return; } // Composite variant edit flow + const compositeCurrentTarget: TargetType = variant.target || 'claude'; console.log(header(`Edit Composite Variant: ${name}`)); console.log(''); if (!variant.tiers) { @@ -585,6 +728,7 @@ export async function handleEdit( console.log(` Sonnet: ${variant.tiers.sonnet.provider} / ${variant.tiers.sonnet.model}`); console.log(` Haiku: ${variant.tiers.haiku.provider} / ${variant.tiers.haiku.model}`); console.log(` Default: ${variant.default_tier}`); + console.log(` Target: ${compositeCurrentTarget}`); console.log(''); const verbose = args.includes('--verbose'); @@ -634,11 +778,32 @@ export async function handleEdit( )) as 'opus' | 'sonnet' | 'haiku'; } + let newCompositeTarget: TargetType | undefined = parsedArgs.target; + if (!parsedArgs.target) { + const changeTarget = await InteractivePrompt.confirm('Change default target?', { + default: false, + }); + if (changeTarget) { + const targetOptions = [ + { id: 'claude', label: 'Claude Code' }, + { id: 'droid', label: 'Factory Droid' }, + ]; + newCompositeTarget = (await InteractivePrompt.selectFromList( + 'Select target:', + targetOptions, + { + defaultIndex: compositeCurrentTarget === 'droid' ? 1 : 0, + } + )) as TargetType; + } + } + console.log(''); console.log(info(`Updating composite ${getBackendLabel(backend)} variant...`)); const result = updateCompositeVariant(name, { tiers: updatedTiers, defaultTier: changeDefault ? newDefaultTier : undefined, + target: newCompositeTarget, }); if (!result.success) { @@ -653,7 +818,8 @@ export async function handleEdit( `Opus: ${finalVariant.tiers.opus.provider} / ${finalVariant.tiers.opus.model}\n` + `Sonnet: ${finalVariant.tiers.sonnet.provider} / ${finalVariant.tiers.sonnet.model}\n` + `Haiku: ${finalVariant.tiers.haiku.provider} / ${finalVariant.tiers.haiku.model}\n` + - `Default: ${finalVariant.default_tier}`; + `Default: ${finalVariant.default_tier}\n` + + `Target: ${finalVariant.target || compositeCurrentTarget}`; const portInfo = finalVariant.port ? `\nPort: ${finalVariant.port}` : ''; console.log( infoBox( diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 1fdc3d63..b7b8ed0e 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -150,10 +150,15 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['Run multiple Claude accounts concurrently'], [ ['ccs auth --help', 'Show account management commands'], - ['ccs auth create ', 'Create account profile (supports context sharing flags)'], + [ + 'ccs auth create ', + 'Create account profile (supports shared groups + --deeper-continuity)', + ], + ['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'], ['ccs auth list', 'List all account profiles'], ['ccs auth default ', 'Set default profile'], ['ccs auth reset-default', 'Restore original CCS default'], + ['ccs cliproxy auth claude', 'Alternative: authenticate Claude account pool via CLIProxy'], ] ); @@ -167,6 +172,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'First run: Browser opens for authentication, then model selection', 'Settings: ~/.ccs/{provider}.settings.json (created after auth)', 'Safety: do not reuse one Google account across "ccs gemini" and "ccs agy" (issue #509)', + 'Antigravity requires multi-step responsibility confirmation (issue #509)', 'If you want to keep Google AI access, do not continue this shared-account setup', 'CCS is as-is and does not take responsibility for account bans/access loss', ], @@ -188,6 +194,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs --accounts', 'List all accounts'], ['ccs --use ', 'Switch to account'], ['ccs --config', 'Change model (agy, gemini)'], + [ + 'ccs agy --accept-agr-risk', + 'Bypass interactive Antigravity confirmation (you accept full responsibility)', + ], [ 'ccs --thinking ', 'Set thinking budget (low/medium/high/xhigh/auto/off or number)', @@ -326,6 +336,21 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); printSubSection('Multi-Target', [ ['ccs glm --target droid', 'Run GLM profile on Droid CLI'], ['ccsd glm', 'Same as above (alias)'], + ['ccsd codex', 'Run built-in CLIProxy Codex profile on Droid'], + ['ccsd agy', 'Run built-in CLIProxy Antigravity profile on Droid'], + [ + 'ccsd codex exec --skip-permissions-unsafe "fix failing tests"', + 'Pass through Droid exec mode', + ], + ['ccsd codex -m custom:gpt-5.3-codex "fix failing tests"', 'Auto-routes short exec flags'], + [ + 'ccsd codex --skip-permissions-unsafe "fix failing tests"', + 'Auto-routes to Droid exec when exec-only flags are detected', + ], + [ + 'ccs cliproxy create my-codex --provider codex --target droid', + 'Create CLIProxy variant with Droid as default target', + ], ['ccs glm', 'Run GLM profile on Claude Code (default)'], ]); @@ -347,7 +372,9 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['', ''], // Spacer ['ccs cliproxy pause

', 'Pause account from rotation'], ['ccs cliproxy resume

', 'Resume paused account'], - ['ccs cliproxy status [provider]', 'Show quota/tier/pause status'], + ['ccs cliproxy status', 'Show CLIProxy process status'], + ['ccs cliproxy quota', 'Show quota/tier/pause status for all providers'], + ['ccs cliproxy quota --provider ', 'Show quota/tier/pause status for one provider'], ]); // CLI Proxy configuration flags (new) @@ -375,6 +402,11 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['--effort ', 'Codex alias for reasoning effort (medium/high/xhigh)'], ['--effort xhigh', 'Pin Codex effort to xhigh for this run'], ['', ''], + ['Droid exec:', 'Use native Droid flag: --reasoning-effort '], + ['', 'CCS auto-maps --thinking/--effort to --reasoning-effort in droid exec mode.'], + ['', 'For interactive droid sessions, CCS applies reasoning via Droid BYOK model config.'], + ['', 'When multiple reasoning flags are provided, the first flag wins.'], + ['', ''], ['Note:', 'Extended thinking allocates compute for step-by-step reasoning'], ['', 'before responding.'], ['', 'Providers: agy/gemini use --thinking, codex uses --effort (or --thinking alias).'], diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 3910a71a..38659b74 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -21,6 +21,7 @@ import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unif import { createEmptyUnifiedConfig } from './unified-config-types'; import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader'; +import { isValidContextGroupName, normalizeContextGroupName } from '../auth/account-context'; import { infoBox, warn } from '../utils/ui'; const BACKUP_DIR_PREFIX = 'backup-v1-'; @@ -148,9 +149,29 @@ export async function migrate(dryRun = false): Promise { if (oldProfiles?.profiles) { for (const [name, meta] of Object.entries(oldProfiles.profiles)) { const metadata = meta as Record; + const rawContextMode = metadata.context_mode; + const rawContextGroup = metadata.context_group; + const rawContinuityMode = metadata.continuity_mode; + const contextMode = rawContextMode === 'shared' ? 'shared' : 'isolated'; + const continuityMode = + contextMode === 'shared' && rawContinuityMode === 'deeper' ? 'deeper' : 'standard'; + let contextGroup: string | undefined; + if (typeof rawContextGroup === 'string' && rawContextGroup.trim().length > 0) { + const normalizedGroup = normalizeContextGroupName(rawContextGroup); + if (isValidContextGroupName(normalizedGroup)) { + contextGroup = normalizedGroup; + } else { + warnings.push( + `Skipped invalid context group for account "${name}": "${rawContextGroup}" (fallback to default shared group)` + ); + } + } const account: AccountConfig = { created: (metadata.created as string) || new Date().toISOString(), last_used: (metadata.last_used as string) || null, + context_mode: contextMode, + context_group: contextMode === 'shared' ? contextGroup : undefined, + continuity_mode: contextMode === 'shared' ? continuityMode : undefined, }; unifiedConfig.accounts[name] = account; } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 8f82351c..1fbec374 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -18,10 +18,12 @@ import { DEFAULT_CURSOR_CONFIG, DEFAULT_GLOBAL_ENV, DEFAULT_CLIPROXY_SERVER_CONFIG, + DEFAULT_CLIPROXY_SAFETY_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_THINKING_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, + CLIProxySafetyConfig, GlobalEnvConfig, ThinkingConfig, DashboardAuthConfig, @@ -241,6 +243,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { accounts: partial.accounts ?? defaults.accounts, profiles: partial.profiles ?? defaults.profiles, cliproxy: { + ...partial.cliproxy, oauth_accounts: partial.cliproxy?.oauth_accounts ?? defaults.cliproxy.oauth_accounts, providers: defaults.cliproxy.providers, // Always use defaults for providers variants: partial.cliproxy?.variants ?? defaults.cliproxy.variants, @@ -249,8 +252,17 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { request_log: partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false, }, + safety: { + antigravity_ack_bypass: + partial.cliproxy?.safety?.antigravity_ack_bypass ?? + DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, + }, + // Kiro browser behavior setting (optional) + kiro_no_incognito: partial.cliproxy?.kiro_no_incognito, // Auth config - preserve user values, no defaults (uses constants as fallback) auth: partial.cliproxy?.auth, + // Background token refresh config (optional) + token_refresh: partial.cliproxy?.token_refresh, // Backend selection - validate and preserve user choice (original vs plus) backend: partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus' @@ -707,14 +719,24 @@ function generateYamlWithComments(config: UnifiedConfig): string { } /** - * Save unified config to YAML file. - * Uses atomic write (temp file + rename) to prevent corruption. - * Uses lockfile to prevent concurrent writes. + * Sync sleep helper for lock retry loops. + * Uses Atomics.wait when available to avoid CPU-intensive busy-wait. */ -export function saveUnifiedConfig(config: UnifiedConfig): void { - const yamlPath = getConfigYamlPath(); - const dir = path.dirname(yamlPath); +function sleepSync(ms: number): void { + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + } catch { + const end = Date.now() + ms; + while (Date.now() < end) { + /* busy-wait */ + } + } +} +/** + * Execute a callback while holding the config lock. + */ +function withConfigWriteLock(callback: () => T): T { // Acquire lock (retry for up to 1 second) const maxRetries = 10; const retryDelayMs = 100; @@ -724,18 +746,7 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { lockAcquired = true; break; } - // Synchronous sleep without CPU-intensive busy-wait - // Uses Atomics.wait which properly sleeps the thread - // Note: saveUnifiedConfig is sync API with 19+ callers, converting to async not feasible - try { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs); - } catch { - // Fallback for environments without SharedArrayBuffer/Atomics support - const end = Date.now() + retryDelayMs; - while (Date.now() < end) { - /* busy-wait */ - } - } + sleepSync(retryDelayMs); } if (!lockAcquired) { @@ -743,57 +754,112 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { } try { - // Ensure directory exists - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - - // Ensure version is set - config.version = UNIFIED_CONFIG_VERSION; - - // Generate YAML with section comments - const yamlContent = generateYamlWithComments(config); - const content = generateYamlHeader() + yamlContent; - - // Atomic write: write to temp file, then rename - const tempPath = `${yamlPath}.tmp.${process.pid}`; - - try { - fs.writeFileSync(tempPath, content, { mode: 0o600 }); - fs.renameSync(tempPath, yamlPath); - } catch (error) { - // Clean up temp file on error - if (fs.existsSync(tempPath)) { - try { - fs.unlinkSync(tempPath); - } catch { - // Ignore cleanup errors - } - } - // Classify filesystem errors - const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOSPC') { - throw new Error('Disk full - cannot save config. Free up space and try again.'); - } else if (err.code === 'EROFS' || err.code === 'EACCES') { - throw new Error(`Cannot write config - check file permissions: ${err.message}`); - } - throw error; - } + return callback(); } finally { // Always release lock releaseLock(); } } +/** + * Load unified config directly from disk while lock is already held. + * Falls back to empty config when file doesn't exist. + */ +function loadUnifiedConfigWithLockHeld(): UnifiedConfig { + const yamlPath = getConfigYamlPath(); + if (!fs.existsSync(yamlPath)) { + return createEmptyUnifiedConfig(); + } + + const content = fs.readFileSync(yamlPath, 'utf8'); + const parsed = yaml.load(content); + + if (!isUnifiedConfig(parsed)) { + throw new Error(`Invalid config format in ${yamlPath}`); + } + + const merged = mergeWithDefaults(parsed); + validateCompositeVariants(merged); + return merged; +} + +/** + * Write unified config to disk while lock is already held. + */ +function writeUnifiedConfigWithLockHeld(config: UnifiedConfig): void { + const yamlPath = getConfigYamlPath(); + const dir = path.dirname(yamlPath); + + // Ensure directory exists + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + // Ensure version is set + config.version = UNIFIED_CONFIG_VERSION; + + // Generate YAML with section comments + const yamlContent = generateYamlWithComments(config); + const content = generateYamlHeader() + yamlContent; + + // Atomic write: write to temp file, then rename + const tempPath = `${yamlPath}.tmp.${process.pid}`; + + try { + fs.writeFileSync(tempPath, content, { mode: 0o600 }); + fs.renameSync(tempPath, yamlPath); + } catch (error) { + // Clean up temp file on error + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + // Classify filesystem errors + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOSPC') { + throw new Error('Disk full - cannot save config. Free up space and try again.'); + } else if (err.code === 'EROFS' || err.code === 'EACCES') { + throw new Error(`Cannot write config - check file permissions: ${err.message}`); + } + throw error; + } +} + +/** + * Save unified config to YAML file. + * Uses atomic write (temp file + rename) to prevent corruption. + * Uses lockfile to prevent concurrent writes. + */ +export function saveUnifiedConfig(config: UnifiedConfig): void { + withConfigWriteLock(() => { + writeUnifiedConfigWithLockHeld(config); + }); +} + +/** + * Atomically mutate unified config with lock held across read-modify-write. + * Prevents stale writes from overwriting concurrent updates. + */ +export function mutateUnifiedConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig { + return withConfigWriteLock(() => { + const current = loadUnifiedConfigWithLockHeld(); + mutator(current); + writeUnifiedConfigWithLockHeld(current); + return current; + }); +} + /** * Update unified config with partial data. * Loads existing config, merges changes, and saves. */ export function updateUnifiedConfig(updates: Partial): UnifiedConfig { - const config = loadOrCreateUnifiedConfig(); - const updated = { ...config, ...updates }; - saveUnifiedConfig(updated); - return updated; + return mutateUnifiedConfig((config) => { + Object.assign(config, updates); + }); } /** @@ -892,6 +958,19 @@ export function getGlobalEnvConfig(): GlobalEnvConfig { }; } +/** + * Get cliproxy safety configuration. + * Returns defaults if not configured. + */ +export function getCliproxySafetyConfig(): CLIProxySafetyConfig { + const config = loadOrCreateUnifiedConfig(); + return { + antigravity_ack_bypass: + config.cliproxy?.safety?.antigravity_ack_bypass ?? + DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, + }; +} + /** * Get thinking configuration. * Returns defaults if not configured. diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 95924ea7..287cba72 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -44,6 +44,8 @@ export interface AccountConfig { context_mode?: 'isolated' | 'shared'; /** Context-sharing group when context_mode='shared' */ context_group?: string; + /** Shared continuity depth when context_mode='shared' */ + continuity_mode?: 'standard' | 'deeper'; } /** @@ -160,6 +162,22 @@ export interface CLIProxyLoggingConfig { request_log?: boolean; } +/** + * CLIProxy safety configuration. + * Controls high-risk flow safeguards for supported providers. + */ +export interface CLIProxySafetyConfig { + /** Allow skipping AGY responsibility acknowledgement flow (default: false) */ + antigravity_ack_bypass?: boolean; +} + +/** + * Default CLIProxy safety configuration. + */ +export const DEFAULT_CLIPROXY_SAFETY_CONFIG: CLIProxySafetyConfig = { + antigravity_ack_bypass: false, +}; + /** * Token refresh configuration. * Manages background token refresh worker settings. @@ -191,6 +209,8 @@ export interface CLIProxyConfig { variants: Record; /** Logging configuration (disabled by default) */ logging?: CLIProxyLoggingConfig; + /** Safety controls for high-risk provider flows */ + safety?: CLIProxySafetyConfig; /** Kiro: disable incognito browser mode (use normal browser to save credentials) */ kiro_no_incognito?: boolean; /** Global auth configuration for CLIProxyAPI */ @@ -781,6 +801,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { enabled: false, request_log: false, }, + safety: { ...DEFAULT_CLIPROXY_SAFETY_CONFIG }, auto_sync: true, }, preferences: { diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index ab3889de..8f49cf74 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import SharedManager from './shared-manager'; +import ProfileContextSyncLock from './profile-context-sync-lock'; import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context'; import { getCcsDir } from '../utils/config-manager'; @@ -18,10 +19,12 @@ import { getCcsDir } from '../utils/config-manager'; class InstanceManager { private readonly instancesDir: string; private readonly sharedManager: SharedManager; + private readonly contextSyncLock: ProfileContextSyncLock; constructor() { this.instancesDir = path.join(getCcsDir(), 'instances'); this.sharedManager = new SharedManager(); + this.contextSyncLock = new ProfileContextSyncLock(this.instancesDir); } /** @@ -33,16 +36,20 @@ class InstanceManager { ): Promise { const instancePath = this.getInstancePath(profileName); - // Lazy initialization - if (!fs.existsSync(instancePath)) { - this.initializeInstance(profileName, instancePath); - } + // Serialize context sync operations per profile across processes. + await this.contextSyncLock.withLock(profileName, async () => { + // Lazy initialization + if (!fs.existsSync(instancePath)) { + this.initializeInstance(profileName, instancePath); + } - // Validate structure (auto-fix missing dirs) - this.validateInstance(instancePath); + // Validate structure (auto-fix missing dirs) + this.validateInstance(instancePath); - // Apply context policy (isolated by default, optional shared group). - await this.sharedManager.syncProjectContext(instancePath, contextPolicy); + // Apply context policy (isolated by default, optional shared group). + await this.sharedManager.syncProjectContext(instancePath, contextPolicy); + await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy); + }); return instancePath; } diff --git a/src/management/profile-context-sync-lock.ts b/src/management/profile-context-sync-lock.ts new file mode 100644 index 00000000..c8158bc3 --- /dev/null +++ b/src/management/profile-context-sync-lock.ts @@ -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; + 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(profileName: string, callback: () => Promise): Promise { + 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; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index b48aeb54..9a010edd 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -27,6 +27,12 @@ class SharedManager { private readonly claudeDir: string; private readonly instancesDir: string; private readonly sharedItems: SharedItem[]; + private readonly advancedContinuityItems: readonly string[] = [ + 'session-env', + 'file-history', + 'shell-snapshots', + 'todos', + ]; constructor() { this.homeDir = os.homedir(); @@ -63,8 +69,14 @@ class SharedManager { const resolvedTarget = path.resolve(path.dirname(target), targetLink); // Check if target points back to our shared dir or link path - const sharedDir = path.join(getCcsDir(), 'shared'); - if (resolvedTarget.startsWith(sharedDir) || resolvedTarget === linkPath) { + const sharedDir = this.resolveCanonicalPath(path.join(getCcsDir(), 'shared')); + 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}`)); return true; } @@ -240,6 +252,7 @@ class SharedManager { if ( currentTarget && path.resolve(currentTarget) !== path.resolve(sharedProjectsPath) && + this.isSafeProjectsMergeSource(currentTarget, instanceName) && (await this.pathExists(currentTarget)) ) { await this.mergeDirectoryWithConflictCopies( @@ -247,6 +260,10 @@ class SharedManager { sharedProjectsPath, instanceName ); + } else if (currentTarget && !this.isSafeProjectsMergeSource(currentTarget, instanceName)) { + console.log( + warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`) + ); } await fs.promises.unlink(projectsPath); @@ -286,9 +303,14 @@ class SharedManager { if ( currentTarget && path.resolve(currentTarget) !== path.resolve(projectsPath) && + this.isSafeProjectsMergeSource(currentTarget, instanceName) && (await this.pathExists(currentTarget)) ) { await this.mergeDirectoryWithConflictCopies(currentTarget, projectsPath, instanceName); + } else if (currentTarget && !this.isSafeProjectsMergeSource(currentTarget, instanceName)) { + console.log( + warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`) + ); } return; @@ -298,6 +320,133 @@ class SharedManager { await this.ensureDirectory(projectsPath); } + /** + * Sync advanced continuity artifacts for shared deeper mode. + * + * - shared + deeper: artifacts are linked per context group. + * - shared + standard / isolated: artifacts stay local to instance. + */ + async syncAdvancedContinuityArtifacts( + instancePath: string, + policy: AccountContextPolicy + ): Promise { + const instanceName = path.basename(instancePath); + const useSharedContinuity = policy.mode === 'shared' && policy.continuityMode === 'deeper'; + const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP; + + for (const artifactName of this.advancedContinuityItems) { + const instanceArtifactPath = path.join(instancePath, artifactName); + + if (useSharedContinuity) { + const sharedArtifactPath = path.join( + this.sharedDir, + 'context-groups', + contextGroup, + 'continuity', + artifactName + ); + + await this.ensureDirectory(sharedArtifactPath); + await this.ensureDirectory(path.dirname(instanceArtifactPath)); + + const currentStats = await this.getLstat(instanceArtifactPath); + if (!currentStats) { + await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath); + continue; + } + + if (currentStats.isSymbolicLink()) { + if (await this.isSymlinkTarget(instanceArtifactPath, sharedArtifactPath)) { + continue; + } + + const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath); + if ( + currentTarget && + path.resolve(currentTarget) !== path.resolve(sharedArtifactPath) && + this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) && + (await this.pathExists(currentTarget)) + ) { + await this.mergeDirectoryWithConflictCopies( + currentTarget, + sharedArtifactPath, + instanceName + ); + } else if ( + currentTarget && + !this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) + ) { + console.log( + warn( + `Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}` + ) + ); + } + + await fs.promises.unlink(instanceArtifactPath); + await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath); + continue; + } + + if (currentStats.isDirectory()) { + await this.mergeDirectoryWithConflictCopies( + instanceArtifactPath, + sharedArtifactPath, + instanceName + ); + await fs.promises.rm(instanceArtifactPath, { recursive: true, force: true }); + await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath); + continue; + } + + await fs.promises.rm(instanceArtifactPath, { force: true }); + await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath); + continue; + } + + const currentStats = await this.getLstat(instanceArtifactPath); + if (!currentStats) { + await this.ensureDirectory(instanceArtifactPath); + continue; + } + + if (currentStats.isDirectory()) { + continue; + } + + if (currentStats.isSymbolicLink()) { + const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath); + await fs.promises.unlink(instanceArtifactPath); + await this.ensureDirectory(instanceArtifactPath); + + if ( + currentTarget && + path.resolve(currentTarget) !== path.resolve(instanceArtifactPath) && + this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) && + (await this.pathExists(currentTarget)) + ) { + await this.mergeDirectoryWithConflictCopies( + currentTarget, + instanceArtifactPath, + instanceName + ); + } else if ( + currentTarget && + !this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) + ) { + console.log( + warn(`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`) + ); + } + + continue; + } + + await fs.promises.rm(instanceArtifactPath, { force: true }); + await this.ensureDirectory(instanceArtifactPath); + } + } + /** * Ensure all project memory directories for an instance are shared. * @@ -678,6 +827,56 @@ class SharedManager { } } + /** + * Guard project merge operations to known CCS-managed roots only. + */ + private isSafeProjectsMergeSource(sourcePath: string, instanceName: string): boolean { + const resolvedSource = this.resolveCanonicalPath(sourcePath); + const sharedContextRoot = this.resolveCanonicalPath( + path.join(this.sharedDir, 'context-groups') + ); + const instanceProjectsRoot = this.resolveCanonicalPath( + path.join(this.instancesDir, instanceName, 'projects') + ); + + return ( + this.isPathWithinDirectory(resolvedSource, sharedContextRoot) || + this.isPathWithinDirectory(resolvedSource, instanceProjectsRoot) + ); + } + + /** + * Guard advanced continuity merge operations to known CCS-managed roots only. + */ + private isSafeContinuityMergeSource( + sourcePath: string, + instanceName: string, + artifactName: string + ): boolean { + const resolvedSource = this.resolveCanonicalPath(sourcePath); + const sharedContextRoot = this.resolveCanonicalPath( + path.join(this.sharedDir, 'context-groups') + ); + const instanceArtifactRoot = this.resolveCanonicalPath( + path.join(this.instancesDir, instanceName, artifactName) + ); + + const normalizedSource = + process.platform === 'win32' ? resolvedSource.toLowerCase() : resolvedSource; + const continuitySegment = + process.platform === 'win32' + ? `${path.sep}continuity${path.sep}`.toLowerCase() + : `${path.sep}continuity${path.sep}`; + + const withinSharedContinuity = + this.isPathWithinDirectory(resolvedSource, sharedContextRoot) && + normalizedSource.includes(continuitySegment); + + return ( + withinSharedContinuity || this.isPathWithinDirectory(resolvedSource, instanceArtifactRoot) + ); + } + /** * Link directory with Windows fallback to recursive copy. */ @@ -708,7 +907,7 @@ class SharedManager { projectsPath: string, instanceName: string ): Promise { - const sharedMemoryRoot = path.resolve(path.join(this.sharedDir, 'memory')); + const sharedMemoryRoot = this.resolveCanonicalPath(path.join(this.sharedDir, 'memory')); let projectEntries: fs.Dirent[] = []; try { @@ -735,15 +934,20 @@ class SharedManager { continue; } - if (!path.resolve(memoryTarget).startsWith(sharedMemoryRoot)) { + const canonicalMemoryTarget = this.resolveCanonicalPath(memoryTarget); + if (!this.isPathWithinDirectory(canonicalMemoryTarget, sharedMemoryRoot)) { continue; } await fs.promises.unlink(memoryPath); await this.ensureDirectory(memoryPath); - if (await this.pathExists(memoryTarget)) { - await this.mergeDirectoryWithConflictCopies(memoryTarget, memoryPath, instanceName); + if (await this.pathExists(canonicalMemoryTarget)) { + await this.mergeDirectoryWithConflictCopies( + canonicalMemoryTarget, + memoryPath, + instanceName + ); } } } @@ -851,6 +1055,27 @@ class SharedManager { 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 { try { await fs.promises.access(targetPath); diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index bb3a8008..e42ed2d0 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -2,7 +2,7 @@ * Droid Adapter * * TargetAdapter implementation for Factory Droid CLI. - * Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:ccs-`. + * Writes credentials + active model to ~/.factory/settings.json and spawns `droid`. */ import { spawn, ChildProcess } from 'child_process'; @@ -11,6 +11,7 @@ import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from ' import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector'; import type { ProfileType } from '../types/profile'; import { upsertCcsModel } from './droid-config-manager'; +import { resolveDroidProvider } from './droid-provider'; import { escapeShellArg } from '../utils/shell-executor'; import { wireChildProcessSignals } from '../utils/signal-forwarder'; import { runCleanup } from '../errors'; @@ -43,22 +44,33 @@ export class DroidAdapter implements TargetAdapter { */ async prepareCredentials(creds: TargetCredentials): Promise { this.validateCredentials(creds); - await upsertCcsModel(creds.profile, { + const provider = resolveDroidProvider({ + provider: creds.provider, + baseUrl: creds.baseUrl, + model: creds.model, + }); + const modelRef = await upsertCcsModel(creds.profile, { model: creds.model || 'claude-opus-4-6', displayName: `CCS ${creds.profile}`, baseUrl: creds.baseUrl, apiKey: creds.apiKey, - provider: creds.provider || 'anthropic', + provider, + reasoningOverride: creds.reasoningOverride, }); + if (!modelRef.selector) { + throw new Error(`Failed to resolve Droid model selector for profile "${creds.profile}"`); + } } buildArgs(profile: string, userArgs: string[]): string[] { - if (!/^[a-zA-Z0-9_-]+$/.test(profile)) { + if (!/^[a-zA-Z0-9._-]+$/.test(profile)) { throw new Error( - `Invalid profile name "${profile}" for Droid target: only alphanumeric, underscore, hyphen allowed` + `Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed` ); } - return ['-m', `custom:ccs-${profile}`, ...userArgs]; + // Droid interactive mode treats unknown argv as queued prompt text. + // Model selection must be persisted in settings.json (`model`) instead of `-m`. + return [...userArgs]; } /** @@ -154,10 +166,8 @@ export class DroidAdapter implements TargetAdapter { }); } - /** - * Droid currently supports direct settings-based and default flows only. - */ + /** Droid supports settings/default and CLIProxy-executed profile flows. */ supportsProfileType(profileType: ProfileType): boolean { - return profileType === 'settings' || profileType === 'default'; + return profileType === 'settings' || profileType === 'default' || profileType === 'cliproxy'; } } diff --git a/src/targets/droid-command-router.ts b/src/targets/droid-command-router.ts new file mode 100644 index 00000000..b5c6d7fd --- /dev/null +++ b/src/targets/droid-command-router.ts @@ -0,0 +1,289 @@ +/** + * Droid command router + * + * Determines whether profile args should launch Droid interactive mode + * (`droid [prompt...]`) or command mode (`droid ...`). + * + * Also normalizes CCS legacy reasoning aliases for `droid exec`: + * - --effort / --thinking -> --reasoning-effort + */ + +export type DroidCommandMode = 'interactive' | 'command'; + +export interface DroidCommandRoute { + mode: DroidCommandMode; + argsForDroid: string[]; + command?: string; + autoPrependedExec: boolean; + reasoningSourceDisplay?: string; + duplicateReasoningDisplays: string[]; +} + +type DroidReasoningFlag = '--reasoning-effort' | '-r' | '--effort' | '--thinking'; + +export class DroidCommandRouterError extends Error { + constructor( + message: string, + public readonly flag: DroidReasoningFlag + ) { + super(message); + this.name = 'DroidCommandRouterError'; + } +} + +const DROID_SUBCOMMANDS = new Set([ + 'exec', + 'mcp', + 'plugin', + 'daemon', + 'search', + 'find', + 'ssh', + 'computer', + 'update', + 'help', +]); + +// Exec-only long flags from Factory Droid CLI help. +const DROID_EXEC_ONLY_LONG_FLAGS = new Set([ + '--output-format', + '--input-format', + '--file', + '--auto', + '--skip-permissions-unsafe', + '--session-id', + '--model', + '--reasoning-effort', + '--enabled-tools', + '--disabled-tools', + '--cwd', + '--tag', + '--log-group-id', + '--list-tools', +]); + +const DROID_EXEC_ONLY_SHORT_FLAGS = new Set(['-o', '-f', '-s', '-m']); +const DROID_REASONING_EFFORT_VALUES = new Set([ + 'none', + 'off', + 'minimal', + 'low', + 'medium', + 'high', + 'max', + 'xhigh', + 'auto', +]); + +function getLongFlagToken(arg: string): string { + const eqIndex = arg.indexOf('='); + return eqIndex >= 0 ? arg.slice(0, eqIndex) : arg; +} + +function isExplicitSubcommand(arg: string | undefined): boolean { + return !!arg && DROID_SUBCOMMANDS.has(arg); +} + +function isLikelyReasoningEffortValue(value: string | undefined): boolean { + if (!value || value.startsWith('-')) return false; + return DROID_REASONING_EFFORT_VALUES.has(value.toLowerCase()); +} + +function hasExecOnlyFlagsAtFront(args: string[]): boolean { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--') return false; + + // CCS legacy aliases may appear before exec-only flags; skip their values when present. + if (arg === '--effort' || arg === '--thinking') { + const possibleValue = args[i + 1]; + if (possibleValue && !possibleValue.startsWith('-')) { + i += 1; + } + continue; + } + if (arg.startsWith('--effort=') || arg.startsWith('--thinking=')) { + continue; + } + + if (!arg.startsWith('-')) return false; + if (!arg.startsWith('--')) { + // Short flags: + // - `-r` is ambiguous (root resume vs exec reasoning-effort), so only route + // when value looks like a reasoning effort level. + if (DROID_EXEC_ONLY_SHORT_FLAGS.has(arg)) { + return true; + } + if (arg === '-r') { + const value = args[i + 1]; + return isLikelyReasoningEffortValue(value); + } + continue; + } + + const flagToken = getLongFlagToken(arg); + if (DROID_EXEC_ONLY_LONG_FLAGS.has(flagToken)) { + return true; + } + } + + return false; +} + +interface ExecReasoningNormalizationResult { + args: string[]; + sourceDisplay?: string; + duplicateDisplays: string[]; +} + +function normalizeExecReasoningFlags(args: string[]): ExecReasoningNormalizationResult { + const normalized: string[] = []; + const duplicateDisplays: string[] = []; + let sourceDisplay: string | undefined; + let hasReasoning = false; + + const applyReasoning = (value: string, display: string): void => { + if (!hasReasoning) { + normalized.push('--reasoning-effort', value); + hasReasoning = true; + sourceDisplay = display; + return; + } + + duplicateDisplays.push(display); + }; + + const handleMissingValue = ( + flag: DroidReasoningFlag, + missingDisplay: string + ): never | undefined => { + if (!hasReasoning) { + throw new DroidCommandRouterError(`${flag} requires a value`, flag); + } + + duplicateDisplays.push(missingDisplay); + return undefined; + }; + + // Preserve leading command token for explicit auto-prepended command mode. + const startsWithExec = args[0] === 'exec'; + let startIndex = 0; + if (startsWithExec) { + normalized.push('exec'); + startIndex = 1; + } + + for (let i = startIndex; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--') { + normalized.push(...args.slice(i)); + break; + } + + if ( + arg === '--reasoning-effort' || + arg === '--effort' || + arg === '--thinking' || + arg === '-r' + ) { + const value = args[i + 1]; + if (!value || value.startsWith('-')) { + handleMissingValue(arg as DroidReasoningFlag, `${arg} `); + continue; + } + + applyReasoning(value, `${arg} ${value}`); + i += 1; + continue; + } + + if (arg.startsWith('--reasoning-effort=')) { + const value = arg.slice('--reasoning-effort='.length); + if (!value) { + handleMissingValue('--reasoning-effort', '--reasoning-effort='); + continue; + } + applyReasoning(value, `--reasoning-effort=${value}`); + continue; + } + + if (arg.startsWith('--effort=')) { + const value = arg.slice('--effort='.length); + if (!value) { + handleMissingValue('--effort', '--effort='); + continue; + } + applyReasoning(value, `--effort=${value}`); + continue; + } + + if (arg.startsWith('--thinking=')) { + const value = arg.slice('--thinking='.length); + if (!value) { + handleMissingValue('--thinking', '--thinking='); + continue; + } + applyReasoning(value, `--thinking=${value}`); + continue; + } + + normalized.push(arg); + } + + return { + args: normalized, + sourceDisplay, + duplicateDisplays, + }; +} + +export function routeDroidCommandArgs(args: string[]): DroidCommandRoute { + if (args.length === 0) { + return { + mode: 'interactive', + argsForDroid: [], + autoPrependedExec: false, + duplicateReasoningDisplays: [], + }; + } + + if (isExplicitSubcommand(args[0])) { + const command = args[0]; + const normalized = + command === 'exec' + ? normalizeExecReasoningFlags(args) + : { + args: [...args], + duplicateDisplays: [], + }; + return { + mode: 'command', + command, + argsForDroid: normalized.args, + autoPrependedExec: false, + reasoningSourceDisplay: normalized.sourceDisplay, + duplicateReasoningDisplays: normalized.duplicateDisplays, + }; + } + + if (hasExecOnlyFlagsAtFront(args)) { + const argsWithExec = ['exec', ...args]; + const normalized = normalizeExecReasoningFlags(argsWithExec); + return { + mode: 'command', + command: 'exec', + argsForDroid: normalized.args, + autoPrependedExec: true, + reasoningSourceDisplay: normalized.sourceDisplay, + duplicateReasoningDisplays: normalized.duplicateDisplays, + }; + } + + return { + mode: 'interactive', + argsForDroid: [...args], + autoPrependedExec: false, + duplicateReasoningDisplays: [], + }; +} diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 3483a2d3..29a3943e 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -20,16 +20,16 @@ const LOCK_RETRY_MAX_MS = 1000; /** * Validate profile name to prevent filesystem/security issues. - * Only alphanumeric, underscore, hyphen allowed. + * Only alphanumeric, dot, underscore, hyphen allowed. */ function isValidProfileName(profile: string): boolean { - return !!profile && /^[a-zA-Z0-9_-]+$/.test(profile); + return !!profile && /^[a-zA-Z0-9._-]+$/.test(profile); } function validateProfileName(profile: string): void { if (!isValidProfileName(profile)) { throw new Error( - `Invalid profile name "${profile}": must contain only alphanumeric characters, underscores, or hyphens` + `Invalid profile name "${profile}": must contain only alphanumeric characters, dots, underscores, or hyphens` ); } } @@ -41,9 +41,19 @@ export interface DroidCustomModel { apiKey: string; provider: 'anthropic' | 'openai' | 'generic-chat-completion-api'; maxOutputTokens?: number; + reasoningOverride?: string | number; +} + +export interface DroidManagedModelRef { + profile: string; + displayName: string; + index: number; + selectorAlias: string; + selector: string; } interface DroidSettings { + model?: string; customModels?: DroidCustomModelEntry[]; [key: string]: unknown; } @@ -55,13 +65,31 @@ interface DroidCustomModelEntry { apiKey: string; provider: string; maxOutputTokens?: number; + extraArgs?: Record; + extra_args?: Record; /** Internal alias used by CCS for lookup. Stored as the model's display name prefix. */ + [key: string]: unknown; } +const DROID_REASONING_OFF_VALUES = new Set(['off', 'none', 'disabled', '0']); +const DROID_ANTHROPIC_BUDGET_BY_EFFORT: Record = { + minimal: 4000, + low: 4000, + medium: 12000, + high: 30000, + max: 50000, + xhigh: 64000, + auto: 30000, +}; + function isSupportedProvider(value: string): value is DroidCustomModel['provider'] { return value === 'anthropic' || value === 'openai' || value === 'generic-chat-completion-api'; } +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function isDroidCustomModelEntry(value: unknown): value is DroidCustomModelEntry { if (!value || typeof value !== 'object') return false; const record = value as Record; @@ -97,6 +125,105 @@ function asModelEntry(value: unknown): DroidCustomModelEntry | null { return isDroidCustomModelEntry(value) ? value : null; } +function isReasoningOffValue(value: string | number): boolean { + if (typeof value === 'number') return value <= 0; + const normalized = value.trim().toLowerCase(); + return DROID_REASONING_OFF_VALUES.has(normalized); +} + +function toAnthropicBudget(value: string | number): number { + if (typeof value === 'number') { + return Math.max(1024, Math.floor(value)); + } + + const normalized = value.trim().toLowerCase(); + if (/^\d+$/.test(normalized)) { + return Math.max(1024, Number.parseInt(normalized, 10)); + } + + return DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalized] ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT.high; +} + +function toReasoningEffort(value: string | number): string { + if (typeof value === 'number') { + if (value <= 4000) return 'low'; + if (value <= 12000) return 'medium'; + if (value <= 30000) return 'high'; + if (value <= 50000) return 'max'; + return 'xhigh'; + } + + const normalized = value.trim().toLowerCase(); + if (!normalized) return 'high'; + return normalized; +} + +function applyReasoningOverride( + entry: DroidCustomModelEntry, + provider: DroidCustomModel['provider'], + reasoningOverride: string | number +): void { + const extraArgsKey: 'extraArgs' | 'extra_args' = Object.prototype.hasOwnProperty.call( + entry, + 'extra_args' + ) + ? 'extra_args' + : 'extraArgs'; + const currentExtraArgs = entry[extraArgsKey]; + const extraArgs = isObject(currentExtraArgs) ? { ...currentExtraArgs } : {}; + + // Normalize legacy aliases before writing provider-specific shape. + delete extraArgs.reasoningEffort; + + if (provider === 'anthropic') { + delete extraArgs.reasoning; + delete extraArgs.reasoning_effort; + + if (isReasoningOffValue(reasoningOverride)) { + delete extraArgs.thinking; + } else { + const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {}; + thinking.type = 'enabled'; + thinking.budget_tokens = toAnthropicBudget(reasoningOverride); + delete thinking.budgetTokens; + extraArgs.thinking = thinking; + } + } else if (provider === 'openai') { + delete extraArgs.reasoning_effort; + delete extraArgs.thinking; + + if (isReasoningOffValue(reasoningOverride)) { + delete extraArgs.reasoning; + } else { + const reasoning = isObject(extraArgs.reasoning) ? { ...extraArgs.reasoning } : {}; + reasoning.effort = toReasoningEffort(reasoningOverride); + extraArgs.reasoning = reasoning; + } + } else { + delete extraArgs.reasoning; + delete extraArgs.thinking; + + if (isReasoningOffValue(reasoningOverride)) { + delete extraArgs.reasoning_effort; + } else { + extraArgs.reasoning_effort = toReasoningEffort(reasoningOverride); + } + } + + if (Object.keys(extraArgs).length === 0) { + delete entry.extraArgs; + delete entry.extra_args; + return; + } + + entry[extraArgsKey] = extraArgs; +} + +function buildSelectorAlias(displayName: string, index: number): string { + const normalizedDisplayName = displayName.trim().replace(/\s+/g, '-'); + return `${normalizedDisplayName}-${index}`; +} + function normalizeCustomModels(value: unknown): DroidCustomModelEntry[] { if (Array.isArray(value)) { return value @@ -302,37 +429,72 @@ function writeDroidSettings(settings: DroidSettings): void { * Upsert a CCS-managed custom model entry. * Acquires file lock to prevent concurrent write races. */ -export async function upsertCcsModel(profile: string, model: DroidCustomModel): Promise { +export async function upsertCcsModel( + profile: string, + model: DroidCustomModel +): Promise { validateProfileName(profile); ensureFactoryDir(); let release: (() => Promise) | undefined; + let ref: DroidManagedModelRef | null = null; try { release = await acquireFactoryLock(10); const settings = readDroidSettings(); settings.customModels = normalizeCustomModels(settings.customModels); - const entry: DroidCustomModelEntry = { - ...model, - displayName: `CCS ${profile}`, - }; - // Find existing current or legacy entry for this profile. const idx = settings.customModels.findIndex( (m) => parseManagedProfile(m.displayName) === profile ); + const { reasoningOverride, ...modelWithoutReasoning } = model; + const existingEntry = idx >= 0 ? settings.customModels[idx] : undefined; + const entry: DroidCustomModelEntry = { + ...(existingEntry ?? {}), + ...modelWithoutReasoning, + displayName: `CCS ${profile}`, + }; + if (reasoningOverride !== undefined) { + applyReasoningOverride(entry, model.provider, reasoningOverride); + } + if (idx >= 0) { settings.customModels[idx] = entry; } else { settings.customModels.push(entry); } + const index = settings.customModels.findIndex( + (entry) => parseManagedProfile(entry.displayName) === profile + ); + const safeIndex = index >= 0 ? index : 0; + const selectorAlias = buildSelectorAlias(entry.displayName, safeIndex); + const selector = `custom:${selectorAlias}`; + // Droid interactive mode uses settings.model for default model selection. + settings.model = selector; writeDroidSettings(settings); + ref = { + profile, + displayName: entry.displayName, + index: safeIndex, + selectorAlias, + selector, + }; } finally { if (release) await release(); } + + return ( + ref || { + profile, + displayName: `CCS ${profile}`, + index: 0, + selectorAlias: `CCS-${profile}-0`, + selector: `custom:CCS-${profile}-0`, + } + ); } /** diff --git a/src/targets/droid-provider.ts b/src/targets/droid-provider.ts new file mode 100644 index 00000000..22a8d8c2 --- /dev/null +++ b/src/targets/droid-provider.ts @@ -0,0 +1,162 @@ +/** + * Droid BYOK provider resolution helpers. + * + * Factory BYOK accepts exactly: + * - anthropic + * - openai + * - generic-chat-completion-api + * + * CCS stores provider hints in profile settings as CCS_DROID_PROVIDER and + * resolves a best-effort provider from base URL/model when the hint is absent. + */ + +export type DroidProvider = 'anthropic' | 'openai' | 'generic-chat-completion-api'; + +const GENERIC_PROVIDER_ALIASES = new Set([ + 'generic', + 'generic-openai', + 'generic-openai-api', + 'generic-chat', + 'generic-chat-completions', + 'openai-compatible', + 'chat-completions', +]); + +const OPENAI_PROVIDER_ALIASES = new Set(['openai-responses', 'openai-official']); +const ANTHROPIC_PROVIDER_ALIASES = new Set(['anthropic-compatible']); + +/** + * Normalize potentially messy provider input into a valid Factory provider. + */ +export function normalizeDroidProvider(provider: string | undefined | null): DroidProvider | null { + if (!provider) return null; + const normalized = provider.trim().toLowerCase(); + if (!normalized) return null; + + if (normalized === 'anthropic' || ANTHROPIC_PROVIDER_ALIASES.has(normalized)) { + return 'anthropic'; + } + if (normalized === 'openai' || OPENAI_PROVIDER_ALIASES.has(normalized)) { + return 'openai'; + } + if (normalized === 'generic-chat-completion-api' || GENERIC_PROVIDER_ALIASES.has(normalized)) { + return 'generic-chat-completion-api'; + } + + return null; +} + +/** + * Infer provider primarily from base URL patterns used in BYOK configs. + */ +export function inferDroidProviderFromBaseUrl( + baseUrl: string | undefined | null +): DroidProvider | null { + if (!baseUrl) return null; + const raw = baseUrl.trim(); + if (!raw) return null; + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + + const host = parsed.host.toLowerCase(); + const pathname = parsed.pathname.toLowerCase(); + const isLocalHost = + host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]'); + + if ( + host.includes('api.openai.com') || + host.includes('.openai.azure.com') || + host.includes('.services.ai.azure.com') + ) { + return 'openai'; + } + + if (host.includes('anthropic.com') || pathname.includes('/anthropic')) { + return 'anthropic'; + } + + if ( + host.includes('openrouter.ai') || + host.includes('api.groq.com') || + host.includes('api.deepinfra.com') || + host.includes('api.fireworks.ai') || + host.includes('inference.baseten.co') || + host.includes('huggingface.co') || + host.includes('ollama.com') || + pathname.includes('/openai') || + pathname.includes('/chat/completions') + ) { + return 'generic-chat-completion-api'; + } + + // Local OpenAI-compatible proxies are commonly exposed at /v1. + if (isLocalHost && (pathname === '/v1' || pathname.startsWith('/v1/'))) { + return 'generic-chat-completion-api'; + } + + return null; +} + +/** + * Infer provider from model naming when URL does not provide a clear signal. + */ +export function inferDroidProviderFromModel( + model: string | undefined | null +): DroidProvider | null { + if (!model) return null; + const normalized = model.trim().toLowerCase(); + if (!normalized) return null; + + if (normalized.startsWith('claude-')) { + return 'anthropic'; + } + if ( + normalized.startsWith('gpt-') || + normalized.startsWith('o1') || + normalized.startsWith('o3') || + normalized.startsWith('o4') + ) { + return 'openai'; + } + if ( + normalized.startsWith('qwen') || + normalized.startsWith('deepseek') || + normalized.startsWith('kimi') + ) { + return 'generic-chat-completion-api'; + } + return null; +} + +export interface DroidProviderResolveInput { + provider?: string | null; + baseUrl?: string | null; + model?: string | null; +} + +/** + * Resolve a provider for Droid custom model entries. + * + * Precedence: + * 1) explicit provider hint (CCS_DROID_PROVIDER) + * 2) base URL inference + * 3) model inference + * 4) anthropic (backward-compatible default for legacy CCS profiles) + */ +export function resolveDroidProvider(input: DroidProviderResolveInput): DroidProvider { + const explicit = normalizeDroidProvider(input.provider); + if (explicit) return explicit; + + const fromUrl = inferDroidProviderFromBaseUrl(input.baseUrl); + if (fromUrl) return fromUrl; + + const fromModel = inferDroidProviderFromModel(input.model); + if (fromModel) return fromModel; + + return 'anthropic'; +} diff --git a/src/targets/droid-reasoning-runtime.ts b/src/targets/droid-reasoning-runtime.ts new file mode 100644 index 00000000..c257e329 --- /dev/null +++ b/src/targets/droid-reasoning-runtime.ts @@ -0,0 +1,56 @@ +import { parseThinkingOverride, type ThinkingFlag } from '../cliproxy/executor/thinking-arg-parser'; +import { resolveRuntimeThinkingOverride } from '../cliproxy/executor/thinking-override-resolver'; + +export class DroidReasoningFlagError extends Error { + constructor( + message: string, + public readonly flag: ThinkingFlag + ) { + super(message); + this.name = 'DroidReasoningFlagError'; + } +} + +export interface DroidReasoningRuntime { + argsWithoutReasoningFlags: string[]; + reasoningOverride: string | number | undefined; + sourceFlag: ThinkingFlag | undefined; + sourceDisplay: string | undefined; + duplicateDisplays: string[]; +} + +function stripReasoningFlags(args: string[]): string[] { + return args.filter((arg, idx) => { + if (arg === '--thinking' || arg === '--effort') return false; + if (arg.startsWith('--thinking=')) return false; + if (arg.startsWith('--effort=')) return false; + if (args[idx - 1] === '--thinking' || args[idx - 1] === '--effort') return false; + return true; + }); +} + +export function resolveDroidReasoningRuntime( + args: string[], + envThinkingValue: string | undefined +): DroidReasoningRuntime { + const parseResult = parseThinkingOverride(args); + if (parseResult.error) { + throw new DroidReasoningFlagError( + `${parseResult.error.flag} requires a value`, + parseResult.error.flag + ); + } + + const { thinkingOverride, thinkingSource } = resolveRuntimeThinkingOverride( + parseResult.value, + envThinkingValue + ); + + return { + argsWithoutReasoningFlags: stripReasoningFlags(args), + reasoningOverride: thinkingOverride, + sourceFlag: thinkingSource === 'flag' ? parseResult.sourceFlag : undefined, + sourceDisplay: parseResult.sourceDisplay, + duplicateDisplays: parseResult.duplicateDisplays, + }; +} diff --git a/src/targets/index.ts b/src/targets/index.ts index a0c42429..2d05d240 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -27,4 +27,6 @@ export { pruneOrphanedModels, } from './droid-config-manager'; export type { DroidCustomModel } from './droid-config-manager'; +export { resolveDroidProvider, normalizeDroidProvider } from './droid-provider'; +export type { DroidProvider } from './droid-provider'; export { resolveTargetType, stripTargetFlag } from './target-resolver'; diff --git a/src/targets/target-adapter.ts b/src/targets/target-adapter.ts index 522ee905..ffe4b890 100644 --- a/src/targets/target-adapter.ts +++ b/src/targets/target-adapter.ts @@ -23,6 +23,12 @@ export interface TargetCredentials { apiKey: string; model?: string; provider?: 'anthropic' | 'openai' | 'generic-chat-completion-api'; + /** + * Runtime reasoning/thinking override resolved from CCS flags/env + * (e.g. --thinking high, --effort xhigh, CCS_THINKING=medium). + * Targets may ignore this when unsupported. + */ + reasoningOverride?: string | number; /** Additional env vars from profile resolution (websearch, hooks, etc.) */ envVars?: NodeJS.ProcessEnv; } diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts index edc23481..b7a811f4 100644 --- a/src/targets/target-resolver.ts +++ b/src/targets/target-resolver.ts @@ -48,9 +48,13 @@ interface ParsedTargetFlags { cleanedArgs: string[]; } +function isValidTarget(target: unknown): target is TargetType { + return typeof target === 'string' && VALID_TARGETS.has(target as TargetType); +} + function normalizeTargetValue(value: string): TargetType { const normalized = value.toLowerCase(); - if (VALID_TARGETS.has(normalized)) { + if (isValidTarget(normalized)) { return normalized as TargetType; } @@ -120,8 +124,8 @@ export function resolveTargetType( } // 2. Check per-profile config - if (profileConfig?.target) { - return profileConfig.target; + if (profileConfig?.target !== undefined) { + return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude'; } // 3. Check argv[0] (busybox pattern) diff --git a/src/types/config.ts b/src/types/config.ts index 2565bd79..bd3787ae 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -4,6 +4,7 @@ */ import type { CLIProxyProvider } from '../cliproxy/types'; +import type { TargetType } from '../targets/target-adapter'; /** * Profile configuration mapping @@ -27,6 +28,8 @@ export interface CLIProxyVariantConfig { account?: string; /** Unique port for variant isolation (8318-8417) */ port?: number; + /** Target CLI to use for this variant (default: claude) */ + target?: TargetType; } /** @@ -44,6 +47,8 @@ export interface CLIProxyVariantsConfig { export interface Config { /** Settings-based profiles (GLM, Kimi, etc.) */ profiles: ProfilesConfig; + /** Per-profile CLI target overrides (legacy mode) */ + profile_targets?: Record; /** User-defined CLIProxy profile variants (optional) */ cliproxy?: CLIProxyVariantsConfig; } @@ -91,6 +96,8 @@ export interface ProfileMetadata { context_mode?: 'isolated' | 'shared'; /** Context-sharing group when context_mode='shared' */ context_group?: string; + /** Shared continuity depth when context_mode='shared' */ + continuity_mode?: 'standard' | 'deeper'; } export interface ProfilesRegistry { diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index dfc654a6..4513d36c 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -559,6 +559,18 @@ const PRICING_REGISTRY: Record = { // --------------------------------------------------------------------------- // MiniMax Models - Source: https://platform.minimax.io/docs/pricing/pay-as-you-go // --------------------------------------------------------------------------- + 'MiniMax-M2.5': { + inputPerMillion: 0.3, + outputPerMillion: 1.2, + cacheCreationPerMillion: 0.375, + cacheReadPerMillion: 0.03, + }, + 'MiniMax-M2.5-lightning': { + inputPerMillion: 0.6, + outputPerMillion: 2.4, + cacheCreationPerMillion: 0.375, + cacheReadPerMillion: 0.03, + }, 'MiniMax-M2.1': { inputPerMillion: 0.3, outputPerMillion: 1.2, @@ -566,7 +578,7 @@ const PRICING_REGISTRY: Record = { cacheReadPerMillion: 0.03, }, 'MiniMax-M2.1-lightning': { - inputPerMillion: 0.3, + inputPerMillion: 0.6, outputPerMillion: 2.4, cacheCreationPerMillion: 0.375, cacheReadPerMillion: 0.03, @@ -577,6 +589,51 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 0.375, cacheReadPerMillion: 0.03, }, + // --------------------------------------------------------------------------- + // Qwen Models - Source: https://www.alibabacloud.com/help/zh/model-studio/model-pricing + // --------------------------------------------------------------------------- + 'qwen3-max': { + inputPerMillion: 1.2, + outputPerMillion: 6, + cacheCreationPerMillion: 1.2, + cacheReadPerMillion: 0.24, + }, + 'qwen3-max-2026-01-23': { + inputPerMillion: 1.2, + outputPerMillion: 6, + cacheCreationPerMillion: 1.2, + cacheReadPerMillion: 0.24, + }, + 'qwen3-max-preview': { + inputPerMillion: 1.2, + outputPerMillion: 6, + cacheCreationPerMillion: 1.2, + cacheReadPerMillion: 0.24, + }, + 'qwen3.5-plus': { + inputPerMillion: 0.4, + outputPerMillion: 2.4, + cacheCreationPerMillion: 0.4, + cacheReadPerMillion: 0.08, + }, + 'qwen3.5-flash': { + inputPerMillion: 0.1, + outputPerMillion: 0.4, + cacheCreationPerMillion: 0.1, + cacheReadPerMillion: 0.02, + }, + 'qwen3-coder-plus': { + inputPerMillion: 1, + outputPerMillion: 5, + cacheCreationPerMillion: 1, + cacheReadPerMillion: 0.2, + }, + 'qwen3-coder-flash': { + inputPerMillion: 0.3, + outputPerMillion: 1.5, + cacheCreationPerMillion: 0.3, + cacheReadPerMillion: 0.06, + }, // --------------------------------------------------------------------------- // DeepSeek Models - Source: better-ccusage @@ -629,6 +686,14 @@ const PRICING_REGISTRY: Record = { }, }; +const MODEL_PRICING_ALIASES: Record = { + // Keep catalog-only IDs on explicit priced equivalents. + 'qwen3-coder': 'qwen3-coder-plus', + 'qwen3-235b': 'qwen3-max', + 'qwen3-vl-plus': 'qwen3.5-plus', + 'qwen3-32b': 'qwen3.5-plus', +}; + // Default pricing for unknown models const UNKNOWN_MODEL_PRICING: ModelPricing = { inputPerMillion: 3.0, @@ -647,39 +712,76 @@ const UNKNOWN_MODEL_PRICING: ModelPricing = { */ function normalizeModelName(model: string): string { // Remove provider prefixes (e.g., "anthropic/claude-..." -> "claude-...") - const normalized = model.toLowerCase().replace(/^[^/]+\//, ''); + const normalized = model + .trim() + .toLowerCase() + .replace(/^[^/]+\//, ''); return normalized; } +const NORMALIZED_PRICING_REGISTRY: Record = Object.entries( + PRICING_REGISTRY +).reduce>((acc, [key, pricing]) => { + acc[normalizeModelName(key)] = pricing; + return acc; +}, {}); + +function getLookupCandidates(model: string): string[] { + const normalized = normalizeModelName(model); + const baseModel = normalized.split(':')[0]; + + return baseModel === normalized ? [normalized] : [normalized, baseModel]; +} + +function getDirectOrAliasPricing(model: string): ModelPricing | undefined { + const directPricing = PRICING_REGISTRY[model]; + if (directPricing !== undefined) { + return directPricing; + } + + for (const candidate of getLookupCandidates(model)) { + const normalizedPricing = NORMALIZED_PRICING_REGISTRY[candidate]; + if (normalizedPricing !== undefined) { + return normalizedPricing; + } + + const alias = MODEL_PRICING_ALIASES[candidate]; + if (alias !== undefined) { + const aliasPricing = NORMALIZED_PRICING_REGISTRY[alias]; + if (aliasPricing !== undefined) { + return aliasPricing; + } + } + } + + return undefined; +} + /** * Get pricing for a model with fuzzy matching fallback * @param model - Model name (exact or with provider prefix) * @returns ModelPricing for the model or fallback pricing */ export function getModelPricing(model: string): ModelPricing { - // Try exact match first - if (PRICING_REGISTRY[model]) { - return PRICING_REGISTRY[model]; + const directOrAliasPricing = getDirectOrAliasPricing(model); + if (directOrAliasPricing !== undefined) { + return directOrAliasPricing; } - // Try normalized match - const normalized = normalizeModelName(model); - if (PRICING_REGISTRY[normalized]) { - return PRICING_REGISTRY[normalized]; - } - - // Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5") - for (const [key, pricing] of Object.entries(PRICING_REGISTRY)) { - if (normalized.endsWith(key) || key.endsWith(normalized)) { - return pricing; + for (const candidate of getLookupCandidates(model)) { + // Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5") + for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) { + if (candidate.endsWith(key) || key.endsWith(candidate)) { + return pricing; + } } - } - // Try partial matching for model families - for (const [key, pricing] of Object.entries(PRICING_REGISTRY)) { - // Match by model family prefix - if (normalized.startsWith(key.split('-').slice(0, 2).join('-'))) { - return pricing; + // Try partial matching for model families + for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) { + // Match by model family prefix + if (candidate.startsWith(key.split('-').slice(0, 2).join('-'))) { + return pricing; + } } } @@ -716,8 +818,5 @@ export function getKnownModels(): string[] { * Check if a model has custom pricing (not using fallback) */ export function hasCustomPricing(model: string): boolean { - return ( - PRICING_REGISTRY[model] !== undefined || - PRICING_REGISTRY[normalizeModelName(model)] !== undefined - ); + return getDirectOrAliasPricing(model) !== undefined; } diff --git a/src/web-server/routes/account-route-helpers.ts b/src/web-server/routes/account-route-helpers.ts new file mode 100644 index 00000000..bbf05469 --- /dev/null +++ b/src/web-server/routes/account-route-helpers.ts @@ -0,0 +1,48 @@ +import type { CLIProxyProvider } from '../../cliproxy/types'; +import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; + +export interface MergedAccountEntry { + type: string; + created: string; + last_used: string | null; + context_mode?: 'isolated' | 'shared'; + context_group?: string; + continuity_mode?: 'standard' | 'deeper'; + context_inferred?: boolean; + continuity_inferred?: boolean; + provider?: string; + displayName?: string; +} + +/** Parse CLIProxy account key format: "provider:accountId" */ +export function parseCliproxyKey( + key: string +): { provider: CLIProxyProvider; accountId: string } | null { + let normalizedKey = key; + if (key.startsWith('cliproxy:')) { + normalizedKey = key.slice('cliproxy:'.length); + } else if (key.startsWith('cliproxy+')) { + normalizedKey = key.slice('cliproxy+'.length); + } + const colonIndex = normalizedKey.indexOf(':'); + if (colonIndex === -1) return null; + + const provider = normalizedKey.slice(0, colonIndex); + const accountId = normalizedKey.slice(colonIndex + 1); + + if (!isCLIProxyProvider(provider) || !accountId) return null; + return { provider, accountId }; +} + +export function buildCliproxyAccountKey( + rawKey: string, + merged: Record +): string | null { + const candidateKeys = [rawKey, `cliproxy:${rawKey}`, `cliproxy+${rawKey}`]; + for (const key of candidateKeys) { + if (!merged[key]) { + return key; + } + } + return null; +} diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 8c35128a..d96009d4 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -7,31 +7,36 @@ import { Router, Request, Response } from 'express'; import ProfileRegistry from '../../auth/profile-registry'; +import InstanceManager from '../../management/instance-manager'; import { isUnifiedMode } from '../../config/unified-config-loader'; import { getAllAccountsSummary, setDefaultAccount as setCliproxyDefault, + getDefaultAccount as getCliproxyDefaultAccount, removeAccount as removeCliproxyAccount, bulkPauseAccounts, bulkResumeAccounts, soloAccount, } from '../../cliproxy/account-manager'; -import type { CLIProxyProvider } from '../../cliproxy/types'; import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; +import { + DEFAULT_ACCOUNT_CONTINUITY_MODE, + isValidContextGroupName, + normalizeContextGroupName, + resolveAccountContextPolicy, +} from '../../auth/account-context'; +import { + buildCliproxyAccountKey, + parseCliproxyKey, + type MergedAccountEntry, +} from './account-route-helpers'; const router = Router(); const registry = new ProfileRegistry(); +const instanceMgr = new InstanceManager(); -/** Parse CLIProxy account key format: "provider:accountId" */ -function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null { - const colonIndex = key.indexOf(':'); - if (colonIndex === -1) return null; - - const provider = key.slice(0, colonIndex); - const accountId = key.slice(colonIndex + 1); - - if (!isCLIProxyProvider(provider) || !accountId) return null; - return { provider, accountId }; +function hasAuthAccount(name: string): boolean { + return registry.hasAccountUnified(name) || registry.hasProfile(name); } /** @@ -47,38 +52,45 @@ router.get('/', (_req: Request, res: Response): void => { const cliproxyAccounts = getAllAccountsSummary(); // Merge profiles: unified config takes precedence - const merged: Record< - string, - { - type: string; - created: string; - last_used: string | null; - context_mode?: 'isolated' | 'shared'; - context_group?: string; - provider?: string; - displayName?: string; - } - > = {}; + const merged: Record = {}; // Add legacy profiles first for (const [name, meta] of Object.entries(legacyProfiles)) { + const contextPolicy = resolveAccountContextPolicy(meta); + const hasExplicitContextMode = + meta.context_mode === 'isolated' || meta.context_mode === 'shared'; + const hasExplicitContinuityMode = + meta.continuity_mode === 'standard' || meta.continuity_mode === 'deeper'; merged[name] = { type: meta.type || 'account', created: meta.created, last_used: meta.last_used || null, - context_mode: meta.context_mode, - context_group: meta.context_group, + context_mode: contextPolicy.mode, + context_group: contextPolicy.group, + continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : undefined, + context_inferred: !hasExplicitContextMode, + continuity_inferred: + contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined, }; } // Override with unified config accounts (takes precedence) for (const [name, account] of Object.entries(unifiedAccounts)) { + const contextPolicy = resolveAccountContextPolicy(account); + const hasExplicitContextMode = + account.context_mode === 'isolated' || account.context_mode === 'shared'; + const hasExplicitContinuityMode = + account.continuity_mode === 'standard' || account.continuity_mode === 'deeper'; merged[name] = { type: 'account', created: account.created, last_used: account.last_used, - context_mode: account.context_mode, - context_group: account.context_group, + context_mode: contextPolicy.mode, + context_group: contextPolicy.group, + continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : undefined, + context_inferred: !hasExplicitContextMode, + continuity_inferred: + contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined, }; } @@ -91,7 +103,11 @@ router.get('/', (_req: Request, res: Response): void => { } // Use unique ID for key to prevent collisions between accounts with same nickname/email const displayName = acct.nickname || acct.email || acct.id; - const key = `${provider}:${acct.id}`; + const rawKey = `${provider}:${acct.id}`; + const key = buildCliproxyAccountKey(rawKey, merged); + if (!key) { + continue; + } merged[key] = { type: 'cliproxy', provider, @@ -130,7 +146,7 @@ router.post('/default', (req: Request, res: Response): void => { } // Check if this is a CLIProxy account (format: "provider:accountId") - const cliproxyKey = parseCliproxyKey(name); + const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null; if (cliproxyKey) { const success = setCliproxyDefault(cliproxyKey.provider, cliproxyKey.accountId); if (!success) { @@ -154,6 +170,140 @@ 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; + const rawContinuityMode = req.body?.continuity_mode; + + 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; + } + + if (mode !== 'shared' && rawContinuityMode !== undefined) { + res + .status(400) + .json({ error: 'Invalid payload: continuity_mode requires context_mode=shared' }); + return; + } + + let normalizedGroup: string | undefined; + let continuityMode: 'standard' | 'deeper' | 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; + } + + if ( + rawContinuityMode !== undefined && + rawContinuityMode !== 'standard' && + rawContinuityMode !== 'deeper' + ) { + res.status(400).json({ + error: 'Invalid continuity_mode: expected standard|deeper', + }); + return; + } + + continuityMode = rawContinuityMode === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE; + } + + const metadata = + mode === 'shared' + ? { + context_mode: 'shared' as const, + context_group: normalizedGroup, + continuity_mode: continuityMode, + } + : { + 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, + continuity_mode: + policy.mode === 'shared' + ? (policy.continuityMode ?? DEFAULT_ACCOUNT_CONTINUITY_MODE) + : null, + context_inferred: false, + continuity_inferred: false, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * DELETE /api/accounts/reset-default - Reset to CCS default */ @@ -192,8 +342,16 @@ router.delete('/:name', (req: Request, res: Response): void => { } // Check if this is a CLIProxy account (format: "provider:accountId") - const cliproxyKey = parseCliproxyKey(name); + const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null; if (cliproxyKey) { + const defaultCliproxyAccount = getCliproxyDefaultAccount(cliproxyKey.provider); + if (defaultCliproxyAccount?.id === cliproxyKey.accountId) { + res.status(400).json({ + error: `Cannot delete default CLIProxy account: ${name}. Set another default first.`, + }); + return; + } + const success = removeCliproxyAccount(cliproxyKey.provider, cliproxyKey.accountId); if (!success) { res.status(404).json({ error: `CLIProxy account not found: ${name}` }); @@ -203,22 +361,24 @@ router.delete('/:name', (req: Request, res: Response): void => { return; } - // Delete from appropriate config (unified and/or legacy) - let deleted = false; - if (isUnifiedMode() && registry.hasAccountUnified(name)) { - registry.removeAccountUnified(name); - deleted = true; - } - if (registry.hasProfile(name)) { - registry.deleteProfile(name); - deleted = true; - } + const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name); + const existsLegacy = registry.hasProfile(name); - if (!deleted) { + if (!existsUnified && !existsLegacy) { res.status(404).json({ error: `Account not found: ${name}` }); return; } + // Match CLI remove ordering: delete instance first, metadata second. + instanceMgr.deleteInstance(name); + + if (existsUnified) { + registry.removeAccountUnified(name); + } + if (existsLegacy) { + registry.deleteProfile(name); + } + res.json({ success: true, deleted: name }); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index b07da8e2..940f9e7d 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -46,12 +46,34 @@ import { import { getOAuthFlowType } from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; +import { + validateAntigravityRiskAcknowledgement, + isAntigravityResponsibilityBypassEnabled, +} from '../../cliproxy/antigravity-responsibility'; const router = Router(); // Valid providers list - derived from canonical CLIPROXY_PROFILES const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; +function logRouteError(context: string, error: unknown): void { + if (error instanceof Error) { + console.error(`[cliproxy-auth-routes] ${context}: ${error.message}`); + return; + } + console.error(`[cliproxy-auth-routes] ${context}: unknown error`); +} + +function respondInternalError( + res: Response, + error: unknown, + fallbackMessage: string, + statusCode = 500 +): void { + logRouteError(fallbackMessage, error); + res.status(statusCode).json({ error: fallbackMessage }); +} + function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } { if (raw === undefined || raw === null) { return { method: normalizeKiroAuthMethod(), invalid: false }; @@ -163,12 +185,12 @@ router.get('/', async (_req: Request, res: Response): Promise => { const target = getProxyTarget(); if (target.isRemote) { res.status(503).json({ - error: (error as Error).message, + error: 'Failed to fetch remote auth status', authStatus: [], source: 'remote', }); } else { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Failed to fetch auth status.'); } } }); @@ -198,13 +220,12 @@ router.get('/accounts', async (_req: Request, res: Response): Promise => { const target = getProxyTarget(); if (target.isRemote) { res.status(503).json({ - error: (error as Error).message, + error: 'Failed to fetch remote account status', accounts: [], source: 'remote', }); } else { - const message = error instanceof Error ? error.message : 'Failed to list accounts'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to list accounts.'); } } }); @@ -225,8 +246,7 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => { const accounts = getProviderAccounts(provider as CLIProxyProvider); res.json({ provider, accounts }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to get provider accounts'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to get provider accounts.'); } }); @@ -268,8 +288,7 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void = .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to set default account'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to set default account.'); } }); @@ -305,8 +324,7 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to remove account'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to remove account.'); } }); @@ -338,8 +356,7 @@ router.post('/accounts/:provider/:accountId/pause', (req: Request, res: Response .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to pause account'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to pause account.'); } }); @@ -370,8 +387,7 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to resume account'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to resume account.'); } }); @@ -381,13 +397,20 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons */ router.post('/:provider/start', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { - nickname: nicknameRaw, - noIncognito: noIncognitoBody, - kiroMethod: kiroMethodRaw, - } = req.body; + const requestBody = + req.body && typeof req.body === 'object' ? (req.body as Record) : {}; + const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined; + const noIncognitoBody = + typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined; + const kiroMethodRaw = requestBody.kiroMethod; + const riskAcknowledgement = requestBody.riskAcknowledgement; + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ error: 'OAuth start flow not available in remote mode' }); + return; + } // Trim nickname for consistency with CLI (oauth-handler.ts trims input) - const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw; + const nickname = nicknameRaw?.trim(); const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); // Validate provider @@ -404,6 +427,17 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise }); } } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Failed to import Kiro token.'); } }); @@ -596,7 +631,7 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise */ router.post('/:provider/start-url', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { kiroMethod: kiroMethodRaw } = req.body ?? {}; + const { kiroMethod: kiroMethodRaw, riskAcknowledgement } = req.body ?? {}; const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); // Check remote mode @@ -620,6 +655,17 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } + if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) { + const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement); + if (!validation.valid) { + res.status(400).json({ + error: validation.error, + code: 'AGY_RISK_ACK_REQUIRED', + }); + return; + } + } + const unsupportedReason = getStartUrlUnsupportedReason(provider as CLIProxyProvider, { kiroMethod: provider === 'kiro' ? kiroMethod : undefined, }); @@ -672,8 +718,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise method: data.method || null, }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to start OAuth'; - res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` }); + respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503); } }); @@ -787,8 +832,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P res.json({ success: true }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to submit callback'; - res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` }); + respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503); } }); diff --git a/src/web-server/routes/config-routes.ts b/src/web-server/routes/config-routes.ts index d00985ca..7b3ed8fd 100644 --- a/src/web-server/routes/config-routes.ts +++ b/src/web-server/routes/config-routes.ts @@ -18,9 +18,88 @@ import { getBackupDirectories, } from '../../config/migration-manager'; import { isUnifiedConfig } from '../../config/unified-config-types'; +import { + DEFAULT_ACCOUNT_CONTINUITY_MODE, + isValidContextGroupName, + normalizeContextGroupName, +} from '../../auth/account-context'; const router = Router(); +function validateAndNormalizeAccountContextMetadata(config: unknown): string | null { + if (typeof config !== 'object' || config === null) { + return 'Invalid config payload'; + } + + const candidate = config as Record; + const accounts = candidate.accounts; + if (accounts === undefined) { + return null; + } + + if (typeof accounts !== 'object' || accounts === null || Array.isArray(accounts)) { + return 'Invalid config.accounts: expected object'; + } + + for (const [accountName, accountValue] of Object.entries(accounts as Record)) { + if (typeof accountValue !== 'object' || accountValue === null || Array.isArray(accountValue)) { + return `Invalid config.accounts.${accountName}: expected object`; + } + + const account = accountValue as Record; + const mode = account.context_mode; + const group = account.context_group; + const continuity = account.continuity_mode; + + if (mode !== undefined && mode !== 'isolated' && mode !== 'shared') { + return `Invalid config.accounts.${accountName}.context_mode: expected isolated|shared`; + } + + if (group !== undefined && typeof group !== 'string') { + return `Invalid config.accounts.${accountName}.context_group: expected string`; + } + + if (continuity !== undefined && continuity !== 'standard' && continuity !== 'deeper') { + return `Invalid config.accounts.${accountName}.continuity_mode: expected standard|deeper`; + } + + if (mode !== 'shared' && group !== undefined) { + return `Invalid config.accounts.${accountName}: context_group requires context_mode=shared`; + } + + if (mode !== 'shared' && continuity !== undefined) { + return `Invalid config.accounts.${accountName}: continuity_mode requires context_mode=shared`; + } + + if (mode === 'shared' && typeof group === 'string' && group.trim().length > 0) { + const normalizedGroup = normalizeContextGroupName(group); + if (!isValidContextGroupName(normalizedGroup)) { + return `Invalid config.accounts.${accountName}.context_group`; + } + account.context_group = normalizedGroup; + } + + if (mode === 'shared') { + account.continuity_mode = + continuity === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE; + } + + 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; + } + + if (mode === 'isolated' && continuity !== undefined) { + delete account.continuity_mode; + } + } + + return null; +} + /** * GET /api/config/format - Return current config format and migration status */ @@ -82,6 +161,12 @@ router.put('/', (req: Request, res: Response): void => { return; } + const accountContextError = validateAndNormalizeAccountContextMetadata(config); + if (accountContextError) { + res.status(400).json({ error: accountContextError }); + return; + } + try { saveUnifiedConfig(config); res.json({ success: true }); @@ -96,6 +181,15 @@ router.put('/', (req: Request, res: Response): void => { router.post('/migrate', async (req: Request, res: Response): Promise => { try { const dryRun = req.query.dryRun === 'true'; + if (!needsMigration()) { + res.json({ + success: true, + migratedFiles: [], + warnings: [], + alreadyMigrated: true, + }); + return; + } const result = await migrate(dryRun); res.json(result); } catch (error) { diff --git a/src/web-server/routes/droid-routes.ts b/src/web-server/routes/droid-routes.ts new file mode 100644 index 00000000..f18867d6 --- /dev/null +++ b/src/web-server/routes/droid-routes.ts @@ -0,0 +1,71 @@ +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import { + DroidRawSettingsConflictError, + DroidRawSettingsValidationError, + getDroidDashboardDiagnostics, + getDroidRawSettings, + saveDroidRawSettings, +} from '../services/droid-dashboard-service'; + +const router = Router(); + +/** + * GET /api/droid/diagnostics + * Dashboard-ready Droid installation + BYOK configuration diagnostics. + */ +router.get('/diagnostics', async (_req: Request, res: Response): Promise => { + try { + res.json(await getDroidDashboardDiagnostics()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/droid/settings/raw + * Raw ~/.factory/settings.json payload for editor. + */ +router.get('/settings/raw', async (_req: Request, res: Response): Promise => { + try { + res.json(await getDroidRawSettings()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/droid/settings/raw + * Save raw ~/.factory/settings.json payload from dashboard editor. + */ +router.put('/settings/raw', async (req: Request, res: Response): Promise => { + try { + const { rawText, expectedMtime } = req.body ?? {}; + + if (typeof rawText !== 'string') { + res.status(400).json({ error: 'rawText must be a string.' }); + return; + } + if ( + expectedMtime !== undefined && + (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime)) + ) { + res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' }); + return; + } + + res.json(await saveDroidRawSettings({ rawText, expectedMtime })); + } catch (error) { + if (error instanceof DroidRawSettingsValidationError) { + res.status(400).json({ error: error.message }); + return; + } + if (error instanceof DroidRawSettingsConflictError) { + res.status(409).json({ error: error.message, mtime: error.mtime }); + return; + } + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index f9517635..09097156 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -21,6 +21,7 @@ import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; import copilotRoutes from './copilot-routes'; import cursorRoutes from './cursor-routes'; +import droidRoutes from './droid-routes'; import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; @@ -67,6 +68,9 @@ apiRoutes.use('/copilot', copilotRoutes); // ==================== Cursor ==================== apiRoutes.use('/cursor', cursorRoutes); +// ==================== Droid ==================== +apiRoutes.use('/droid', droidRoutes); + // ==================== CLIProxy Server Settings ==================== apiRoutes.use('/cliproxy-server', cliproxyServerRoutes); diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 37a895dc..eb176802 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -7,12 +7,35 @@ import { Router, Request, Response } from 'express'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; -import { createApiProfile, removeApiProfile } from '../../api/services/profile-writer'; +import { + createApiProfile, + removeApiProfile, + updateApiProfileTarget, +} from '../../api/services/profile-writer'; import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader'; +import type { TargetType } from '../../targets/target-adapter'; +import { normalizeDroidProvider } from '../../targets/droid-provider'; import { updateSettingsFile } from './route-helpers'; const router = Router(); +export function parseTarget(rawTarget: unknown): TargetType | null { + if (rawTarget === undefined || rawTarget === null || rawTarget === '') { + return null; + } + + if (typeof rawTarget !== 'string') { + return null; + } + + const normalized = rawTarget.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + + return null; +} + // ==================== Profile CRUD ==================== /** @@ -26,6 +49,7 @@ router.get('/', (_req: Request, res: Response): void => { name: p.name, settingsPath: p.settingsPath, configured: p.isConfigured, + target: p.target, })); res.json({ profiles }); } catch (error) { @@ -37,7 +61,21 @@ router.get('/', (_req: Request, res: Response): void => { * POST /api/profiles - Create new profile */ router.post('/', (req: Request, res: Response): void => { - const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; + const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; + const providerHint = req.body?.droidProvider ?? req.body?.provider; + const parsedProvider = normalizeDroidProvider(providerHint); + + const parsedTarget = parseTarget(target); + if (target !== undefined && parsedTarget === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } + if (providerHint !== undefined && parsedProvider === null) { + res.status(400).json({ + error: 'Invalid droid provider. Expected: anthropic, openai, or generic-chat-completion-api', + }); + return; + } if (!name || !baseUrl || !apiKey) { res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' }); @@ -60,19 +98,30 @@ router.post('/', (req: Request, res: Response): void => { } // Create profile using unified-config-aware service - const result = createApiProfile(name, baseUrl, apiKey, { - default: model || '', - opus: opusModel || model || '', - sonnet: sonnetModel || model || '', - haiku: haikuModel || model || '', - }); + const result = createApiProfile( + name, + baseUrl, + apiKey, + { + default: model || '', + opus: opusModel || model || '', + sonnet: sonnetModel || model || '', + haiku: haikuModel || model || '', + }, + parsedTarget || 'claude', + parsedProvider || undefined + ); if (!result.success) { res.status(500).json({ error: result.error || 'Failed to create profile' }); return; } - res.status(201).json({ name, settingsPath: result.settingsFile }); + res.status(201).json({ + name, + settingsPath: result.settingsFile, + target: parsedTarget || 'claude', + }); }); /** @@ -80,7 +129,21 @@ router.post('/', (req: Request, res: Response): void => { */ router.put('/:name', (req: Request, res: Response): void => { const { name } = req.params; - const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; + const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; + const providerHint = req.body?.droidProvider ?? req.body?.provider; + const parsedProvider = normalizeDroidProvider(providerHint); + + const parsedTarget = parseTarget(target); + if (target !== undefined && parsedTarget === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } + if (providerHint !== undefined && parsedProvider === null) { + res.status(400).json({ + error: 'Invalid droid provider. Expected: anthropic, openai, or generic-chat-completion-api', + }); + return; + } // Check if profile exists (uses unified config when available) if (!apiProfileExists(name)) { @@ -99,8 +162,46 @@ router.put('/:name', (req: Request, res: Response): void => { } try { - updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel }); - res.json({ name, updated: true }); + const hasSettingsUpdates = + baseUrl !== undefined || + apiKey !== undefined || + model !== undefined || + opusModel !== undefined || + sonnetModel !== undefined || + haikuModel !== undefined || + providerHint !== undefined; + const hasTargetUpdate = target !== undefined; + + if (!hasSettingsUpdates && !hasTargetUpdate) { + res.status(400).json({ error: 'No updates provided' }); + return; + } + + if (hasSettingsUpdates) { + updateSettingsFile(name, { + baseUrl, + apiKey, + model, + opusModel, + sonnetModel, + haikuModel, + provider: parsedProvider || undefined, + }); + } + + if (hasTargetUpdate && parsedTarget) { + const targetUpdate = updateApiProfileTarget(name, parsedTarget); + if (!targetUpdate.success) { + res.status(500).json({ error: targetUpdate.error || 'Failed to update target' }); + return; + } + } + + res.json({ + name, + updated: true, + ...(hasTargetUpdate && parsedTarget && { target: parsedTarget }), + }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 076a12d7..24b54245 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; import { getClaudeSettingsPath } from '../../utils/claude-config-path'; +import { resolveDroidProvider } from '../../targets/droid-provider'; import type { Config, Settings } from '../../types/config'; /** Model mapping for API profiles */ @@ -64,10 +65,16 @@ export function createSettingsFile( name: string, baseUrl: string, apiKey: string, - models: ModelMapping = {} + models: ModelMapping = {}, + provider?: string ): string { const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); const { model, opusModel, sonnetModel, haikuModel } = models; + const droidProvider = resolveDroidProvider({ + provider, + baseUrl, + model, + }); const settings: Settings = { env: { @@ -77,6 +84,7 @@ export function createSettingsFile( ...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }), ...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }), ...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }), + CCS_DROID_PROVIDER: droidProvider, }, }; @@ -96,6 +104,7 @@ export function updateSettingsFile( opusModel?: string; sonnetModel?: string; haikuModel?: string; + provider?: string; } ): void { const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); @@ -153,6 +162,21 @@ export function updateSettingsFile( } } + if ( + updates.provider !== undefined || + updates.baseUrl !== undefined || + updates.model !== undefined || + settings.env?.CCS_DROID_PROVIDER + ) { + settings.env = settings.env || {}; + const resolvedProvider = resolveDroidProvider({ + provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER, + baseUrl: updates.baseUrl ?? settings.env.ANTHROPIC_BASE_URL, + model: updates.model ?? settings.env.ANTHROPIC_MODEL, + }); + settings.env.CCS_DROID_PROVIDER = resolvedProvider; + } + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index f5cfb39b..54a3e265 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -19,6 +19,11 @@ import { } from '../../cliproxy'; import { regenerateConfig } from '../../cliproxy/config-generator'; import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; +import { + getDashboardAuthConfig, + loadOrCreateUnifiedConfig, + mutateUnifiedConfig, +} from '../../config/unified-config-loader'; import type { Settings } from '../../types/config'; const router = Router(); @@ -31,6 +36,73 @@ const MODEL_ENV_KEYS = [ ] as const; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; +function logRouteError(context: string, error: unknown): void { + if (error instanceof Error) { + console.error(`[settings-routes] ${context}: ${error.message}`); + return; + } + console.error(`[settings-routes] ${context}: unknown error`); +} + +function respondInternalError( + res: Response, + error: unknown, + fallbackMessage: string, + statusCode = 500 +): void { + logRouteError(fallbackMessage, error); + res.status(statusCode).json({ error: fallbackMessage }); +} + +function isLoopbackAddress(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().replace(/^\[|\]$/g, ''); + return ( + normalized === '::1' || + normalized === '127.0.0.1' || + normalized.startsWith('127.') || + normalized === '::ffff:127.0.0.1' || + normalized.startsWith('::ffff:127.') + ); +} + +function requireSensitiveLocalAccess(req: Request, res: Response): boolean { + const dashboardAuth = getDashboardAuthConfig(); + if (dashboardAuth.enabled) { + return true; + } + + const forwarded = req.headers['x-forwarded-for']; + const firstForwarded = + typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined; + const candidateAddress = firstForwarded || req.socket.remoteAddress || req.ip; + + if (isLoopbackAddress(candidateAddress)) { + return true; + } + + res.status(403).json({ + error: 'Sensitive settings endpoints require localhost access when dashboard auth is disabled.', + }); + return false; +} + +function classifyConfigSaveFailure(error: unknown): { statusCode: number; message: string } { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + + if (message.includes('failed to acquire config lock')) { + return { statusCode: 409, message: 'Configuration is busy. Retry in a moment.' }; + } + if (message.includes('eacces') || message.includes('eperm') || message.includes('permission')) { + return { statusCode: 403, message: 'Insufficient permission to update configuration.' }; + } + if (message.includes('enospc') || message.includes('no space left')) { + return { statusCode: 507, message: 'Insufficient disk space to update configuration.' }; + } + + return { statusCode: 500, message: 'Failed to update Antigravity power user mode.' }; +} + /** * Helper: Resolve settings path for profile or variant * Variants have settings paths in config, regular profiles use {name}.settings.json @@ -144,7 +216,7 @@ router.get('/:profile', (req: Request, res: Response): void => { path: settingsPath, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -171,7 +243,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { path: settingsPath, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -264,7 +336,7 @@ router.put('/:profile', (req: Request, res: Response): void => { }), }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -286,7 +358,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => { const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); res.json({ presets: settings.presets || [] }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -345,7 +417,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { res.status(201).json({ preset }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -377,12 +449,61 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => res.json({ success: true }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); // ==================== Auth Tokens ==================== +/** + * GET /api/settings/auth/antigravity-risk - Get AGY responsibility bypass setting + */ +router.get('/auth/antigravity-risk', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + + try { + const config = loadOrCreateUnifiedConfig(); + res.json({ + antigravityAckBypass: config.cliproxy?.safety?.antigravity_ack_bypass === true, + }); + } catch (error) { + respondInternalError(res, error, 'Failed to load Antigravity power user mode.'); + } +}); + +/** + * PUT /api/settings/auth/antigravity-risk - Update AGY responsibility bypass setting + */ +router.put('/auth/antigravity-risk', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + + try { + const body = req.body as { antigravityAckBypass?: unknown } | null | undefined; + const antigravityAckBypass = + body && typeof body === 'object' ? body.antigravityAckBypass : undefined; + + if (typeof antigravityAckBypass !== 'boolean') { + res.status(400).json({ error: 'antigravityAckBypass must be a boolean' }); + return; + } + + const updatedConfig = mutateUnifiedConfig((config) => { + config.cliproxy.safety = { + ...(config.cliproxy.safety ?? {}), + antigravity_ack_bypass: antigravityAckBypass, + }; + }); + + res.json({ + success: true, + antigravityAckBypass: updatedConfig.cliproxy?.safety?.antigravity_ack_bypass === true, + }); + } catch (error) { + const classified = classifyConfigSaveFailure(error); + respondInternalError(res, error, classified.message, classified.statusCode); + } +}); + /** * GET /api/settings/auth/tokens - Get current auth token status (masked) */ @@ -401,7 +522,7 @@ router.get('/auth/tokens', (_req: Request, res: Response): void => { }, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -409,7 +530,9 @@ router.get('/auth/tokens', (_req: Request, res: Response): void => { * GET /api/settings/auth/tokens/raw - Get current auth tokens unmasked * NOTE: Sensitive endpoint - no caching, localhost only */ -router.get('/auth/tokens/raw', (_req: Request, res: Response): void => { +router.get('/auth/tokens/raw', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + try { // Prevent caching of sensitive data res.setHeader('Cache-Control', 'no-store'); @@ -427,7 +550,7 @@ router.get('/auth/tokens/raw', (_req: Request, res: Response): void => { }, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Failed to load raw auth tokens.'); } }); @@ -463,7 +586,7 @@ router.put('/auth/tokens', (req: Request, res: Response): void => { message: 'Restart CLIProxy to apply changes', }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -487,7 +610,7 @@ router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): vo message: 'Restart CLIProxy to apply changes', }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -515,7 +638,7 @@ router.post('/auth/tokens/reset', (_req: Request, res: Response): void => { message: 'Tokens reset to defaults. Restart CLIProxy to apply.', }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index c21429ba..65a4c07b 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -7,6 +7,7 @@ import { Router, Request, Response } from 'express'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import type { CLIProxyProvider } from '../../cliproxy/types'; +import type { TargetType } from '../../targets/target-adapter'; import { createVariant, removeVariant, @@ -23,6 +24,23 @@ import { const router = Router(); +export function parseTarget(rawTarget: unknown): TargetType | null { + if (rawTarget === undefined || rawTarget === null || rawTarget === '') { + return null; + } + + if (typeof rawTarget !== 'string') { + return null; + } + + const normalized = rawTarget.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + + return null; +} + /** * GET /api/cliproxy - List cliproxy variants * Uses variant-service for consistent behavior with CLI @@ -36,6 +54,7 @@ router.get('/', (_req: Request, res: Response) => { account: variant.account || 'default', port: variant.port, // Include port for port isolation model: variant.model, + target: variant.target || 'claude', type: variant.type, default_tier: variant.default_tier, tiers: variant.tiers, @@ -50,6 +69,12 @@ router.get('/', (_req: Request, res: Response) => { */ router.post('/', (req: Request, res: Response): void => { const { name, provider, model, account, type, default_tier, tiers } = req.body; + const parsedTarget = parseTarget(req.body.target); + + if (req.body.target !== undefined && parsedTarget === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } if (!name) { res.status(400).json({ error: 'Missing required field: name' }); @@ -91,7 +116,12 @@ router.post('/', (req: Request, res: Response): void => { let result; try { - result = createCompositeVariant({ name, defaultTier: default_tier, tiers }); + result = createCompositeVariant({ + name, + defaultTier: default_tier, + target: parsedTarget || 'claude', + tiers, + }); } catch (error) { res.status(400).json({ error: (error as Error).message }); return; @@ -109,6 +139,7 @@ router.post('/', (req: Request, res: Response): void => { tiers, settings: result.settingsPath, port: result.variant?.port, + target: result.variant?.target || 'claude', }); return; } @@ -126,7 +157,13 @@ router.post('/', (req: Request, res: Response): void => { } // Use variant-service for proper port allocation - const result = createVariant(name, provider as CLIProxyProvider, model, account); + const result = createVariant( + name, + provider as CLIProxyProvider, + model, + account, + parsedTarget || 'claude' + ); if (!result.success) { res.status(409).json({ error: result.error }); @@ -140,6 +177,7 @@ router.post('/', (req: Request, res: Response): void => { account: account || 'default', port: result.variant?.port, model: result.variant?.model, + target: result.variant?.target || 'claude', }); }); @@ -154,6 +192,12 @@ router.put('/:name', (req: Request, res: Response): void => { try { const { name } = req.params; const { provider, account, model, default_tier, tiers } = req.body; + const parsedTarget = parseTarget(req.body.target); + + if (req.body.target !== undefined && parsedTarget === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } // Check if variant is composite - use updateCompositeVariant if so const variants = listVariants(); @@ -165,8 +209,8 @@ router.put('/:name', (req: Request, res: Response): void => { } if (existing.type === 'composite') { - if (!default_tier && !tiers) { - res.status(400).json({ error: 'Must provide at least default_tier or tiers' }); + if (!default_tier && !tiers && req.body.target === undefined) { + res.status(400).json({ error: 'Must provide at least default_tier, tiers, or target' }); return; } @@ -189,7 +233,11 @@ router.put('/:name', (req: Request, res: Response): void => { } } - const result = updateCompositeVariant(name, { defaultTier: default_tier, tiers }); + const result = updateCompositeVariant(name, { + defaultTier: default_tier, + tiers, + target: req.body.target !== undefined && parsedTarget ? parsedTarget : undefined, + }); if (!result.success) { const status = result.error?.includes('not found') ? 404 : 400; @@ -207,13 +255,19 @@ router.put('/:name', (req: Request, res: Response): void => { tiers: persisted?.tiers, settings: persisted?.settings, port: persisted?.port, + target: persisted?.target || 'claude', updated: true, }); return; } // Use variant-service for proper update handling (single provider) - const result = updateVariant(name, { provider, account, model }); + const result = updateVariant(name, { + provider, + account, + model, + target: req.body.target !== undefined && parsedTarget ? parsedTarget : undefined, + }); if (!result.success) { const status = result.error?.includes('not found') ? 404 : 400; @@ -227,6 +281,7 @@ router.put('/:name', (req: Request, res: Response): void => { account: result.variant?.account || 'default', settings: result.variant?.settings, port: result.variant?.port, + target: result.variant?.target || 'claude', updated: true, }); } catch (error) { diff --git a/src/web-server/services/compatible-cli-docs-registry.ts b/src/web-server/services/compatible-cli-docs-registry.ts new file mode 100644 index 00000000..ac3fc862 --- /dev/null +++ b/src/web-server/services/compatible-cli-docs-registry.ts @@ -0,0 +1,107 @@ +export interface CompatibleCliDocLink { + id: string; + label: string; + url: string; + category: 'overview' | 'configuration' | 'byok' | 'reference'; + source: 'factory' | 'provider'; + description: string; +} + +export interface CompatibleCliProviderDocLink { + provider: string; + label: string; + apiFormat: string; + url: string; +} + +export interface CompatibleCliDocsReference { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + links: CompatibleCliDocLink[]; + providerDocs: CompatibleCliProviderDocLink[]; +} + +interface CompatibleCliDocsRegistryEntry { + cliId: string; + displayName: string; + docsReference: CompatibleCliDocsReference; +} + +const COMPATIBLE_CLI_DOCS_REGISTRY: Record = { + droid: { + cliId: 'droid', + displayName: 'Droid CLI', + docsReference: { + providerValues: ['anthropic', 'openai', 'generic-chat-completion-api'], + settingsHierarchy: [ + 'project-level config', + 'user-level config', + 'home-level config', + 'CLI flags and env vars', + ], + notes: [ + 'BYOK custom models are read from ~/.factory/settings.json customModels[]', + 'Legacy key style (custom_models, model_display_name, base_url, api_key, max_tokens) remains in circulation', + 'Factory docs mention legacy support for ~/.factory/config.json', + 'Interactive model selection uses settings.model (custom:)', + 'Provider-specific reasoning keys in extraArgs: generic-chat-completion-api => reasoning_effort, openai => reasoning.effort, anthropic => thinking.{type,budget_tokens}', + 'droid exec supports --model for one-off execution mode', + ], + links: [ + { + id: 'droid-cli-overview', + label: 'Droid CLI Overview', + url: 'https://docs.factory.ai/cli/', + category: 'overview', + source: 'factory', + description: 'Primary entry docs for setup, auth, and core CLI usage.', + }, + { + id: 'droid-byok-overview', + label: 'BYOK Overview', + url: 'https://docs.factory.ai/cli/byok/overview/', + category: 'byok', + source: 'factory', + description: 'BYOK model/provider shape, provider values, and migration notes.', + }, + { + id: 'droid-settings-reference', + label: 'settings.json Reference', + url: 'https://docs.factory.ai/cli/configuration/settings/', + category: 'configuration', + source: 'factory', + description: 'Supported settings keys, defaults, and allowed values.', + }, + ], + providerDocs: [ + { + provider: 'anthropic', + label: 'Anthropic Messages API', + apiFormat: 'Messages API', + url: 'https://docs.anthropic.com/en/api/messages', + }, + { + provider: 'openai', + label: 'OpenAI Responses API', + apiFormat: 'Responses API', + url: 'https://platform.openai.com/docs/api-reference/responses', + }, + { + provider: 'generic-chat-completion-api', + label: 'OpenAI Chat Completions Spec', + apiFormat: 'Chat Completions API', + url: 'https://platform.openai.com/docs/api-reference/chat', + }, + ], + }, + }, +}; + +export function getCompatibleCliDocsReference(cliId: string): CompatibleCliDocsReference { + const entry = COMPATIBLE_CLI_DOCS_REGISTRY[cliId]; + if (!entry) { + throw new Error(`Unsupported compatible CLI docs reference: ${cliId}`); + } + return entry.docsReference; +} diff --git a/src/web-server/services/compatible-cli-json-file-service.ts b/src/web-server/services/compatible-cli-json-file-service.ts new file mode 100644 index 00000000..eacf3a65 --- /dev/null +++ b/src/web-server/services/compatible-cli-json-file-service.ts @@ -0,0 +1,231 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; + +export interface JsonFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface JsonFileProbe { + diagnostics: JsonFileDiagnostics; + json: Record | null; + rawText: string; +} + +interface WriteJsonObjectFileInput { + filePath: string; + rawText: string; + expectedMtime?: number; + fileLabel?: string; + dirMode?: number; + fileMode?: number; +} + +interface WriteJsonObjectFileResult { + mtime: number; +} + +export class JsonFileValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'JsonFileValidationError'; + } +} + +export class JsonFileConflictError extends Error { + readonly code = 'CONFLICT'; + readonly mtime: number; + + constructor(message: string, mtime: number) { + super(message); + this.name = 'JsonFileConflictError'; + this.mtime = mtime; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function statPath(filePath: string): Promise { + try { + return await fs.lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + } +} + +export async function probeJsonObjectFile( + filePath: string, + label: string, + displayPath: string +): Promise { + const stat = await statPath(filePath); + if (!stat) { + return { + diagnostics: { + label, + path: displayPath, + resolvedPath: filePath, + exists: false, + isSymlink: false, + isRegularFile: false, + sizeBytes: null, + mtimeMs: null, + parseError: null, + readError: null, + }, + json: null, + rawText: '{}', + }; + } + + const diagnostics: JsonFileDiagnostics = { + label, + path: displayPath, + resolvedPath: filePath, + exists: true, + isSymlink: stat.isSymbolicLink(), + isRegularFile: stat.isFile(), + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + parseError: null, + readError: null, + }; + + if (diagnostics.isSymlink) { + diagnostics.readError = 'Refusing symlink file for safety.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + if (!diagnostics.isRegularFile) { + diagnostics.readError = 'Target is not a regular file.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + try { + const rawText = await fs.readFile(filePath, 'utf8'); + try { + const parsed = JSON.parse(rawText); + if (!isObject(parsed)) { + diagnostics.parseError = 'JSON root must be an object.'; + return { diagnostics, json: null, rawText }; + } + return { diagnostics, json: parsed, rawText }; + } catch (error) { + diagnostics.parseError = (error as Error).message; + return { diagnostics, json: null, rawText }; + } + } catch (error) { + diagnostics.readError = (error as Error).message; + return { diagnostics, json: null, rawText: '{}' }; + } +} + +export function parseJsonObjectText( + rawText: string, + fieldName = 'rawText' +): Record { + let parsed: unknown; + try { + parsed = JSON.parse(rawText); + } catch (error) { + throw new JsonFileValidationError(`Invalid JSON in ${fieldName}: ${(error as Error).message}`); + } + + if (!isObject(parsed)) { + throw new JsonFileValidationError(`${fieldName} JSON root must be an object.`); + } + + return parsed; +} + +export async function writeJsonObjectFileAtomic( + input: WriteJsonObjectFileInput +): Promise { + const fileLabel = input.fileLabel || path.basename(input.filePath); + const parsed = parseJsonObjectText(input.rawText, fileLabel); + const targetPath = input.filePath; + const targetDir = path.dirname(targetPath); + const tempPath = targetPath + '.tmp'; + const dirMode = input.dirMode ?? 0o700; + const fileMode = input.fileMode ?? 0o600; + + await fs.mkdir(targetDir, { recursive: true, mode: dirMode }); + + const targetStat = await statPath(targetPath); + if (targetStat) { + const stat = targetStat; + if (stat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel} is a symlink.`); + } + if (!stat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel} is not a regular file.`); + } + + if (typeof input.expectedMtime !== 'number' || !Number.isFinite(input.expectedMtime)) { + throw new JsonFileConflictError('File metadata not loaded. Refresh and retry.', stat.mtimeMs); + } + if (Math.abs(stat.mtimeMs - input.expectedMtime) > 1000) { + throw new JsonFileConflictError('File modified externally.', stat.mtimeMs); + } + } + + let wroteTemp = false; + try { + const existingTempStat = await statPath(tempPath); + if (existingTempStat) { + const tempStat = existingTempStat; + if (tempStat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); + } + if (!tempStat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); + } + } + + await fs.writeFile(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode }); + wroteTemp = true; + + const tempStat = await fs.lstat(tempPath); + if (tempStat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); + } + if (!tempStat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); + } + + await fs.rename(tempPath, targetPath); + wroteTemp = false; + + try { + await fs.chmod(targetPath, fileMode); + } catch { + // Best-effort permission hardening. + } + + const stat = await fs.stat(targetPath); + return { mtime: stat.mtimeMs }; + } finally { + if (wroteTemp) { + try { + await fs.unlink(tempPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } + } +} diff --git a/src/web-server/services/compatible-cli-types.ts b/src/web-server/services/compatible-cli-types.ts new file mode 100644 index 00000000..6d93c1ce --- /dev/null +++ b/src/web-server/services/compatible-cli-types.ts @@ -0,0 +1,90 @@ +export type DroidBinarySource = 'CCS_DROID_PATH' | 'PATH' | 'missing'; + +export interface DroidBinaryDiagnostics { + installed: boolean; + path: string | null; + installDir: string | null; + source: DroidBinarySource; + version: string | null; + overridePath: string | null; +} + +export interface DroidConfigFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface DroidCustomModelDiagnostics { + displayName: string; + model: string; + provider: string; + baseUrl: string; + host: string | null; + maxOutputTokens: number | null; + isCcsManaged: boolean; + apiKeyState: 'set' | 'missing'; + apiKeyPreview: string | null; +} + +export interface DroidByokDiagnostics { + activeModelSelector: string | null; + customModelCount: number; + ccsManagedCount: number; + userManagedCount: number; + invalidModelEntryCount: number; + providerBreakdown: Record; + customModels: DroidCustomModelDiagnostics[]; +} + +export interface CompatibleCliDocLink { + id: string; + label: string; + url: string; + category: 'overview' | 'configuration' | 'byok' | 'reference'; + source: 'factory' | 'provider'; + description: string; +} + +export interface CompatibleCliProviderDocLink { + provider: string; + label: string; + apiFormat: string; + url: string; +} + +export interface CompatibleCliDocsReference { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + links: CompatibleCliDocLink[]; + providerDocs: CompatibleCliProviderDocLink[]; +} + +export interface DroidDashboardDiagnostics { + binary: DroidBinaryDiagnostics; + files: { + settings: DroidConfigFileDiagnostics; + legacyConfig: DroidConfigFileDiagnostics; + }; + byok: DroidByokDiagnostics; + warnings: string[]; + docsReference: CompatibleCliDocsReference; +} + +export interface DroidRawSettingsResponse { + path: string; + resolvedPath: string; + exists: boolean; + mtime: number; + rawText: string; + settings: Record | null; + parseError: string | null; +} diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts new file mode 100644 index 00000000..e061e022 --- /dev/null +++ b/src/web-server/services/droid-dashboard-service.ts @@ -0,0 +1,283 @@ +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { detectDroidCli } from '../../targets/droid-detector'; +import type { + DroidByokDiagnostics, + DroidCustomModelDiagnostics, + DroidDashboardDiagnostics, + DroidRawSettingsResponse, +} from './compatible-cli-types'; +import { + JsonFileConflictError, + JsonFileValidationError, + probeJsonObjectFile, + writeJsonObjectFileAtomic, +} from './compatible-cli-json-file-service'; +import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry'; + +interface DroidConfigPaths { + settingsPath: string; + settingsDisplayPath: string; + legacyConfigPath: string; + legacyConfigDisplayPath: string; +} + +interface SaveDroidRawSettingsInput { + rawText: string; + expectedMtime?: number; +} + +interface SaveDroidRawSettingsResult { + success: true; + mtime: number; +} + +export { + JsonFileConflictError as DroidRawSettingsConflictError, + JsonFileValidationError as DroidRawSettingsValidationError, +}; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asObject(value: unknown): Record | null { + return isObject(value) ? value : null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function parseHost(value: string): string | null { + try { + return new URL(value).host || null; + } catch { + return null; + } +} + +export function maskApiKeyPreview(value: string): string { + if (!value) return ''; + const suffix = value.slice(-4); + return `***${suffix}`; +} + +function isCcsManagedDisplayName(displayName: string): boolean { + return displayName.startsWith('CCS ') || displayName.startsWith('ccs-'); +} + +export function resolveDroidConfigPaths( + options: { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDir?: string; + } = {} +): DroidConfigPaths { + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + + const byokBase = env.CCS_HOME || homeDir; + const settingsPath = path.join(byokBase, '.factory', 'settings.json'); + const legacyConfigPath = path.join(byokBase, '.factory', 'config.json'); + + return { + settingsPath, + settingsDisplayPath: '~/.factory/settings.json', + legacyConfigPath, + legacyConfigDisplayPath: '~/.factory/config.json', + }; +} + +function getBinaryVersion(binaryPath: string): string | null { + try { + return execFileSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }) + .trim() + .split('\n')[0] + .trim(); + } catch { + return null; + } +} + +export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByokDiagnostics { + const rows: DroidCustomModelDiagnostics[] = []; + const providerBreakdown: Record = {}; + let invalidModelEntryCount = 0; + + const source = Array.isArray(customModelsValue) + ? customModelsValue + : isObject(customModelsValue) + ? Object.values(customModelsValue) + : []; + + for (const item of source) { + if (!isObject(item)) { + invalidModelEntryCount += 1; + continue; + } + + const displayName = asString(item.displayName) ?? asString(item.model_display_name); + const model = asString(item.model); + const baseUrl = asString(item.baseUrl) ?? asString(item.base_url); + const providerRaw = asString(item.provider); + const apiKey = asString(item.apiKey) ?? asString(item.api_key); + + if (!displayName || !model || !baseUrl || !providerRaw) { + invalidModelEntryCount += 1; + continue; + } + + const provider = providerRaw.toLowerCase(); + providerBreakdown[provider] = (providerBreakdown[provider] ?? 0) + 1; + + rows.push({ + displayName, + model, + provider, + baseUrl, + host: parseHost(baseUrl), + maxOutputTokens: asNumber(item.maxOutputTokens) ?? asNumber(item.max_tokens), + isCcsManaged: isCcsManagedDisplayName(displayName), + apiKeyState: apiKey ? 'set' : 'missing', + apiKeyPreview: apiKey ? maskApiKeyPreview(apiKey) : null, + }); + } + + const ccsManagedCount = rows.filter((row) => row.isCcsManaged).length; + + return { + activeModelSelector: null, + customModelCount: rows.length, + ccsManagedCount, + userManagedCount: rows.length - ccsManagedCount, + invalidModelEntryCount, + providerBreakdown, + customModels: rows, + }; +} + +function resolveCustomModelsValue(settings: Record | null): unknown { + if (!settings) return undefined; + const modern = settings.customModels; + if (Array.isArray(modern) || isObject(modern)) return modern; + + const legacy = settings.custom_models; + if (Array.isArray(legacy) || isObject(legacy)) return legacy; + return undefined; +} + +function usesLegacyCustomModelsKey(settings: Record | null): boolean { + if (!settings) return false; + const modern = settings.customModels; + if (Array.isArray(modern) || isObject(modern)) return false; + + const legacy = settings.custom_models; + return Array.isArray(legacy) || isObject(legacy); +} + +export async function getDroidDashboardDiagnostics(): Promise { + const paths = resolveDroidConfigPaths(); + const binaryPath = detectDroidCli(); + const docsReference = getCompatibleCliDocsReference('droid'); + + const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing'; + + const settingsProbe = await probeJsonObjectFile( + paths.settingsPath, + 'BYOK settings', + paths.settingsDisplayPath + ); + const legacyConfigProbe = await probeJsonObjectFile( + paths.legacyConfigPath, + 'Legacy config', + paths.legacyConfigDisplayPath + ); + + const settingsJson = asObject(settingsProbe.json); + const legacyJson = asObject(legacyConfigProbe.json); + const settingsCustomModels = resolveCustomModelsValue(settingsJson); + const legacyCustomModels = resolveCustomModelsValue(legacyJson); + const byok = summarizeDroidCustomModels(settingsCustomModels ?? legacyCustomModels); + byok.activeModelSelector = asString(settingsProbe.json?.model); + + const warnings: string[] = []; + if (!binaryPath) warnings.push('Droid binary is not detected in PATH or CCS_DROID_PATH.'); + if (settingsProbe.diagnostics.parseError) { + warnings.push('~/.factory/settings.json contains invalid JSON.'); + } + if (byok.invalidModelEntryCount > 0) { + warnings.push(`${byok.invalidModelEntryCount} customModels entries are malformed.`); + } + if (legacyConfigProbe.diagnostics.parseError) { + warnings.push('Legacy Droid config (~/.factory/config.json) JSON is invalid.'); + } + if (usesLegacyCustomModelsKey(settingsJson)) { + warnings.push( + 'settings.json uses legacy "custom_models" key; prefer "customModels" for forward compatibility.' + ); + } + + return { + binary: { + installed: !!binaryPath, + path: binaryPath, + installDir: binaryPath ? path.dirname(binaryPath) : null, + source, + version: binaryPath ? getBinaryVersion(binaryPath) : null, + overridePath: process.env.CCS_DROID_PATH || null, + }, + files: { + settings: settingsProbe.diagnostics, + legacyConfig: legacyConfigProbe.diagnostics, + }, + byok, + warnings, + docsReference, + }; +} + +export async function getDroidRawSettings(): Promise { + const paths = resolveDroidConfigPaths(); + const settingsProbe = await probeJsonObjectFile( + paths.settingsPath, + 'BYOK settings', + paths.settingsDisplayPath + ); + + return { + path: paths.settingsDisplayPath, + resolvedPath: paths.settingsPath, + exists: settingsProbe.diagnostics.exists, + mtime: settingsProbe.diagnostics.mtimeMs ?? Date.now(), + rawText: settingsProbe.rawText, + settings: settingsProbe.json, + parseError: settingsProbe.diagnostics.parseError, + }; +} + +export async function saveDroidRawSettings( + input: SaveDroidRawSettingsInput +): Promise { + const paths = resolveDroidConfigPaths(); + if (typeof input.rawText !== 'string') { + throw new JsonFileValidationError('rawText must be a string.'); + } + + const saved = await writeJsonObjectFileAtomic({ + filePath: paths.settingsPath, + rawText: input.rawText, + expectedMtime: input.expectedMtime, + fileLabel: 'settings.json', + }); + return { success: true, mtime: saved.mtime }; +} diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index 1682cab0..af876196 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -7,11 +7,20 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; +import * as yaml from 'js-yaml'; import { getCcsDir } from '../utils/config-manager'; import { getClaudeConfigDir } from '../utils/claude-config-path'; export const sharedRoutes = Router(); +const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10; +const MAX_DESCRIPTION_LENGTH = 140; +const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB +const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB +const SHARED_ITEMS_CACHE_TTL_MS = 1000; + +type SharedCollectionType = 'commands' | 'skills' | 'agents'; + interface SharedItem { name: string; description: string; @@ -19,6 +28,14 @@ interface SharedItem { type: 'command' | 'skill' | 'agent'; } +interface SharedItemsCacheEntry { + items: SharedItem[]; + sharedDir: string; + expiresAt: number; +} + +const sharedItemsCache = new Map(); + /** * GET /api/shared/commands */ @@ -43,6 +60,41 @@ sharedRoutes.get('/agents', (_req: Request, res: Response) => { res.json({ items }); }); +/** + * GET /api/shared/content?type=commands|skills|agents&path= + */ +sharedRoutes.get('/content', (req: Request, res: Response) => { + const typeParam = req.query.type; + const itemPathParam = req.query.path; + + if (!isSharedCollectionType(typeParam)) { + res.status(400).json({ error: 'Invalid or missing type parameter' }); + return; + } + if (typeof itemPathParam !== 'string' || itemPathParam.trim().length === 0) { + res.status(400).json({ error: 'Invalid or missing path parameter' }); + return; + } + + const ccsDir = getCcsDir(); + const sharedDir = path.join(ccsDir, 'shared', typeParam); + if (!fs.existsSync(sharedDir)) { + res.status(404).json({ error: 'Shared directory not found' }); + return; + } + + const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); + const allowedRoots = resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot); + const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots); + + if (!contentResult) { + res.status(404).json({ error: 'Shared content not found' }); + return; + } + + res.json(contentResult); +}); + /** * GET /api/shared/summary */ @@ -60,17 +112,20 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => { }); }); -function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { - const ccsDir = getCcsDir(); - const sharedDir = path.join(ccsDir, 'shared', type); +function isSharedCollectionType(value: unknown): value is SharedCollectionType { + return value === 'commands' || value === 'skills' || value === 'agents'; +} - if (!fs.existsSync(sharedDir)) { - return []; +function resolveAllowedRoots( + type: SharedCollectionType, + ccsDir: string, + sharedDirRoot: string +): Set { + if (type === 'commands') { + return new Set([sharedDirRoot]); } - const items: SharedItem[] = []; - const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); - const allowedSkillAgentRoots = new Set([ + return new Set([ sharedDirRoot, ...[ path.join(getClaudeConfigDir(), type), @@ -80,6 +135,36 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { .map((dirPath) => safeRealPath(dirPath)) .filter((dirPath): dirPath is string => typeof dirPath === 'string'), ]); +} + +function getSharedItems(type: SharedCollectionType): SharedItem[] { + const ccsDir = getCcsDir(); + const sharedDir = path.join(ccsDir, 'shared', type); + const now = Date.now(); + + if (!fs.existsSync(sharedDir)) { + sharedItemsCache.delete(type); + return []; + } + + const cached = sharedItemsCache.get(type); + if (cached && cached.sharedDir === sharedDir && cached.expiresAt > now) { + return cached.items; + } + + const items: SharedItem[] = []; + const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); + const allowedRoots = resolveAllowedRoots(type, ccsDir, sharedDirRoot); + + if (type === 'commands') { + const commandItems = getCommandItems(sharedDir, allowedRoots); + sharedItemsCache.set(type, { + items: commandItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return commandItems; + } try { const entries = fs.readdirSync(sharedDir, { withFileTypes: true }); @@ -87,56 +172,25 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { for (const entry of entries) { try { const entryPath = path.join(sharedDir, entry.name); - - if (type === 'commands') { - if (!entry.name.endsWith('.md')) { - continue; - } - if (!entry.isFile() && !entry.isSymbolicLink()) { - continue; - } - - const commandPath = safeRealPath(entryPath); - if (!commandPath || !isPathWithin(commandPath, sharedDirRoot)) { - continue; - } - - const description = readMarkdownDescription(commandPath, sharedDirRoot); - if (!description) { - continue; - } - - items.push({ - name: entry.name.replace('.md', ''), - description, - path: entryPath, - type: 'command', - }); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { continue; } - // Skills/agents are directory-based and may be symlinked directories. - if (!entry.isDirectory() && !entry.isSymbolicLink()) { + const stats = fs.statSync(resolvedEntryPath); + const item = getSkillOrAgentItem( + type, + entry, + entryPath, + resolvedEntryPath, + allowedRoots, + stats + ); + if (!item) { continue; } - const entryRoot = safeRealPath(entryPath); - if (!entryRoot || !isPathWithinAny(entryRoot, allowedSkillAgentRoots)) { - continue; - } - - const markdownFile = type === 'skills' ? 'SKILL.md' : 'prompt.md'; - const description = readMarkdownDescription(path.join(entryRoot, markdownFile), entryRoot); - if (!description) { - continue; - } - - items.push({ - name: entry.name, - description, - path: entryPath, - type: type === 'skills' ? 'skill' : 'agent', - }); + items.push(item); } catch { // Fail soft per entry so one bad item does not hide valid results. } @@ -145,25 +199,259 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { // Directory read failed } + const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name)); + sharedItemsCache.set(type, { + items: sortedItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return sortedItems; +} + +function getCommandItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots); + const items: SharedItem[] = []; + + for (const markdownFile of markdownFiles) { + const description = readMarkdownDescription(markdownFile.resolvedPath, allowedRoots); + if (!description) { + continue; + } + + const relativePath = path.relative(sharedDir, markdownFile.displayPath); + const normalizedName = relativePath.split(path.sep).join('/').replace(/\.md$/i, ''); + if (!normalizedName) { + continue; + } + + items.push({ + name: normalizedName, + description, + path: markdownFile.displayPath, + type: 'command', + }); + } + return items.sort((a, b) => a.name.localeCompare(b.name)); } -function extractDescription(content: string): string { - // Extract first non-empty, non-heading line - const lines = content.split('\n'); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('---')) { - return trimmed.slice(0, 100); +function getSkillOrAgentItem( + type: 'skills' | 'agents', + entry: fs.Dirent, + entryPath: string, + resolvedEntryPath: string, + allowedRoots: Set, + stats: fs.Stats +): SharedItem | null { + if (type === 'skills') { + if (!stats.isDirectory()) { + return null; + } + + const description = readMarkdownDescription( + path.join(resolvedEntryPath, 'SKILL.md'), + allowedRoots + ); + if (!description) { + return null; + } + + return { + name: entry.name, + description, + path: entryPath, + type: 'skill', + }; + } + + if (stats.isDirectory()) { + const description = readFirstMarkdownDescription( + [ + path.join(resolvedEntryPath, 'prompt.md'), + path.join(resolvedEntryPath, 'AGENT.md'), + path.join(resolvedEntryPath, 'agent.md'), + ], + allowedRoots + ); + if (!description) { + return null; + } + + return { + name: entry.name, + description, + path: entryPath, + type: 'agent', + }; + } + + if (!stats.isFile() || !entry.name.toLowerCase().endsWith('.md')) { + return null; + } + + const description = readMarkdownDescription(resolvedEntryPath, allowedRoots); + if (!description) { + return null; + } + + return { + name: entry.name.replace(/\.md$/i, ''), + description, + path: entryPath, + type: 'agent', + }; +} + +interface MarkdownFileEntry { + displayPath: string; + resolvedPath: string; +} + +function collectMarkdownFiles(sharedDir: string, allowedRoots: Set): MarkdownFileEntry[] { + const directoriesToVisit: Array<{ path: string; depth: number }> = [ + { path: sharedDir, depth: 0 }, + ]; + const visitedDirectories = new Set(); + const markdownFiles: MarkdownFileEntry[] = []; + + while (directoriesToVisit.length > 0) { + const current = directoriesToVisit.pop(); + if (!current) { + continue; + } + + const currentDir = current.path; + const resolvedCurrentDir = safeRealPath(currentDir); + if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) { + continue; + } + + const normalizedDirPath = normalizeForPathComparison(resolvedCurrentDir); + if (visitedDirectories.has(normalizedDirPath)) { + continue; + } + visitedDirectories.add(normalizedDirPath); + + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { + continue; + } + + let stats: fs.Stats; + try { + stats = fs.statSync(resolvedEntryPath); + } catch { + continue; + } + + if (stats.isDirectory()) { + if (current.depth < MAX_DIRECTORY_TRAVERSAL_DEPTH) { + directoriesToVisit.push({ path: entryPath, depth: current.depth + 1 }); + } + continue; + } + + if (stats.isFile() && entry.name.toLowerCase().endsWith('.md')) { + markdownFiles.push({ + displayPath: entryPath, + resolvedPath: resolvedEntryPath, + }); + } } } + + return markdownFiles; +} + +function extractDescription(content: string): string { + const frontmatterDescription = extractFrontmatterDescription(content); + if (frontmatterDescription) { + return trimDescription(frontmatterDescription); + } + + // Extract first non-empty, non-heading line from the markdown body. + const lines = stripFrontmatter(content).split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (isDescriptionBodyLine(trimmed)) { + return trimDescription(trimmed); + } + } + return 'No description'; } -function readMarkdownDescription(markdownPath: string, allowedRoot: string): string | null { +function isDescriptionBodyLine(line: string): boolean { + if (!line) { + return false; + } + + if (line === '---' || line === '...') { + return false; + } + + return !line.startsWith('#') && !line.startsWith('