From 5c1553e6f5722e5f61780a389d4729428529fdc3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 09:52:59 -0400 Subject: [PATCH 01/24] fix(ci): publish docker image from releases --- .github/workflows/docker-release.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 6d84b530..ca9a94fb 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -1,9 +1,9 @@ name: Publish Docker Image on: - push: - tags: - - 'v*.*.*' + release: + types: + - published concurrency: group: docker-release-${{ github.ref }} @@ -11,27 +11,32 @@ concurrency: jobs: publish: - if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') + if: ${{ !github.event.release.prerelease && startsWith(github.event.release.tag_name, 'v') && !contains(github.event.release.tag_name, '-') }} runs-on: ubuntu-latest permissions: contents: read packages: write + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + steps: - name: Checkout release tag uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} - name: Validate stable semver tag id: tag run: | - if [[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "publish=true" >> "$GITHUB_OUTPUT" exit 0 fi echo "publish=false" >> "$GITHUB_OUTPUT" - echo "Skipping non-stable semver tag ${GITHUB_REF_NAME}" + echo "Skipping non-stable semver tag ${RELEASE_TAG}" - name: Set up QEMU if: steps.tag.outputs.publish == 'true' @@ -45,7 +50,7 @@ jobs: if: steps.tag.outputs.publish == 'true' id: meta run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="${RELEASE_TAG#v}" MINOR="${VERSION%.*}" MAJOR="${VERSION%%.*}" OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]') From 3a7e778bec13869bf03d3c4c39d2934473eb9fce Mon Sep 17 00:00:00 2001 From: Max Loo Date: Mon, 23 Mar 2026 21:53:17 +0800 Subject: [PATCH 02/24] fix(dashboard): remove the unnecessary blank on api config page --- ui/src/pages/api.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index b1474d57..47cdaf84 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -206,7 +206,7 @@ export function ApiPage() { }; return ( -
+
setCreateDialogOpen(true)} />
From 5616c68471c052e68e65578ac3a48c8998036c86 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 10:53:33 -0400 Subject: [PATCH 03/24] fix(ci): harden release automation workflows --- .github/workflows/dev-release.yml | 42 +++-- .github/workflows/docker-release.yml | 66 +++++--- .github/workflows/label-pending-release.yml | 13 +- .github/workflows/release.yml | 60 ++++---- .github/workflows/sync-dev-after-release.yml | 9 +- scripts/dev-release.sh | 152 +++++++++++++------ 6 files changed, 223 insertions(+), 119 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 1c12c76e..c13926d3 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -11,8 +11,7 @@ concurrency: jobs: release: - # Skip if commit message contains [skip ci] - if: "!contains(github.event.head_commit.message, '[skip ci]')" + if: ${{ github.ref == 'refs/heads/dev' && (github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip ci]')) }} runs-on: ubuntu-latest permissions: @@ -65,12 +64,7 @@ jobs: # Use custom dev release script instead of semantic-release # This ensures dev versions follow {stable}-dev.{N} pattern chmod +x scripts/dev-release.sh - if ./scripts/dev-release.sh; then - echo "released=true" >> $GITHUB_OUTPUT - else - echo "Dev release failed or skipped" - echo "released=false" >> $GITHUB_OUTPUT - fi + ./scripts/dev-release.sh - name: Notify Discord if: success() && steps.release.outputs.released == 'true' @@ -87,28 +81,46 @@ jobs: if: success() && steps.release.outputs.released == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PREVIOUS_DEV_TAG: ${{ steps.release.outputs.previous_dev_tag }} + STABLE_TAG: ${{ steps.release.outputs.stable_tag }} run: | # Get version from package.json VERSION=$(jq -r '.version' package.json) # Get commits ONLY since last dev tag (not all release commits) # This prevents re-tagging issues from older releases - LAST_DEV_TAG=$(git tag -l "v*-dev.*" --sort=-v:refname | head -1 || echo "") - - if [ -n "$LAST_DEV_TAG" ]; then + if [[ -n "$PREVIOUS_DEV_TAG" ]]; then # Commits between last dev tag and current (excluding release commit) - RANGE="${LAST_DEV_TAG}..HEAD~1" + RANGE="${PREVIOUS_DEV_TAG}..HEAD~1" else # First dev release - check commits since last stable tag - STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]" --merged origin/main --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || echo "") RANGE="${STABLE_TAG:-HEAD~10}..HEAD~1" fi echo "Checking commits in range: $RANGE" - # Extract issue numbers from commit messages - ISSUES=$(git log $RANGE --pretty=format:"%s %b" 2>/dev/null | \ + COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true) + ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \ grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \ + grep -oE "#[0-9]+" || true) + + PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \ + grep -oE "Merge pull request #[0-9]+|\\(#[0-9]+\\)" | \ + grep -oE "[0-9]+" | sort -u || true) + + PR_TEXT="" + for PR_NUM in $PR_CANDIDATES; do + DETAILS=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json title,body --jq '.title + "\n" + (.body // "")' 2>/dev/null || true) + if [[ -n "$DETAILS" ]]; then + PR_TEXT="${PR_TEXT}"$'\n'"${DETAILS}" + fi + done + + ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \ + grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \ + grep -oE "#[0-9]+" || true) + + ISSUES=$(printf '%s\n%s\n' "$ISSUES_FROM_COMMITS" "$ISSUES_FROM_PRS" | \ grep -oE "#[0-9]+" | sort -u || true) if [[ -z "$ISSUES" ]]; then diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index ca9a94fb..6fd5c00a 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -4,39 +4,57 @@ on: release: types: - published + workflow_dispatch: + inputs: + tag: + description: Stable tag to publish manually, for example v7.55.0 + required: true + type: string concurrency: - group: docker-release-${{ github.ref }} + group: docker-release-${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag || github.ref }} cancel-in-progress: false jobs: publish: - if: ${{ !github.event.release.prerelease && startsWith(github.event.release.tag_name, 'v') && !contains(github.event.release.tag_name, '-') }} + if: ${{ github.event_name != 'release' || !github.event.release.prerelease }} runs-on: ubuntu-latest permissions: contents: read packages: write - env: - RELEASE_TAG: ${{ github.event.release.tag_name }} - steps: - - name: Checkout release tag - uses: actions/checkout@v4 - with: - ref: ${{ github.event.release.tag_name }} + - name: Resolve target tag + id: target + env: + MANUAL_TAG: ${{ inputs.tag }} + RELEASE_EVENT_TAG: ${{ github.event.release.tag_name }} + run: | + if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then + TARGET_TAG="${RELEASE_EVENT_TAG}" + else + TARGET_TAG="${MANUAL_TAG}" + fi + + echo "tag=${TARGET_TAG}" >> "$GITHUB_OUTPUT" - name: Validate stable semver tag id: tag run: | - if [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ "${{ steps.target.outputs.tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "publish=true" >> "$GITHUB_OUTPUT" exit 0 fi echo "publish=false" >> "$GITHUB_OUTPUT" - echo "Skipping non-stable semver tag ${RELEASE_TAG}" + echo "Skipping non-stable semver tag ${{ steps.target.outputs.tag }}" + + - name: Checkout release tag + if: steps.tag.outputs.publish == 'true' + uses: actions/checkout@v4 + with: + ref: ${{ steps.target.outputs.tag }} - name: Set up QEMU if: steps.tag.outputs.publish == 'true' @@ -49,18 +67,34 @@ jobs: - name: Derive image metadata if: steps.tag.outputs.publish == 'true' id: meta + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - VERSION="${RELEASE_TAG#v}" + VERSION="${{ steps.target.outputs.tag }}" + VERSION="${VERSION#v}" MINOR="${VERSION%.*}" MAJOR="${VERSION%%.*}" OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]') IMAGE="ghcr.io/${OWNER_LOWER}/ccs-dashboard" + REVISION=$(git rev-parse HEAD) + TAGS="${IMAGE}:${VERSION} + ${IMAGE}:${MINOR} + ${IMAGE}:${MAJOR}" + + if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then + TAGS="${TAGS} + ${IMAGE}:latest" + fi { echo "version=${VERSION}" echo "minor=${MINOR}" echo "major=${MAJOR}" echo "image=${IMAGE}" + echo "revision=${REVISION}" + echo "tags<> "$GITHUB_OUTPUT" - name: Log in to GitHub Container Registry @@ -79,17 +113,13 @@ jobs: file: docker/Dockerfile platforms: linux/amd64,linux/arm64 push: true - tags: | - ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }} - ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.minor }} - ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major }} - ${{ steps.meta.outputs.image }}:latest + tags: ${{ steps.meta.outputs.tags }} labels: | org.opencontainers.image.title=ccs-dashboard org.opencontainers.image.description=CCS Dashboard container image org.opencontainers.image.url=https://github.com/${{ github.repository }} org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.version=${{ steps.meta.outputs.version }} - org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.revision=${{ steps.meta.outputs.revision }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/label-pending-release.yml b/.github/workflows/label-pending-release.yml index a966d113..8a5c604a 100644 --- a/.github/workflows/label-pending-release.yml +++ b/.github/workflows/label-pending-release.yml @@ -2,7 +2,7 @@ name: Label Pending Release on: pull_request: - types: [opened, reopened] + types: [opened, reopened, edited, synchronize] branches: [dev] jobs: @@ -10,17 +10,22 @@ jobs: runs-on: ubuntu-latest permissions: issues: write + pull-requests: read steps: - name: Label linked issues as pending-release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} PR_BODY: ${{ github.event.pull_request.body }} run: | - # Extract issue numbers from PR title and body (passed via env vars for safety) - # Using env vars prevents shell injection from backticks in markdown - PR_TEXT="$PR_TITLE $PR_BODY" + # Extract issue numbers from PR title/body plus current PR commits. + # Using env vars prevents shell injection from backticks in markdown. + COMMIT_TEXT=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate --jq '.[].commit.message' 2>/dev/null || true) + PR_TEXT="$PR_TITLE + $PR_BODY + $COMMIT_TEXT" ISSUES=$(echo "$PR_TEXT" | grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | grep -oE "#[0-9]+" | sort -u || true) if [[ -z "$ISSUES" ]]; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bec763a..cd5a4eec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: jobs: release: + if: ${{ github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest permissions: @@ -57,10 +58,17 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - OUTPUT=$(bunx semantic-release 2>&1) || true + set +e + OUTPUT=$(bunx semantic-release 2>&1) + STATUS=$? + set -e echo "$OUTPUT" # Strip ANSI color codes before matching (semantic-release outputs colored text) CLEAN=$(echo "$OUTPUT" | sed 's/\x1b\[[0-9;]*m//g') + if [[ "$STATUS" -ne 0 ]]; then + echo "released=false" >> $GITHUB_OUTPUT + exit "$STATUS" + fi if echo "$CLEAN" | grep -q "Published GitHub release"; then echo "released=true" >> $GITHUB_OUTPUT else @@ -83,31 +91,31 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # semantic-release already adds "released" label via .releaserc.cjs - # This step removes transitional labels and closes issues now in stable. + VERSION=$(jq -r '.version' package.json) + RELEASE_BODY=$(gh release view "v${VERSION}" --repo "${{ github.repository }}" --json body --jq '.body' 2>/dev/null || echo "") + RELEASE_ISSUES=$(printf '%s\n' "$RELEASE_BODY" | \ + perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ + grep -oE '#[0-9]+' | tr -d '#' | sort -u || true) + RESOLVED_ISSUES=$(printf '%s\n' "$RELEASE_BODY" | \ + perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \ + grep -oE '#[0-9]+' | tr -d '#' | sort -u || true) - # Find issues with both "released" and "released-dev" labels - ISSUES=$(gh issue list --label "released" --label "released-dev" --state all --json number --jq '.[].number' 2>/dev/null || echo "") + if [[ -z "$RELEASE_ISSUES" ]]; then + echo "No release-scoped issues found for v${VERSION}" + exit 0 + fi - for NUM in $ISSUES; do - echo "Cleaning up labels on issue #$NUM" - gh issue edit "$NUM" --remove-label "released-dev" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true - done - - # Also clean pending-release from any issues with released label - PENDING=$(gh issue list --label "released" --label "pending-release" --state all --json number --jq '.[].number' 2>/dev/null || echo "") - - for NUM in $PENDING; do - echo "Removing pending-release from issue #$NUM" - gh issue edit "$NUM" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true - done - - # Close open issues that are marked as released - OPEN_RELEASED=$(gh issue list --label "released" --state open --json number --jq '.[].number' 2>/dev/null || echo "") - - for NUM in $OPEN_RELEASED; do - echo "Closing released issue #$NUM" - gh issue close "$NUM" \ - --comment "[bot] Closing issue because this fix/feature is now in stable release (@latest)." \ - --repo "${{ github.repository }}" || true + for NUM in $RELEASE_ISSUES; do + echo "Cleaning release state on issue #$NUM" + gh issue edit "$NUM" \ + --remove-label "released-dev" \ + --remove-label "pending-release" \ + --repo "${{ github.repository }}" 2>/dev/null || true + + HAS_RELEASED=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '[.labels[].name | select(. == "released")] | length' 2>/dev/null || echo "0") + if [[ "$HAS_RELEASED" -gt 0 ]] && printf '%s\n' "$RESOLVED_ISSUES" | grep -qx "$NUM"; then + gh issue close "$NUM" \ + --comment "[bot] Closing issue because this fix/feature is now in stable release (@latest)." \ + --repo "${{ github.repository }}" || true + fi done diff --git a/.github/workflows/sync-dev-after-release.yml b/.github/workflows/sync-dev-after-release.yml index b89d1cdf..55ec94b5 100644 --- a/.github/workflows/sync-dev-after-release.yml +++ b/.github/workflows/sync-dev-after-release.yml @@ -1,15 +1,12 @@ name: Sync Dev After Main Release on: - workflow_run: - workflows: ["Release"] - types: [completed] - branches: [main] + release: + types: [published] jobs: sync-dev: - # Only run if Release workflow succeeded on main branch - if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }} + if: ${{ !github.event.release.prerelease && github.event.release.target_commitish == 'main' && startsWith(github.event.release.tag_name, 'v') && !contains(github.event.release.tag_name, '-') }} runs-on: ubuntu-latest permissions: diff --git a/scripts/dev-release.sh b/scripts/dev-release.sh index 1fb74bc3..5b372216 100755 --- a/scripts/dev-release.sh +++ b/scripts/dev-release.sh @@ -26,8 +26,8 @@ log_info() { echo -e "${GREEN}[i]${NC} $1"; } log_warn() { echo -e "${YELLOW}[!]${NC} $1"; } log_error() { echo -e "${RED}[X]${NC} $1"; } -# Ensure we have the latest tags -git fetch --tags origin main +# Ensure we have the latest tags and branch refs +git fetch --tags origin main dev # Get latest stable tag from main (exclude prereleases like -dev, -beta, -rc) # Match only clean semver tags: vX.Y.Z @@ -39,58 +39,100 @@ if [ -z "$STABLE_TAG" ]; then fi STABLE=${STABLE_TAG#v} +CURRENT_VERSION=$(jq -r '.version' package.json) +HEAD_SUBJECT=$(git log -1 --pretty=%s 2>/dev/null || echo "") +DEV_VERSION_REGEX="^${STABLE//./\\.}-dev\\.[0-9]+$" +PREVIOUS_DEV_TAG="" +RECOVERY_MODE=false + log_info "Current stable version: ${STABLE}" -# Find latest dev tag for this stable version -LATEST_DEV=$(git tag -l "v${STABLE}-dev.*" --sort=-v:refname | head -1 || echo "") +if [[ "$CURRENT_VERSION" =~ $DEV_VERSION_REGEX ]] && [[ "$HEAD_SUBJECT" == "chore(release): ${CURRENT_VERSION} [skip ci]" ]]; then + VERSION="$CURRENT_VERSION" + CURRENT_TAG="v${VERSION}" + PREVIOUS_DEV_TAG=$(git tag -l "v${STABLE}-dev.*" --sort=-v:refname | grep -vx "$CURRENT_TAG" | head -1 || echo "") + RECOVERY_MODE=true + log_warn "Recovery mode for ${VERSION}" -# Calculate next dev number -if [ -z "$LATEST_DEV" ]; then - DEV_NUM=1 - log_info "No existing dev tags for ${STABLE}, starting at dev.1" + if git rev-parse "${CURRENT_TAG}" >/dev/null 2>&1; then + TAG_COMMIT=$(git rev-parse "${CURRENT_TAG}^{commit}") + HEAD_COMMIT=$(git rev-parse HEAD) + if [[ "$TAG_COMMIT" != "$HEAD_COMMIT" ]]; then + log_error "Tag ${CURRENT_TAG} exists but does not point to HEAD" + exit 1 + fi + log_info "Reusing existing tag ${CURRENT_TAG}" + else + git tag "${CURRENT_TAG}" + log_warn "Recreated missing tag ${CURRENT_TAG} on release commit" + fi else - DEV_NUM=$(echo "$LATEST_DEV" | sed 's/.*dev\.\([0-9]*\)/\1/') - DEV_NUM=$((DEV_NUM + 1)) - log_info "Latest dev tag: ${LATEST_DEV}, incrementing to dev.${DEV_NUM}" + # Find latest dev tag for this stable version + LATEST_DEV=$(git tag -l "v${STABLE}-dev.*" --sort=-v:refname | head -1 || echo "") + PREVIOUS_DEV_TAG="$LATEST_DEV" + + # Calculate next dev number + if [ -z "$LATEST_DEV" ]; then + DEV_NUM=1 + log_info "No existing dev tags for ${STABLE}, starting at dev.1" + else + DEV_NUM=$(echo "$LATEST_DEV" | sed 's/.*dev\.\([0-9]*\)/\1/') + DEV_NUM=$((DEV_NUM + 1)) + log_info "Latest dev tag: ${LATEST_DEV}, incrementing to dev.${DEV_NUM}" + fi + + VERSION="${STABLE}-dev.${DEV_NUM}" + CURRENT_TAG="v${VERSION}" + log_info "New version: ${VERSION}" + + # Check if tag already exists (safety check) + if git rev-parse "${CURRENT_TAG}" >/dev/null 2>&1; then + log_error "Tag ${CURRENT_TAG} already exists!" + exit 1 + fi + + # Update package.json + npm version "$VERSION" --no-git-tag-version + log_info "Updated package.json to ${VERSION}" + + # Configure git for GitHub Actions + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Commit version change + git add package.json + git commit -m "chore(release): ${VERSION} [skip ci]" + log_info "Created release commit" + + # Create tag + git tag "${CURRENT_TAG}" + log_info "Created tag ${CURRENT_TAG}" fi -VERSION="${STABLE}-dev.${DEV_NUM}" -log_info "New version: ${VERSION}" - -# Check if tag already exists (safety check) -if git rev-parse "v${VERSION}" >/dev/null 2>&1; then - log_error "Tag v${VERSION} already exists!" - exit 1 +PACKAGE_NAME=$(jq -r '.name' package.json) +if npm view "${PACKAGE_NAME}@${VERSION}" version >/dev/null 2>&1; then + log_info "npm already has ${PACKAGE_NAME}@${VERSION}, skipping publish" +else + npm publish --tag dev + log_info "Published to npm with @dev tag" fi -# Update package.json -npm version "$VERSION" --no-git-tag-version -log_info "Updated package.json to ${VERSION}" +if git merge-base --is-ancestor HEAD origin/dev >/dev/null 2>&1; then + log_info "origin/dev already contains release commit" +else + git push origin HEAD:dev + log_info "Pushed release commit to origin/dev" +fi -# Configure git for GitHub Actions -git config user.name "github-actions[bot]" -git config user.email "github-actions[bot]@users.noreply.github.com" - -# Commit version change -git add package.json -git commit -m "chore(release): ${VERSION} [skip ci]" -log_info "Created release commit" - -# Create tag -git tag "v${VERSION}" -log_info "Created tag v${VERSION}" - -# Push commit and tag -git push origin dev -git push origin "v${VERSION}" -log_info "Pushed to origin" - -# Publish to npm with dev tag -npm publish --tag dev -log_info "Published to npm with @dev tag" +if git ls-remote --exit-code --tags origin "refs/tags/${CURRENT_TAG}" >/dev/null 2>&1; then + log_info "Remote tag ${CURRENT_TAG} already exists" +else + git push origin "${CURRENT_TAG}" + log_info "Pushed tag ${CURRENT_TAG}" +fi # Generate release notes from commits since last tag -PREV_TAG=$(git tag -l "v*" --sort=-v:refname | sed -n '2p' || echo "") +PREV_TAG="${PREVIOUS_DEV_TAG:-$STABLE_TAG}" if [ -n "$PREV_TAG" ]; then # Get commits between previous tag and the one before our release commit NOTES=$(git log --pretty=format:"- %s" "${PREV_TAG}..HEAD~1" 2>/dev/null | grep -v "chore(release):" | head -15 || echo "- Dev release") @@ -99,12 +141,16 @@ else fi # Create GitHub prerelease -gh release create "v${VERSION}" \ - --title "v${VERSION}" \ - --notes "${NOTES}" \ - --prerelease +if gh release view "${CURRENT_TAG}" >/dev/null 2>&1; then + log_info "GitHub prerelease ${CURRENT_TAG} already exists" +else + gh release create "${CURRENT_TAG}" \ + --title "${CURRENT_TAG}" \ + --notes "${NOTES}" \ + --prerelease -log_info "Created GitHub prerelease" + log_info "Created GitHub prerelease" +fi # Save release info for Discord notification # This file is read by send-discord-release.cjs for dev releases @@ -117,7 +163,13 @@ EOF log_info "Saved release info for Discord notification" # Output for GitHub Actions -echo "version=${VERSION}" >> "${GITHUB_OUTPUT:-/dev/null}" -echo "released=true" >> "${GITHUB_OUTPUT:-/dev/null}" +{ + echo "current_tag=${CURRENT_TAG}" + echo "previous_dev_tag=${PREVIOUS_DEV_TAG}" + echo "recovery_mode=${RECOVERY_MODE}" + echo "released=true" + echo "stable_tag=${STABLE_TAG}" + echo "version=${VERSION}" +} >> "${GITHUB_OUTPUT:-/dev/null}" log_info "Dev release ${VERSION} complete!" From e06e13ad0db514ef6b3ef3af2062cc2ee28a2170 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 10:57:24 -0400 Subject: [PATCH 04/24] fix(ci): close release automation edge cases --- .github/workflows/dev-release.yml | 9 +-- .github/workflows/docker-release.yml | 6 +- .github/workflows/label-pending-release.yml | 73 ++++++++++++++------- .github/workflows/release.yml | 50 ++++++++++++-- 4 files changed, 102 insertions(+), 36 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index c13926d3..e048677e 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -25,6 +25,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + ref: dev # Always use the built-in workflow token; PAT rotation/breakage must not block dev releases. token: ${{ github.token }} @@ -101,8 +102,8 @@ jobs: COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true) ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \ - grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \ - grep -oE "#[0-9]+" || true) + perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ + grep -oE '#[0-9]+' || true) PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \ grep -oE "Merge pull request #[0-9]+|\\(#[0-9]+\\)" | \ @@ -117,8 +118,8 @@ jobs: done ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \ - grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \ - grep -oE "#[0-9]+" || true) + perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ + grep -oE '#[0-9]+' || true) ISSUES=$(printf '%s\n%s\n' "$ISSUES_FROM_COMMITS" "$ISSUES_FROM_PRS" | \ grep -oE "#[0-9]+" | sort -u || true) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 6fd5c00a..ad46ad84 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -77,12 +77,12 @@ jobs: OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]') IMAGE="ghcr.io/${OWNER_LOWER}/ccs-dashboard" REVISION=$(git rev-parse HEAD) - TAGS="${IMAGE}:${VERSION} - ${IMAGE}:${MINOR} - ${IMAGE}:${MAJOR}" + TAGS="${IMAGE}:${VERSION}" if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then TAGS="${TAGS} + ${IMAGE}:${MINOR} + ${IMAGE}:${MAJOR} ${IMAGE}:latest" fi diff --git a/.github/workflows/label-pending-release.yml b/.github/workflows/label-pending-release.yml index 8a5c604a..dffe8a2d 100644 --- a/.github/workflows/label-pending-release.yml +++ b/.github/workflows/label-pending-release.yml @@ -13,36 +13,59 @@ jobs: pull-requests: read steps: - - name: Label linked issues as pending-release + - name: Reconcile pending-release labels for open dev PRs env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_BODY: ${{ github.event.pull_request.body }} run: | - # Extract issue numbers from PR title/body plus current PR commits. - # Using env vars prevents shell injection from backticks in markdown. - COMMIT_TEXT=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate --jq '.[].commit.message' 2>/dev/null || true) - PR_TEXT="$PR_TITLE - $PR_BODY - $COMMIT_TEXT" - ISSUES=$(echo "$PR_TEXT" | grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | grep -oE "#[0-9]+" | sort -u || true) + # Recompute the complete open-PR issue set so edited/synchronized PRs + # can remove stale pending-release labels as well as add new ones. + OPEN_PRS=$(gh pr list \ + --repo "${{ github.repository }}" \ + --base dev \ + --state open \ + --json number,title,body \ + --jq '.[] | @base64' 2>/dev/null || true) + + ALL_REFERENCED_ISSUES="" + while IFS= read -r PR_ROW; do + [[ -z "$PR_ROW" ]] && continue + PR_JSON=$(printf '%s' "$PR_ROW" | base64 --decode) + PR_NUM=$(printf '%s' "$PR_JSON" | jq -r '.number') + PR_TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title') + PR_BODY=$(printf '%s' "$PR_JSON" | jq -r '.body // ""') + COMMIT_TEXT=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUM}/commits" --paginate --jq '.[].commit.message' 2>/dev/null || true) + PR_TEXT="$PR_TITLE + $PR_BODY + $COMMIT_TEXT" + PR_ISSUES=$(printf '%s\n' "$PR_TEXT" | \ + perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ + grep -oE '#[0-9]+' || true) + if [[ -n "$PR_ISSUES" ]]; then + ALL_REFERENCED_ISSUES=$(printf '%s\n%s\n' "$ALL_REFERENCED_ISSUES" "$PR_ISSUES") + fi + done <<< "$OPEN_PRS" + + ISSUES=$(printf '%s\n' "$ALL_REFERENCED_ISSUES" | grep -oE '#[0-9]+' | sort -u || true) + CURRENT_PENDING=$(gh issue list --repo "${{ github.repository }}" --label "pending-release" --state all --json number --jq '.[].number' 2>/dev/null || true) + + gh label create "pending-release" \ + --color "fbca04" \ + --description "Fix in review, awaiting merge" \ + --repo ${{ github.repository }} 2>/dev/null || true if [[ -z "$ISSUES" ]]; then - echo "No linked issues found in PR" - exit 0 + echo "No linked issues found across open dev PRs" + else + for ISSUE in $ISSUES; do + NUM=${ISSUE#\#} + echo "Ensuring issue #$NUM has pending-release" + gh issue edit "$NUM" --add-label "pending-release" --repo "${{ github.repository }}" || true + done fi - for ISSUE in $ISSUES; do - NUM=${ISSUE#\#} - echo "Labeling issue #$NUM as pending-release" - - # Create label if doesn't exist - gh label create "pending-release" \ - --color "fbca04" \ - --description "Fix in review, awaiting merge" \ - --repo ${{ github.repository }} 2>/dev/null || true - - # Add label to issue - gh issue edit $NUM --add-label "pending-release" --repo ${{ github.repository }} || true + for NUM in $CURRENT_PENDING; do + if ! printf '%s\n' "$ISSUES" | grep -qx "#${NUM}"; then + echo "Removing stale pending-release from issue #$NUM" + gh issue edit "$NUM" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true + fi done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd5a4eec..5b0e2220 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,12 +93,54 @@ jobs: run: | VERSION=$(jq -r '.version' package.json) RELEASE_BODY=$(gh release view "v${VERSION}" --repo "${{ github.repository }}" --json body --jq '.body' 2>/dev/null || echo "") - RELEASE_ISSUES=$(printf '%s\n' "$RELEASE_BODY" | \ + PREVIOUS_STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]" --sort=-v:refname | \ + grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \ + grep -vx "v${VERSION}" | head -1 || echo "") + RANGE="${PREVIOUS_STABLE_TAG:+${PREVIOUS_STABLE_TAG}..HEAD~1}" + if [[ -z "$RANGE" ]]; then + RANGE="HEAD~50..HEAD~1" + fi + + COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true) + PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \ + grep -oE "Merge pull request #[0-9]+|\\(#[0-9]+\\)" | \ + grep -oE "[0-9]+" | sort -u || true) + + PR_TEXT="" + for PR_NUM in $PR_CANDIDATES; do + DETAILS=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json title,body --jq '.title + "\n" + (.body // "")' 2>/dev/null || true) + if [[ -n "$DETAILS" ]]; then + PR_TEXT="${PR_TEXT}"$'\n'"${DETAILS}" + fi + done + + RELEASE_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \ perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ - grep -oE '#[0-9]+' | tr -d '#' | sort -u || true) - RESOLVED_ISSUES=$(printf '%s\n' "$RELEASE_BODY" | \ + grep -oE '#[0-9]+' | tr -d '#' || true) + RELEASE_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \ + perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ + grep -oE '#[0-9]+' | tr -d '#' || true) + RELEASE_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \ + perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ + grep -oE '#[0-9]+' | tr -d '#' || true) + RELEASE_ISSUES=$(printf '%s\n%s\n%s\n' \ + "$RELEASE_ISSUES_FROM_BODY" \ + "$RELEASE_ISSUES_FROM_COMMITS" \ + "$RELEASE_ISSUES_FROM_PRS" | sort -u || true) + + RESOLVED_ISSUES_FROM_BODY=$(printf '%s\n' "$RELEASE_BODY" | \ perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \ - grep -oE '#[0-9]+' | tr -d '#' | sort -u || true) + grep -oE '#[0-9]+' | tr -d '#' || true) + RESOLVED_ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \ + perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \ + grep -oE '#[0-9]+' | tr -d '#' || true) + RESOLVED_ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \ + perl -ne 'if (/(fixes|closes|resolves)(.*)/i) { print "$2\n"; }' | \ + grep -oE '#[0-9]+' | tr -d '#' || true) + RESOLVED_ISSUES=$(printf '%s\n%s\n%s\n' \ + "$RESOLVED_ISSUES_FROM_BODY" \ + "$RESOLVED_ISSUES_FROM_COMMITS" \ + "$RESOLVED_ISSUES_FROM_PRS" | sort -u || true) if [[ -z "$RELEASE_ISSUES" ]]; then echo "No release-scoped issues found for v${VERSION}" From bdb7ac2937d144dc2564d09b01e717e8a4bc44eb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 09:16:52 -0400 Subject: [PATCH 05/24] feat(cliproxy): allow optional provider nicknames - stop requiring manual unique nicknames for no-email providers by default - preserve explicit nickname validation and same-account reauth semantics - persist optional nicknames through dashboard manual callback flows --- README.md | 2 + src/cliproxy/account-manager.ts | 3 + src/cliproxy/accounts/index.ts | 3 + src/cliproxy/accounts/query.ts | 4 +- src/cliproxy/accounts/registry.ts | 95 ++++----- src/cliproxy/accounts/token-file-ops.ts | 86 ++++++++ src/cliproxy/auth/oauth-handler.ts | 153 +++++++------- src/cliproxy/auth/oauth-process.ts | 10 +- src/cliproxy/auth/token-manager.ts | 38 ++-- src/cliproxy/executor/index.ts | 2 +- src/commands/help-command.ts | 2 +- src/web-server/routes/cliproxy-auth-routes.ts | 186 ++++++++++++++---- .../components/account/add-account-dialog.tsx | 17 +- ui/src/lib/i18n.ts | 8 +- ui/src/lib/provider-config.ts | 9 - 15 files changed, 398 insertions(+), 220 deletions(-) diff --git a/README.md b/README.md index 6e60745d..828919e2 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,8 @@ The dashboard provides visual management for all account types: > **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`. +> **Kiro / Copilot account naming:** Manual nicknames are optional. If the provider does not expose an email, CCS derives a safe internal identifier automatically and you can rename it later. + > **AI Providers dashboard:** Configure CLIProxy-managed API key families at `ccs config` -> `CLIProxy` -> `AI Providers`. Use `API Profiles` only for CCS-native Anthropic-compatible profiles. **Powered by:** diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index a49fd640..bb1b8a07 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -28,8 +28,11 @@ export { getPausedDir, getAccountTokenPath, extractAccountIdFromTokenFile, + deriveNoEmailProviderAccountId, generateNickname, validateNickname, + hasAccountNameConflict, + findAccountNameMatch, tokenFileExists, loadAccountsRegistry, saveAccountsRegistry, diff --git a/src/cliproxy/accounts/index.ts b/src/cliproxy/accounts/index.ts index 621d5562..9408bea7 100644 --- a/src/cliproxy/accounts/index.ts +++ b/src/cliproxy/accounts/index.ts @@ -21,8 +21,11 @@ export { getPausedDir, getAccountTokenPath, extractAccountIdFromTokenFile, + deriveNoEmailProviderAccountId, generateNickname, validateNickname, + hasAccountNameConflict, + findAccountNameMatch, tokenFileExists, } from './token-file-ops'; diff --git a/src/cliproxy/accounts/query.ts b/src/cliproxy/accounts/query.ts index c91f9f45..c1189129 100644 --- a/src/cliproxy/accounts/query.ts +++ b/src/cliproxy/accounts/query.ts @@ -67,12 +67,12 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A if (exactMatch) return exactMatch; // Partial match on nickname or email prefix - const partialMatch = accounts.find( + const partialMatches = accounts.filter( (a) => a.nickname?.toLowerCase().startsWith(lowerQuery) || a.email?.toLowerCase().startsWith(lowerQuery) ); - return partialMatch || null; + return partialMatches.length === 1 ? partialMatches[0] : null; } /** diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index 553864d5..6287d50d 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -13,7 +13,9 @@ import { getAccountsRegistryPath, getPausedDir, extractAccountIdFromTokenFile, + deriveNoEmailProviderAccountId, generateNickname, + hasAccountNameConflict, validateNickname, moveTokenToPaused, moveTokenFromPaused, @@ -21,10 +23,12 @@ import { } from './token-file-ops'; /** Default registry structure */ -const DEFAULT_REGISTRY: AccountsRegistry = { - version: 1, - providers: {}, -}; +function createDefaultRegistry(): AccountsRegistry { + return { + version: 1, + providers: {}, + }; +} /** * Load accounts registry @@ -33,7 +37,7 @@ export function loadAccountsRegistry(): AccountsRegistry { const registryPath = getAccountsRegistryPath(); if (!fs.existsSync(registryPath)) { - return { ...DEFAULT_REGISTRY }; + return createDefaultRegistry(); } try { @@ -44,7 +48,7 @@ export function loadAccountsRegistry(): AccountsRegistry { providers: data.providers || {}, }; } catch { - return { ...DEFAULT_REGISTRY }; + return createDefaultRegistry(); } } @@ -115,8 +119,8 @@ export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean * Called after successful OAuth to record the account * * For providers without email (kiro, ghcp): - * - nickname is REQUIRED and used as accountId - * - Uniqueness is enforced to prevent overwriting + * - internal accountId is derived from token metadata + * - nickname is optional metadata * * For providers with email: * - email is used as accountId @@ -149,23 +153,22 @@ export function registerAccount( let accountNickname: string; if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { - // For kiro/ghcp: nickname is REQUIRED and used as accountId - if (!nickname || nickname === 'default') { - throw new Error( - `Nickname is required when adding ${provider} accounts. ` + - `Use --nickname or provide a nickname in the UI.` - ); - } + accountId = email + ? extractAccountIdFromTokenFile(tokenFile, email) + : deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts); + const existingAccount = providerAccounts.accounts[accountId]; - // Validate nickname format - const validationError = validateNickname(nickname); - if (validationError) { - throw new Error(validationError); - } + if (nickname) { + const validationError = validateNickname(nickname); + if (validationError) { + throw new Error(validationError); + } - // Check uniqueness - for (const [existingId, _account] of Object.entries(providerAccounts.accounts)) { - if (existingId.toLowerCase() === nickname.toLowerCase()) { + const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({ + id, + nickname: account.nickname, + })); + if (hasAccountNameConflict(existingAccounts, nickname, accountId)) { throw new Error( `An account with nickname "${nickname}" already exists for ${provider}. ` + `Choose a different nickname.` @@ -173,8 +176,8 @@ export function registerAccount( } } - accountId = nickname; - accountNickname = nickname; + accountNickname = + nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId); } else { // For other providers: use email as accountId, fallback to filename extraction accountId = extractAccountIdFromTokenFile(tokenFile, email); @@ -184,11 +187,12 @@ export function registerAccount( const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; // Create or update account + const existingAccount = providerAccounts.accounts[accountId]; const accountMeta: Omit = { email, nickname: accountNickname, tokenFile, - createdAt: new Date().toISOString(), + createdAt: existingAccount?.createdAt || new Date().toISOString(), lastUsedAt: new Date().toISOString(), }; @@ -339,11 +343,12 @@ export function renameAccount( return false; } - // Check if nickname is already used by another account - for (const [id, account] of Object.entries(providerAccounts.accounts)) { - if (id !== accountId && account.nickname?.toLowerCase() === newNickname.toLowerCase()) { - throw new Error(`Nickname "${newNickname}" is already used by another account`); - } + const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({ + id, + nickname: account.nickname, + })); + if (hasAccountNameConflict(existingAccounts, newNickname, accountId)) { + throw new Error(`Nickname "${newNickname}" is already used by another account`); } providerAccounts.accounts[accountId].nickname = newNickname; @@ -476,28 +481,10 @@ export function discoverExistingAccounts(): void { continue; } - // Determine accountId based on provider type - let accountId: string; - - if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email) { - // For kiro/ghcp without email: extract from filename or generate unique - // Pattern: kiro-github-ABC123.json -> github-ABC123 - const filenameId = extractAccountIdFromTokenFile(file, undefined); - - if (filenameId !== 'default') { - accountId = filenameId; - } else { - // Generate unique ID: provider + incrementing index - let index = 1; - while (providerAccounts.accounts[`${provider}-${index}`]) { - index++; - } - accountId = `${provider}-${index}`; - } - } else { - // For providers with email: use email or filename extraction - accountId = extractAccountIdFromTokenFile(file, email); - } + const accountId = + PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email + ? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts) + : extractAccountIdFromTokenFile(file, email); // Skip if account already registered if (providerAccounts.accounts[accountId]) { @@ -517,7 +504,7 @@ export function discoverExistingAccounts(): void { const lastModified = stats.mtime || stats.birthtime || new Date(); const accountMeta: Omit = { email, - nickname: generateNickname(email), + nickname: email ? generateNickname(email) : accountId, tokenFile: file, createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), lastUsedAt: lastModified.toISOString(), diff --git a/src/cliproxy/accounts/token-file-ops.ts b/src/cliproxy/accounts/token-file-ops.ts index d06f4df7..2f35d95d 100644 --- a/src/cliproxy/accounts/token-file-ops.ts +++ b/src/cliproxy/accounts/token-file-ops.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCliproxyDir, getAuthDir } from '../config-generator'; +import type { CLIProxyProvider } from '../types'; import { AccountInfo } from './types'; /** @@ -143,6 +144,50 @@ export function extractAccountIdFromTokenFile(filename: string, email?: string): return 'default'; } +/** + * Derive a collision-safe internal account ID for providers that may not expose email. + * Reuses the existing entry when the token file is already known, otherwise prefers the + * filename-derived ID before falling back to provider-scoped sequential IDs. + */ +export function deriveNoEmailProviderAccountId( + provider: CLIProxyProvider, + tokenFile: string, + existingAccounts: Record> +): string { + const existingEntries = Object.entries(existingAccounts); + const existingEntry = existingEntries.find(([, account]) => account.tokenFile === tokenFile); + if (existingEntry) { + return existingEntry[0]; + } + + const extractedId = extractAccountIdFromTokenFile(tokenFile); + const lowerExtractedId = extractedId.toLowerCase(); + + if ( + extractedId !== 'default' && + !existingEntries.some( + ([existingId, account]) => + existingId.toLowerCase() === lowerExtractedId || + account.nickname?.toLowerCase() === lowerExtractedId + ) + ) { + return extractedId; + } + + let index = 1; + while ( + existingEntries.some( + ([existingId, account]) => + existingId.toLowerCase() === `${provider}-${index}` || + account.nickname?.toLowerCase() === `${provider}-${index}` + ) + ) { + index++; + } + + return `${provider}-${index}`; +} + /** * Generate nickname from email * Takes prefix before @ symbol, sanitizes whitespace @@ -180,3 +225,44 @@ export function validateNickname(nickname: string): string | null { } return null; } + +/** + * Check whether a nickname would collide with any existing account ID or nickname. + */ +export function hasAccountNameConflict( + accounts: Array>, + candidateName: string, + excludeAccountId?: string +): boolean { + const normalizedCandidate = candidateName.toLowerCase(); + const normalizedExcludedId = excludeAccountId?.toLowerCase(); + + return accounts.some((account) => { + if (normalizedExcludedId && account.id.toLowerCase() === normalizedExcludedId) { + return false; + } + + return ( + account.id.toLowerCase() === normalizedCandidate || + account.nickname?.toLowerCase() === normalizedCandidate + ); + }); +} + +/** + * Find the existing account that already owns the supplied id/nickname. + */ +export function findAccountNameMatch( + accounts: Array>, + candidateName: string +): Pick | null { + const normalizedCandidate = candidateName.toLowerCase(); + + return ( + accounts.find( + (account) => + account.id.toLowerCase() === normalizedCandidate || + account.nickname?.toLowerCase() === normalizedCandidate + ) || null + ); +} diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index e989ae56..b30baf09 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -20,6 +20,8 @@ import { getProviderAccounts, getDefaultAccount, touchAccount, + hasAccountNameConflict, + findAccountNameMatch, PROVIDERS_WITHOUT_EMAIL, validateNickname, } from '../account-manager'; @@ -88,6 +90,28 @@ export async function requestPasteCallbackStart( return (await response.json()) as PasteCallbackStartData; } +export function getCliAuthNicknameError( + provider: CLIProxyProvider, + nickname: string | undefined, + existingAccounts: Array>, + allowExistingAccountId?: string +): string | null { + if (!nickname || !PROVIDERS_WITHOUT_EMAIL.includes(provider)) { + return null; + } + + const validationError = validateNickname(nickname); + if (validationError) { + return validationError; + } + + if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) { + return `Nickname "${nickname}" is already in use. Choose a different one.`; + } + + return null; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -206,74 +230,6 @@ async function promptOAuthModeChoice(callbackPort: number | null): Promise<'past }); } -/** - * Prompt user for account nickname (required for kiro/ghcp) - * Returns null if user cancels - */ -async function promptNickname( - provider: CLIProxyProvider, - existingAccounts: AccountInfo[] -): Promise { - const readline = await import('readline'); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - const existingNicknames = existingAccounts.map( - (a) => a.nickname?.toLowerCase() || a.id.toLowerCase() - ); - - console.log(''); - console.log(info(`${provider} accounts require a unique nickname to distinguish them.`)); - if (existingNicknames.length > 0) { - console.log(` Existing: ${existingNicknames.join(', ')}`); - } - - return new Promise((resolve) => { - let resolved = false; - - // Handle Ctrl+C gracefully (only if not already resolved) - rl.on('close', () => { - if (!resolved) { - resolved = true; - resolve(null); - } - }); - - const askForNickname = () => { - rl.question('[?] Enter a nickname for this account: ', (answer) => { - const nickname = answer.trim(); - - if (!nickname) { - console.log(fail('Nickname cannot be empty')); - askForNickname(); - return; - } - - const validationError = validateNickname(nickname); - if (validationError) { - console.log(fail(validationError)); - askForNickname(); - return; - } - - if (existingNicknames.includes(nickname.toLowerCase())) { - console.log(fail(`Nickname "${nickname}" is already in use. Choose a different one.`)); - askForNickname(); - return; - } - - resolved = true; - rl.close(); - resolve(nickname); - }); - }; - - askForNickname(); - }); -} - /** * Run pre-flight OAuth checks */ @@ -350,7 +306,8 @@ async function handlePasteCallbackMode( oauthConfig: ProviderOAuthConfig, verbose: boolean, tokenDir: string, - nickname?: string + nickname?: string, + expectedAccountId?: string ): Promise { // Resolve CLIProxyAPI target (local or remote based on config) const target = getProxyTarget(); @@ -474,7 +431,13 @@ async function handlePasteCallbackMode( } console.log(ok('Authentication successful!')); - const account = registerAccountFromToken(provider, tokenDir, nickname); + const account = registerAccountFromToken( + provider, + tokenDir, + nickname, + verbose, + expectedAccountId + ); // Account safety: check for cross-provider conflicts if (account?.email) { @@ -509,7 +472,7 @@ export async function triggerOAuth( warnOAuthBanRisk(provider); const { verbose = false, add = false, fromUI = false, noIncognito = true } = options; const acceptAgyRisk = options.acceptAgyRisk === true; - let { nickname } = options; + const { nickname } = options; const resolvedKiroMethod = provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD; @@ -533,21 +496,26 @@ export async function triggerOAuth( // Check for existing accounts const existingAccounts = getProviderAccounts(provider); + const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null; + const nicknameError = !fromUI + ? getCliAuthNicknameError(provider, nickname, existingAccounts, existingNameMatch?.id) + : null; + if (nicknameError) { + console.log(fail(nicknameError)); + return null; + } // Handle paste-callback mode if (options.pasteCallback) { const tokenDir = getProviderTokenDir(provider); - return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname); - } - - // For kiro/ghcp: require nickname if not provided (CLI only, not fromUI) - if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) { - const promptedNickname = await promptNickname(provider, existingAccounts); - if (!promptedNickname) { - console.log(info('Cancelled')); - return null; - } - nickname = promptedNickname; + return handlePasteCallbackMode( + provider, + oauthConfig, + verbose, + tokenDir, + nickname, + existingNameMatch?.id + ); } // Handle --import flag: skip OAuth and import from Kiro IDE directly @@ -555,7 +523,7 @@ export async function triggerOAuth( const tokenDir = getProviderTokenDir(provider); const success = await importKiroToken(verbose); if (success) { - return registerAccountFromToken(provider, tokenDir, nickname); + return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id); } return null; } @@ -589,12 +557,26 @@ export async function triggerOAuth( // Non-interactive environment (piped input) - default to paste mode if (!process.stdin.isTTY) { const tokenDir = getProviderTokenDir(provider); - return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname); + return handlePasteCallbackMode( + provider, + oauthConfig, + verbose, + tokenDir, + nickname, + existingNameMatch?.id + ); } const mode = await promptOAuthModeChoice(callbackPort); if (mode === 'paste') { const tokenDir = getProviderTokenDir(provider); - return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname); + return handlePasteCallbackMode( + provider, + oauthConfig, + verbose, + tokenDir, + nickname, + existingNameMatch?.id + ); } // mode === 'forward' continues to existing port-forwarding flow below } @@ -678,6 +660,7 @@ export async function triggerOAuth( verbose, isCLI, nickname, + expectedAccountId: existingNameMatch?.id, }); // Show hint for Kiro users about --no-incognito option (first-time auth only) diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index d3458a18..8fcdfa60 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -50,6 +50,7 @@ export interface OAuthProcessOptions { verbose: boolean; isCLI: boolean; nickname?: string; + expectedAccountId?: string; } /** Internal state for OAuth process */ @@ -276,6 +277,7 @@ async function handleTokenNotFound( callbackPort: number | null, tokenDir: string, nickname: string | undefined, + expectedAccountId: string | undefined, verbose: boolean, failureReason?: string ): Promise { @@ -289,7 +291,7 @@ async function handleTokenNotFound( if (result.success) { const providerInfo = result.provider ? ` (Provider: ${result.provider})` : ''; console.log(ok(`Imported Kiro token from IDE${providerInfo}`)); - return registerAccountFromToken(provider, tokenDir, nickname); + return registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId); } console.log(fail(`Auto-import failed: ${result.error}`)); @@ -376,6 +378,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { @@ -538,7 +541,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise f.endsWith('.json')); - let newestFile: string | null = null; let newestMtime = 0; for (const file of jsonFiles) { @@ -233,19 +235,33 @@ export function registerAccountFromToken( const email = data.email || undefined; const projectId = data.project_id || undefined; - const account = registerAccount( - provider, - newestFile, - email, - nickname || generateNickname(email), - projectId - ); + const account = registerAccount(provider, newestFile, email, nickname, projectId); // Upload token to remote server if configured (async, don't block) uploadTokenToRemoteAsync(tokenPath, verbose); return account; - } catch { + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (verbose) { + console.error(`[auth] Failed to register token-backed account: ${message}`); + } + + if (typeof newestFile === 'string') { + const hasExistingRegistration = getProviderAccounts(provider).some( + (account) => account.tokenFile === newestFile + ); + if (!hasExistingRegistration) { + deleteTokenFile(newestFile); + } + } + + if (expectedAccountId && verbose) { + console.error( + `[auth] Reauthentication target ${expectedAccountId} did not resolve cleanly from the new token` + ); + } + return null; } } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 9945ef4d..ec772b15 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -418,7 +418,7 @@ export async function execClaudeWithCLIProxy( const nickname = acct.nickname ? `[${acct.nickname}]` : ''; console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`); } - console.log(`\n Use "ccs ${provider} --use " to switch accounts`); + console.log(`\n Use "ccs ${provider} --use " to switch accounts`); } process.exit(0); } diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 41d365aa..acb6adbe 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -209,7 +209,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'Show auth URL and prompt for callback paste (cross-browser)', ], ['ccs --accounts', 'List all accounts'], - ['ccs --use ', 'Switch to account'], + ['ccs --use ', 'Switch to account'], ['ccs --config', 'Change model (agy, gemini)'], [ 'ccs agy --accept-agr-risk', diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index af838c5b..0072fc67 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -22,6 +22,8 @@ import { pauseAccount as pauseAccountFn, resumeAccount as resumeAccountFn, touchAccount, + hasAccountNameConflict, + findAccountNameMatch, PROVIDERS_WITHOUT_EMAIL, validateNickname, } from '../../cliproxy/account-manager'; @@ -33,7 +35,7 @@ import { import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; -import { getProviderTokenDir } from '../../cliproxy/auth/token-manager'; +import { getProviderTokenDir, registerAccountFromToken } from '../../cliproxy/auth/token-manager'; import { CLIPROXY_CALLBACK_PROVIDER_MAP, CLIPROXY_AUTH_URL_PROVIDER_MAP, @@ -53,12 +55,55 @@ import { import { createRouteErrorHelpers } from './route-helpers'; const router = Router(); +const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000; +const pendingManualAuthState = new Map< + string, + { nickname?: string; expectedAccountId?: string; createdAt: number } +>(); // Valid providers list - derived from canonical CLIPROXY_PROFILES const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; const { respondInternalError } = createRouteErrorHelpers('cliproxy-auth-routes'); +function pruneExpiredManualAuthState(now = Date.now()): void { + for (const [state, pending] of pendingManualAuthState.entries()) { + if (now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS) { + pendingManualAuthState.delete(state); + } + } +} + +function rememberManualAuthState( + state: string, + pending: { nickname?: string; expectedAccountId?: string } +): void { + pruneExpiredManualAuthState(); + pendingManualAuthState.set(state, { + ...pending, + createdAt: Date.now(), + }); +} + +function getManualAuthState( + state: string | undefined +): { nickname?: string; expectedAccountId?: string } | null { + if (!state) { + return null; + } + + pruneExpiredManualAuthState(); + const pending = pendingManualAuthState.get(state); + if (!pending) { + return null; + } + + return { + nickname: pending.nickname, + expectedAccountId: pending.expectedAccountId, + }; +} + function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } { if (raw === undefined || raw === null) { return { method: normalizeKiroAuthMethod(), invalid: false }; @@ -104,6 +149,34 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string { return 'Authentication failed or was cancelled'; } +export function getStartAuthNicknameError( + provider: CLIProxyProvider, + nickname: string | undefined, + existingAccounts: Array<{ id: string; nickname?: string }>, + allowExistingAccountId?: string +): { error: string; code: 'INVALID_NICKNAME' | 'NICKNAME_EXISTS' } | null { + if (!PROVIDERS_WITHOUT_EMAIL.includes(provider) || !nickname) { + return null; + } + + const validationError = validateNickname(nickname); + if (validationError) { + return { + error: validationError, + code: 'INVALID_NICKNAME', + }; + } + + if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) { + return { + error: `Nickname "${nickname}" is already in use. Choose a different one.`, + code: 'NICKNAME_EXISTS', + }; + } + + return null; +} + /** * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers @@ -430,37 +503,17 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise a.nickname?.toLowerCase() || a.id.toLowerCase() - ); - if (existingNicknames.includes(nickname.toLowerCase())) { - res.status(400).json({ - error: `Nickname "${nickname}" is already in use. Choose a different one.`, - code: 'NICKNAME_EXISTS', - }); - return; - } + const existingAccounts = getProviderAccounts(provider as CLIProxyProvider); + const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null; + const nicknameError = getStartAuthNicknameError( + provider as CLIProxyProvider, + nickname, + existingAccounts, + existingNameMatch?.id + ); + if (nicknameError) { + res.status(400).json(nicknameError); + return; } // Check Kiro no-incognito setting from config (or request body) @@ -625,7 +678,12 @@ 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, riskAcknowledgement } = req.body ?? {}; + const requestBody = + req.body && typeof req.body === 'object' ? (req.body as Record) : {}; + const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined; + const kiroMethodRaw = requestBody.kiroMethod; + const riskAcknowledgement = requestBody.riskAcknowledgement; + const nickname = nicknameRaw?.trim(); const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); // Check remote mode @@ -668,6 +726,19 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } + const existingAccounts = getProviderAccounts(provider as CLIProxyProvider); + const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null; + const nicknameError = getStartAuthNicknameError( + provider as CLIProxyProvider, + nickname, + existingAccounts, + existingNameMatch?.id + ); + if (nicknameError) { + res.status(400).json(nicknameError); + return; + } + try { const authUrlProvider = CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider; @@ -696,19 +767,27 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise method?: string; }; const authUrl = data.url || data.auth_url; + const oauthState = data.state || parseAuthUrlState(authUrl); // Some upstream flows return state first and provide auth_url in subsequent status polling. - if (!authUrl && !data.state) { + if (!authUrl && !oauthState) { res .status(500) .json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' }); return; } + if (oauthState) { + rememberManualAuthState(oauthState, { + nickname: nickname || undefined, + expectedAccountId: existingNameMatch?.id, + }); + } + res.json({ success: true, authUrl: authUrl || null, - state: data.state || null, + state: oauthState, method: data.method || null, }); } catch (error) { @@ -768,6 +847,18 @@ function parseCallbackUrl(url: string): { code?: string; state?: string } { } } +function parseAuthUrlState(url: string | null | undefined): string | null { + if (!url) { + return null; + } + + try { + return new URL(url).searchParams.get('state'); + } catch { + return null; + } +} + /** * POST /api/cliproxy/auth/:provider/submit-callback - Submit OAuth callback URL manually * For cross-browser OAuth flows where callback cannot redirect directly @@ -800,6 +891,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P res.status(400).json({ error: 'Invalid callback URL: missing code parameter' }); return; } + const pendingAuth = getManualAuthState(parsed.state); try { const callbackProvider = @@ -824,7 +916,29 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P return; } - res.json({ success: true }); + const account = registerAccountFromToken( + provider as CLIProxyProvider, + getProviderTokenDir(provider as CLIProxyProvider), + pendingAuth?.nickname, + false, + pendingAuth?.expectedAccountId + ); + if (parsed.state) { + pendingManualAuthState.delete(parsed.state); + } + + res.json({ + success: true, + account: account + ? { + id: account.id, + email: account.email, + nickname: account.nickname, + provider: account.provider, + isDefault: account.isDefault, + } + : null, + }); } catch (error) { respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503); } diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index bdb2e90a..6ec2d361 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -41,7 +41,6 @@ import { DEFAULT_KIRO_AUTH_METHOD, getKiroAuthMethodOption, isDeviceCodeProvider, - isNicknameRequiredProvider, KIRO_AUTH_METHOD_OPTIONS, } from '@/lib/provider-config'; import type { KiroAuthMethod } from '@/lib/provider-config'; @@ -89,7 +88,6 @@ export function AddAccountDialog({ const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist); const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE; const defaultDeviceCode = isDeviceCodeProvider(provider); - const requiresNickname = isNicknameRequiredProvider(provider); const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod); const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode; const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending; @@ -266,10 +264,6 @@ export function AddAccountDialog({ ); return; } - if (requiresNickname && !nicknameTrimmed) { - setLocalError(`Nickname is required for ${displayName} accounts.`); - return; - } setLocalError(null); wasAuthenticatingRef.current = true; authFlow.startAuth(provider, { @@ -394,11 +388,7 @@ export function AddAccountDialog({ {/* Nickname input - only show before auth starts */} {!showAuthUI && (
- +

- {requiresNickname - ? t('addAccountDialog.nicknameRequiredHint') - : t('addAccountDialog.nicknameOptionalHint')} + {t('addAccountDialog.nicknameOptionalHint')}

)} @@ -554,7 +542,6 @@ export function AddAccountDialog({ disabled={ isPending || isAgyBypassStatePending || - (requiresNickname && !nicknameTrimmed) || (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) || (requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged) } diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 038d5ee0..4dd64dfe 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -473,7 +473,7 @@ const resources = { nicknameRequiredHint: 'Required for this provider. Use a unique friendly name (e.g., work, personal).', nicknameOptionalHint: - 'A friendly name to identify this account. Auto-generated from email if left empty.', + 'A friendly name to identify this account. Leave blank to use a safe generated identifier.', waitingForAuth: 'Waiting for authentication...', deviceCodeHint: 'A verification code dialog will appear shortly. Enter the code on the provider website.', @@ -1632,7 +1632,7 @@ const resources = { nicknameOptional: 'ๆ˜ต็งฐ๏ผˆ้€‰ๅกซ๏ผ‰', nicknamePlaceholder: 'ไพ‹ๅฆ‚๏ผšๅทฅไฝœใ€ไธชไบบ', nicknameRequiredHint: '่ฏฅๆไพ›ๅ•†ๅฟ…ๅกซใ€‚่ฏทไฝฟ็”จๅ”ฏไธ€ๆ˜“่ฎฐๅ็งฐ๏ผˆๅฆ‚ๅทฅไฝœใ€ไธชไบบ๏ผ‰ใ€‚', - nicknameOptionalHint: '็”จไบŽๅŒบๅˆ†่ดฆๅท็š„ๅ‹ๅฅฝๅ็งฐใ€‚็•™็ฉบๅฐ†ๆ นๆฎ้‚ฎ็ฎฑ่‡ชๅŠจ็”Ÿๆˆใ€‚', + nicknameOptionalHint: '็”จไบŽๅŒบๅˆ†่ดฆๅท็š„ๅ‹ๅฅฝๅ็งฐใ€‚็•™็ฉบๅฐ†่‡ชๅŠจ็”Ÿๆˆๅฎ‰ๅ…จๆ ‡่ฏ†ใ€‚', waitingForAuth: '็ญ‰ๅพ…่ฎค่ฏไธญ...', deviceCodeHint: '้ชŒ่ฏ็ ๅฏน่ฏๆก†ๅณๅฐ†ๅ‡บ็Žฐ๏ผŒ่ฏทๅœจๆไพ›ๅ•†็ฝ‘็ซ™่พ“ๅ…ฅ้ชŒ่ฏ็ ใ€‚', browserHint: 'ๅœจๆต่งˆๅ™จไธญๅฎŒๆˆ่ฎค่ฏๅŽ๏ผŒๆœฌๅฏน่ฏๆก†ๅฐ†่‡ชๅŠจๅ…ณ้—ญใ€‚', @@ -2804,7 +2804,7 @@ const resources = { nicknameRequiredHint: 'Bแบฏt buแป™c vแป›i nhร  cung cแบฅp nร y. Dรนng tรชn thรขn thiแป‡n duy nhแบฅt (vรญ dแปฅ: work, personal).', nicknameOptionalHint: - 'Mแป™t cรกi tรชn thรขn thiแป‡n ฤ‘แปƒ xรกc ฤ‘แป‹nh tร i khoแบฃn nร y. Tแปฑ ฤ‘แป™ng tแบกo tแปซ email nแบฟu ฤ‘แปƒ trแป‘ng.', + 'Tรชn thรขn thiแป‡n ฤ‘แปƒ nhแบญn biแบฟt tร i khoแบฃn nร y. ฤแปƒ trแป‘ng ฤ‘แปƒ dรนng mรฃ nhแบญn dแบกng an toร n do hแป‡ thแป‘ng tแบกo.', waitingForAuth: 'ฤang chแป xรกc thแปฑc...', deviceCodeHint: 'Hแป™p thoแบกi mรฃ xรกc minh sแบฝ sแป›m xuแบฅt hiแป‡n. Nhแบญp mรฃ trรชn trang web cแปงa nhร  cung cแบฅp.', @@ -4004,7 +4004,7 @@ const resources = { nicknameRequiredHint: 'ใ“ใฎใƒ—ใƒญใƒใ‚คใƒ€ใƒผใงใฏๅฟ…้ ˆใงใ™ใ€‚้‡่ค‡ใ—ใชใ„ใ‚ใ‹ใ‚Šใ‚„ใ™ใ„ๅๅ‰ใ‚’ไป˜ใ‘ใฆใใ ใ•ใ„๏ผˆไพ‹: work, personal๏ผ‰ใ€‚', nicknameOptionalHint: - 'ใ“ใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’่ญ˜ๅˆฅใ—ใ‚„ใ™ใ„ๅๅ‰ใงใ™ใ€‚็ฉบๆฌ„ใชใ‚‰ใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใ‹ใ‚‰่‡ชๅ‹•็”Ÿๆˆใ•ใ‚Œใพใ™ใ€‚', + 'ใ“ใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’่ญ˜ๅˆฅใ—ใ‚„ใ™ใ„ๅๅ‰ใงใ™ใ€‚็ฉบๆฌ„ใฎๅ ดๅˆใฏๅฎ‰ๅ…จใช่ญ˜ๅˆฅๅญใ‚’่‡ชๅ‹•็”Ÿๆˆใ—ใพใ™ใ€‚', waitingForAuth: '่ช่จผใ‚’ๅพ…ๆฉŸไธญ...', deviceCodeHint: '็ขบ่ชใ‚ณใƒผใƒ‰ใฎใƒ€ใ‚คใ‚ขใƒญใ‚ฐใŒใพใ‚‚ใชใ่กจ็คบใ•ใ‚Œใพใ™ใ€‚ใƒ—ใƒญใƒใ‚คใƒ€ใƒผใฎใ‚ตใ‚คใƒˆใงใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„ใ€‚', diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 9d8b96a0..b1cbf0d2 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -233,15 +233,6 @@ export function getDeviceCodeProviderInstruction(provider: unknown): string { return 'Complete the authorization in your browser.'; } -/** Providers that require nickname because token payload may not include email. */ -export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro']; - -/** Check if provider requires user-supplied nickname in auth flow */ -export function isNicknameRequiredProvider(provider: unknown): boolean { - const normalized = normalizeProviderInput(provider); - return isValidProvider(normalized) && NICKNAME_REQUIRED_PROVIDERS.includes(normalized); -} - /** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */ export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const; export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number]; From 834704938db14635b87e3a2e337a9d75e3f43d6b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 09:17:25 -0400 Subject: [PATCH 06/24] test(cliproxy): cover optional nickname auth flows - add registry coverage for generated ids, reauth, and ambiguous prefixes - add CLI and dashboard validation tests for optional nickname handling - add manual callback coverage for persisted social OAuth nicknames --- ...account-registry-optional-nickname.test.ts | 101 ++++++++++++ .../oauth-handler-paste-callback.test.ts | 51 ++++++ ...iproxy-auth-routes-manual-callback.test.ts | 145 ++++++++++++++++++ .../web-server/cliproxy-auth-routes.test.ts | 42 +++++ 4 files changed, 339 insertions(+) create mode 100644 tests/unit/cliproxy/account-registry-optional-nickname.test.ts create mode 100644 tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts diff --git a/tests/unit/cliproxy/account-registry-optional-nickname.test.ts b/tests/unit/cliproxy/account-registry-optional-nickname.test.ts new file mode 100644 index 00000000..04ef857a --- /dev/null +++ b/tests/unit/cliproxy/account-registry-optional-nickname.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runWithScopedCcsHome } from '../../../src/utils/config-manager'; + +async function loadAccountManager() { + return import(`../../../src/cliproxy/account-manager?optional-nickname=${Date.now()}`); +} + +async function withIsolatedHome(fn: () => Promise | T): Promise { + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-optional-nickname-')); + try { + return await runWithScopedCcsHome(testDir, fn); + } finally { + fs.rmSync(testDir, { recursive: true, force: true }); + } +} + +describe('registerAccount optional nickname flow', () => { + it('uses a filename-derived id when Kiro/GHCP nickname is omitted', async () => { + const account = await withIsolatedHome(async () => { + const { registerAccount } = await loadAccountManager(); + return registerAccount('kiro', 'kiro-github-ABC123.json'); + }); + + expect(account.id).toBe('github-ABC123'); + expect(account.nickname).toBe('github-ABC123'); + }); + + it('falls back to provider-scoped sequential ids when the filename is not descriptive', async () => { + const { first, second } = await withIsolatedHome(async () => { + const { registerAccount } = await loadAccountManager(); + return { + first: registerAccount('kiro', 'kiro-nomail.json'), + second: registerAccount('kiro', 'kiro-second.json'), + }; + }); + + expect(first.id).toBe('kiro-1'); + expect(first.nickname).toBe('kiro-1'); + expect(second.id).toBe('kiro-2'); + expect(second.nickname).toBe('kiro-2'); + }); + + it('keeps user nicknames optional metadata separate from internal ids', async () => { + const account = await withIsolatedHome(async () => { + const { registerAccount } = await loadAccountManager(); + return registerAccount('ghcp', 'ghcp-amazon-XYZ789.json', undefined, 'work'); + }); + + expect(account.id).toBe('amazon-XYZ789'); + expect(account.nickname).toBe('work'); + }); + + it('preserves an existing custom nickname when the same token file is re-registered', async () => { + const reauthenticated = await withIsolatedHome(async () => { + const { registerAccount } = await loadAccountManager(); + registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'work'); + return registerAccount('kiro', 'kiro-github-ABC123.json'); + }); + + expect(reauthenticated.id).toBe('github-ABC123'); + expect(reauthenticated.nickname).toBe('work'); + }); + + it('rejects nickname collisions against existing account ids and nicknames', async () => { + await withIsolatedHome(async () => { + const { registerAccount, renameAccount } = await loadAccountManager(); + registerAccount('kiro', 'kiro-github-ABC123.json'); + const second = registerAccount('kiro', 'kiro-google-XYZ789.json', undefined, 'personal'); + + expect(() => + registerAccount('kiro', 'kiro-google-NEW123.json', undefined, 'github-ABC123') + ).toThrow(/already exists/i); + expect(() => renameAccount('kiro', second.id, 'github-ABC123')).toThrow(/already used/i); + }); + }); + + it('avoids auto-generated ids that would collide with an existing nickname', async () => { + const added = await withIsolatedHome(async () => { + const { registerAccount } = await loadAccountManager(); + registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'google-XYZ789'); + return registerAccount('kiro', 'kiro-google-XYZ789.json'); + }); + + expect(added.id).toBe('kiro-1'); + expect(added.nickname).toBe('kiro-1'); + }); + + it('does not resolve ambiguous nickname prefixes to the first generated account', async () => { + const match = await withIsolatedHome(async () => { + const { registerAccount, findAccountByQuery } = await loadAccountManager(); + registerAccount('kiro', 'kiro-github-ABC123.json'); + registerAccount('kiro', 'kiro-github-DEF456.json'); + return findAccountByQuery('kiro', 'github'); + }); + + expect(match).toBeNull(); + }); +}); diff --git a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts index 1b8da8a9..9ad75161 100644 --- a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts +++ b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts @@ -102,3 +102,54 @@ describe('resolvePasteCallbackAuthUrl', () => { expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key'); }); }); + +describe('getCliAuthNicknameError', () => { + it('allows omitted nicknames for no-email providers', async () => { + const { getCliAuthNicknameError } = await import( + `../../../src/cliproxy/auth/oauth-handler?cli-nickname-empty=${Date.now()}` + ); + + expect(getCliAuthNicknameError('kiro', undefined, [])).toBeNull(); + expect(getCliAuthNicknameError('ghcp', undefined, [])).toBeNull(); + }); + + it('rejects invalid supplied nicknames before OAuth starts', async () => { + const { getCliAuthNicknameError } = await import( + `../../../src/cliproxy/auth/oauth-handler?cli-nickname-invalid=${Date.now()}` + ); + + expect(getCliAuthNicknameError('kiro', 'bad nickname', [])).toBe( + 'Nickname cannot contain whitespace' + ); + }); + + it('rejects supplied nicknames that collide with existing ids or nicknames', async () => { + const { getCliAuthNicknameError } = await import( + `../../../src/cliproxy/auth/oauth-handler?cli-nickname-conflict=${Date.now()}` + ); + const existingAccounts = [ + { id: 'github-ABC123', nickname: 'work' }, + { id: 'ghcp-2', nickname: 'personal' }, + ]; + + expect(getCliAuthNicknameError('ghcp', 'github-ABC123', existingAccounts)).toBe( + 'Nickname "github-ABC123" is already in use. Choose a different one.' + ); + expect(getCliAuthNicknameError('ghcp', 'work', existingAccounts)).toBe( + 'Nickname "work" is already in use. Choose a different one.' + ); + }); + + it('allows reauth when the supplied nickname already belongs to the same account', async () => { + const { getCliAuthNicknameError } = await import( + `../../../src/cliproxy/auth/oauth-handler?cli-nickname-reauth=${Date.now()}` + ); + const existingAccounts = [ + { id: 'github-ABC123', nickname: 'work' }, + { id: 'amazon-XYZ789', nickname: 'personal' }, + ]; + + expect(getCliAuthNicknameError('kiro', 'work', existingAccounts, 'github-ABC123')).toBeNull(); + expect(getCliAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull(); + }); +}); diff --git a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts new file mode 100644 index 00000000..eb7ccb03 --- /dev/null +++ b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts @@ -0,0 +1,145 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as http from 'http'; +import type { Server } from 'http'; +import cliproxyAuthRoutes from '../../../src/web-server/routes/cliproxy-auth-routes'; +import { restoreFetch, mockFetch } from '../../mocks'; + +describe('cliproxy-auth-routes manual callback nickname persistence', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/cliproxy/auth', cliproxyAuthRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-manual-callback-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + restoreFetch(); + + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + async function postJson(route: string, body: Record) { + return await new Promise<{ status: number; body: unknown }>((resolve, reject) => { + const payload = JSON.stringify(body); + const url = new URL(`${baseUrl}${route}`); + const request = http.request( + { + method: 'POST', + hostname: url.hostname, + port: url.port, + path: url.pathname, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + }, + (response) => { + let responseBody = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + responseBody += chunk; + }); + response.on('end', () => { + resolve({ + status: response.statusCode || 0, + body: responseBody ? JSON.parse(responseBody) : null, + }); + }); + } + ); + + request.on('error', reject); + request.write(payload); + request.end(); + }); + } + + it('persists the supplied nickname for Kiro social start-url flows after callback submission', async () => { + mockFetch([ + { + url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=google$/, + response: { + auth_url: 'https://auth.example.com/authorize?state=state-123', + state: 'state-123', + }, + }, + { + url: /\/v0\/management\/oauth-callback$/, + method: 'POST', + response: { status: 'ok' }, + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/kiro/start-url', { + nickname: 'work', + kiroMethod: 'google', + }); + expect(startResponse.status).toBe(200); + + const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(tokenDir, { recursive: true }); + fs.writeFileSync( + path.join(tokenDir, 'kiro-github-ABC123.json'), + JSON.stringify({ type: 'kiro' }), + 'utf8' + ); + + const callbackResponse = await postJson('/api/cliproxy/auth/kiro/submit-callback', { + redirectUrl: 'http://localhost/callback?code=abc123&state=state-123', + }); + + expect(callbackResponse.status).toBe(200); + + const registryPath = path.join(tempHome, '.ccs', 'cliproxy', 'accounts.json'); + const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as { + providers: { + kiro: { + accounts: Record; + }; + }; + }; + + expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work'); + }); +}); diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index 9314f32c..ec2ce8d3 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { getStartAuthFailureMessage, + getStartAuthNicknameError, getStartUrlUnsupportedReason, } from '../../../src/web-server/routes/cliproxy-auth-routes'; @@ -43,3 +44,44 @@ describe('cliproxy-auth-routes start failure messaging', () => { expect(getStartAuthFailureMessage('kiro')).toBe('Authentication failed or was cancelled'); }); }); + +describe('cliproxy-auth-routes nickname validation', () => { + it('allows Kiro and GHCP start requests without a nickname', () => { + expect(getStartAuthNicknameError('kiro', undefined, [])).toBeNull(); + expect(getStartAuthNicknameError('ghcp', undefined, [])).toBeNull(); + }); + + it('rejects invalid supplied nicknames for no-email providers', () => { + expect(getStartAuthNicknameError('kiro', 'bad nickname', [])).toEqual({ + error: 'Nickname cannot contain whitespace', + code: 'INVALID_NICKNAME', + }); + }); + + it('rejects nicknames that collide with an existing account id or nickname', () => { + const existingAccounts = [ + { id: 'github-ABC123', nickname: 'work' }, + { id: 'ghcp-2', nickname: 'personal' }, + ]; + + expect(getStartAuthNicknameError('ghcp', 'github-ABC123', existingAccounts)).toEqual({ + error: 'Nickname "github-ABC123" is already in use. Choose a different one.', + code: 'NICKNAME_EXISTS', + }); + + expect(getStartAuthNicknameError('ghcp', 'work', existingAccounts)).toEqual({ + error: 'Nickname "work" is already in use. Choose a different one.', + code: 'NICKNAME_EXISTS', + }); + }); + + it('allows reauth when the nickname already belongs to the same account', () => { + const existingAccounts = [ + { id: 'github-ABC123', nickname: 'work' }, + { id: 'ghcp-2', nickname: 'personal' }, + ]; + + expect(getStartAuthNicknameError('kiro', 'work', existingAccounts, 'github-ABC123')).toBeNull(); + expect(getStartAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull(); + }); +}); From 4df08f6d99e3006107a0431609b7651f13ebc083 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 11:01:38 -0400 Subject: [PATCH 07/24] feat: make cliproxy provider nicknames optional by default --- docs/dashboard-auth-cli.md | 7 +- docs/project-roadmap.md | 4 +- docs/system-architecture/provider-flows.md | 6 + src/api/services/profile-lifecycle-service.ts | 26 +- src/api/services/profile-writer.ts | 74 +- src/auth/profile-registry.ts | 98 ++- src/cliproxy/accounts/query.ts | 8 +- src/cliproxy/accounts/registry.ts | 638 +++++++++--------- src/cliproxy/accounts/token-file-ops.ts | 9 +- src/cliproxy/ai-providers/config-store.ts | 64 +- src/cliproxy/ai-providers/service.ts | 59 +- src/cliproxy/ai-providers/types.ts | 2 + src/cliproxy/auth-token-manager.ts | 102 ++- src/cliproxy/auth/token-manager.ts | 93 ++- .../services/variant-config-adapter.ts | 89 +-- .../cliproxy/proxy-lifecycle-subcommand.ts | 12 +- .../cliproxy/resolve-lifecycle-port.ts | 11 + src/commands/config-auth/disable-command.ts | 23 +- src/commands/config-auth/setup-command.ts | 25 +- src/commands/copilot-command.ts | 26 +- src/commands/cursor-command.ts | 30 +- src/commands/setup-command.ts | 9 +- src/config/unified-config-loader.ts | 73 +- src/web-server/health-service.ts | 8 +- src/web-server/middleware/auth-middleware.ts | 29 + src/web-server/routes/ai-provider-routes.ts | 39 +- src/web-server/routes/cliproxy-auth-routes.ts | 53 +- .../routes/cliproxy-stats-routes.ts | 13 + src/web-server/routes/cliproxy-sync-routes.ts | 20 +- .../routes/cursor-settings-routes.ts | 86 ++- src/web-server/routes/misc-routes.ts | 69 +- src/web-server/routes/proxy-routes.ts | 13 + src/web-server/routes/settings-routes.ts | 307 +++++---- src/web-server/routes/websearch-routes.ts | 98 ++- src/web-server/shared-routes.ts | 13 + .../account-registry-integrity.test.ts | 102 +++ ...account-registry-optional-nickname.test.ts | 36 +- .../ai-provider-service-stable-id.test.ts | 147 ++++ .../proxy-lifecycle-subcommand.test.ts | 86 ++- .../web-server/ai-provider-routes.test.ts | 173 +++++ ...iproxy-auth-routes-manual-callback.test.ts | 43 +- ui/src/hooks/use-cliproxy-ai-providers.ts | 10 +- ui/src/hooks/use-cliproxy-auth-flow.ts | 110 ++- ui/src/lib/api-client.ts | 24 +- ui/src/pages/cliproxy-ai-providers.tsx | 6 +- .../hooks/use-cliproxy-auth-flow.test.tsx | 199 ++++++ 46 files changed, 2076 insertions(+), 1096 deletions(-) create mode 100644 src/commands/cliproxy/resolve-lifecycle-port.ts create mode 100644 tests/unit/cliproxy/account-registry-integrity.test.ts create mode 100644 tests/unit/cliproxy/ai-provider-service-stable-id.test.ts create mode 100644 tests/unit/web-server/ai-provider-routes.test.ts create mode 100644 ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index df381442..62c77709 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -1,6 +1,6 @@ # Dashboard Authentication CLI -Last Updated: 2026-03-17 +Last Updated: 2026-03-23 CLI commands for managing CCS dashboard authentication. @@ -10,6 +10,8 @@ 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. +When auth stays disabled, CCS now applies a localhost-only fallback on sensitive management endpoints. Remote devices can still open the dashboard UI when you intentionally bind it beyond loopback, but write-capable routes such as AI Provider management and CLIProxy auth/status helpers reject non-loopback requests until you enable dashboard auth. + ## Account Context Modes (Related Feature) Dashboard auth and account context metadata are separate: @@ -180,7 +182,8 @@ dashboard_auth: 1. **Bcrypt hashing**: Passwords are hashed with bcrypt (10 rounds) before storage 2. **Session cookies**: Sessions use HTTP-only cookies (not accessible via JavaScript) 3. **Rate limiting**: Login attempts are rate-limited (5 per 15 minutes) -4. **File permissions**: Config file is created with 0o600 permissions +4. **Fail-closed remote writes**: When auth is disabled, sensitive management routes allow localhost only +5. **File permissions**: Config file is created with 0o600 permissions ## Troubleshooting diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 4ca846c8..e93db791 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-03-19 +Last Updated: 2026-03-23 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,8 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-03-23**: CLIProxy providers that do not expose an email no longer require a user-supplied nickname on first auth. CCS now derives a stable internal account identifier for Kiro/Copilot-style flows, preserves later rename support, hardens account discovery/registry sync around that identifier, and updates AI Provider CRUD to use stable entry IDs instead of dashboard list indexes. +- **2026-03-23**: Sensitive dashboard management routes now fail closed to localhost-only access whenever dashboard auth is disabled. Remote access remains available after `ccs config auth setup`, but AI Provider management, CLIProxy auth/status helpers, and other write-capable settings endpoints no longer trust unauthenticated non-loopback requests. - **2026-03-19**: **#649** CCS splits CLIProxy provider-key authoring into a dedicated `CLIProxy -> AI Providers` dashboard route. `/cliproxy` now stays focused on OAuth accounts and variants, `/cliproxy/ai-providers` owns Gemini/Codex/Claude/Vertex/OpenAI-compatible key management, and `/providers` stays reserved for CCS-native API Profiles. - **2026-03-18**: **#755** Marketplace refresh no longer reuses one shared `known_marketplaces.json` across isolated instances. CCS now keeps marketplace payload directories shared while reconciling per-instance marketplace metadata so Claude Code validation succeeds for alternating or concurrent profiles, including Windows copy fallback. - **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint. diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 8ccec44c..69d9f317 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -361,6 +361,10 @@ function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null { **Providers**: GitHub Copilot (ghcp) +Provider identity note: +- Providers that do not expose a reliable email no longer require a manual nickname during first auth. +- CCS derives a stable internal account identifier from the token/cache context and still allows the user to rename the account later. + ``` +===========================================================================+ | OAuth - Device Code Flow (No Port Needed) | @@ -432,6 +436,8 @@ function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null { - Device Code method uses /start route (no callback port) - Callback/social methods use /start-url + status polling - Some management flows return state first, auth_url later + - Manual nicknames are optional when the upstream provider does not return an email + - Account storage uses a stable internal identifier so reauth/update flows do not depend on dashboard list order ``` ### API Key Profiles (GLM, Kimi) diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts index 5e91f455..a3e69a7a 100644 --- a/src/api/services/profile-lifecycle-service.ts +++ b/src/api/services/profile-lifecycle-service.ts @@ -12,11 +12,7 @@ import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-man import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import { isSensitiveKey } from '../../utils/sensitive-keys'; import { isReservedName } from '../../config/reserved-names'; -import { - isUnifiedMode, - loadOrCreateUnifiedConfig, - saveUnifiedConfig, -} from '../../config/unified-config-loader'; +import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader'; import { validateApiName } from './validation-service'; import { listApiProfiles } from './profile-reader'; import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation'; @@ -64,17 +60,17 @@ function writeJsonObjectAtomically(filePath: string, value: unknown): void { function registerApiProfileInConfig(name: string, target: TargetType, force = false): void { if (isUnifiedMode()) { - const config = loadOrCreateUnifiedConfig(); - if (config.profiles[name] && !force) { - throw new Error(`API profile already exists: ${name}`); - } + mutateUnifiedConfig((config) => { + if (config.profiles[name] && !force) { + throw new Error(`API profile already exists: ${name}`); + } - config.profiles[name] = { - type: 'api', - settings: `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`, - ...(target !== 'claude' && { target }), - }; - saveUnifiedConfig(config); + config.profiles[name] = { + type: 'api', + settings: `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`, + ...(target !== 'claude' && { target }), + }; + }); return; } diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index ae77908c..a0e230de 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -7,11 +7,7 @@ import * as path from 'path'; import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; import { validateApiName } from './validation-service'; -import { - loadOrCreateUnifiedConfig, - saveUnifiedConfig, - isUnifiedMode, -} from '../../config/unified-config-loader'; +import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import type { TargetType } from '../../targets/target-adapter'; import { resolveDroidProvider } from '../../targets/droid-provider'; @@ -198,13 +194,13 @@ function createApiProfileUnified( // Inject WebSearch hooks into profile settings ensureProfileHooks(name); - const config = loadOrCreateUnifiedConfig(); - config.profiles[name] = { - type: 'api', - settings: `~/.ccs/${settingsFile}`, - ...(target !== 'claude' && { target }), - }; - saveUnifiedConfig(config); + mutateUnifiedConfig((config) => { + config.profiles[name] = { + type: 'api', + settings: `~/.ccs/${settingsFile}`, + ...(target !== 'claude' && { target }), + }; + }); } /** Create a new API profile */ @@ -308,17 +304,17 @@ export function updateApiProfileTarget( ): UpdateApiProfileTargetResult { try { if (isUnifiedMode()) { - const config = loadOrCreateUnifiedConfig(); - if (!config.profiles[name]) { - return { success: false, error: `API profile not found: ${name}` }; - } + mutateUnifiedConfig((config) => { + if (!config.profiles[name]) { + throw new Error(`API profile not found: ${name}`); + } - if (target === 'claude') { - delete config.profiles[name].target; - } else { - config.profiles[name].target = target; - } - saveUnifiedConfig(config); + if (target === 'claude') { + delete config.profiles[name].target; + } else { + config.profiles[name].target = target; + } + }); return { success: true, target }; } @@ -357,30 +353,26 @@ export function updateApiProfileTarget( /** Remove API profile from unified config */ function removeApiProfileUnified(name: string): void { - const config = loadOrCreateUnifiedConfig(); - const profile = config.profiles[name]; + mutateUnifiedConfig((config) => { + const profile = config.profiles[name]; - if (!profile) { - throw new Error(`API profile not found: ${name}`); - } - - // Delete the settings file if it exists. - // Uses expandPath() for cross-platform path handling. - if (profile.settings) { - const settingsPath = expandPath(profile.settings); - if (fs.existsSync(settingsPath)) { - fs.unlinkSync(settingsPath); + if (!profile) { + throw new Error(`API profile not found: ${name}`); } - } - delete config.profiles[name]; + if (profile.settings) { + const settingsPath = expandPath(profile.settings); + if (fs.existsSync(settingsPath)) { + fs.unlinkSync(settingsPath); + } + } - // Clear default if it was the deleted profile - if (config.default === name) { - config.default = undefined; - } + delete config.profiles[name]; - saveUnifiedConfig(config); + if (config.default === name) { + config.default = undefined; + } + }); } /** Remove API profile from legacy config */ diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index fb47da90..a0b944b2 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { ProfileMetadata } from '../types'; import { loadOrCreateUnifiedConfig, - saveUnifiedConfig, + mutateUnifiedConfig, isUnifiedMode, } from '../config/unified-config-loader'; import type { AccountConfig } from '../config/unified-config-types'; @@ -313,74 +313,72 @@ export class ProfileRegistry { * Create account in unified config (config.yaml) */ createAccountUnified(name: string, metadata: CreateMetadata = {}): void { - const config = loadOrCreateUnifiedConfig(); - if (config.accounts[name]) { - throw new Error(`Account already exists: ${name}`); - } - config.accounts[name] = this.normalizeUnifiedAccountConfig({ - created: new Date().toISOString(), - last_used: null, - context_mode: metadata.context_mode, - context_group: metadata.context_group, - continuity_mode: metadata.continuity_mode, - bare: metadata.bare, + mutateUnifiedConfig((config) => { + if (config.accounts[name]) { + throw new Error(`Account already exists: ${name}`); + } + config.accounts[name] = this.normalizeUnifiedAccountConfig({ + created: new Date().toISOString(), + last_used: null, + context_mode: metadata.context_mode, + context_group: metadata.context_group, + continuity_mode: metadata.continuity_mode, + bare: metadata.bare, + }); }); - saveUnifiedConfig(config); } /** * Update account metadata in unified config */ updateAccountUnified(name: string, updates: Partial): void { - const config = loadOrCreateUnifiedConfig(); - if (!config.accounts[name]) { - throw new Error(`Account not found: ${name}`); - } - config.accounts[name] = this.normalizeUnifiedAccountConfig({ - ...config.accounts[name], - ...updates, + mutateUnifiedConfig((config) => { + if (!config.accounts[name]) { + throw new Error(`Account not found: ${name}`); + } + config.accounts[name] = this.normalizeUnifiedAccountConfig({ + ...config.accounts[name], + ...updates, + }); }); - saveUnifiedConfig(config); } /** * Remove account from unified config */ removeAccountUnified(name: string): void { - const config = loadOrCreateUnifiedConfig(); - if (!config.accounts[name]) { - throw new Error(`Account not found: ${name}`); - } - delete config.accounts[name]; - // Clear default if it was the deleted account - if (config.default === name) { - config.default = undefined; - } - saveUnifiedConfig(config); + mutateUnifiedConfig((config) => { + if (!config.accounts[name]) { + throw new Error(`Account not found: ${name}`); + } + delete config.accounts[name]; + if (config.default === name) { + config.default = undefined; + } + }); } /** * Set default profile in unified config */ setDefaultUnified(name: string): void { - const config = loadOrCreateUnifiedConfig(); - // Check if exists in accounts, profiles, or cliproxy variants - const exists = - config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name]; - if (!exists) { - throw new Error(`Profile not found: ${name}`); - } - config.default = name; - saveUnifiedConfig(config); + mutateUnifiedConfig((config) => { + const exists = + config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name]; + if (!exists) { + throw new Error(`Profile not found: ${name}`); + } + config.default = name; + }); } /** * Clear default profile in unified config (restore original CCS behavior) */ clearDefaultUnified(): void { - const config = loadOrCreateUnifiedConfig(); - config.default = undefined; - saveUnifiedConfig(config); + mutateUnifiedConfig((config) => { + config.default = undefined; + }); } /** @@ -418,13 +416,13 @@ export class ProfileRegistry { * Update account last_used in unified config */ touchAccountUnified(name: string): void { - const config = loadOrCreateUnifiedConfig(); - if (!config.accounts[name]) { - throw new Error(`Account not found: ${name}`); - } - config.accounts[name].last_used = new Date().toISOString(); - config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]); - saveUnifiedConfig(config); + mutateUnifiedConfig((config) => { + if (!config.accounts[name]) { + throw new Error(`Account not found: ${name}`); + } + config.accounts[name].last_used = new Date().toISOString(); + config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]); + }); } // ========================================== diff --git a/src/cliproxy/accounts/query.ts b/src/cliproxy/accounts/query.ts index c1189129..4cee1123 100644 --- a/src/cliproxy/accounts/query.ts +++ b/src/cliproxy/accounts/query.ts @@ -6,7 +6,7 @@ import { CLIProxyProvider } from '../types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { AccountInfo } from './types'; -import { loadAccountsRegistry, syncRegistryWithTokenFiles, saveAccountsRegistry } from './registry'; +import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry'; /** * Get all accounts for a provider @@ -14,10 +14,8 @@ import { loadAccountsRegistry, syncRegistryWithTokenFiles, saveAccountsRegistry export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] { const registry = loadAccountsRegistry(); - // Sync with actual token files (removes stale entries) - if (syncRegistryWithTokenFiles(registry)) { - saveAccountsRegistry(registry); - } + // Sync in-memory view with actual token files without mutating disk on read. + syncRegistryWithTokenFiles(registry); const providerAccounts = registry.providers[provider]; diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index 6287d50d..0c7228a1 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -5,9 +5,10 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; import { CLIProxyProvider } from '../types'; import { PROVIDER_TYPE_VALUES } from '../auth/auth-types'; -import { getAuthDir } from '../config-generator'; +import { getAuthDir, getCliproxyDir } from '../config-generator'; import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL } from './types'; import { getAccountsRegistryPath, @@ -30,32 +31,58 @@ function createDefaultRegistry(): AccountsRegistry { }; } -/** - * Load accounts registry - */ -export function loadAccountsRegistry(): AccountsRegistry { +function withAccountsRegistryLock(callback: () => T): T { + const lockTarget = getCliproxyDir(); + let release: (() => void) | undefined; + + if (!fs.existsSync(lockTarget)) { + fs.mkdirSync(lockTarget, { recursive: true, mode: 0o700 }); + } + + try { + release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void; + return callback(); + } finally { + if (release) { + try { + release(); + } catch { + // Best-effort release + } + } + } +} + +function readAccountsRegistryFromDisk(): AccountsRegistry { const registryPath = getAccountsRegistryPath(); if (!fs.existsSync(registryPath)) { return createDefaultRegistry(); } + const content = fs.readFileSync(registryPath, 'utf-8'); + let data: unknown; try { - const content = fs.readFileSync(registryPath, 'utf-8'); - const data = JSON.parse(content); - return { - version: data.version || 1, - providers: data.providers || {}, - }; - } catch { - return createDefaultRegistry(); + data = JSON.parse(content); + } catch (error) { + throw new Error(`Accounts registry is corrupted: ${(error as Error).message}`); } + + if (!data || typeof data !== 'object') { + throw new Error('Accounts registry is corrupted: expected object'); + } + + const parsed = data as { version?: unknown; providers?: unknown }; + return { + version: typeof parsed.version === 'number' ? parsed.version : 1, + providers: + parsed.providers && typeof parsed.providers === 'object' + ? (parsed.providers as AccountsRegistry['providers']) + : {}, + }; } -/** - * Save accounts registry - */ -export function saveAccountsRegistry(registry: AccountsRegistry): void { +function writeAccountsRegistryToDisk(registry: AccountsRegistry): void { const registryPath = getAccountsRegistryPath(); const dir = path.dirname(registryPath); @@ -63,9 +90,39 @@ export function saveAccountsRegistry(registry: AccountsRegistry): void { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } - fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', { + const tempPath = `${registryPath}.tmp.${process.pid}`; + fs.writeFileSync(tempPath, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, }); + fs.renameSync(tempPath, registryPath); +} + +function mutateAccountsRegistry(mutator: (registry: AccountsRegistry) => T): T { + return withAccountsRegistryLock(() => { + const registry = readAccountsRegistryFromDisk(); + const initialSnapshot = JSON.stringify(registry); + const result = mutator(registry); + if (JSON.stringify(registry) !== initialSnapshot) { + writeAccountsRegistryToDisk(registry); + } + return result; + }); +} + +/** + * Load accounts registry + */ +export function loadAccountsRegistry(): AccountsRegistry { + return readAccountsRegistryFromDisk(); +} + +/** + * Save accounts registry + */ +export function saveAccountsRegistry(registry: AccountsRegistry): void { + withAccountsRegistryLock(() => { + writeAccountsRegistryToDisk(registry); + }); } /** @@ -133,110 +190,103 @@ export function registerAccount( nickname?: string, projectId?: string ): AccountInfo { - const registry = loadAccountsRegistry(); + return mutateAccountsRegistry((registry) => { + syncRegistryWithTokenFiles(registry); - // Initialize provider section if needed - if (!registry.providers[provider]) { - registry.providers[provider] = { - default: 'default', - accounts: {}, - }; - } - - const providerAccounts = registry.providers[provider]; - if (!providerAccounts) { - throw new Error('Failed to initialize provider accounts'); - } - - // Determine account ID based on provider type - let accountId: string; - let accountNickname: string; - - if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { - accountId = email - ? extractAccountIdFromTokenFile(tokenFile, email) - : deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts); - const existingAccount = providerAccounts.accounts[accountId]; - - if (nickname) { - const validationError = validateNickname(nickname); - if (validationError) { - throw new Error(validationError); - } - - const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({ - id, - nickname: account.nickname, - })); - if (hasAccountNameConflict(existingAccounts, nickname, accountId)) { - throw new Error( - `An account with nickname "${nickname}" already exists for ${provider}. ` + - `Choose a different nickname.` - ); - } + if (!registry.providers[provider]) { + registry.providers[provider] = { + default: 'default', + accounts: {}, + }; } - accountNickname = - nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId); - } else { - // For other providers: use email as accountId, fallback to filename extraction - accountId = extractAccountIdFromTokenFile(tokenFile, email); - accountNickname = nickname || generateNickname(email); - } + const providerAccounts = registry.providers[provider]; + if (!providerAccounts) { + throw new Error('Failed to initialize provider accounts'); + } - const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; + let accountId: string; + let accountNickname: string; - // Create or update account - const existingAccount = providerAccounts.accounts[accountId]; - const accountMeta: Omit = { - email, - nickname: accountNickname, - tokenFile, - createdAt: existingAccount?.createdAt || new Date().toISOString(), - lastUsedAt: new Date().toISOString(), - }; + if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { + accountId = email + ? extractAccountIdFromTokenFile(tokenFile, email) + : deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts); + const existingAccount = providerAccounts.accounts[accountId]; - // Include projectId for Antigravity accounts - if (provider === 'agy' && projectId) { - accountMeta.projectId = projectId; - } + if (nickname) { + const validationError = validateNickname(nickname); + if (validationError) { + throw new Error(validationError); + } - providerAccounts.accounts[accountId] = accountMeta; + const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({ + id, + nickname: account.nickname, + })); + if (hasAccountNameConflict(existingAccounts, nickname, accountId)) { + throw new Error( + `An account with nickname "${nickname}" already exists for ${provider}. ` + + `Choose a different nickname.` + ); + } + } - // Set as default if first account - if (isFirstAccount) { - providerAccounts.default = accountId; - } + accountNickname = + nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId); + } else { + accountId = extractAccountIdFromTokenFile(tokenFile, email); + accountNickname = nickname || generateNickname(email); + } - saveAccountsRegistry(registry); + const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; + const existingAccount = providerAccounts.accounts[accountId]; + const accountMeta: Omit = { + email, + nickname: accountNickname, + tokenFile, + createdAt: existingAccount?.createdAt || new Date().toISOString(), + lastUsedAt: new Date().toISOString(), + }; - return { - id: accountId, - provider, - isDefault: accountId === providerAccounts.default, - email, - nickname: accountNickname, - tokenFile, - createdAt: providerAccounts.accounts[accountId].createdAt, - lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt, - projectId: providerAccounts.accounts[accountId].projectId, - }; + if (provider === 'agy' && projectId) { + accountMeta.projectId = projectId; + } + + providerAccounts.accounts[accountId] = accountMeta; + + if (isFirstAccount) { + providerAccounts.default = accountId; + } + + return { + id: accountId, + provider, + isDefault: accountId === providerAccounts.default, + email, + nickname: accountNickname, + tokenFile, + createdAt: providerAccounts.accounts[accountId].createdAt, + lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt, + projectId: providerAccounts.accounts[accountId].projectId, + }; + }); } /** * Set default account for a provider */ export function setDefaultAccount(provider: CLIProxyProvider, accountId: string): boolean { - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; + return mutateAccountsRegistry((registry) => { + const providerAccounts = registry.providers[provider]; - if (!providerAccounts || !providerAccounts.accounts[accountId]) { - return false; - } + if (!providerAccounts || !providerAccounts.accounts[accountId]) { + return false; + } - providerAccounts.default = accountId; - saveAccountsRegistry(registry); - return true; + providerAccounts.default = accountId; + return true; + }); } /** @@ -244,27 +294,26 @@ export function setDefaultAccount(provider: CLIProxyProvider, accountId: string) * Moves token file to paused/ subdir so CLIProxyAPI won't discover it */ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean { - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; + return mutateAccountsRegistry((registry) => { + const providerAccounts = registry.providers[provider]; - if (!providerAccounts?.accounts[accountId]) { - return false; - } + if (!providerAccounts?.accounts[accountId]) { + return false; + } - const accountMeta = providerAccounts.accounts[accountId]; + const accountMeta = providerAccounts.accounts[accountId]; + if (accountMeta.paused) { + return true; + } - // Skip if already paused (idempotent) - if (accountMeta.paused) { + if (!moveTokenToPaused(accountMeta.tokenFile)) { + return false; + } + + providerAccounts.accounts[accountId].paused = true; + providerAccounts.accounts[accountId].pausedAt = new Date().toISOString(); return true; - } - - // Move token file to paused directory (if it exists in auth dir) - moveTokenToPaused(accountMeta.tokenFile); - - providerAccounts.accounts[accountId].paused = true; - providerAccounts.accounts[accountId].pausedAt = new Date().toISOString(); - saveAccountsRegistry(registry); - return true; + }); } /** @@ -272,55 +321,53 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo * Moves token file back from paused/ to auth/ so CLIProxyAPI can discover it */ export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean { - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; + return mutateAccountsRegistry((registry) => { + const providerAccounts = registry.providers[provider]; - if (!providerAccounts?.accounts[accountId]) { - return false; - } + if (!providerAccounts?.accounts[accountId]) { + return false; + } - const accountMeta = providerAccounts.accounts[accountId]; + const accountMeta = providerAccounts.accounts[accountId]; + if (!accountMeta.paused) { + return true; + } - // Skip if already active (idempotent) - if (!accountMeta.paused) { + if (!moveTokenFromPaused(accountMeta.tokenFile)) { + return false; + } + + providerAccounts.accounts[accountId].paused = false; + providerAccounts.accounts[accountId].pausedAt = undefined; return true; - } - - // Move token file back from paused directory (if it exists in paused dir) - moveTokenFromPaused(accountMeta.tokenFile); - - providerAccounts.accounts[accountId].paused = false; - providerAccounts.accounts[accountId].pausedAt = undefined; - saveAccountsRegistry(registry); - return true; + }); } /** * Remove an account */ export function removeAccount(provider: CLIProxyProvider, accountId: string): boolean { - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; + return mutateAccountsRegistry((registry) => { + const providerAccounts = registry.providers[provider]; - if (!providerAccounts || !providerAccounts.accounts[accountId]) { - return false; - } + if (!providerAccounts || !providerAccounts.accounts[accountId]) { + return false; + } - // Delete token file from both auth and paused directories - const tokenFile = providerAccounts.accounts[accountId].tokenFile; - deleteTokenFile(tokenFile); + const tokenFile = providerAccounts.accounts[accountId].tokenFile; + if (!deleteTokenFile(tokenFile)) { + return false; + } - // Remove from registry - delete providerAccounts.accounts[accountId]; + delete providerAccounts.accounts[accountId]; - // Update default if needed - const remainingAccounts = Object.keys(providerAccounts.accounts); - if (providerAccounts.default === accountId && remainingAccounts.length > 0) { - providerAccounts.default = remainingAccounts[0]; - } + const remainingAccounts = Object.keys(providerAccounts.accounts); + if (providerAccounts.default === accountId && remainingAccounts.length > 0) { + providerAccounts.default = remainingAccounts[0]; + } - saveAccountsRegistry(registry); - return true; + return true; + }); } /** @@ -336,37 +383,36 @@ export function renameAccount( throw new Error(validationError); } - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; + return mutateAccountsRegistry((registry) => { + const providerAccounts = registry.providers[provider]; - if (!providerAccounts?.accounts[accountId]) { - return false; - } + if (!providerAccounts?.accounts[accountId]) { + return false; + } - const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({ - id, - nickname: account.nickname, - })); - if (hasAccountNameConflict(existingAccounts, newNickname, accountId)) { - throw new Error(`Nickname "${newNickname}" is already used by another account`); - } + const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({ + id, + nickname: account.nickname, + })); + if (hasAccountNameConflict(existingAccounts, newNickname, accountId)) { + throw new Error(`Nickname "${newNickname}" is already used by another account`); + } - providerAccounts.accounts[accountId].nickname = newNickname; - saveAccountsRegistry(registry); - return true; + providerAccounts.accounts[accountId].nickname = newNickname; + return true; + }); } /** * Update last used timestamp for an account */ export function touchAccount(provider: CLIProxyProvider, accountId: string): void { - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; - - if (providerAccounts?.accounts[accountId]) { - providerAccounts.accounts[accountId].lastUsedAt = new Date().toISOString(); - saveAccountsRegistry(registry); - } + mutateAccountsRegistry((registry) => { + const providerAccounts = registry.providers[provider]; + if (providerAccounts?.accounts[accountId]) { + providerAccounts.accounts[accountId].lastUsedAt = new Date().toISOString(); + } + }); } /** @@ -377,16 +423,16 @@ export function setAccountTier( accountId: string, tier: 'free' | 'pro' | 'ultra' | 'unknown' ): boolean { - const registry = loadAccountsRegistry(); - const providerAccounts = registry.providers[provider]; + return mutateAccountsRegistry((registry) => { + const providerAccounts = registry.providers[provider]; - if (!providerAccounts?.accounts[accountId]) { - return false; - } + if (!providerAccounts?.accounts[accountId]) { + return false; + } - providerAccounts.accounts[accountId].tier = tier; - saveAccountsRegistry(registry); - return true; + providerAccounts.accounts[accountId].tier = tier; + return true; + }); } /** @@ -404,163 +450,103 @@ export function discoverExistingAccounts(): void { return; } - const registry = loadAccountsRegistry(); const files = fs.readdirSync(authDir); + mutateAccountsRegistry((registry) => { + syncRegistryWithTokenFiles(registry); - // Track whether any accounts were discovered (to avoid saving empty registry) - let discoveredCount = 0; + for (const file of files) { + if (!file.endsWith('.json')) continue; - for (const file of files) { - if (!file.endsWith('.json')) continue; + const filePath = path.join(authDir, file); - const filePath = path.join(authDir, file); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content); + if (!data.type) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const data = JSON.parse(content); - - // Skip if no type field - if (!data.type) continue; - - // Build reverse mapping from PROVIDER_TYPE_VALUES (type value -> provider) - // e.g., "antigravity" -> "agy", "kiro" -> "kiro", "codewhisperer" -> "kiro" - const typeValue = data.type.toLowerCase(); - let provider: CLIProxyProvider | undefined; - for (const [prov, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) { - if (typeValues.includes(typeValue)) { - provider = prov as CLIProxyProvider; - break; + const typeValue = data.type.toLowerCase(); + let provider: CLIProxyProvider | undefined; + for (const [prov, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) { + if (typeValues.includes(typeValue)) { + provider = prov as CLIProxyProvider; + break; + } } - } - // Skip if unknown provider type - if (!provider) { - continue; - } - - // Extract email if available, fallback to filename-based ID - let email = data.email || undefined; - - // Fallback: extract email from filename (e.g., "kiro-google-user@example.com.json") - if (!email && file.includes('@')) { - const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/); - if (match) { - email = match[1]; + if (!provider) { + continue; } - } - // Initialize provider section if needed - if (!registry.providers[provider]) { - registry.providers[provider] = { - default: 'default', - accounts: {}, + let email = data.email || undefined; + if (!email && file.includes('@')) { + const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/); + if (match) { + email = match[1]; + } + } + + if (!registry.providers[provider]) { + registry.providers[provider] = { + default: 'default', + accounts: {}, + }; + } + + const providerAccounts = registry.providers[provider]; + if (!providerAccounts) continue; + + const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile); + if (existingTokenFiles.includes(file)) { + const projectIdValue = + typeof data.project_id === 'string' && data.project_id.trim() + ? data.project_id.trim() + : null; + if (provider === 'agy' && projectIdValue) { + const existingEntry = Object.entries(providerAccounts.accounts).find( + ([, meta]) => meta.tokenFile === file + ); + if (existingEntry && existingEntry[1].projectId !== projectIdValue) { + existingEntry[1].projectId = projectIdValue; + } + } + continue; + } + + const accountId = + PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email + ? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts) + : extractAccountIdFromTokenFile(file, email); + + if (providerAccounts.accounts[accountId]) { + continue; + } + + if (Object.keys(providerAccounts.accounts).length === 0) { + providerAccounts.default = accountId; + } + + const stats = fs.statSync(filePath); + const lastModified = stats.mtime || stats.birthtime || new Date(); + const accountMeta: Omit = { + email, + nickname: email ? generateNickname(email) : accountId, + tokenFile: file, + createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), + lastUsedAt: lastModified.toISOString(), }; - } - const providerAccounts = registry.providers[provider]; - if (!providerAccounts) continue; - - // Skip if token file already registered (under any accountId) - const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile); - if (existingTokenFiles.includes(file)) { - // Token file exists - check if we need to update projectId for agy accounts - const projectIdValue = + const discoveredProjectId = typeof data.project_id === 'string' && data.project_id.trim() ? data.project_id.trim() : null; - if (provider === 'agy' && projectIdValue) { - const existingEntry = Object.entries(providerAccounts.accounts).find( - ([, meta]) => meta.tokenFile === file - ); - // Update if missing or changed - if (existingEntry && existingEntry[1].projectId !== projectIdValue) { - existingEntry[1].projectId = projectIdValue; - discoveredCount++; // Count projectId updates as changes - } + if (provider === 'agy' && discoveredProjectId) { + accountMeta.projectId = discoveredProjectId; } + + providerAccounts.accounts[accountId] = accountMeta; + } catch { continue; } - - const accountId = - PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email - ? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts) - : extractAccountIdFromTokenFile(file, email); - - // Skip if account already registered - if (providerAccounts.accounts[accountId]) { - continue; - } - - // Set as default if first account - if (Object.keys(providerAccounts.accounts).length === 0) { - providerAccounts.default = accountId; - } - - // Get file stats for creation time - const stats = fs.statSync(filePath); - - // Register account with auto-generated nickname - // Use mtime as lastUsedAt (when token was last modified = last auth/refresh) - const lastModified = stats.mtime || stats.birthtime || new Date(); - const accountMeta: Omit = { - email, - nickname: email ? generateNickname(email) : accountId, - tokenFile: file, - createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), - lastUsedAt: lastModified.toISOString(), - }; - - // Read project_id for Antigravity accounts (read-only field from auth token) - const discoveredProjectId = - typeof data.project_id === 'string' && data.project_id.trim() - ? data.project_id.trim() - : null; - if (provider === 'agy' && discoveredProjectId) { - accountMeta.projectId = discoveredProjectId; - } - - providerAccounts.accounts[accountId] = accountMeta; - discoveredCount++; - } catch { - // Skip invalid files - continue; } - } - - // Only save if at least one account was discovered or updated - // This prevents creating accounts.json with empty provider sections - if (discoveredCount === 0) { - return; - } - - // Reload-merge pattern: reduce race condition with concurrent OAuth registration - // Reload fresh registry and merge discovered accounts (fresh registry wins on conflicts) - const freshRegistry = loadAccountsRegistry(); - for (const [providerName, discovered] of Object.entries(registry.providers)) { - if (!discovered) continue; - // Skip empty provider sections (no accounts discovered) - if (Object.keys(discovered.accounts).length === 0) continue; - - const prov = providerName as CLIProxyProvider; - if (!freshRegistry.providers[prov]) { - freshRegistry.providers[prov] = discovered; - } else { - // Merge accounts, preferring fresh registry's existing entries but updating projectId - const freshProviderAccounts = freshRegistry.providers[prov]; - if (!freshProviderAccounts) continue; - for (const [id, meta] of Object.entries(discovered.accounts)) { - if (!freshProviderAccounts.accounts[id]) { - freshProviderAccounts.accounts[id] = meta; - // Set default if none exists - if (!freshProviderAccounts.default || freshProviderAccounts.default === 'default') { - freshProviderAccounts.default = id; - } - } else if (meta.projectId && !freshProviderAccounts.accounts[id].projectId) { - // Update existing account with projectId if discovered from auth file - freshProviderAccounts.accounts[id].projectId = meta.projectId; - } - } - } - } - saveAccountsRegistry(freshRegistry); + }); } diff --git a/src/cliproxy/accounts/token-file-ops.ts b/src/cliproxy/accounts/token-file-ops.ts index 2f35d95d..74b51e85 100644 --- a/src/cliproxy/accounts/token-file-ops.ts +++ b/src/cliproxy/accounts/token-file-ops.ts @@ -93,16 +93,17 @@ export function moveTokenFromPaused(tokenFile: string): boolean { * Delete token file from both auth and paused directories * Idempotent */ -export function deleteTokenFile(tokenFile: string): void { +export function deleteTokenFile(tokenFile: string): boolean { const tokenPath = path.join(getAuthDir(), tokenFile); const pausedPath = path.join(getPausedDir(), tokenFile); + let success = true; // Delete from auth directory if (fs.existsSync(tokenPath)) { try { fs.unlinkSync(tokenPath); } catch { - // Ignore deletion errors + success = false; } } @@ -111,9 +112,11 @@ export function deleteTokenFile(tokenFile: string): void { try { fs.unlinkSync(pausedPath); } catch { - // Ignore deletion errors + success = false; } } + + return success; } /** diff --git a/src/cliproxy/ai-providers/config-store.ts b/src/cliproxy/ai-providers/config-store.ts index 2cc665f4..e1d1f79d 100644 --- a/src/cliproxy/ai-providers/config-store.ts +++ b/src/cliproxy/ai-providers/config-store.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as yaml from 'js-yaml'; import { configExists, getCliproxyConfigPath, regenerateConfig } from '../config-generator'; @@ -21,6 +22,41 @@ type FamilyEntriesMap = { export type FamilyEntries = FamilyEntriesMap[F]; +type EntryWithOptionalId = { + id?: string; +}; + +function createFamilyEntryId(family: AiProviderFamilyId): string { + return `${family}-${randomUUID()}`; +} + +function ensureStableEntryIds( + family: F, + entries: FamilyEntries +): { entries: FamilyEntries; changed: boolean } { + const seenIds = new Set(); + let changed = false; + + const normalizedEntries = entries.map((entry) => { + const rawId = (entry as EntryWithOptionalId).id; + const normalizedId = typeof rawId === 'string' && rawId.trim().length > 0 ? rawId.trim() : null; + const nextId = + normalizedId && !seenIds.has(normalizedId) ? normalizedId : createFamilyEntryId(family); + + if (normalizedId !== nextId) { + changed = true; + } + + seenIds.add(nextId); + return { + ...entry, + id: nextId, + }; + }) as FamilyEntries; + + return { entries: normalizedEntries, changed }; +} + function ensureLocalConfigPath(): string { if (!configExists()) { regenerateConfig(); @@ -91,7 +127,12 @@ export async function readFamilyEntries( if (!target.isRemote) { const config = readLocalConfig(); - return (config[family] || []) as FamilyEntries; + const entries = (Array.isArray(config[family]) ? config[family] : []) as FamilyEntries; + const normalized = ensureStableEntryIds(family, entries); + if (normalized.changed) { + writeLocalFamilySection(family, normalized.entries); + } + return normalized.entries; } const client = createManagementClient({ @@ -102,17 +143,29 @@ export async function readFamilyEntries( auth_token: target.authToken, }); - return client.getSection[number]>(family) as Promise>; + const entries = (await client.getSection[number]>(family)) as FamilyEntries; + const normalized = ensureStableEntryIds( + family, + Array.isArray(entries) ? entries : ([] as unknown as FamilyEntries) + ); + if (normalized.changed) { + await client.putSection[number]>( + family, + normalized.entries as FamilyEntries[number][] + ); + } + return normalized.entries; } export async function writeFamilyEntries( family: F, entries: FamilyEntries ): Promise { + const normalized = ensureStableEntryIds(family, entries); const target = getProxyTarget(); if (!target.isRemote) { - writeLocalFamilySection(family, entries); + writeLocalFamilySection(family, normalized.entries); return; } @@ -124,5 +177,8 @@ export async function writeFamilyEntries( auth_token: target.authToken, }); - await client.putSection[number]>(family, entries as FamilyEntries[number][]); + await client.putSection[number]>( + family, + normalized.entries as FamilyEntries[number][] + ); } diff --git a/src/cliproxy/ai-providers/service.ts b/src/cliproxy/ai-providers/service.ts index a03f959a..12a5e496 100644 --- a/src/cliproxy/ai-providers/service.ts +++ b/src/cliproxy/ai-providers/service.ts @@ -51,7 +51,7 @@ function buildApiKeyEntryView( index: number ): AiProviderEntryView { return { - id: `${family}:${index}`, + id: entry.id || `${family}:${index}`, index, label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`, baseUrl: entry['base-url']?.trim() || undefined, @@ -67,7 +67,7 @@ function buildApiKeyEntryView( function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): AiProviderEntryView { return { - id: `openai-compatibility:${index}`, + id: entry.id || `openai-compatibility:${index}`, index, name: entry.name, label: entry.name, @@ -131,6 +131,7 @@ function toApiKeyEntry( : existing?.['api-key'] || ''; return { + id: existing?.id, 'api-key': nextSecret, 'base-url': input.baseUrl?.trim() || undefined, 'proxy-url': input.proxyUrl?.trim() || undefined, @@ -155,6 +156,7 @@ function toOpenAiCompatEntry( : (existing?.['api-key-entries'] || []).map((entry) => entry['api-key']); return { + id: existing?.id, name: input.name?.trim() || existing?.name || 'connector', 'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '', headers: normalizeHeaders(input.headers), @@ -163,8 +165,41 @@ function toOpenAiCompatEntry( }; } -function assertIndex(entries: unknown[], index: number): void { - if (!Number.isInteger(index) || index < 0 || index >= entries.length) { +function resolveEntryIndex(entries: Array<{ id?: string }>, entryId: string): number { + const normalizedEntryId = entryId.trim(); + const matchedIndex = entries.findIndex((entry) => entry.id === normalizedEntryId); + if (matchedIndex !== -1) { + return matchedIndex; + } + + const legacyIndex = Number.parseInt(normalizedEntryId, 10); + if ( + Number.isInteger(legacyIndex) && + legacyIndex >= 0 && + legacyIndex < entries.length && + String(legacyIndex) === normalizedEntryId + ) { + return legacyIndex; + } + + if (normalizedEntryId.startsWith('openai-compatibility:') || normalizedEntryId.includes(':')) { + const legacySuffix = normalizedEntryId.split(':').at(-1) || ''; + const legacySuffixIndex = Number.parseInt(legacySuffix, 10); + if ( + Number.isInteger(legacySuffixIndex) && + legacySuffixIndex >= 0 && + legacySuffixIndex < entries.length && + String(legacySuffixIndex) === legacySuffix + ) { + return legacySuffixIndex; + } + } + + throw new Error('Entry not found'); +} + +function assertEntryId(entryId: string): void { + if (!entryId.trim()) { throw new Error('Entry not found'); } } @@ -208,12 +243,14 @@ export async function createAiProviderEntry( export async function updateAiProviderEntry( family: AiProviderFamilyId, - index: number, + entryId: string, input: UpsertAiProviderEntryInput ): Promise { + assertEntryId(entryId); + if (family === 'openai-compatibility') { const entries = await readFamilyEntries(family); - assertIndex(entries, index); + const index = resolveEntryIndex(entries, entryId); validateFamilyInput(family, input); entries[index] = toOpenAiCompatEntry(input, entries[index]); await writeFamilyEntries(family, entries); @@ -221,7 +258,7 @@ export async function updateAiProviderEntry( } const entries = await readFamilyEntries(family); - assertIndex(entries, index); + const index = resolveEntryIndex(entries, entryId); validateFamilyInput(family, input); entries[index] = toApiKeyEntry(input, entries[index]); await writeFamilyEntries(family, entries); @@ -229,18 +266,20 @@ export async function updateAiProviderEntry( export async function deleteAiProviderEntry( family: AiProviderFamilyId, - index: number + entryId: string ): Promise { + assertEntryId(entryId); + if (family === 'openai-compatibility') { const entries = await readFamilyEntries(family); - assertIndex(entries, index); + const index = resolveEntryIndex(entries, entryId); entries.splice(index, 1); await writeFamilyEntries(family, entries); return; } const entries = await readFamilyEntries(family); - assertIndex(entries, index); + const index = resolveEntryIndex(entries, entryId); entries.splice(index, 1); await writeFamilyEntries(family, entries); } diff --git a/src/cliproxy/ai-providers/types.ts b/src/cliproxy/ai-providers/types.ts index 3fe9cff6..a6d0bf7e 100644 --- a/src/cliproxy/ai-providers/types.ts +++ b/src/cliproxy/ai-providers/types.ts @@ -14,6 +14,7 @@ export interface AiProviderModelAlias { } export interface AiProviderApiKeyEntry { + id?: string; 'api-key': string; 'base-url'?: string; 'proxy-url'?: string; @@ -29,6 +30,7 @@ export interface OpenAICompatApiKeyEntry { } export interface OpenAICompatEntry { + id?: string; name: string; 'base-url': string; headers?: Record; diff --git a/src/cliproxy/auth-token-manager.ts b/src/cliproxy/auth-token-manager.ts index 4daebb13..ead0ce99 100644 --- a/src/cliproxy/auth-token-manager.ts +++ b/src/cliproxy/auth-token-manager.ts @@ -8,7 +8,7 @@ */ import { randomBytes } from 'crypto'; -import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../config/unified-config-loader'; import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from './config-generator'; /** @@ -87,19 +87,17 @@ export function getEffectiveManagementSecret(): string { * @param apiKey - New API key (or undefined to reset to default) */ export function setGlobalApiKey(apiKey: string | undefined): void { - const config = loadOrCreateUnifiedConfig(); + mutateUnifiedConfig((config) => { + if (!config.cliproxy.auth) { + config.cliproxy.auth = {}; + } - if (!config.cliproxy.auth) { - config.cliproxy.auth = {}; - } - - if (apiKey === undefined) { - delete config.cliproxy.auth.api_key; - } else { - config.cliproxy.auth.api_key = apiKey; - } - - saveUnifiedConfig(config); + if (apiKey === undefined) { + delete config.cliproxy.auth.api_key; + } else { + config.cliproxy.auth.api_key = apiKey; + } + }); } /** @@ -109,19 +107,17 @@ export function setGlobalApiKey(apiKey: string | undefined): void { * @param secret - New management secret (or undefined to reset to default) */ export function setGlobalManagementSecret(secret: string | undefined): void { - const config = loadOrCreateUnifiedConfig(); + mutateUnifiedConfig((config) => { + if (!config.cliproxy.auth) { + config.cliproxy.auth = {}; + } - if (!config.cliproxy.auth) { - config.cliproxy.auth = {}; - } - - if (secret === undefined) { - delete config.cliproxy.auth.management_secret; - } else { - config.cliproxy.auth.management_secret = secret; - } - - saveUnifiedConfig(config); + if (secret === undefined) { + delete config.cliproxy.auth.management_secret; + } else { + config.cliproxy.auth.management_secret = secret; + } + }); } /** @@ -132,28 +128,26 @@ export function setGlobalManagementSecret(secret: string | undefined): void { * @param apiKey - New API key (or undefined to remove override) */ export function setVariantApiKey(variantName: string, apiKey: string | undefined): void { - const config = loadOrCreateUnifiedConfig(); - const variant = config.cliproxy.variants[variantName]; + mutateUnifiedConfig((config) => { + const variant = config.cliproxy.variants[variantName]; - if (!variant) { - throw new Error(`Variant '${variantName}' not found`); - } - - if (!variant.auth) { - variant.auth = {}; - } - - if (apiKey === undefined) { - delete variant.auth.api_key; - // Clean up empty auth object - if (Object.keys(variant.auth).length === 0) { - delete variant.auth; + if (!variant) { + throw new Error(`Variant '${variantName}' not found`); } - } else { - variant.auth.api_key = apiKey; - } - saveUnifiedConfig(config); + if (!variant.auth) { + variant.auth = {}; + } + + if (apiKey === undefined) { + delete variant.auth.api_key; + if (Object.keys(variant.auth).length === 0) { + delete variant.auth; + } + } else { + variant.auth.api_key = apiKey; + } + }); } /** @@ -161,20 +155,16 @@ export function setVariantApiKey(variantName: string, apiKey: string | undefined * Removes cliproxy.auth and all variant auth overrides. */ export function resetAuthToDefaults(): void { - const config = loadOrCreateUnifiedConfig(); + mutateUnifiedConfig((config) => { + delete config.cliproxy.auth; - // Remove global auth - delete config.cliproxy.auth; - - // Remove all variant auth overrides - for (const variantName of Object.keys(config.cliproxy.variants)) { - const variant = config.cliproxy.variants[variantName]; - if (variant.auth) { - delete variant.auth; + for (const variantName of Object.keys(config.cliproxy.variants)) { + const variant = config.cliproxy.variants[variantName]; + if (variant.auth) { + delete variant.auth; + } } - } - - saveUnifiedConfig(config); + }); } /** diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 7ed68a36..788ebe73 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -11,7 +11,7 @@ import { CLIProxyProvider } from '../types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { getProviderAuthDir } from '../config-generator'; import { getProviderAccounts, getDefaultAccount } from '../account-manager'; -import { deleteTokenFile } from '../accounts/token-file-ops'; +import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops'; import { AuthStatus, PROVIDER_AUTH_PREFIXES, @@ -206,40 +206,78 @@ export function registerAccountFromToken( verbose = false, expectedAccountId?: string ): import('../account-manager').AccountInfo | null { + type TokenCandidate = { + file: string; + filePath: string; + email?: string; + projectId?: string; + accountId: string; + mtimeMs: number; + alreadyRegistered: boolean; + }; + const { registerAccount } = require('../account-manager'); - let newestFile: string | null = null; + let selectedCandidate: Omit | null = null; try { const files = fs.readdirSync(tokenDir); const jsonFiles = files.filter((f: string) => f.endsWith('.json')); + const existingAccounts = getProviderAccounts(provider); + const candidates: TokenCandidate[] = jsonFiles + .map((file): TokenCandidate | null => { + const filePath = path.join(tokenDir, file); + if (!isTokenFileForProvider(filePath, provider)) return null; - let newestMtime = 0; + const content = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content) as { email?: string; project_id?: string }; + const email = data.email || undefined; + const projectId = data.project_id || undefined; + const accountId = extractAccountIdFromTokenFile(file, email); + const stats = fs.statSync(filePath); + return { + file, + filePath, + email, + projectId, + accountId, + mtimeMs: stats.mtimeMs, + alreadyRegistered: existingAccounts.some((account) => account.tokenFile === file), + }; + }) + .filter((candidate): candidate is TokenCandidate => candidate !== null) + .sort((a, b) => b.mtimeMs - a.mtimeMs); - for (const file of jsonFiles) { - const filePath = path.join(tokenDir, file); - if (!isTokenFileForProvider(filePath, provider)) continue; - - const stats = fs.statSync(filePath); - if (stats.mtimeMs > newestMtime) { - newestMtime = stats.mtimeMs; - newestFile = file; - } + if (expectedAccountId) { + selectedCandidate = + candidates.find((candidate) => candidate.accountId === expectedAccountId) || + candidates.find((candidate) => { + const existingAccount = existingAccounts.find( + (account) => account.id === expectedAccountId + ); + return !!existingAccount && existingAccount.tokenFile === candidate.file; + }) || + null; + } else { + selectedCandidate = candidates[0] || null; } - if (!newestFile) { + if (!selectedCandidate) { + if (verbose && expectedAccountId) { + console.error( + `[auth] No token matched the expected account ${expectedAccountId}; refusing ambiguous registration` + ); + } return null; } - const tokenPath = path.join(tokenDir, newestFile); - const content = fs.readFileSync(tokenPath, 'utf-8'); - const data = JSON.parse(content); - const email = data.email || undefined; - const projectId = data.project_id || undefined; - - const account = registerAccount(provider, newestFile, email, nickname, projectId); - - // Upload token to remote server if configured (async, don't block) - uploadTokenToRemoteAsync(tokenPath, verbose); + const account = registerAccount( + provider, + selectedCandidate.file, + selectedCandidate.email, + nickname, + selectedCandidate.projectId + ); + uploadTokenToRemoteAsync(selectedCandidate.filePath, verbose); return account; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -247,13 +285,8 @@ export function registerAccountFromToken( console.error(`[auth] Failed to register token-backed account: ${message}`); } - if (typeof newestFile === 'string') { - const hasExistingRegistration = getProviderAccounts(provider).some( - (account) => account.tokenFile === newestFile - ); - if (!hasExistingRegistration) { - deleteTokenFile(newestFile); - } + if (selectedCandidate && !selectedCandidate.alreadyRegistered && !expectedAccountId) { + deleteTokenFile(selectedCandidate.file); } if (expectedAccountId && verbose) { diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts index e8179b51..2f4814f4 100644 --- a/src/cliproxy/services/variant-config-adapter.ts +++ b/src/cliproxy/services/variant-config-adapter.ts @@ -10,7 +10,7 @@ import { } from '../../config/unified-config-types'; import { loadOrCreateUnifiedConfig, - saveUnifiedConfig, + mutateUnifiedConfig, isUnifiedMode, } from '../../config/unified-config-loader'; import { CLIPROXY_DEFAULT_PORT } from '../config-generator'; @@ -171,21 +171,20 @@ export function listVariantsFromConfig(): Record { } export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void { - const unifiedConfig = loadOrCreateUnifiedConfig(); + mutateUnifiedConfig((unifiedConfig) => { + if (!unifiedConfig.cliproxy) { + unifiedConfig.cliproxy = { + oauth_accounts: {}, + providers: [...CLIPROXY_SUPPORTED_PROVIDERS], + variants: {}, + }; + } + if (!unifiedConfig.cliproxy.variants) { + unifiedConfig.cliproxy.variants = {}; + } - if (!unifiedConfig.cliproxy) { - unifiedConfig.cliproxy = { - oauth_accounts: {}, - providers: [...CLIPROXY_SUPPORTED_PROVIDERS], - variants: {}, - }; - } - if (!unifiedConfig.cliproxy.variants) { - unifiedConfig.cliproxy.variants = {}; - } - - unifiedConfig.cliproxy.variants[name] = config; - saveUnifiedConfig(unifiedConfig); + unifiedConfig.cliproxy.variants[name] = config; + }); } export function saveVariantUnified( @@ -196,28 +195,26 @@ export function saveVariantUnified( port?: number, target: TargetType = 'claude' ): void { - const config = loadOrCreateUnifiedConfig(); + mutateUnifiedConfig((config) => { + if (!config.cliproxy) { + config.cliproxy = { + oauth_accounts: {}, + providers: [...CLIPROXY_SUPPORTED_PROVIDERS], + variants: {}, + }; + } + if (!config.cliproxy.variants) { + config.cliproxy.variants = {}; + } - if (!config.cliproxy) { - config.cliproxy = { - oauth_accounts: {}, - providers: [...CLIPROXY_SUPPORTED_PROVIDERS], - variants: {}, + config.cliproxy.variants[name] = { + provider, + account, + settings: settingsPath, + port, + ...(target !== 'claude' && { target }), }; - } - if (!config.cliproxy.variants) { - config.cliproxy.variants = {}; - } - - config.cliproxy.variants[name] = { - provider, - account, - settings: settingsPath, - port, - ...(target !== 'claude' && { target }), - }; - - saveUnifiedConfig(config); + }); } export function saveVariantLegacy( @@ -268,18 +265,22 @@ export function saveVariantLegacy( } export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null { - const config = loadOrCreateUnifiedConfig(); + let removedVariant: CLIProxyVariantConfig | CompositeVariantConfig | null = null; + mutateUnifiedConfig((config) => { + if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) { + return; + } - if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) { + removedVariant = config.cliproxy.variants[name]; + delete config.cliproxy.variants[name]; + }); + + if (!removedVariant) { return null; } - const variant = config.cliproxy.variants[name]; - delete config.cliproxy.variants[name]; - saveUnifiedConfig(config); - - if ('type' in variant && variant.type === 'composite') { - const composite = variant as CompositeVariantConfig; + const composite = removedVariant as CompositeVariantConfig; + if (composite.type === 'composite') { return { provider: composite.tiers[composite.default_tier].provider, settings: composite.settings, @@ -290,7 +291,7 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu tiers: composite.tiers, }; } - const singleVariant = variant as CLIProxyVariantConfig; + const singleVariant = removedVariant as CLIProxyVariantConfig; return { provider: singleVariant.provider, settings: singleVariant.settings, diff --git a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts index 1dad5b5a..1d289475 100644 --- a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts +++ b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts @@ -11,17 +11,7 @@ import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui'; import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services'; import { detectRunningProxy } from '../../cliproxy/proxy-detector'; -import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; - -/** - * Resolve the local CLIProxy lifecycle port from unified config. - * Falls back to default port when unset/invalid. - */ -export function resolveLifecyclePort(): number { - const config = loadOrCreateUnifiedConfig(); - return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); -} +import { resolveLifecyclePort } from './resolve-lifecycle-port'; export async function handleStart(verbose = false): Promise { await initUI(); diff --git a/src/commands/cliproxy/resolve-lifecycle-port.ts b/src/commands/cliproxy/resolve-lifecycle-port.ts new file mode 100644 index 00000000..85e0b33c --- /dev/null +++ b/src/commands/cliproxy/resolve-lifecycle-port.ts @@ -0,0 +1,11 @@ +import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + +/** + * Resolve the local CLIProxy lifecycle port from unified config. + * Falls back to default port when unset/invalid. + */ +export function resolveLifecyclePort(): number { + const config = loadOrCreateUnifiedConfig(); + return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); +} diff --git a/src/commands/config-auth/disable-command.ts b/src/commands/config-auth/disable-command.ts index fd66cb46..d6efb0c2 100644 --- a/src/commands/config-auth/disable-command.ts +++ b/src/commands/config-auth/disable-command.ts @@ -5,11 +5,7 @@ */ import { InteractivePrompt } from '../../utils/prompt'; -import { - getDashboardAuthConfig, - loadOrCreateUnifiedConfig, - saveUnifiedConfig, -} from '../../config/unified-config-loader'; +import { getDashboardAuthConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; import { initUI, header, ok, info, warn, dim } from '../../utils/ui'; /** @@ -57,15 +53,14 @@ export async function handleDisable(): Promise { } // Disable auth - const fullConfig = loadOrCreateUnifiedConfig(); - fullConfig.dashboard_auth = { - enabled: false, - username: fullConfig.dashboard_auth?.username ?? '', - password_hash: fullConfig.dashboard_auth?.password_hash ?? '', - session_timeout_hours: fullConfig.dashboard_auth?.session_timeout_hours ?? 24, - }; - - saveUnifiedConfig(fullConfig); + mutateUnifiedConfig((fullConfig) => { + fullConfig.dashboard_auth = { + enabled: false, + username: fullConfig.dashboard_auth?.username ?? '', + password_hash: fullConfig.dashboard_auth?.password_hash ?? '', + session_timeout_hours: fullConfig.dashboard_auth?.session_timeout_hours ?? 24, + }; + }); console.log(''); console.log(ok('Dashboard authentication disabled')); diff --git a/src/commands/config-auth/setup-command.ts b/src/commands/config-auth/setup-command.ts index de40c32d..75df5d39 100644 --- a/src/commands/config-auth/setup-command.ts +++ b/src/commands/config-auth/setup-command.ts @@ -8,7 +8,7 @@ import bcrypt from 'bcrypt'; import { InteractivePrompt } from '../../utils/prompt'; -import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; +import { mutateUnifiedConfig } from '../../config/unified-config-loader'; import { initUI, header, subheader, ok, fail, info, warn, dim } from '../../utils/ui'; import type { AuthSetupResult } from './types'; @@ -99,24 +99,23 @@ export async function handleSetup(): Promise { // Hash password const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); - // Load existing config and update - const config = loadOrCreateUnifiedConfig(); - config.dashboard_auth = { - enabled: true, - username, - password_hash: passwordHash, - session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24, - }; - - // Save config - saveUnifiedConfig(config); + const config = mutateUnifiedConfig((currentConfig) => { + currentConfig.dashboard_auth = { + enabled: true, + username, + password_hash: passwordHash, + session_timeout_hours: currentConfig.dashboard_auth?.session_timeout_hours ?? 24, + }; + }); console.log(''); console.log(ok('Dashboard authentication configured')); console.log(''); console.log(info('Settings saved to ~/.ccs/config.yaml')); console.log(info(`Username: ${username}`)); - console.log(info(`Session timeout: ${config.dashboard_auth.session_timeout_hours} hours`)); + console.log( + info(`Session timeout: ${config.dashboard_auth?.session_timeout_hours ?? 24} hours`) + ); console.log(''); console.log(dim(' Start dashboard: ccs config')); console.log(dim(' Show status: ccs config auth show')); diff --git a/src/commands/copilot-command.ts b/src/commands/copilot-command.ts index 40d36bcc..f25ebd52 100644 --- a/src/commands/copilot-command.ts +++ b/src/commands/copilot-command.ts @@ -15,7 +15,7 @@ import { normalizeCopilotConfigWithWarnings, } from '../copilot'; import type { CopilotModel } from '../copilot'; -import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../config/unified-config-loader'; import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types'; import { ok, fail, info, color, warn } from '../utils/ui'; import { normalizeCopilotSubcommand } from '../copilot/constants'; @@ -361,14 +361,13 @@ async function handleStop(): Promise { * Handle enable subcommand. */ async function handleEnable(): Promise { - const config = loadOrCreateUnifiedConfig(); + mutateUnifiedConfig((config) => { + if (!config.copilot) { + config.copilot = { ...DEFAULT_COPILOT_CONFIG }; + } - if (!config.copilot) { - config.copilot = { ...DEFAULT_COPILOT_CONFIG }; - } - - config.copilot.enabled = true; - saveUnifiedConfig(config); + config.copilot.enabled = true; + }); console.log(ok('Copilot integration enabled')); console.log(''); @@ -384,12 +383,11 @@ async function handleEnable(): Promise { * Handle disable subcommand. */ async function handleDisable(): Promise { - const config = loadOrCreateUnifiedConfig(); - - if (config.copilot) { - config.copilot.enabled = false; - saveUnifiedConfig(config); - } + mutateUnifiedConfig((config) => { + if (config.copilot) { + config.copilot.enabled = false; + } + }); console.log(ok('Copilot integration disabled')); diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index 585c395e..ae8d5363 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -15,11 +15,7 @@ import { getAvailableModels, getDefaultModel, } from '../cursor'; -import { - getCursorConfig, - loadOrCreateUnifiedConfig, - saveUnifiedConfig, -} from '../config/unified-config-loader'; +import { getCursorConfig, mutateUnifiedConfig } from '../config/unified-config-loader'; import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types'; import { renderCursorHelp, renderCursorModels, renderCursorStatus } from './cursor-command-display'; import { ok, fail, info } from '../utils/ui'; @@ -239,14 +235,13 @@ async function handleStop(): Promise { * Handle enable subcommand. */ async function handleEnable(): Promise { - const config = loadOrCreateUnifiedConfig(); + mutateUnifiedConfig((config) => { + if (!config.cursor) { + config.cursor = { ...DEFAULT_CURSOR_CONFIG }; + } - if (!config.cursor) { - config.cursor = { ...DEFAULT_CURSOR_CONFIG }; - } - - config.cursor.enabled = true; - saveUnifiedConfig(config); + config.cursor.enabled = true; + }); console.log(ok('Cursor integration enabled')); console.log(''); @@ -262,12 +257,11 @@ async function handleEnable(): Promise { * Handle disable subcommand. */ async function handleDisable(): Promise { - const config = loadOrCreateUnifiedConfig(); - - if (config.cursor) { - config.cursor.enabled = false; - saveUnifiedConfig(config); - } + mutateUnifiedConfig((config) => { + if (config.cursor) { + config.cursor.enabled = false; + } + }); console.log(ok('Cursor integration disabled')); return 0; diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index 681ea9ad..ed1f884d 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -19,7 +19,7 @@ import { initUI, header, ok, info, warn } from '../utils/ui'; import { loadOrCreateUnifiedConfig, loadUnifiedConfig, - saveUnifiedConfig, + mutateUnifiedConfig, hasUnifiedConfig, } from '../config/unified-config-loader'; import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; @@ -377,9 +377,10 @@ async function runSetupWizard(force: boolean = false): Promise { console.log(' After creating, edit the settings file to add your API key.'); } - // Save config - config.setup_completed = true; - saveUnifiedConfig(config); + mutateUnifiedConfig((currentConfig) => { + currentConfig.setup_completed = true; + currentConfig.cliproxy_server = config.cliproxy_server; + }); // Final summary console.log(''); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 19bd3266..108f9f20 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as crypto from 'crypto'; import * as yaml from 'js-yaml'; import { getCcsDir } from '../utils/config-manager'; import { @@ -64,65 +65,73 @@ function getLockFilePath(): string { /** * Acquire lockfile for config write operations. - * Returns true if lock acquired, false if already locked by another process. + * Returns a lock token if acquired, null if already locked by another process. * Cleans up stale locks (older than LOCK_STALE_MS). */ -function acquireLock(): boolean { +function acquireLock(): string | null { const lockPath = getLockFilePath(); - const lockData = `${process.pid}\n${Date.now()}`; + const lockToken = crypto.randomUUID(); + const lockData = `${process.pid}\n${Date.now()}\n${lockToken}`; try { // Check if lock exists if (fs.existsSync(lockPath)) { const content = fs.readFileSync(lockPath, 'utf8'); const [pidStr, timestampStr] = content.trim().split('\n'); - const timestamp = parseInt(timestampStr, 10); + const pid = Number.parseInt(pidStr, 10); + const timestamp = Number.parseInt(timestampStr, 10); + const hasLiveOwner = Number.isInteger(pid) && pid > 0 && processExists(pid); + const isStale = !Number.isFinite(timestamp) || Date.now() - timestamp > LOCK_STALE_MS; - // Check if lock is stale - if (Date.now() - timestamp > LOCK_STALE_MS) { - // Stale lock - remove and acquire + if (hasLiveOwner) { + return null; + } + + if (isStale || !hasLiveOwner) { fs.unlinkSync(lockPath); - } else { - // Check if process still exists - try { - process.kill(parseInt(pidStr, 10), 0); // Signal 0 checks if process exists - // Process exists - lock is valid - return false; - } catch { - // Process doesn't exist - remove stale lock - fs.unlinkSync(lockPath); - } } } // Acquire lock fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 }); - return true; + return lockToken; } catch (error) { // EEXIST means another process acquired the lock between our check and write if ((error as NodeJS.ErrnoException).code === 'EEXIST') { - return false; + return null; } - return false; + return null; } } /** * Release lockfile after config write operation. */ - -function releaseLock(): void { +function releaseLock(lockToken: string): void { const lockPath = getLockFilePath(); try { if (fs.existsSync(lockPath)) { - fs.unlinkSync(lockPath); + const content = fs.readFileSync(lockPath, 'utf8'); + const fileToken = content.trim().split('\n')[2]; + if (fileToken === lockToken) { + fs.unlinkSync(lockPath); + } } } catch { // Ignore cleanup errors } } +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + /** * Check if unified config.yaml exists */ @@ -152,7 +161,7 @@ export function getConfigFormat(): 'yaml' | 'json' | 'none' { /** * Load unified config from YAML file. - * Returns null if file doesn't exist or format check fails. + * Returns null if file doesn't exist. * Auto-upgrades config if version is outdated (regenerates comments). */ export function loadUnifiedConfig(): UnifiedConfig | null { @@ -168,8 +177,7 @@ export function loadUnifiedConfig(): UnifiedConfig | null { const parsed = yaml.load(content); if (!isUnifiedConfig(parsed)) { - console.error(`[!] Invalid config format in ${yamlPath}`); - return null; + throw new Error(`Invalid config format in ${yamlPath}`); } // Auto-upgrade if version is outdated (regenerates YAML with new comments and fields) @@ -208,7 +216,7 @@ export function loadUnifiedConfig(): UnifiedConfig | null { const error = err instanceof Error ? err.message : 'Unknown error'; console.error(`[X] Failed to load config: ${error}`); } - return null; + throw err; } } @@ -808,16 +816,17 @@ function withConfigWriteLock(callback: () => T): T { // Acquire lock (retry for up to 1 second) const maxRetries = 10; const retryDelayMs = 100; - let lockAcquired = false; + let lockToken: string | null = null; for (let i = 0; i < maxRetries; i++) { - if (acquireLock()) { - lockAcquired = true; + const acquiredToken = acquireLock(); + if (acquiredToken) { + lockToken = acquiredToken; break; } sleepSync(retryDelayMs); } - if (!lockAcquired) { + if (!lockToken) { throw new Error('Config file is locked by another process. Wait a moment and try again.'); } @@ -825,7 +834,7 @@ function withConfigWriteLock(callback: () => T): T { return callback(); } finally { // Always release lock - releaseLock(); + releaseLock(lockToken); } } diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts index 69703484..c76ae57f 100644 --- a/src/web-server/health-service.ts +++ b/src/web-server/health-service.ts @@ -145,12 +145,8 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st // Use appropriate config based on unified mode const { isUnifiedMode } = require('../config/unified-config-loader'); if (isUnifiedMode()) { - const { - loadOrCreateUnifiedConfig, - saveUnifiedConfig, - } = require('../config/unified-config-loader'); - const config = loadOrCreateUnifiedConfig(); - saveUnifiedConfig(config); + const { mutateUnifiedConfig } = require('../config/unified-config-loader'); + mutateUnifiedConfig(() => {}); return { success: true, message: 'Created/updated config.yaml' }; } const configPath = getConfigPath(); diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index 044963d7..cbdff588 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -132,3 +132,32 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): // Unauthorized res.status(401).json({ error: 'Authentication required' }); } + +export function isLoopbackRemoteAddress(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.') + ); +} + +export function requireLocalAccessWhenAuthDisabled( + req: Request, + res: Response, + error = 'This endpoint requires localhost access when dashboard auth is disabled.' +): boolean { + if (getDashboardAuthConfig().enabled) { + return true; + } + + if (isLoopbackRemoteAddress(req.socket.remoteAddress)) { + return true; + } + + res.status(403).json({ error }); + return false; +} diff --git a/src/web-server/routes/ai-provider-routes.ts b/src/web-server/routes/ai-provider-routes.ts index cb2a3492..a7772dfe 100644 --- a/src/web-server/routes/ai-provider-routes.ts +++ b/src/web-server/routes/ai-provider-routes.ts @@ -8,9 +8,22 @@ import { type AiProviderFamilyId, type UpsertAiProviderEntryInput, } from '../../cliproxy/ai-providers'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); +router.use((req: Request, res: Response, next) => { + if ( + requireLocalAccessWhenAuthDisabled( + req, + res, + 'AI provider endpoints require localhost access when dashboard auth is disabled.' + ) + ) { + next(); + } +}); + function isAiProviderFamilyId(value: string): value is AiProviderFamilyId { return AI_PROVIDER_FAMILY_IDS.includes(value as AiProviderFamilyId); } @@ -24,13 +37,13 @@ function parseFamily(req: Request, res: Response): AiProviderFamilyId | null { return family; } -function parseIndex(req: Request, res: Response): number | null { - const index = Number.parseInt(req.params.index || '', 10); - if (!Number.isInteger(index) || index < 0) { - res.status(400).json({ error: 'Invalid entry index' }); +function parseEntryId(req: Request, res: Response): string | null { + const entryId = req.params.entryId?.trim(); + if (!entryId) { + res.status(400).json({ error: 'Invalid entry id' }); return null; } - return index; + return entryId; } function parseInput(body: unknown): UpsertAiProviderEntryInput { @@ -95,14 +108,14 @@ router.post('/:family', async (req: Request, res: Response) => { } }); -router.put('/:family/:index', async (req: Request, res: Response) => { +router.put('/:family/:entryId', async (req: Request, res: Response) => { const family = parseFamily(req, res); if (!family) return; - const index = parseIndex(req, res); - if (index === null) return; + const entryId = parseEntryId(req, res); + if (!entryId) return; try { - await updateAiProviderEntry(family, index, parseInput(req.body)); + await updateAiProviderEntry(family, entryId, parseInput(req.body)); res.json({ success: true }); } catch (error) { const message = (error as Error).message; @@ -110,14 +123,14 @@ router.put('/:family/:index', async (req: Request, res: Response) => { } }); -router.delete('/:family/:index', async (req: Request, res: Response) => { +router.delete('/:family/:entryId', async (req: Request, res: Response) => { const family = parseFamily(req, res); if (!family) return; - const index = parseIndex(req, res); - if (index === null) return; + const entryId = parseEntryId(req, res); + if (!entryId) return; try { - await deleteAiProviderEntry(family, index); + await deleteAiProviderEntry(family, entryId); res.json({ success: true }); } catch (error) { const message = (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 0072fc67..b6a96a88 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -23,7 +23,6 @@ import { resumeAccount as resumeAccountFn, touchAccount, hasAccountNameConflict, - findAccountNameMatch, PROVIDERS_WITHOUT_EMAIL, validateNickname, } from '../../cliproxy/account-manager'; @@ -53,6 +52,7 @@ import { isAntigravityResponsibilityBypassEnabled, } from '../../cliproxy/antigravity-responsibility'; import { createRouteErrorHelpers } from './route-helpers'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000; @@ -66,6 +66,18 @@ const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; const { respondInternalError } = createRouteErrorHelpers('cliproxy-auth-routes'); +router.use((req: Request, res: Response, next) => { + if ( + requireLocalAccessWhenAuthDisabled( + req, + res, + 'CLIProxy auth endpoints require localhost access when dashboard auth is disabled.' + ) + ) { + next(); + } +}); + function pruneExpiredManualAuthState(now = Date.now()): void { for (const [state, pending] of pendingManualAuthState.entries()) { if (now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS) { @@ -149,6 +161,13 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string { return 'Authentication failed or was cancelled'; } +function getManualCallbackRegistrationError(provider: CLIProxyProvider): string { + if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { + return 'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.'; + } + return 'Authenticated token could not be registered. Retry the flow.'; +} + export function getStartAuthNicknameError( provider: CLIProxyProvider, nickname: string | undefined, @@ -504,12 +523,10 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise(); +router.use((req: Request, res: Response, next) => { + if ( + requireLocalAccessWhenAuthDisabled( + req, + res, + 'CLIProxy management endpoints require localhost access when dashboard auth is disabled.' + ) + ) { + next(); + } +}); + function buildQuotaRateLimitKey(req: Request, provider: string): string { const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; return `${clientIp}:${provider}`; diff --git a/src/web-server/routes/cliproxy-sync-routes.ts b/src/web-server/routes/cliproxy-sync-routes.ts index 1bbec5fc..d414bbda 100644 --- a/src/web-server/routes/cliproxy-sync-routes.ts +++ b/src/web-server/routes/cliproxy-sync-routes.ts @@ -3,7 +3,7 @@ */ import { Router, Request, Response } from 'express'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { mutateUnifiedConfig } from '../../config/unified-config-loader'; import { generateSyncPayload, generateSyncPreview, @@ -12,7 +12,6 @@ import { syncToLocalConfig, getLocalSyncStatus, } from '../../cliproxy/sync'; -import { saveUnifiedConfig } from '../../config/unified-config-loader'; const router = Router(); @@ -130,18 +129,13 @@ router.put('/auto-sync', async (req: Request, res: Response): Promise => { return; } - // Update config - const config = loadOrCreateUnifiedConfig(); - if (!config.cliproxy) { - // Should not happen as loadOrCreate initializes it, but handle gracefully - res.status(500).json({ error: 'CLIProxy config not initialized' }); - return; - } - - // Save config try { - config.cliproxy.auto_sync = enabled; - saveUnifiedConfig(config); + mutateUnifiedConfig((config) => { + if (!config.cliproxy) { + throw new Error('CLIProxy config not initialized'); + } + config.cliproxy.auto_sync = enabled; + }); } catch (error) { res.status(500).json({ error: `Failed to save config: ${(error as Error).message}` }); return; diff --git a/src/web-server/routes/cursor-settings-routes.ts b/src/web-server/routes/cursor-settings-routes.ts index 8a120dfe..56c9dcc1 100644 --- a/src/web-server/routes/cursor-settings-routes.ts +++ b/src/web-server/routes/cursor-settings-routes.ts @@ -9,11 +9,7 @@ import { promises as fsp } from 'fs'; import * as path from 'path'; import { getCcsDir } from '../../utils/config-manager'; import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types'; -import { - loadOrCreateUnifiedConfig, - saveUnifiedConfig, - getCursorConfig, -} from '../../config/unified-config-loader'; +import { mutateUnifiedConfig, getCursorConfig } from '../../config/unified-config-loader'; import type { CursorConfig } from '../../config/unified-config-types'; const router = Router(); @@ -184,36 +180,38 @@ router.put('/', async (req: Request, res: Response): Promise => { return; } - const config = loadOrCreateUnifiedConfig(); const normalizedModel = parseRequiredModel(updates.model); + const config = mutateUnifiedConfig((currentConfig) => { + currentConfig.cursor = { + enabled: updates.enabled ?? currentConfig.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled, + port: updates.port ?? currentConfig.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port, + auto_start: + updates.auto_start ?? + currentConfig.cursor?.auto_start ?? + DEFAULT_CURSOR_CONFIG.auto_start, + ghost_mode: + updates.ghost_mode ?? + currentConfig.cursor?.ghost_mode ?? + DEFAULT_CURSOR_CONFIG.ghost_mode, + model: normalizedModel ?? currentConfig.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model, + opus_model: + 'opus_model' in updates + ? parseOptionalModel(updates.opus_model) + : currentConfig.cursor?.opus_model, + sonnet_model: + 'sonnet_model' in updates + ? parseOptionalModel(updates.sonnet_model) + : currentConfig.cursor?.sonnet_model, + haiku_model: + 'haiku_model' in updates + ? parseOptionalModel(updates.haiku_model) + : currentConfig.cursor?.haiku_model, + }; + }); - // Merge updates with existing config - // Only known fields are merged โ€” unknown properties are ignored - config.cursor = { - enabled: updates.enabled ?? config.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled, - port: updates.port ?? config.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port, - auto_start: - updates.auto_start ?? config.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start, - ghost_mode: - updates.ghost_mode ?? config.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode, - model: normalizedModel ?? config.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model, - opus_model: - 'opus_model' in updates - ? parseOptionalModel(updates.opus_model) - : config.cursor?.opus_model, - sonnet_model: - 'sonnet_model' in updates - ? parseOptionalModel(updates.sonnet_model) - : config.cursor?.sonnet_model, - haiku_model: - 'haiku_model' in updates - ? parseOptionalModel(updates.haiku_model) - : config.cursor?.haiku_model, - }; - - saveUnifiedConfig(config); - await syncRawSettingsFromCursorConfig(config.cursor); - res.json({ success: true, cursor: config.cursor }); + const cursorConfig = config.cursor ?? DEFAULT_CURSOR_CONFIG; + await syncRawSettingsFromCursorConfig(cursorConfig); + res.json({ success: true, cursor: cursorConfig }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -295,19 +293,19 @@ router.put('/raw', (req: Request, res: Response): void => { // Keep unified config aligned with raw settings edits (parity with Copilot raw editor). const parsedPort = parseLocalCursorPort(settings); - const config = loadOrCreateUnifiedConfig(); const env = (settings as { env?: Record }).env ?? {}; - const model = parseRequiredModel(env.ANTHROPIC_MODEL) ?? config.cursor?.model; + mutateUnifiedConfig((config) => { + const model = parseRequiredModel(env.ANTHROPIC_MODEL) ?? config.cursor?.model; - config.cursor = { - ...(config.cursor ?? DEFAULT_CURSOR_CONFIG), - ...(parsedPort !== null ? { port: parsedPort } : {}), - ...(model ? { model } : {}), - opus_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_OPUS_MODEL), - sonnet_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_SONNET_MODEL), - haiku_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_HAIKU_MODEL), - }; - saveUnifiedConfig(config); + config.cursor = { + ...(config.cursor ?? DEFAULT_CURSOR_CONFIG), + ...(parsedPort !== null ? { port: parsedPort } : {}), + ...(model ? { model } : {}), + opus_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_OPUS_MODEL), + sonnet_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_SONNET_MODEL), + haiku_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_HAIKU_MODEL), + }; + }); const stat = fs.statSync(settingsPath); res.json({ success: true, mtime: stat.mtimeMs }); diff --git a/src/web-server/routes/misc-routes.ts b/src/web-server/routes/misc-routes.ts index a3c7fbdc..ce95c457 100644 --- a/src/web-server/routes/misc-routes.ts +++ b/src/web-server/routes/misc-routes.ts @@ -8,8 +8,6 @@ import * as path from 'path'; import { getCcsDir } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; import { - loadOrCreateUnifiedConfig, - saveUnifiedConfig, mutateUnifiedConfig, getGlobalEnvConfig, getThinkingConfig, @@ -24,9 +22,22 @@ import { THINKING_OFF_VALUES, } from '../../cliproxy'; import { validateFilePath } from './route-helpers'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); +router.use((req: Request, res: Response, next) => { + if ( + requireLocalAccessWhenAuthDisabled( + req, + res, + 'Local configuration endpoints require localhost access when dashboard auth is disabled.' + ) + ) { + next(); + } +}); + export function resolveThinkingProviderOverridesForSave( currentProviderOverrides: ThinkingConfig['provider_overrides'] | undefined, updatesProviderOverrides: Record> | undefined, @@ -304,14 +315,10 @@ router.put('/thinking', (req: Request, res: Response): void => { } } - const config = loadOrCreateUnifiedConfig(); const shouldClearOverride = clearOverrideFlag === true || updates.override === null; const shouldClearProviderOverrides = clearProviderOverridesFlag === true || updates.provider_overrides === null; - let normalizedOverride: string | number | undefined = config.thinking?.override as - | string - | number - | undefined; + let normalizedOverride: string | number | undefined; let normalizedProviderOverrides: | Record> | undefined; @@ -438,28 +445,32 @@ router.put('/thinking', (req: Request, res: Response): void => { Object.keys(sanitizedOverrides).length > 0 ? sanitizedOverrides : undefined; } - // Update thinking section - config.thinking = { - mode: updates.mode ?? config.thinking?.mode ?? 'auto', - override: shouldClearOverride - ? undefined - : updates.override !== undefined - ? normalizedOverride - : config.thinking?.override, - tier_defaults: { - opus: updates.tier_defaults?.opus ?? config.thinking?.tier_defaults?.opus ?? 'high', - sonnet: updates.tier_defaults?.sonnet ?? config.thinking?.tier_defaults?.sonnet ?? 'medium', - haiku: updates.tier_defaults?.haiku ?? config.thinking?.tier_defaults?.haiku ?? 'low', - }, - provider_overrides: resolveThinkingProviderOverridesForSave( - config.thinking?.provider_overrides, - updates.provider_overrides !== undefined ? normalizedProviderOverrides : undefined, - shouldClearProviderOverrides - ), - show_warnings: updates.show_warnings ?? config.thinking?.show_warnings ?? true, - }; - - saveUnifiedConfig(config); + const config = mutateUnifiedConfig((currentConfig) => { + currentConfig.thinking = { + mode: updates.mode ?? currentConfig.thinking?.mode ?? 'auto', + override: shouldClearOverride + ? undefined + : updates.override !== undefined + ? normalizedOverride + : currentConfig.thinking?.override, + tier_defaults: { + opus: + updates.tier_defaults?.opus ?? currentConfig.thinking?.tier_defaults?.opus ?? 'high', + sonnet: + updates.tier_defaults?.sonnet ?? + currentConfig.thinking?.tier_defaults?.sonnet ?? + 'medium', + haiku: + updates.tier_defaults?.haiku ?? currentConfig.thinking?.tier_defaults?.haiku ?? 'low', + }, + provider_overrides: resolveThinkingProviderOverridesForSave( + currentConfig.thinking?.provider_overrides, + updates.provider_overrides !== undefined ? normalizedProviderOverrides : undefined, + shouldClearProviderOverrides + ), + show_warnings: updates.show_warnings ?? currentConfig.thinking?.show_warnings ?? true, + }; + }); // W4: Return new mtime for subsequent requests let newMtime: number | undefined; diff --git a/src/web-server/routes/proxy-routes.ts b/src/web-server/routes/proxy-routes.ts index 2d9ba46d..3b6a34d7 100644 --- a/src/web-server/routes/proxy-routes.ts +++ b/src/web-server/routes/proxy-routes.ts @@ -17,9 +17,22 @@ import { CliproxyServerConfig, } from '../../config/unified-config-types'; import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); +router.use((req: Request, res: Response, next) => { + if ( + requireLocalAccessWhenAuthDisabled( + req, + res, + 'CLIProxy server endpoints require localhost access when dashboard auth is disabled.' + ) + ) { + next(); + } +}); + /** * GET /api/cliproxy-server - Get proxy configuration */ diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 9f2eec1d..6d60fd32 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -6,6 +6,7 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; import { getCcsDir, loadSettings } from '../../utils/config-manager'; import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys'; import { listVariants } from '../../cliproxy/services/variant-service'; @@ -20,11 +21,8 @@ import { import { regenerateConfig } from '../../cliproxy/config-generator'; import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; import { resolveCliproxyBridgeMetadata } from '../../api/services'; -import { - getDashboardAuthConfig, - loadOrCreateUnifiedConfig, - mutateUnifiedConfig, -} from '../../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import type { Settings } from '../../types/config'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; @@ -43,40 +41,25 @@ const MODEL_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_HAIKU_MODEL', ] as const; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; +const SETTINGS_IDENTIFIER_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/; const { logRouteError, respondInternalError } = createRouteErrorHelpers('settings-routes'); -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 resolvePathWithin(basePath: string, targetPath: string): string { + const resolvedBase = path.resolve(basePath); + const resolvedTarget = path.resolve(targetPath); + if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`)) { + throw new Error('Invalid settings path'); + } + return resolvedTarget; } function requireSensitiveLocalAccess(req: Request, res: Response): boolean { - const dashboardAuth = getDashboardAuthConfig(); - if (dashboardAuth.enabled) { - return true; - } - - // Use only socket-level address for security decisions. - // X-Forwarded-For is trivially spoofable and must NOT be trusted - // without an explicit trust proxy configuration. - const candidateAddress = req.socket.remoteAddress; - - if (isLoopbackAddress(candidateAddress)) { - return true; - } - - res.status(403).json({ - error: 'Sensitive settings endpoints require localhost access when dashboard auth is disabled.', - }); - return false; + return requireLocalAccessWhenAuthDisabled( + req, + res, + 'Sensitive settings endpoints require localhost access when dashboard auth is disabled.' + ); } function classifyConfigSaveFailure(error: unknown): { statusCode: number; message: string } { @@ -100,18 +83,26 @@ function classifyConfigSaveFailure(error: unknown): { statusCode: number; messag * Variants have settings paths in config, regular profiles use {name}.settings.json */ function resolveSettingsPath(profileOrVariant: string): string { + if (!SETTINGS_IDENTIFIER_PATTERN.test(profileOrVariant)) { + throw new Error('Invalid profile name'); + } + const ccsDir = getCcsDir(); + const resolvedCcsDir = path.resolve(ccsDir); // Check if this is a variant const variants = listVariants(); const variant = variants[profileOrVariant]; if (variant?.settings) { // Variant settings path (e.g., ~/.ccs/agy-g3.settings.json) - return variant.settings.replace(/^~/, os.homedir()); + return resolvePathWithin(resolvedCcsDir, variant.settings.replace(/^~/, os.homedir())); } // Regular profile settings - return path.join(ccsDir, `${profileOrVariant}.settings.json`); + return resolvePathWithin( + resolvedCcsDir, + path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`) + ); } function resolveProviderForProfile(profileOrVariant: string): CLIProxyProvider | null { @@ -261,11 +252,29 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting } function writeSettingsAtomically(settingsPath: string, settings: Settings): void { - const tempPath = settingsPath + '.tmp'; + const tempPath = `${settingsPath}.tmp.${process.pid}`; fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); fs.renameSync(tempPath, settingsPath); } +function withSettingsFileLock(settingsPath: string, callback: () => T): T { + const lockTarget = fs.existsSync(settingsPath) ? settingsPath : path.dirname(settingsPath); + let release: (() => void) | undefined; + + try { + release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void; + return callback(); + } finally { + if (release) { + try { + release(); + } catch { + // Best-effort release + } + } + } +} + function loadCanonicalProfileSettings( profileOrVariant: string, settingsPath: string, @@ -339,6 +348,8 @@ router.get('/:profile', (req: Request, res: Response): void => { * GET /api/settings/:profile/raw - Get full settings (for editing) */ router.get('/:profile/raw', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + try { const { profile } = req.params; const settingsPath = resolveSettingsPath(profile); @@ -379,6 +390,8 @@ function checkRequiredEnvVars(settings: Settings): string[] { * PUT /api/settings/:profile - Update settings with conflict detection and backup */ router.put('/:profile', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + try { const { profile } = req.params; const { settings, expectedMtime } = req.body; @@ -407,53 +420,56 @@ router.put('/:profile', (req: Request, res: Response): void => { const missingFields = checkRequiredEnvVars(normalizedSettings); const settingsPath = resolveSettingsPath(profile); - const fileExists = fs.existsSync(settingsPath); - - // Only check conflict if file exists and expectedMtime was provided - if (fileExists && expectedMtime) { - const stat = fs.statSync(settingsPath); - if (stat.mtime.getTime() !== expectedMtime) { - res.status(409).json({ - error: 'File modified externally', - currentMtime: stat.mtime.getTime(), - }); - return; - } - } - - // Create backup only if file exists AND content actually changed let backupPath: string | undefined; - const newContent = JSON.stringify(normalizedSettings, null, 2) + '\n'; - if (fileExists) { - const existingContent = fs.readFileSync(settingsPath, 'utf8'); - // Only create backup if content differs - if (existingContent !== newContent) { - const backupDir = path.join(ccsDir, 'backups'); - if (!fs.existsSync(backupDir)) { - fs.mkdirSync(backupDir, { recursive: true }); + let created = false; + let newMtime = 0; + + withSettingsFileLock(settingsPath, () => { + const fileExists = fs.existsSync(settingsPath); + + if (fileExists && expectedMtime) { + const stat = fs.statSync(settingsPath); + if (stat.mtime.getTime() !== expectedMtime) { + res.status(409).json({ + error: 'File modified externally', + currentMtime: stat.mtime.getTime(), + }); + return; } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); - fs.copyFileSync(settingsPath, backupPath); } + + const newContent = JSON.stringify(normalizedSettings, null, 2) + '\n'; + if (fileExists) { + const existingContent = fs.readFileSync(settingsPath, 'utf8'); + if (existingContent !== newContent) { + const backupDir = path.join(ccsDir, 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); + fs.copyFileSync(settingsPath, backupPath); + } + } else { + created = true; + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + } + + const tempPath = `${settingsPath}.tmp.${process.pid}`; + fs.writeFileSync(tempPath, newContent); + fs.renameSync(tempPath, settingsPath); + newMtime = fs.statSync(settingsPath).mtime.getTime(); + }); + + if (res.headersSent) { + return; } - // Ensure directory exists for new files - if (!fileExists) { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - } - - // Write new settings atomically - const tempPath = settingsPath + '.tmp'; - fs.writeFileSync(tempPath, newContent); - fs.renameSync(tempPath, settingsPath); - - const newStat = fs.statSync(settingsPath); res.json({ profile, - mtime: newStat.mtime.getTime(), + mtime: newMtime, backupPath, - created: !fileExists, + created, // Include warning if fields missing (runtime will use defaults) ...(missingFields.length > 0 && { warning: `Missing fields will use defaults: ${missingFields.join(', ')}`, @@ -491,6 +507,8 @@ router.get('/:profile/presets', (req: Request, res: Response): void => { * POST /api/settings/:profile/presets - Create a new preset */ router.post('/:profile/presets', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + try { const { profile } = req.params; const { name, default: defaultModel, opus, sonnet, haiku } = req.body; @@ -502,56 +520,65 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { const settingsPath = resolveSettingsPath(profile); - // Create settings file if it doesn't exist - if (!fs.existsSync(settingsPath)) { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); - } + let persistedPreset: + | { + name: string; + default: string; + opus: string; + sonnet: string; + haiku: string; + } + | undefined; - const settings = loadCanonicalProfileSettings(profile, settingsPath, false); - settings.presets = settings.presets || []; + withSettingsFileLock(settingsPath, () => { + if (!fs.existsSync(settingsPath)) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); + } - // Check for duplicate name - if (settings.presets.some((p) => p.name === name)) { - res.status(409).json({ error: 'Preset with this name already exists' }); + const settings = loadCanonicalProfileSettings(profile, settingsPath, false); + settings.presets = settings.presets || []; + + if (settings.presets.some((p) => p.name === name)) { + res.status(409).json({ error: 'Preset with this name already exists' }); + return; + } + + const normalizePresetModel = (modelId: string): string => + canonicalizeProfileModelId(profile, modelId, settings); + + for (const modelId of [ + defaultModel, + opus || defaultModel, + sonnet || defaultModel, + haiku || defaultModel, + ]) { + const deniedReason = findDeniedProfileModel(profile, modelId, settings); + if (deniedReason) { + res.status(400).json({ error: deniedReason }); + return; + } + } + + const preset = { + name, + default: normalizePresetModel(defaultModel), + opus: normalizePresetModel(opus || defaultModel), + sonnet: normalizePresetModel(sonnet || defaultModel), + haiku: normalizePresetModel(haiku || defaultModel), + }; + + settings.presets.push(preset); + const canonicalizedSettings = canonicalizeProfileSettings(profile, settings); + writeSettingsAtomically(settingsPath, canonicalizedSettings); + persistedPreset = + canonicalizedSettings.presets?.find((entry) => entry.name === name) || preset; + }); + + if (res.headersSent) { return; } - const normalizePresetModel = (modelId: string): string => - canonicalizeProfileModelId(profile, modelId, settings); - - for (const modelId of [ - defaultModel, - opus || defaultModel, - sonnet || defaultModel, - haiku || defaultModel, - ]) { - const deniedReason = findDeniedProfileModel(profile, modelId, settings); - if (deniedReason) { - res.status(400).json({ error: deniedReason }); - return; - } - } - - const normalizedDefaultModel = normalizePresetModel(defaultModel); - const normalizedOpusModel = normalizePresetModel(opus || defaultModel); - const normalizedSonnetModel = normalizePresetModel(sonnet || defaultModel); - const normalizedHaikuModel = normalizePresetModel(haiku || defaultModel); - - const preset = { - name, - default: normalizedDefaultModel, - opus: normalizedOpusModel, - sonnet: normalizedSonnetModel, - haiku: normalizedHaikuModel, - }; - - settings.presets.push(preset); - const canonicalizedSettings = canonicalizeProfileSettings(profile, settings); - writeSettingsAtomically(settingsPath, canonicalizedSettings); - - const persistedPreset = - canonicalizedSettings.presets?.find((entry) => entry.name === name) || preset; res.status(201).json({ preset: persistedPreset }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); @@ -562,25 +589,33 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { * DELETE /api/settings/:profile/presets/:name - Delete a preset */ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + try { const { profile, name } = req.params; const settingsPath = resolveSettingsPath(profile); - if (!fs.existsSync(settingsPath)) { - res.status(404).json({ error: 'Settings not found' }); + withSettingsFileLock(settingsPath, () => { + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: 'Settings not found' }); + return; + } + + const settings = loadCanonicalProfileSettings(profile, settingsPath, false); + if (!settings.presets || !settings.presets.some((p) => p.name === name)) { + res.status(404).json({ error: 'Preset not found' }); + return; + } + + settings.presets = settings.presets.filter((p) => p.name !== name); + const canonicalizedSettings = canonicalizeProfileSettings(profile, settings); + writeSettingsAtomically(settingsPath, canonicalizedSettings); + }); + + if (res.headersSent) { return; } - const settings = loadCanonicalProfileSettings(profile, settingsPath, false); - if (!settings.presets || !settings.presets.some((p) => p.name === name)) { - res.status(404).json({ error: 'Preset not found' }); - return; - } - - settings.presets = settings.presets.filter((p) => p.name !== name); - const canonicalizedSettings = canonicalizeProfileSettings(profile, settings); - writeSettingsAtomically(settingsPath, canonicalizedSettings); - res.json({ success: true }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); @@ -642,6 +677,8 @@ router.put('/auth/antigravity-risk', (req: Request, res: Response): void => { * GET /api/settings/auth/tokens - Get current auth token status (masked) */ router.get('/auth/tokens', (_req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(_req, res)) return; + try { const summary = getAuthSummary(); @@ -692,6 +729,8 @@ router.get('/auth/tokens/raw', (req: Request, res: Response): void => { * PUT /api/settings/auth/tokens - Update auth tokens */ router.put('/auth/tokens', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + try { const { apiKey, managementSecret } = req.body; @@ -728,6 +767,8 @@ router.put('/auth/tokens', (req: Request, res: Response): void => { * POST /api/settings/auth/tokens/regenerate-secret - Generate new management secret */ router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(_req, res)) return; + try { const newSecret = generateSecureToken(32); setGlobalManagementSecret(newSecret); @@ -752,6 +793,8 @@ router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): vo * POST /api/settings/auth/tokens/reset - Reset auth tokens to defaults */ router.post('/auth/tokens/reset', (_req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(_req, res)) return; + try { resetAuthToDefaults(); diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index 564ca508..ae8e9054 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -3,11 +3,7 @@ */ import { Router, Request, Response } from 'express'; -import { - loadUnifiedConfig, - saveUnifiedConfig, - getWebSearchConfig, -} from '../../config/unified-config-loader'; +import { mutateUnifiedConfig, getWebSearchConfig } from '../../config/unified-config-loader'; import type { WebSearchConfig } from '../../config/unified-config-types'; import { getWebSearchReadiness, @@ -52,59 +48,45 @@ router.put('/', (req: Request, res: Response): void => { } try { - // Load existing config and update websearch section - const existingConfig = loadUnifiedConfig(); - if (!existingConfig) { - res.status(500).json({ error: 'Failed to load config' }); - return; - } - - // Merge updates - supports Gemini CLI and Grok CLI - existingConfig.websearch = { - enabled: enabled ?? existingConfig.websearch?.enabled ?? true, - providers: providers - ? { - gemini: { - enabled: - providers.gemini?.enabled ?? - existingConfig.websearch?.providers?.gemini?.enabled ?? - true, - model: - providers.gemini?.model ?? - existingConfig.websearch?.providers?.gemini?.model ?? - 'gemini-2.5-flash', - timeout: - providers.gemini?.timeout ?? - existingConfig.websearch?.providers?.gemini?.timeout ?? - 55, - }, - grok: { - enabled: - providers.grok?.enabled ?? - existingConfig.websearch?.providers?.grok?.enabled ?? - false, - timeout: - providers.grok?.timeout ?? existingConfig.websearch?.providers?.grok?.timeout ?? 55, - }, - opencode: { - enabled: - providers.opencode?.enabled ?? - existingConfig.websearch?.providers?.opencode?.enabled ?? - false, - model: - providers.opencode?.model ?? - existingConfig.websearch?.providers?.opencode?.model ?? - 'opencode/grok-code', - timeout: - providers.opencode?.timeout ?? - existingConfig.websearch?.providers?.opencode?.timeout ?? - 60, - }, - } - : existingConfig.websearch?.providers, - }; - - saveUnifiedConfig(existingConfig); + const existingConfig = mutateUnifiedConfig((config) => { + config.websearch = { + enabled: enabled ?? config.websearch?.enabled ?? true, + providers: providers + ? { + gemini: { + enabled: + providers.gemini?.enabled ?? config.websearch?.providers?.gemini?.enabled ?? true, + model: + providers.gemini?.model ?? + config.websearch?.providers?.gemini?.model ?? + 'gemini-2.5-flash', + timeout: + providers.gemini?.timeout ?? config.websearch?.providers?.gemini?.timeout ?? 55, + }, + grok: { + enabled: + providers.grok?.enabled ?? config.websearch?.providers?.grok?.enabled ?? false, + timeout: + providers.grok?.timeout ?? config.websearch?.providers?.grok?.timeout ?? 55, + }, + opencode: { + enabled: + providers.opencode?.enabled ?? + config.websearch?.providers?.opencode?.enabled ?? + false, + model: + providers.opencode?.model ?? + config.websearch?.providers?.opencode?.model ?? + 'opencode/grok-code', + timeout: + providers.opencode?.timeout ?? + config.websearch?.providers?.opencode?.timeout ?? + 60, + }, + } + : config.websearch?.providers, + }; + }); res.json({ success: true, diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index af876196..ade2e0c3 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -10,9 +10,22 @@ import * as path from 'path'; import * as yaml from 'js-yaml'; import { getCcsDir } from '../utils/config-manager'; import { getClaudeConfigDir } from '../utils/claude-config-path'; +import { requireLocalAccessWhenAuthDisabled } from './middleware/auth-middleware'; export const sharedRoutes = Router(); +sharedRoutes.use((req: Request, res: Response, next) => { + if ( + requireLocalAccessWhenAuthDisabled( + req, + res, + 'Shared-content endpoints require localhost access when dashboard auth is disabled.' + ) + ) { + next(); + } +}); + const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10; const MAX_DESCRIPTION_LENGTH = 140; const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB diff --git a/tests/unit/cliproxy/account-registry-integrity.test.ts b/tests/unit/cliproxy/account-registry-integrity.test.ts new file mode 100644 index 00000000..6d0eb81d --- /dev/null +++ b/tests/unit/cliproxy/account-registry-integrity.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runWithScopedCcsHome } from '../../../src/utils/config-manager'; + +async function withIsolatedHome(fn: (homeDir: string) => Promise | T): Promise { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-account-registry-')); + try { + return await runWithScopedCcsHome(homeDir, () => fn(homeDir)); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } +} + +async function loadRegistryModule() { + return import(`../../../src/cliproxy/accounts/registry?registry-integrity=${Date.now()}`); +} + +async function loadAccountManager() { + return import(`../../../src/cliproxy/account-manager?account-registry-integrity=${Date.now()}`); +} + +describe('account registry integrity', () => { + it('does not create accounts.json during no-op discovery', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json'); + fs.mkdirSync(authDir, { recursive: true }); + + const { discoverExistingAccounts } = await loadRegistryModule(); + discoverExistingAccounts(); + + expect(fs.existsSync(registryPath)).toBe(false); + }); + }); + + it('does not write accounts.json during provider account reads', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json'); + fs.mkdirSync(authDir, { recursive: true }); + + const { getProviderAccounts } = await loadAccountManager(); + expect(getProviderAccounts('kiro')).toEqual([]); + expect(fs.existsSync(registryPath)).toBe(false); + }); + }); + + it('removes stale accounts before choosing the next default during registration', async () => { + await withIsolatedHome(async (homeDir) => { + const cliproxyDir = path.join(homeDir, '.ccs', 'cliproxy'); + const authDir = path.join(cliproxyDir, 'auth'); + const registryPath = path.join(cliproxyDir, 'accounts.json'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync(path.join(authDir, 'kiro-github-ABC123.json'), JSON.stringify({ type: 'kiro' })); + fs.writeFileSync( + registryPath, + JSON.stringify({ + version: 1, + providers: { + kiro: { + default: 'github-OLD999', + accounts: { + 'github-OLD999': { + nickname: 'old', + tokenFile: 'kiro-github-OLD999.json', + createdAt: '2025-01-01T00:00:00.000Z', + lastUsedAt: '2025-01-01T00:00:00.000Z', + }, + }, + }, + }, + }), + 'utf8' + ); + + const { registerAccount } = await loadAccountManager(); + const account = registerAccount('kiro', 'kiro-github-ABC123.json'); + + const { loadAccountsRegistry } = await loadRegistryModule(); + const registry = loadAccountsRegistry(); + const kiroAccounts = registry.providers.kiro; + + expect(account.id).toBe('github-ABC123'); + expect(kiroAccounts?.default).toBe('github-ABC123'); + expect(kiroAccounts?.accounts['github-OLD999']).toBeUndefined(); + expect(Object.keys(kiroAccounts?.accounts ?? {})).toEqual(['github-ABC123']); + }); + }); + + it('fails closed on corrupted accounts.json', async () => { + await withIsolatedHome(async (homeDir) => { + const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json'); + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync(registryPath, '{not-valid-json', 'utf8'); + + const { loadAccountsRegistry } = await loadRegistryModule(); + expect(() => loadAccountsRegistry()).toThrow(/corrupted/i); + }); + }); +}); diff --git a/tests/unit/cliproxy/account-registry-optional-nickname.test.ts b/tests/unit/cliproxy/account-registry-optional-nickname.test.ts index 04ef857a..5fb576e9 100644 --- a/tests/unit/cliproxy/account-registry-optional-nickname.test.ts +++ b/tests/unit/cliproxy/account-registry-optional-nickname.test.ts @@ -8,10 +8,16 @@ async function loadAccountManager() { return import(`../../../src/cliproxy/account-manager?optional-nickname=${Date.now()}`); } -async function withIsolatedHome(fn: () => Promise | T): Promise { +function writeTokenFile(homeDir: string, tokenFile: string): void { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync(path.join(authDir, tokenFile), '{}', 'utf8'); +} + +async function withIsolatedHome(fn: (homeDir: string) => Promise | T): Promise { const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-optional-nickname-')); try { - return await runWithScopedCcsHome(testDir, fn); + return await runWithScopedCcsHome(testDir, () => fn(testDir)); } finally { fs.rmSync(testDir, { recursive: true, force: true }); } @@ -19,7 +25,8 @@ async function withIsolatedHome(fn: () => Promise | T): Promise { describe('registerAccount optional nickname flow', () => { it('uses a filename-derived id when Kiro/GHCP nickname is omitted', async () => { - const account = await withIsolatedHome(async () => { + const account = await withIsolatedHome(async (homeDir) => { + writeTokenFile(homeDir, 'kiro-github-ABC123.json'); const { registerAccount } = await loadAccountManager(); return registerAccount('kiro', 'kiro-github-ABC123.json'); }); @@ -29,7 +36,9 @@ describe('registerAccount optional nickname flow', () => { }); it('falls back to provider-scoped sequential ids when the filename is not descriptive', async () => { - const { first, second } = await withIsolatedHome(async () => { + const { first, second } = await withIsolatedHome(async (homeDir) => { + writeTokenFile(homeDir, 'kiro-nomail.json'); + writeTokenFile(homeDir, 'kiro-second.json'); const { registerAccount } = await loadAccountManager(); return { first: registerAccount('kiro', 'kiro-nomail.json'), @@ -44,7 +53,8 @@ describe('registerAccount optional nickname flow', () => { }); it('keeps user nicknames optional metadata separate from internal ids', async () => { - const account = await withIsolatedHome(async () => { + const account = await withIsolatedHome(async (homeDir) => { + writeTokenFile(homeDir, 'ghcp-amazon-XYZ789.json'); const { registerAccount } = await loadAccountManager(); return registerAccount('ghcp', 'ghcp-amazon-XYZ789.json', undefined, 'work'); }); @@ -54,7 +64,8 @@ describe('registerAccount optional nickname flow', () => { }); it('preserves an existing custom nickname when the same token file is re-registered', async () => { - const reauthenticated = await withIsolatedHome(async () => { + const reauthenticated = await withIsolatedHome(async (homeDir) => { + writeTokenFile(homeDir, 'kiro-github-ABC123.json'); const { registerAccount } = await loadAccountManager(); registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'work'); return registerAccount('kiro', 'kiro-github-ABC123.json'); @@ -65,7 +76,10 @@ describe('registerAccount optional nickname flow', () => { }); it('rejects nickname collisions against existing account ids and nicknames', async () => { - await withIsolatedHome(async () => { + await withIsolatedHome(async (homeDir) => { + writeTokenFile(homeDir, 'kiro-github-ABC123.json'); + writeTokenFile(homeDir, 'kiro-google-XYZ789.json'); + writeTokenFile(homeDir, 'kiro-google-NEW123.json'); const { registerAccount, renameAccount } = await loadAccountManager(); registerAccount('kiro', 'kiro-github-ABC123.json'); const second = registerAccount('kiro', 'kiro-google-XYZ789.json', undefined, 'personal'); @@ -78,7 +92,9 @@ describe('registerAccount optional nickname flow', () => { }); it('avoids auto-generated ids that would collide with an existing nickname', async () => { - const added = await withIsolatedHome(async () => { + const added = await withIsolatedHome(async (homeDir) => { + writeTokenFile(homeDir, 'kiro-github-ABC123.json'); + writeTokenFile(homeDir, 'kiro-google-XYZ789.json'); const { registerAccount } = await loadAccountManager(); registerAccount('kiro', 'kiro-github-ABC123.json', undefined, 'google-XYZ789'); return registerAccount('kiro', 'kiro-google-XYZ789.json'); @@ -89,7 +105,9 @@ describe('registerAccount optional nickname flow', () => { }); it('does not resolve ambiguous nickname prefixes to the first generated account', async () => { - const match = await withIsolatedHome(async () => { + const match = await withIsolatedHome(async (homeDir) => { + writeTokenFile(homeDir, 'kiro-github-ABC123.json'); + writeTokenFile(homeDir, 'kiro-github-DEF456.json'); const { registerAccount, findAccountByQuery } = await loadAccountManager(); registerAccount('kiro', 'kiro-github-ABC123.json'); registerAccount('kiro', 'kiro-github-DEF456.json'); diff --git a/tests/unit/cliproxy/ai-provider-service-stable-id.test.ts b/tests/unit/cliproxy/ai-provider-service-stable-id.test.ts new file mode 100644 index 00000000..a4de2cf6 --- /dev/null +++ b/tests/unit/cliproxy/ai-provider-service-stable-id.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; + +function getCliproxyConfigPath(homeDir: string): string { + return path.join(homeDir, '.ccs', 'cliproxy', 'config.yaml'); +} + +function writeCliproxyConfig(homeDir: string, value: Record): void { + const configPath = getCliproxyConfigPath(homeDir); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, yaml.dump(value), 'utf8'); +} + +function readCliproxyConfig(homeDir: string): Record { + return ( + (yaml.load(fs.readFileSync(getCliproxyConfigPath(homeDir), 'utf8')) as Record) || {} + ); +} + +async function loadAiProviderService() { + return import(`../../../src/cliproxy/ai-providers/service?stable-id=${Date.now()}`); +} + +describe('ai-provider service stable ids', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ai-provider-ids-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('backfills and persists stable ids for api-key provider entries', async () => { + const { listAiProviders } = await loadAiProviderService(); + + writeCliproxyConfig(tempHome, { + 'gemini-api-key': [{ 'api-key': 'alpha' }, { 'api-key': 'beta' }], + }); + + const listed = await listAiProviders(); + const family = listed.families.find((entry) => entry.id === 'gemini-api-key'); + + expect(family).toBeDefined(); + expect(family?.entries).toHaveLength(2); + expect(family?.entries[0]?.id).toBeTruthy(); + expect(family?.entries[1]?.id).toBeTruthy(); + expect(family?.entries[0]?.id).not.toBe(family?.entries[1]?.id); + + const persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array>; + expect(persisted[0]?.id).toBe(family?.entries[0]?.id); + expect(persisted[1]?.id).toBe(family?.entries[1]?.id); + }); + + it('updates and deletes api-key entries by stable id while preserving the id', async () => { + const { updateAiProviderEntry, deleteAiProviderEntry } = await loadAiProviderService(); + + writeCliproxyConfig(tempHome, { + 'gemini-api-key': [ + { 'api-key': 'alpha', id: 'gemini-a' }, + { 'api-key': 'beta', id: 'gemini-b' }, + ], + }); + + await updateAiProviderEntry('gemini-api-key', 'gemini-a', { + apiKey: 'gamma', + baseUrl: 'https://example.test/gemini', + }); + + let persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array>; + expect(persisted[0]?.id).toBe('gemini-a'); + expect(persisted[0]?.['api-key']).toBe('gamma'); + expect(persisted[0]?.['base-url']).toBe('https://example.test/gemini'); + + await deleteAiProviderEntry('gemini-api-key', 'gemini-a'); + + persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array>; + expect(persisted).toHaveLength(1); + expect(persisted[0]?.id).toBe('gemini-b'); + }); + + it('keeps legacy numeric index updates working during the route transition', async () => { + const { updateAiProviderEntry } = await loadAiProviderService(); + + writeCliproxyConfig(tempHome, { + 'gemini-api-key': [{ 'api-key': 'alpha', id: 'gemini-a' }], + }); + + await updateAiProviderEntry('gemini-api-key', '0', { + apiKey: 'legacy-index-update', + }); + + const persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array>; + expect(persisted[0]?.id).toBe('gemini-a'); + expect(persisted[0]?.['api-key']).toBe('legacy-index-update'); + }); + + it('backfills and preserves stable ids for openai-compatible connectors', async () => { + const { listAiProviders, updateAiProviderEntry } = await loadAiProviderService(); + + writeCliproxyConfig(tempHome, { + 'openai-compatibility': [ + { + name: 'openrouter', + 'base-url': 'https://openrouter.ai/api/v1', + 'api-key-entries': [{ 'api-key': 'sk-openrouter' }], + }, + ], + }); + + const listed = await listAiProviders(); + const family = listed.families.find((entry) => entry.id === 'openai-compatibility'); + const connectorId = family?.entries[0]?.id; + + expect(connectorId).toBeTruthy(); + + await updateAiProviderEntry('openai-compatibility', connectorId!, { + name: 'openrouter', + baseUrl: 'https://router.example/v1', + preserveSecrets: true, + }); + + const persisted = readCliproxyConfig(tempHome)['openai-compatibility'] as Array< + Record + >; + expect(persisted[0]?.id).toBe(connectorId); + expect(persisted[0]?.['base-url']).toBe('https://router.example/v1'); + expect((persisted[0]?.['api-key-entries'] as Array>)[0]?.['api-key']).toBe( + 'sk-openrouter' + ); + }); +}); diff --git a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts index 0cc8835b..32da192c 100644 --- a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts +++ b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts @@ -1,64 +1,62 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resolveLifecyclePort } from '../../../src/commands/cliproxy/proxy-lifecycle-subcommand'; +import { afterEach, describe, expect, it, mock } from 'bun:test'; import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; -import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; -let tempDir: string; +type MockUnifiedConfig = { + cliproxy_server?: { + local?: { + port?: number; + }; + }; +}; -function writeUnifiedConfig(localPort: number): void { - const configPath = path.join(tempDir, 'config.yaml'); - const yaml = `version: 2 -accounts: {} -profiles: {} -preferences: - theme: system - telemetry: false - auto_update: true -cliproxy: - oauth_accounts: {} - providers: - - gemini - - codex - - agy - variants: {} -cliproxy_server: - local: - port: ${localPort} -`; - fs.writeFileSync(configPath, yaml, 'utf8'); +function mockUnifiedConfig(config: MockUnifiedConfig): void { + mock.module('../../../src/config/unified-config-loader', () => ({ + loadOrCreateUnifiedConfig: () => config, + })); +} + +async function loadResolveLifecyclePort() { + const mod = await import( + `../../../src/commands/cliproxy/resolve-lifecycle-port?proxy-lifecycle-port=${Date.now()}-${Math.random()}` + ); + return mod.resolveLifecyclePort; } describe('resolveLifecyclePort', () => { - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-lifecycle-')); - }); - afterEach(() => { - if (tempDir && fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } + mock.restore(); }); it('uses configured cliproxy_server.local.port', async () => { - writeUnifiedConfig(9456); - await runWithScopedConfigDir(tempDir, () => { - expect(resolveLifecyclePort()).toBe(9456); + mockUnifiedConfig({ + cliproxy_server: { + local: { + port: 9456, + }, + }, }); + + const resolveLifecyclePort = await loadResolveLifecyclePort(); + expect(resolveLifecyclePort()).toBe(9456); }); it('falls back to default port when configured local port is invalid', async () => { - writeUnifiedConfig(70000); - await runWithScopedConfigDir(tempDir, () => { - expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + mockUnifiedConfig({ + cliproxy_server: { + local: { + port: 70000, + }, + }, }); + + const resolveLifecyclePort = await loadResolveLifecyclePort(); + expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); }); it('falls back to default port when config file is missing', async () => { - await runWithScopedConfigDir(tempDir, () => { - expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); - }); + mockUnifiedConfig({}); + + const resolveLifecyclePort = await loadResolveLifecyclePort(); + expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); }); }); diff --git a/tests/unit/web-server/ai-provider-routes.test.ts b/tests/unit/web-server/ai-provider-routes.test.ts new file mode 100644 index 00000000..8b638f6f --- /dev/null +++ b/tests/unit/web-server/ai-provider-routes.test.ts @@ -0,0 +1,173 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; +import express from 'express'; +import type { Server } from 'http'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const listCalls: string[] = []; +const updateCalls: Array<{ family: string; entryId: string; data: Record }> = []; + +mock.module('../../../src/cliproxy/ai-providers', () => ({ + AI_PROVIDER_FAMILY_DEFINITIONS: { + 'gemini-api-key': { + id: 'gemini-api-key', + displayName: 'Gemini', + description: 'Mock Gemini family', + authMode: 'hybrid', + supportsNamedEntries: false, + routePath: '/api/provider/gemini', + }, + 'openai-compatibility': { + id: 'openai-compatibility', + displayName: 'OpenAI-Compatible', + description: 'Mock connector family', + authMode: 'connector', + supportsNamedEntries: true, + routePath: '/api/provider/openai-compat', + }, + }, + AI_PROVIDER_FAMILY_IDS: ['gemini-api-key', 'openai-compatibility'], + listAiProviders: async () => { + listCalls.push('list'); + return { + source: { + mode: 'local', + label: 'Local CLIProxy', + target: 'http://127.0.0.1:8317', + managementAuth: 'configured', + }, + families: [], + }; + }, + createAiProviderEntry: async () => {}, + updateAiProviderEntry: async (family: string, entryId: string, data: Record) => { + updateCalls.push({ family, entryId, data }); + }, + deleteAiProviderEntry: async () => {}, +})); + +describe('ai-provider-routes', () => { + let router: typeof import('../../../src/web-server/routes/ai-provider-routes').default; + let server: Server; + let baseUrl = ''; + let forcedRemoteAddress = '127.0.0.1'; + let tempHome = ''; + let originalDashboardAuthEnabled: string | undefined; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + ({ default: router } = await import( + `../../../src/web-server/routes/ai-provider-routes?ai-provider-routes=${Date.now()}` + )); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: forcedRemoteAddress, + configurable: true, + }); + next(); + }); + app.use('/api/cliproxy/ai-providers', router); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + server.once('error', reject); + server.once('listening', () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + originalCcsHome = process.env.CCS_HOME; + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ai-provider-routes-')); + process.env.CCS_HOME = tempHome; + forcedRemoteAddress = '127.0.0.1'; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + listCalls.length = 0; + updateCalls.length = 0; + }); + + afterEach(() => { + if (originalDashboardAuthEnabled !== undefined) { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } else { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + tempHome = ''; + } + }); + + it('blocks remote access when dashboard auth is disabled', async () => { + forcedRemoteAddress = '10.10.0.24'; + + const response = await fetch(`${baseUrl}/api/cliproxy/ai-providers`); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'AI provider endpoints require localhost access when dashboard auth is disabled.', + }); + expect(listCalls).toHaveLength(0); + }); + + it('allows non-local access when dashboard auth is enabled', async () => { + forcedRemoteAddress = '10.10.0.24'; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + + const response = await fetch(`${baseUrl}/api/cliproxy/ai-providers`); + expect(response.status).toBe(200); + expect(listCalls).toHaveLength(1); + }); + + it('passes stable entry ids through update routes', async () => { + const response = await fetch( + `${baseUrl}/api/cliproxy/ai-providers/gemini-api-key/entry-alpha-123`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiKey: 'sk-test' }), + } + ); + + expect(response.status).toBe(200); + expect(updateCalls).toEqual([ + { + family: 'gemini-api-key', + entryId: 'entry-alpha-123', + data: { + apiKey: 'sk-test', + apiKeys: undefined, + baseUrl: undefined, + excludedModels: undefined, + headers: undefined, + models: undefined, + name: undefined, + prefix: undefined, + preserveSecrets: false, + proxyUrl: undefined, + }, + }, + ]); + }); +}); diff --git a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts index eb7ccb03..b61e785b 100644 --- a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts @@ -112,9 +112,9 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { ]); const startResponse = await postJson('/api/cliproxy/auth/kiro/start-url', { - nickname: 'work', - kiroMethod: 'google', - }); + nickname: 'work', + kiroMethod: 'google', + }); expect(startResponse.status).toBe(200); const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); @@ -126,8 +126,8 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { ); const callbackResponse = await postJson('/api/cliproxy/auth/kiro/submit-callback', { - redirectUrl: 'http://localhost/callback?code=abc123&state=state-123', - }); + redirectUrl: 'http://localhost/callback?code=abc123&state=state-123', + }); expect(callbackResponse.status).toBe(200); @@ -142,4 +142,37 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work'); }); + + it('returns 409 when callback completes upstream but no account can be registered locally', async () => { + mockFetch([ + { + url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=google$/, + response: { + auth_url: 'https://auth.example.com/authorize?state=state-409', + state: 'state-409', + }, + }, + { + url: /\/v0\/management\/oauth-callback$/, + method: 'POST', + response: { status: 'ok' }, + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/kiro/start-url', { + nickname: 'work', + kiroMethod: 'google', + }); + expect(startResponse.status).toBe(200); + + const callbackResponse = await postJson('/api/cliproxy/auth/kiro/submit-callback', { + redirectUrl: 'http://localhost/callback?code=abc123&state=state-409', + }); + + expect(callbackResponse.status).toBe(409); + expect(callbackResponse.body).toEqual({ + error: + 'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.', + }); + }); }); diff --git a/ui/src/hooks/use-cliproxy-ai-providers.ts b/ui/src/hooks/use-cliproxy-ai-providers.ts index e95598b5..8493493c 100644 --- a/ui/src/hooks/use-cliproxy-ai-providers.ts +++ b/ui/src/hooks/use-cliproxy-ai-providers.ts @@ -42,13 +42,13 @@ export function useUpdateCliproxyAiProviderEntry() { return useMutation({ mutationFn: ({ family, - index, + entryId, data, }: { family: AiProviderFamilyId; - index: number; + entryId: string; data: UpsertAiProviderEntryInput; - }) => api.cliproxy.aiProviders.update(family, index, data), + }) => api.cliproxy.aiProviders.update(family, entryId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: QUERY_KEY }); toast.success('Provider entry updated'); @@ -63,8 +63,8 @@ export function useDeleteCliproxyAiProviderEntry() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ family, index }: { family: AiProviderFamilyId; index: number }) => - api.cliproxy.aiProviders.delete(family, index), + mutationFn: ({ family, entryId }: { family: AiProviderFamilyId; entryId: string }) => + api.cliproxy.aiProviders.delete(family, entryId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: QUERY_KEY }); toast.success('Provider entry removed'); diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index c31c42e8..a2a9b3bd 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -41,6 +41,8 @@ interface StartAuthOptions { const POLL_INTERVAL = 3000; /** Maximum polling duration (5 minutes) */ const MAX_POLL_DURATION = 5 * 60 * 1000; +/** Fail visibly after repeated poll transport errors instead of retrying forever */ +const MAX_POLL_FAILURES = 3; async function parseResponseBody(response: Response): Promise> { const text = await response.text(); @@ -69,18 +71,26 @@ const INITIAL_STATE: AuthFlowState = { export function useCliproxyAuthFlow() { const [state, setState] = useState(INITIAL_STATE); + const attemptIdRef = useRef(0); const abortControllerRef = useRef(null); const pollIntervalRef = useRef | null>(null); const pollStartRef = useRef(0); + const pollFailureCountRef = useRef(0); const openedAuthUrlRef = useRef(false); const queryClient = useQueryClient(); + const isActiveAttempt = useCallback( + (attemptId: number) => attemptId === attemptIdRef.current, + [] + ); + // Clear polling const stopPolling = useCallback(() => { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } + pollFailureCountRef.current = 0; }, []); // Cleanup on unmount @@ -94,15 +104,21 @@ export function useCliproxyAuthFlow() { // Poll OAuth status const pollStatus = useCallback( - async (provider: string, oauthState: string) => { + async (provider: string, oauthState: string, attemptId: number) => { + if (!isActiveAttempt(attemptId)) { + return; + } + // Check timeout if (Date.now() - pollStartRef.current > MAX_POLL_DURATION) { stopPolling(); - setState((prev) => ({ - ...prev, - isAuthenticating: false, - error: 'Authentication timed out. Please try again.', - })); + if (isActiveAttempt(attemptId)) { + setState((prev) => ({ + ...prev, + isAuthenticating: false, + error: 'Authentication timed out. Please try again.', + })); + } return; } @@ -110,6 +126,10 @@ export function useCliproxyAuthFlow() { const response = await fetch( `/api/cliproxy/auth/${provider}/status?state=${encodeURIComponent(oauthState)}` ); + if (!isActiveAttempt(attemptId)) { + return; + } + const data = (await response.json()) as { status?: string; error?: string; @@ -118,6 +138,7 @@ export function useCliproxyAuthFlow() { verification_url?: string; user_code?: string; }; + pollFailureCountRef.current = 0; if (data.status === 'ok') { stopPolling(); @@ -161,11 +182,30 @@ export function useCliproxyAuthFlow() { })); } // status === 'wait' (or pending) means continue polling - } catch { - // Network error - continue polling + } catch (error) { + if (!isActiveAttempt(attemptId)) { + return; + } + + pollFailureCountRef.current += 1; + if (pollFailureCountRef.current < MAX_POLL_FAILURES) { + return; + } + + stopPolling(); + const message = + error instanceof Error && error.message.trim().length > 0 + ? error.message + : 'Lost contact with the auth status endpoint'; + toast.error(message); + setState((prev) => ({ + ...prev, + isAuthenticating: false, + error: message, + })); } }, - [queryClient, stopPolling] + [isActiveAttempt, queryClient, stopPolling] ); const startAuth = useCallback( @@ -182,9 +222,12 @@ export function useCliproxyAuthFlow() { abortControllerRef.current?.abort(); stopPolling(); openedAuthUrlRef.current = false; + pollFailureCountRef.current = 0; // Create fresh controller and capture locally to avoid race with cancelAuth const controller = new AbortController(); + const attemptId = attemptIdRef.current + 1; + attemptIdRef.current = attemptId; abortControllerRef.current = controller; const flowType = @@ -219,7 +262,13 @@ export function useCliproxyAuthFlow() { signal: controller.signal, }) .then(async (response) => { + if (!isActiveAttempt(attemptId)) { + return; + } const data = await parseResponseBody(response); + if (!isActiveAttempt(attemptId)) { + return; + } const success = data.success === true; if (response.ok && success) { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); @@ -240,6 +289,9 @@ export function useCliproxyAuthFlow() { } }) .catch((error) => { + if (!isActiveAttempt(attemptId)) { + return; + } if (error instanceof Error && error.name === 'AbortError') { // Cancelled - state already reset by cancelAuth return; @@ -261,8 +313,14 @@ export function useCliproxyAuthFlow() { body: JSON.stringify(payload), signal: controller.signal, }); + if (!isActiveAttempt(attemptId)) { + return; + } const data = await parseResponseBody(response); + if (!isActiveAttempt(attemptId)) { + return; + } const success = data.success === true; if (!response.ok || !success) { @@ -290,11 +348,14 @@ export function useCliproxyAuthFlow() { if (oauthState) { pollStartRef.current = Date.now(); pollIntervalRef.current = setInterval(() => { - pollStatus(provider, oauthState); + void pollStatus(provider, oauthState, attemptId); }, POLL_INTERVAL); } } } catch (error) { + if (!isActiveAttempt(attemptId)) { + return; + } if (error instanceof Error && error.name === 'AbortError') { openedAuthUrlRef.current = false; setState(INITIAL_STATE); @@ -309,11 +370,12 @@ export function useCliproxyAuthFlow() { })); } }, - [pollStatus, stopPolling, queryClient] + [isActiveAttempt, pollStatus, stopPolling, queryClient] ); const cancelAuth = useCallback(() => { const currentProvider = state.provider; + attemptIdRef.current += 1; abortControllerRef.current?.abort(); stopPolling(); openedAuthUrlRef.current = false; @@ -329,37 +391,53 @@ export function useCliproxyAuthFlow() { const submitCallback = useCallback( async (redirectUrl: string) => { if (!state.provider) return; + const attemptId = attemptIdRef.current; + const currentProvider = state.provider; setState((prev) => ({ ...prev, isSubmittingCallback: true, error: null })); try { - const response = await fetch(`/api/cliproxy/auth/${state.provider}/submit-callback`, { + const response = await fetch(`/api/cliproxy/auth/${currentProvider}/submit-callback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ redirectUrl }), }); + if (!isActiveAttempt(attemptId)) { + return; + } const data = await parseResponseBody(response); + if (!isActiveAttempt(attemptId)) { + return; + } const success = data.success === true; + const hasAccount = typeof data.account === 'object' && data.account !== null; - if (response.ok && success) { + if (response.ok && success && hasAccount) { stopPolling(); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); - toast.success(`${state.provider} authentication successful`); + toast.success(`${currentProvider} authentication successful`); setState(INITIAL_STATE); } else { const errorMsg = - typeof data.error === 'string' ? data.error : 'Callback submission failed'; + typeof data.error === 'string' + ? data.error + : success + ? 'Authenticated account could not be registered' + : 'Callback submission failed'; throw new Error(errorMsg); } } catch (error) { + if (!isActiveAttempt(attemptId)) { + return; + } const message = error instanceof Error ? error.message : 'Failed to submit callback'; toast.error(message); setState((prev) => ({ ...prev, isSubmittingCallback: false, error: message })); } }, - [state.provider, queryClient, stopPolling] + [isActiveAttempt, state.provider, queryClient, stopPolling] ); return useMemo( diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 86faae54..e6c18c2c 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -854,15 +854,21 @@ export const api = { method: 'POST', body: JSON.stringify(data), }), - update: (family: AiProviderFamilyId, index: number, data: UpsertAiProviderEntryInput) => - request(`/cliproxy/ai-providers/${encodeURIComponent(family)}/${index}`, { - method: 'PUT', - body: JSON.stringify(data), - }), - delete: (family: AiProviderFamilyId, index: number) => - request(`/cliproxy/ai-providers/${encodeURIComponent(family)}/${index}`, { - method: 'DELETE', - }), + update: (family: AiProviderFamilyId, entryId: string, data: UpsertAiProviderEntryInput) => + request( + `/cliproxy/ai-providers/${encodeURIComponent(family)}/${encodeURIComponent(entryId)}`, + { + method: 'PUT', + body: JSON.stringify(data), + } + ), + delete: (family: AiProviderFamilyId, entryId: string) => + request( + `/cliproxy/ai-providers/${encodeURIComponent(family)}/${encodeURIComponent(entryId)}`, + { + method: 'DELETE', + } + ), }, // Config YAML for Config tab diff --git a/ui/src/pages/cliproxy-ai-providers.tsx b/ui/src/pages/cliproxy-ai-providers.tsx index 78deca16..7b747da4 100644 --- a/ui/src/pages/cliproxy-ai-providers.tsx +++ b/ui/src/pages/cliproxy-ai-providers.tsx @@ -1759,7 +1759,7 @@ export function CliproxyAiProvidersPage() { onSave={async (payload) => { await updateMutation.mutateAsync({ family: selectedFamily, - index: selectedEntry.index, + entryId: selectedEntry.id, data: payload, }); void refetch(); @@ -1793,7 +1793,7 @@ export function CliproxyAiProvidersPage() { if (editingEntry) { await updateMutation.mutateAsync({ family: selectedFamily, - index: editingEntry.index, + entryId: editingEntry.id, data: payload, }); } else { @@ -1820,7 +1820,7 @@ export function CliproxyAiProvidersPage() { if (!deleteEntry) return; await deleteMutation.mutateAsync({ family: selectedFamily, - index: deleteEntry.index, + entryId: deleteEntry.id, }); setDeleteEntry(null); }} diff --git a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx new file mode 100644 index 00000000..8498ce9f --- /dev/null +++ b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx @@ -0,0 +1,199 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { toast } from 'sonner'; +import { AllProviders } from '../../setup/test-utils'; +import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +function createJsonResponse(body: Record, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('useCliproxyAuthFlow', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + vi.spyOn(window, 'open').mockImplementation(() => null); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('ignores stale poll completions from a superseded auth attempt', async () => { + const firstPoll = createDeferred(); + let startCount = 0; + + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/start-url')) { + startCount += 1; + return Promise.resolve( + createJsonResponse({ + success: true, + authUrl: `https://auth.example/${startCount}`, + state: `state-${startCount}`, + }) + ); + } + + if (url.includes('/status?state=state-1')) { + return firstPoll.promise; + } + + if (url.includes('/status?state=state-2')) { + return Promise.resolve(createJsonResponse({ status: 'wait' })); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('gemini', { startEndpoint: 'start-url' }); + }); + + expect(result.current.oauthState).toBe('state-1'); + + await act(async () => { + vi.advanceTimersByTime(3000); + await Promise.resolve(); + }); + + await act(async () => { + await result.current.startAuth('gemini', { startEndpoint: 'start-url' }); + }); + + expect(result.current.oauthState).toBe('state-2'); + expect(result.current.isAuthenticating).toBe(true); + + await act(async () => { + firstPoll.resolve(createJsonResponse({ status: 'ok' })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.oauthState).toBe('state-2'); + expect(result.current.isAuthenticating).toBe(true); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('surfaces repeated poll transport failures instead of retrying forever', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/start-url')) { + return Promise.resolve( + createJsonResponse({ + success: true, + authUrl: 'https://auth.example/fail', + state: 'state-fail', + }) + ); + } + + if (url.includes('/status?state=state-fail')) { + return Promise.reject(new Error('poll failed')); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('gemini', { startEndpoint: 'start-url' }); + }); + + for (let attempt = 0; attempt < 3; attempt += 1) { + await act(async () => { + vi.advanceTimersByTime(3000); + await Promise.resolve(); + await Promise.resolve(); + }); + } + + expect(result.current.error).toBe('poll failed'); + expect(result.current.isAuthenticating).toBe(false); + expect(toast.error).toHaveBeenCalledWith('poll failed'); + }); + + it('treats callback responses without an account as failures', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/start-url')) { + return Promise.resolve( + createJsonResponse({ + success: true, + authUrl: 'https://auth.example/callback', + state: 'state-callback', + }) + ); + } + + if (url.includes('/submit-callback')) { + return Promise.resolve(createJsonResponse({ success: true, account: null })); + } + + if (url.includes('/status?state=state-callback')) { + return Promise.resolve(createJsonResponse({ status: 'wait' })); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('gemini', { startEndpoint: 'start-url' }); + }); + + await act(async () => { + await result.current.submitCallback( + 'http://localhost/callback?code=abc123&state=state-callback' + ); + }); + + expect(result.current.error).toBe('Authenticated account could not be registered'); + expect(result.current.isSubmittingCallback).toBe(false); + expect(toast.error).toHaveBeenCalledWith('Authenticated account could not be registered'); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); From aef58d66ce69777cf8aba6f43f615560ef820b62 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 11:43:06 -0400 Subject: [PATCH 08/24] fix(dashboard): refine api profile quick start state - prevent the providers page from introducing nested overflow - show a select-profile state when profiles already exist - localize the new quick-start and AI Providers copy --- .../profiles/openrouter-quick-start.tsx | 372 ++++++++++-------- ui/src/lib/i18n.ts | 76 ++++ ui/src/pages/api.tsx | 10 +- 3 files changed, 281 insertions(+), 177 deletions(-) diff --git a/ui/src/components/profiles/openrouter-quick-start.tsx b/ui/src/components/profiles/openrouter-quick-start.tsx index 7dc74afd..9fc4e746 100644 --- a/ui/src/components/profiles/openrouter-quick-start.tsx +++ b/ui/src/components/profiles/openrouter-quick-start.tsx @@ -1,32 +1,92 @@ -/** - * OpenRouter Quick Start Card - * Prominent CTA for new users to create OpenRouter profile - */ - +import type { ReactNode } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; import { useOpenRouterReady } from '@/hooks/use-openrouter-models'; +import { cn } from '@/lib/utils'; import { - Sparkles, - ExternalLink, ArrowRight, - Zap, CloudCog, + ExternalLink, KeyRound, SlidersHorizontal, + Sparkles, + Zap, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; interface OpenRouterQuickStartProps { + hasProfiles: boolean; + profileCount: number; onOpenRouterClick: () => void; onAlibabaCodingPlanClick: () => void; onCliproxyClick: () => void; onCustomClick: () => void; } +interface QuickStartCardProps { + badge: string; + badgeClassName?: string; + className?: string; + title: string; + description: string; + visual: ReactNode; + highlights: Array<{ icon: ReactNode; label: string }>; + actionLabel: string; + actionClassName: string; + onAction: () => void; + footer?: ReactNode; +} + +function QuickStartCard({ + badge, + badgeClassName, + className, + title, + description, + visual, + highlights, + actionLabel, + actionClassName, + onAction, + footer, +}: QuickStartCardProps) { + return ( + + +
+ {visual} + + {badge} + +
+
+ {title} + {description} +
+
+ +
+ {highlights.map((item) => ( +
+ {item.icon} + {item.label} +
+ ))} +
+ + {footer ?
{footer}
: null} +
+
+ ); +} + export function OpenRouterQuickStart({ + hasProfiles, + profileCount, onOpenRouterClick, onAlibabaCodingPlanClick, onCliproxyClick, @@ -34,185 +94,151 @@ export function OpenRouterQuickStart({ }: OpenRouterQuickStartProps) { const { t } = useTranslation(); const { modelCount, isLoading } = useOpenRouterReady(); + const modelCountLabel = isLoading ? '300+' : `${modelCount}+`; + const profileSummaryLabel = hasProfiles + ? t('openrouterQuickStart.profileCount', { count: profileCount }) + : t('openrouterQuickStart.recommended'); + const summaryTitle = hasProfiles + ? t('openrouterQuickStart.selectProfileTitle') + : t('apiProfiles.noProfilesYet'); + const summaryDescription = hasProfiles + ? t('openrouterQuickStart.summaryDescriptionWithProfiles', { count: profileCount }) + : t('openrouterQuickStart.summaryDescriptionNoProfiles'); return ( -
-
- {/* Main OpenRouter Card */} - - -
-
- OpenRouter +
+
+ + +
+
+ {profileSummaryLabel} + + {t('openrouterQuickStart.openrouterModelsBadge', { modelCountLabel })} +
- - {t('openrouterQuickStart.recommended')} - -
- {t('openrouterQuickStart.title')} - - {t('openrouterQuickStart.description', { - modelCountLabel: isLoading ? '300+' : `${modelCount}+`, - })} - - - - {/* Key Features */} -
-
- - {t('openrouterQuickStart.featureOneApi')} -
-
- - {t('openrouterQuickStart.featureTierMapping')} +
+

{summaryTitle}

+

+ {summaryDescription} +

- - - -

- {t('openrouterQuickStart.getApiKeyAt')}{' '} - - openrouter.ai/keys - - -

- {/* Alibaba Coding Plan Card */} - - -
-
+
+ + OpenRouter +
+ } + highlights={[ + { + icon: , + label: t('openrouterQuickStart.featureOneApi'), + }, + { + icon: , + label: t('openrouterQuickStart.featureTierMapping'), + }, + ]} + actionLabel={t('openrouterQuickStart.createOpenRouterProfile')} + actionClassName="w-full bg-accent text-white hover:bg-accent/90" + onAction={onOpenRouterClick} + footer={ + <> + {t('openrouterQuickStart.getApiKeyAt')}{' '} + + openrouter.ai/keys + + + + } + /> + + + +
+ } + highlights={[ + { + icon: , + label: t('openrouterQuickStart.runtimeProviderFeatureConnectors'), + }, + { + icon: , + label: t('openrouterQuickStart.runtimeProviderFeatureSecrets'), + }, + ]} + actionLabel={t('openrouterQuickStart.runtimeProviderTitle')} + actionClassName="w-full bg-emerald-600 text-white hover:bg-emerald-600/90" + onAction={onCliproxyClick} + footer={{t('openrouterQuickStart.runtimeProviderFooter')}} + /> + + Alibaba Coding Plan
- - {t('alibabaCodingPlanQuickStart.recommended')} - -
- {t('alibabaCodingPlanQuickStart.title')} - - {t('alibabaCodingPlanQuickStart.description')} - - - -
-
- - {t('alibabaCodingPlanQuickStart.featureEndpoint')} -
-
- - {t('alibabaCodingPlanQuickStart.featureKeyFormat')} -
-
- - - -

- {t('alibabaCodingPlanQuickStart.readGuideAt')}{' '} - - Alibaba Cloud Model Studio - - -

-
-
- - - -
-
- -
- - Configure in AI Providers - -
- Manage CLIProxy AI providers - - Configure Gemini, Codex, Claude, Vertex, and OpenAI-compatible connectors directly in - the dedicated CLIProxy AI Providers page. - -
- -
-
- - Dedicated /cliproxy/ai-providers workspace -
-
- - Manage provider secrets outside API Profiles -
-
- - - -

- Keep runtime provider configuration in CLIProxy, then create API Profiles only when - you need standalone Anthropic-compatible endpoints. -

-
-
- - {/* Divider */} -
- - {t('openrouterQuickStart.or')} - + } + highlights={[ + { + icon: , + label: t('alibabaCodingPlanQuickStart.featureEndpoint'), + }, + { + icon: , + label: t('alibabaCodingPlanQuickStart.featureKeyFormat'), + }, + ]} + actionLabel={t('alibabaCodingPlanQuickStart.createAlibabaProfile')} + actionClassName="w-full bg-orange-600 text-white hover:bg-orange-600/90" + onAction={onAlibabaCodingPlanClick} + footer={ + <> + {t('alibabaCodingPlanQuickStart.readGuideAt')}{' '} + + Alibaba Cloud Model Studio + + + + } + />
- - {/* Custom Option */} -
); diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 038d5ee0..a411699b 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -241,6 +241,25 @@ const resources = { 'Access {{modelCountLabel}} models from OpenAI, Anthropic, Google, Meta and more - all through one API.', featureOneApi: 'One API, all providers', featureTierMapping: 'Model tier mapping', + profileCount: '{{count}} profile', + profileCount_other: '{{count}} profiles', + selectProfileTitle: 'Select an API profile', + summaryDescriptionWithProfiles: + 'You already have {{count}} profile in this workspace. Select one from the left rail to edit it, or create another profile from here.', + summaryDescriptionWithProfiles_other: + 'You already have {{count}} profiles in this workspace. Select one from the left rail to edit it, or create another profile from here.', + summaryDescriptionNoProfiles: + 'Use API Profiles for Anthropic-compatible endpoints. Use AI Providers for Gemini, Codex, Claude, Vertex, and OpenAI-compatible connectors.', + openrouterModelsBadge: '{{modelCountLabel}} OpenRouter models', + runtimeProviderBadge: 'Runtime provider setup', + runtimeProviderTitle: 'Open AI Providers', + runtimeProviderDescription: + 'Manage shared provider keys and runtime connectors before creating standalone API Profiles.', + runtimeProviderFeatureConnectors: + 'Gemini, Codex, Claude, Vertex, and OpenAI-compatible connectors', + runtimeProviderFeatureSecrets: 'Shared secrets stay outside individual API profiles', + runtimeProviderFooter: + 'Best when multiple profiles should share one provider configuration.', createOpenRouterProfile: 'Create OpenRouter Profile', getApiKeyAt: 'Get your API key at', or: 'or', @@ -1420,6 +1439,24 @@ const resources = { '้€š่ฟ‡ไธ€ไธช API ๅณๅฏ่ฎฟ้—ฎๆฅ่‡ช OpenAIใ€Anthropicใ€Googleใ€Meta ็ญ‰็š„ {{modelCountLabel}} ไธชๆจกๅž‹ใ€‚', featureOneApi: 'ไธ€ไธช API๏ผŒๆŽฅๅ…ฅๅ…จ้ƒจๆไพ›ๅ•†', featureTierMapping: 'ๆจกๅž‹ๆกฃไฝๆ˜ ๅฐ„', + profileCount: '{{count}} ไธช API ้…็ฝฎ', + profileCount_other: '{{count}} ไธช API ้…็ฝฎ', + selectProfileTitle: '้€‰ๆ‹ฉไธ€ไธช API ้…็ฝฎ', + summaryDescriptionWithProfiles: + 'ๆญคๅทฅไฝœๅŒบไธญๅทฒๅญ˜ๅœจ {{count}} ไธช API ้…็ฝฎใ€‚่ฏทไปŽๅทฆไพงๆ ้€‰ๆ‹ฉไธ€ไธช่ฟ›่กŒ็ผ–่พ‘๏ผŒๆˆ–ๅœจๆญค็ปง็ปญๅˆ›ๅปบๆ–ฐ็š„้…็ฝฎใ€‚', + summaryDescriptionWithProfiles_other: + 'ๆญคๅทฅไฝœๅŒบไธญๅทฒๅญ˜ๅœจ {{count}} ไธช API ้…็ฝฎใ€‚่ฏทไปŽๅทฆไพงๆ ้€‰ๆ‹ฉไธ€ไธช่ฟ›่กŒ็ผ–่พ‘๏ผŒๆˆ–ๅœจๆญค็ปง็ปญๅˆ›ๅปบๆ–ฐ็š„้…็ฝฎใ€‚', + summaryDescriptionNoProfiles: + 'API ้…็ฝฎ้€‚็”จไบŽ Anthropic ๅ…ผๅฎน็ซฏ็‚นใ€‚AI Providers ้€‚็”จไบŽ Geminiใ€Codexใ€Claudeใ€Vertex ไปฅๅŠ OpenAI ๅ…ผๅฎน่ฟžๆŽฅๅ™จใ€‚', + openrouterModelsBadge: '{{modelCountLabel}} ไธช OpenRouter ๆจกๅž‹', + runtimeProviderBadge: '่ฟ่กŒๆ—ถๆไพ›ๅ•†่ฎพ็ฝฎ', + runtimeProviderTitle: 'ๆ‰“ๅผ€ AI Providers', + runtimeProviderDescription: + 'ๅ…ˆ็ฎก็†ๅ…ฑไบซ็š„ๆไพ›ๅ•†ๅฏ†้’ฅๅ’Œ่ฟ่กŒๆ—ถ่ฟžๆŽฅๅ™จ๏ผŒๅ†ๅˆ›ๅปบ็‹ฌ็ซ‹็š„ API ้…็ฝฎใ€‚', + runtimeProviderFeatureConnectors: + 'Geminiใ€Codexใ€Claudeใ€Vertex ไปฅๅŠ OpenAI ๅ…ผๅฎน่ฟžๆŽฅๅ™จ', + runtimeProviderFeatureSecrets: 'ๅ…ฑไบซๅฏ†้’ฅไธŽๅ•ไธช API ้…็ฝฎๅˆ†็ฆปๅญ˜ๆ”พ', + runtimeProviderFooter: 'ๅฝ“ๅคšไธช้…็ฝฎ้œ€่ฆๅ…ฑไบซๅŒไธ€ๆไพ›ๅ•†่ฎพ็ฝฎๆ—ถๆœ€้€‚ๅˆไฝฟ็”จใ€‚', createOpenRouterProfile: 'ๅˆ›ๅปบ OpenRouter ้…็ฝฎ', getApiKeyAt: 'ๅœจๆญค่Žทๅ– API Key๏ผš', or: 'ๆˆ–', @@ -2566,6 +2603,25 @@ const resources = { 'Truy cแบญp cรกc mรด hรฌnh {{modelCountLabel}} tแปซ OpenAI, Anthropic, Google, Meta, v.v. - tแบฅt cแบฃ ฤ‘แปu thรดng qua mแป™t API.', featureOneApi: 'Mแป™t API cho mแปi nhร  cung cแบฅp', featureTierMapping: 'รnh xแบก tแบงng mรด hรฌnh', + profileCount: '{{count}} hแป“ sฦก', + profileCount_other: '{{count}} hแป“ sฦก', + selectProfileTitle: 'Chแปn mแป™t hแป“ sฦก API', + summaryDescriptionWithProfiles: + 'Bแบกn ฤ‘รฃ cรณ {{count}} hแป“ sฦก trong workspace nร y. Chแปn mแป™t hแป“ sฦก แปŸ thanh bรชn trรกi ฤ‘แปƒ chแป‰nh sแปญa, hoแบทc tแบกo thรชm hแป“ sฦก mแป›i tแบกi ฤ‘รขy.', + summaryDescriptionWithProfiles_other: + 'Bแบกn ฤ‘รฃ cรณ {{count}} hแป“ sฦก trong workspace nร y. Chแปn mแป™t hแป“ sฦก แปŸ thanh bรชn trรกi ฤ‘แปƒ chแป‰nh sแปญa, hoแบทc tแบกo thรชm hแป“ sฦก mแป›i tแบกi ฤ‘รขy.', + summaryDescriptionNoProfiles: + 'Dรนng API Profiles cho cรกc endpoint tฦฐฦกng thรญch Anthropic. Dรนng AI Providers cho Gemini, Codex, Claude, Vertex vร  cรกc connector tฦฐฦกng thรญch OpenAI.', + openrouterModelsBadge: '{{modelCountLabel}} mรด hรฌnh OpenRouter', + runtimeProviderBadge: 'Thiแบฟt lแบญp nhร  cung cแบฅp runtime', + runtimeProviderTitle: 'MแปŸ AI Providers', + runtimeProviderDescription: + 'Quแบฃn lรฝ khรณa nhร  cung cแบฅp dรนng chung vร  cรกc connector runtime trฦฐแป›c khi tแบกo API Profiles ฤ‘แป™c lแบญp.', + runtimeProviderFeatureConnectors: + 'Gemini, Codex, Claude, Vertex vร  cรกc connector tฦฐฦกng thรญch OpenAI', + runtimeProviderFeatureSecrets: 'Khรณa dรนng chung tรกch biแป‡t khแปi tแปซng API Profile', + runtimeProviderFooter: + 'Phรน hแปฃp nhแบฅt khi nhiแปu hแป“ sฦก cแบงn dรนng chung mแป™t cแบฅu hรฌnh nhร  cung cแบฅp.', createOpenRouterProfile: 'Tแบกo hแป“ sฦก OpenRouter', getApiKeyAt: 'Nhแบญn khรณa API cแปงa bแบกn tแบกi', or: 'hoแบทc', @@ -3767,6 +3823,26 @@ const resources = { 'OpenAIใ€Anthropicใ€Googleใ€Meta ใชใฉใฎ {{modelCountLabel}} ใƒขใƒ‡ใƒซใ‚’ใ€1 ใคใฎ API ใงๅˆฉ็”จใงใใพใ™ใ€‚', featureOneApi: '1 ใคใฎ API ใงๅ…จใƒ—ใƒญใƒใ‚คใƒ€ใƒผ', featureTierMapping: 'ใƒขใƒ‡ใƒซใƒ†ใ‚ฃใ‚ขใƒžใƒƒใƒ”ใƒณใ‚ฐ', + profileCount: '{{count}} ๅ€‹ใฎ API ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซ', + profileCount_other: '{{count}} ๅ€‹ใฎ API ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซ', + selectProfileTitle: 'API ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’้ธๆŠž', + summaryDescriptionWithProfiles: + 'ใ“ใฎใƒฏใƒผใ‚ฏใ‚นใƒšใƒผใ‚นใซใฏๆ—ขใซ {{count}} ๅ€‹ใฎ API ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใŒใ‚ใ‚Šใพใ™ใ€‚ๅทฆๅดใƒฌใƒผใƒซใ‹ใ‚‰ 1 ใค้ธใ‚“ใง็ทจ้›†ใ™ใ‚‹ใ‹ใ€ใ“ใ“ใ‹ใ‚‰ๆ–ฐใ—ใ„ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’ไฝœๆˆใ—ใฆใใ ใ•ใ„ใ€‚', + summaryDescriptionWithProfiles_other: + 'ใ“ใฎใƒฏใƒผใ‚ฏใ‚นใƒšใƒผใ‚นใซใฏๆ—ขใซ {{count}} ๅ€‹ใฎ API ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใŒใ‚ใ‚Šใพใ™ใ€‚ๅทฆๅดใƒฌใƒผใƒซใ‹ใ‚‰ 1 ใค้ธใ‚“ใง็ทจ้›†ใ™ใ‚‹ใ‹ใ€ใ“ใ“ใ‹ใ‚‰ๆ–ฐใ—ใ„ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’ไฝœๆˆใ—ใฆใใ ใ•ใ„ใ€‚', + summaryDescriptionNoProfiles: + 'API Profiles ใฏ Anthropic ไบ’ๆ›ใ‚จใƒณใƒ‰ใƒใ‚คใƒณใƒˆๅ‘ใ‘ใงใ™ใ€‚AI Providers ใฏ Geminiใ€Codexใ€Claudeใ€Vertexใ€OpenAI ไบ’ๆ›ใ‚ณใƒใ‚ฏใ‚ฟๅ‘ใ‘ใงใ™ใ€‚', + openrouterModelsBadge: '{{modelCountLabel}} ๅ€‹ใฎ OpenRouter ใƒขใƒ‡ใƒซ', + runtimeProviderBadge: 'ใƒฉใƒณใ‚ฟใ‚คใƒ ใƒ—ใƒญใƒใ‚คใƒ€ใƒผ่จญๅฎš', + runtimeProviderTitle: 'AI Providers ใ‚’้–‹ใ', + runtimeProviderDescription: + 'ๅ€‹ๅˆฅใฎ API ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’ไฝœๆˆใ™ใ‚‹ๅ‰ใซใ€ๅ…ฑๆœ‰ใƒ—ใƒญใƒใ‚คใƒ€ใƒผใ‚ญใƒผใจใƒฉใƒณใ‚ฟใ‚คใƒ ใ‚ณใƒใ‚ฏใ‚ฟใ‚’็ฎก็†ใ—ใพใ™ใ€‚', + runtimeProviderFeatureConnectors: + 'Geminiใ€Codexใ€Claudeใ€Vertexใ€OpenAI ไบ’ๆ›ใ‚ณใƒใ‚ฏใ‚ฟ', + runtimeProviderFeatureSecrets: + 'ๅ…ฑๆœ‰ใ‚ทใƒผใ‚ฏใƒฌใƒƒใƒˆใ‚’ๅ€‹ๅˆฅใฎ API ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‹ใ‚‰ๅˆ‡ใ‚Š้›ขใ—ใฆ็ฎก็†', + runtimeProviderFooter: + '่ค‡ๆ•ฐใฎใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใง 1 ใคใฎใƒ—ใƒญใƒใ‚คใƒ€ใƒผ่จญๅฎšใ‚’ๅ…ฑๆœ‰ใ—ใŸใ„ๅ ดๅˆใซๆœ€้ฉใงใ™ใ€‚', createOpenRouterProfile: 'OpenRouter ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’ไฝœๆˆ', getApiKeyAt: 'API ใ‚ญใƒผๅ–ๅพ—:', or: 'ใพใŸใฏ', diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 47cdaf84..3fb37b33 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -206,9 +206,9 @@ export function ApiPage() { }; return ( -
+
setCreateDialogOpen(true)} /> -
+
@@ -262,7 +262,7 @@ export function ApiPage() {
- + {isLoading ? (
{t('apiProfiles.loadingProfiles')} @@ -354,7 +354,7 @@ export function ApiPage() { />
-
+
{selectedProfileData ? ( <>
@@ -387,6 +387,8 @@ export function ApiPage() { ) : ( 0} + profileCount={profiles.length} onCliproxyClick={() => { navigate('/cliproxy/ai-providers'); }} From 10f284585f1473f3198288bc0419c58b058442b3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 11:43:13 -0400 Subject: [PATCH 09/24] test(dashboard): cover api profile quick start states --- .../profiles/openrouter-quick-start.test.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx diff --git a/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx b/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx new file mode 100644 index 00000000..2fdd1394 --- /dev/null +++ b/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import i18n from '@/lib/i18n'; +import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start'; +import { render, screen } from '@tests/setup/test-utils'; + +vi.mock('@/hooks/use-openrouter-models', () => ({ + useOpenRouterReady: () => ({ + modelCount: 318, + isLoading: false, + }), +})); + +describe('OpenRouterQuickStart', () => { + const props = { + onOpenRouterClick: vi.fn(), + onAlibabaCodingPlanClick: vi.fn(), + onCliproxyClick: vi.fn(), + onCustomClick: vi.fn(), + }; + + beforeEach(async () => { + await i18n.changeLanguage('en'); + }); + + it('prompts the user to select an existing API profile instead of showing the empty-state copy', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Select an API profile' })).toBeInTheDocument(); + expect(screen.getByText('1 profile')).toBeInTheDocument(); + expect(screen.getByText(/You already have 1 profile in this workspace\./)).toBeInTheDocument(); + expect(screen.queryByText('No API profiles yet')).not.toBeInTheDocument(); + }); + + it('keeps the original empty-state title when no API profiles exist', () => { + render(); + + expect(screen.getByRole('heading', { name: 'No API profiles yet' })).toBeInTheDocument(); + expect(screen.getAllByText('Recommended')).not.toHaveLength(0); + }); +}); From 89a4d5669eb50057d76ea27517b6aeea67da3ed4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 15:45:48 +0000 Subject: [PATCH 10/24] chore(release): 7.56.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4858ecc9..88de3b28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.56.0", + "version": "7.56.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 19c1e1da4f56afce466903d884ea49cf52b75255 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 16:05:44 +0000 Subject: [PATCH 11/24] chore(release): 7.56.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 88de3b28..c353c70a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.56.0-dev.1", + "version": "7.56.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 160be319d0b1982c05dc2984673a81aa6ec245b5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 13:44:32 -0400 Subject: [PATCH 12/24] feat: add explicit droid runtime alias surface --- README.md | 29 +++++--- docs/code-standards.md | 3 +- docs/codebase-summary.md | 2 +- docs/system-architecture/index.md | 9 ++- docs/system-architecture/target-adapters.md | 25 +++++-- package.json | 1 + src/commands/api-command/create-command.ts | 18 ++++- src/commands/cliproxy/variant-subcommand.ts | 28 +++++-- src/commands/help-command.ts | 16 ++-- src/targets/target-resolver.ts | 73 ++++++++++++++++--- .../targets/ccsd-alias-integration.test.ts | 11 +++ tests/unit/targets/target-resolver.test.ts | 34 +++++++++ .../provider-editor/provider-info-tab.tsx | 6 +- .../profiles/editor/info-section.tsx | 10 +-- .../profiles/profile-create-dialog.tsx | 24 ++++-- ui/src/lib/i18n.ts | 9 +-- ui/src/lib/support-updates-catalog.ts | 16 ++-- 17 files changed, 229 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 828919e2..62012ab1 100644 --- a/README.md +++ b/README.md @@ -194,16 +194,25 @@ ccs api export glm --out ./glm.ccs-profile.json # Export for cross-device trans ccs api import ./glm.ccs-profile.json # Import exported profile bundle ``` -### Droid Alias (`argv[0]` pattern) +### Runtime Aliases (`argv[0]` pattern) -By default, invoking CCS as `ccsd` auto-selects the Droid target: +Built-in Droid runtime aliases: ```bash +ln -s "$(command -v ccs)" /usr/local/bin/ccs-droid ln -s "$(command -v ccs)" /usr/local/bin/ccsd -ccsd glm +ccs-droid glm # explicit alias +ccsd glm # legacy shortcut ``` -Need additional alias names? Set `CCS_DROID_ALIASES` as a comma-separated list (for example: `CCS_DROID_ALIASES=ccs-droid,mydroid`). +Need additional alias names? Use `CCS_TARGET_ALIASES` (preferred) or keep using +legacy `CCS_DROID_ALIASES`: + +```bash +CCS_TARGET_ALIASES='droid=mydroid,team-droid' +# Legacy fallback still supported: +CCS_DROID_ALIASES='mydroid,team-droid' +``` For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hint (`CCS_DROID_PROVIDER`) using one of: @@ -217,9 +226,9 @@ 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 +ccs-droid codex exec --skip-permissions-unsafe "fix failing tests" +ccs-droid codex --skip-permissions-unsafe "fix failing tests" # auto-routed to: droid exec ... +ccs-droid 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`), @@ -245,10 +254,10 @@ 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-droid codex +ccs-droid agy ccs codex --target droid -ccsd codex exec --auto high "triage this bug report" +ccs-droid codex exec --auto high "triage this bug report" ``` Dashboard parity: diff --git a/docs/code-standards.md b/docs/code-standards.md index 8d6182ba..60e41441 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -200,7 +200,8 @@ Resolves which adapter to use via `resolveTargetType()`: โ†“ 2. Profile config: profileConfig.target field โ†“ -3. argv[0] detection (busybox pattern): +3. argv[0] detection (runtime alias pattern): + - ccs-droid โ†’ droid - ccsd โ†’ droid - ccs โ†’ default โ†“ diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 17ae9fca..37049183 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -246,7 +246,7 @@ The targets module provides an extensible interface for dispatching profiles to 2. **Target Resolution** - Priority order: - `--target ` flag (CLI argument) - Per-profile `target` field (from config.yaml) - - `argv[0]` detection (busybox pattern: `ccsd` โ†’ droid) + - `argv[0]` detection (runtime alias pattern: `ccs-droid` / `ccsd` โ†’ droid) - Default: `claude` 3. **Implementations:** diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index 7de060eb..6c4e1647 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -86,11 +86,12 @@ Spawn Target Process - Spawns: `droid -m custom:ccs- ` - Model config includes baseUrl, apiKey, provider -**Binary alias pattern (busybox-style):** +**Runtime alias pattern (argv[0]-style):** ``` -ccs โ†’ Target: claude (default) -ccsd โ†’ Target: droid (auto-selected via argv[0]) +ccs โ†’ Target: claude (default) +ccs-droid โ†’ Target: droid (explicit alias) +ccsd โ†’ Target: droid (legacy shortcut) ``` For details on the adapter architecture, see [Target Adapters](./target-adapters.md). @@ -392,7 +393,7 @@ See [Provider Flows](./provider-flows.md) โ†’ Authentication Flow section. | +---> Creates symlink: ccs --> dist/ccs.js | - +---> Binary alias: ccsd โ†’ ccs (auto-selects droid target) + +---> Runtime aliases: ccs-droid / ccsd โ†’ ccs (auto-select droid target) | +---> First run creates: ~/.ccs/ ``` diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index c1944c6b..5d81005e 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -79,8 +79,9 @@ CCS resolves which adapter to use via priority-ordered checks: glm: target: droid -3. argv[0] detection (busybox pattern) โ€” binary name mapping - โ””โ”€ ccsd (symlink/batch file) โ†’ droid +3. argv[0] detection (runtime alias pattern) โ€” binary name mapping + โ””โ”€ ccs-droid (explicit alias) โ†’ droid + โ””โ”€ ccsd (legacy shortcut) โ†’ droid โ””โ”€ ccs (regular command) โ†’ default 4. Fallback: 'claude' โ€” lowest priority @@ -351,22 +352,30 @@ ccs --target droid glm (credentials loaded from ~/.factory/settings.json) ``` -### Binary Alias Pattern +### Runtime Alias Pattern ```bash -# Create alias/symlink to auto-select droid target -# Built-in alias: ccsd +# Create aliases/symlinks to auto-select droid target +ln -s /path/to/ccs /path/to/ccs-droid ln -s /path/to/ccs /path/to/ccsd # Usage +ccs-droid glm +โ†’ Target: droid (detected from argv[0]) +โ†’ droid -m custom:ccs-glm "args..." + +# Legacy shortcut still works ccsd glm โ†’ Target: droid (detected from argv[0]) โ†’ droid -m custom:ccs-glm "args..." ``` -On Windows, `ccsd.cmd`, `ccsd.bat`, `ccsd.ps1`, and `ccsd.exe` wrappers are also recognized. +On Windows, `ccs-droid.cmd`, `ccsd.cmd`, `ccsd.bat`, `ccsd.ps1`, and `ccsd.exe` wrappers are also recognized. -Additional alias names can be configured at runtime via `CCS_DROID_ALIASES` (comma-separated). Example: `CCS_DROID_ALIASES=ccs-droid,mydroid`. +Additional alias names can be configured at runtime via `CCS_TARGET_ALIASES` +(preferred, `target=alias1,alias2;...`) or legacy `CCS_DROID_ALIASES` +(comma-separated). Example: +`CCS_TARGET_ALIASES=droid=mydroid,team-droid` --- @@ -612,7 +621,7 @@ ccs --target claude help ccs --target droid help # Test argv[0] detection -ccsd help +ccs-droid help ``` --- diff --git a/package.json b/package.json index c353c70a..ef687955 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "types": "dist/ccs.d.ts", "bin": { "ccs": "dist/ccs.js", + "ccs-droid": "dist/ccs.js", "ccsd": "dist/ccs.js" }, "files": [ diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 0bcce273..dbd798a6 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -366,12 +366,18 @@ export async function handleApiCreateCommand(args: string[]): Promise { ` ${color(`ccs ${result.name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` ); console.log( - ` ${color(`ccsd ${result.name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ` ${color(`ccs-droid ${result.name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccsd ${result.name} "your prompt"`, 'command')} ${dim('# legacy shortcut')}` ); } else { console.log(` ${color(`ccs ${result.name} "your prompt"`, 'command')}`); console.log( - ` ${color(`ccs ${result.name} --target droid "your prompt"`, 'command')} ${dim('# optional target override')}` + ` ${color(`ccs-droid ${result.name} "your prompt"`, 'command')} ${dim('# explicit one-off droid alias')}` + ); + console.log( + ` ${color(`ccs ${result.name} --target droid "your prompt"`, 'command')} ${dim('# target flag alternative')}` ); } console.log(''); @@ -447,8 +453,9 @@ export async function handleApiCreateCommand(args: string[]): Promise { ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` ); console.log( - ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ` ${color(`ccs-droid ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` ); + console.log(` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# legacy shortcut')}`); console.log( ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` ); @@ -457,7 +464,10 @@ export async function handleApiCreateCommand(args: string[]): Promise { ` ${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')}` + ` ${color(`ccs-droid ${name} "your prompt"`, 'command')} ${dim('# explicit one-off droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# target flag alternative')}` ); } console.log(''); diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index b3a00056..44d69d2b 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -290,7 +290,10 @@ export async function handleCreate( ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` ); console.log( - ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ` ${color(`ccs-droid ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# legacy shortcut')}` ); console.log( ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` @@ -300,7 +303,10 @@ export async function handleCreate( ` ${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')}` + ` ${color(`ccs-droid ${name} "your prompt"`, 'command')} ${dim('# explicit one-off droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# target flag alternative')}` ); } console.log(''); @@ -452,8 +458,9 @@ export async function handleCreate( ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` ); console.log( - ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ` ${color(`ccs-droid ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` ); + console.log(` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# legacy shortcut')}`); console.log( ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` ); @@ -462,7 +469,10 @@ export async function handleCreate( ` ${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')}` + ` ${color(`ccs-droid ${name} "your prompt"`, 'command')} ${dim('# explicit one-off droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# target flag alternative')}` ); } console.log(''); @@ -697,7 +707,10 @@ export async function handleEdit( ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` ); console.log( - ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ` ${color(`ccs-droid ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# legacy shortcut')}` ); console.log( ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` @@ -707,7 +720,10 @@ export async function handleEdit( ` ${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')}` + ` ${color(`ccs-droid ${name} "your prompt"`, 'command')} ${dim('# explicit one-off droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# target flag alternative')}` ); } console.log(''); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index acb6adbe..0d8c0e26 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -355,22 +355,24 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // Aliases printSubSection('Aliases', [ - ['ccsd [args]', 'Shorthand for: ccs --target droid'], + ['ccs-droid [args]', 'Explicit Droid runtime alias'], + ['ccsd [args]', 'Legacy shortcut for: ccs-droid [args]'], ]); // Multi-target examples 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'], + ['ccs-droid glm', 'Same as above (explicit alias)'], + ['ccsd glm', 'Legacy shortcut for ccs-droid'], + ['ccs-droid codex', 'Run built-in CLIProxy Codex profile on Droid'], + ['ccs-droid agy', 'Run built-in CLIProxy Antigravity profile on Droid'], [ - 'ccsd codex exec --skip-permissions-unsafe "fix failing tests"', + 'ccs-droid 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'], + ['ccs-droid codex -m custom:gpt-5.3-codex "fix failing tests"', 'Auto-routes short exec flags'], [ - 'ccsd codex --skip-permissions-unsafe "fix failing tests"', + 'ccs-droid codex --skip-permissions-unsafe "fix failing tests"', 'Auto-routes to Droid exec when exec-only flags are detected', ], [ diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts index b7a811f4..eced5f8a 100644 --- a/src/targets/target-resolver.ts +++ b/src/targets/target-resolver.ts @@ -12,27 +12,76 @@ import * as path from 'path'; import { TargetType } from './target-adapter'; /** - * Map of binary names to target types (busybox pattern). - * When CCS is invoked as `ccsd`, it auto-selects the droid target. + * Built-in argv[0] aliases for explicit runtime entrypoints. + * `ccs-droid` is the transparent alias; `ccsd` remains as a legacy shortcut. */ -const ARGV0_TARGET_MAP: Record = { +const BUILTIN_ARGV0_TARGET_MAP: Record = { + 'ccs-droid': 'droid', ccsd: 'droid', }; const ALIAS_NAME_REGEX = /^[a-z0-9._-]+$/; +const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES'; +const LEGACY_TARGET_ALIAS_ENV_VARS: Partial> = { + droid: 'CCS_DROID_ALIASES', +}; -function buildArgv0TargetMap(): Record { - const map: Record = { ...ARGV0_TARGET_MAP }; - const envAliases = process.env['CCS_DROID_ALIASES']; - if (!envAliases) { - return map; +function addAliasToMap(map: Record, alias: string, target: TargetType): void { + const normalizedAlias = alias.trim().toLowerCase(); + if (!normalizedAlias || !ALIAS_NAME_REGEX.test(normalizedAlias)) { + return; } - for (const rawAlias of envAliases.split(',')) { - const alias = rawAlias.trim().toLowerCase(); - if (!alias || !ALIAS_NAME_REGEX.test(alias)) { + map[normalizedAlias] = target; +} + +function addAliasListToMap( + map: Record, + target: TargetType, + rawAliases: string +): void { + for (const rawAlias of rawAliases.split(',')) { + addAliasToMap(map, rawAlias, target); + } +} + +function parseGenericTargetAliasConfig(map: Record, rawConfig: string): void { + for (const rawEntry of rawConfig.split(';')) { + const entry = rawEntry.trim(); + if (!entry) { continue; } - map[alias] = 'droid'; + + const separatorIndex = entry.indexOf('='); + if (separatorIndex <= 0 || separatorIndex === entry.length - 1) { + continue; + } + + const rawTarget = entry.slice(0, separatorIndex).trim().toLowerCase(); + const rawAliases = entry.slice(separatorIndex + 1).trim(); + if (!rawAliases || !isValidTarget(rawTarget)) { + continue; + } + + addAliasListToMap(map, rawTarget, rawAliases); + } +} + +function buildArgv0TargetMap(): Record { + const map: Record = { ...BUILTIN_ARGV0_TARGET_MAP }; + const genericAliasConfig = process.env[GENERIC_TARGET_ALIAS_ENV_VAR]; + if (genericAliasConfig) { + parseGenericTargetAliasConfig(map, genericAliasConfig); + } + + for (const [target, envVar] of Object.entries(LEGACY_TARGET_ALIAS_ENV_VARS) as Array< + [TargetType, string] + >) { + const rawAliases = process.env[envVar]; + if (!rawAliases) { + continue; + } + + addAliasListToMap(map, target, rawAliases); } return map; diff --git a/tests/unit/targets/ccsd-alias-integration.test.ts b/tests/unit/targets/ccsd-alias-integration.test.ts index ebc4fd69..1c5c8f74 100644 --- a/tests/unit/targets/ccsd-alias-integration.test.ts +++ b/tests/unit/targets/ccsd-alias-integration.test.ts @@ -62,11 +62,22 @@ describe('ccsd alias integration', () => { expect(path.basename(argvPath)).toBe('ccsd'); }); + it('should preserve ccs-droid symlink basename in argv[1] under node', () => { + if (process.platform === 'win32') { + return; + } + + const argvPath = probeArgvPath('ccs-droid'); + expect(path.basename(argvPath)).toBe('ccs-droid'); + }); + it('should preserve extension-style alias basenames for wrapper compatibility', () => { const cmdArgvPath = probeArgvPathDirect('ccsd.cmd'); const ps1ArgvPath = probeArgvPathDirect('ccsd.ps1'); + const explicitCmdArgvPath = probeArgvPathDirect('ccs-droid.cmd'); expect(path.basename(cmdArgvPath)).toBe('ccsd.cmd'); expect(path.basename(ps1ArgvPath)).toBe('ccsd.ps1'); + expect(path.basename(explicitCmdArgvPath)).toBe('ccs-droid.cmd'); }); }); diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index 9841b9ce..7628ed63 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -7,6 +7,7 @@ import { resolveTargetType, stripTargetFlag } from '../../../src/targets/target- describe('resolveTargetType', () => { const originalArgv = process.argv; const originalDroidAliases = process.env.CCS_DROID_ALIASES; + const originalTargetAliases = process.env.CCS_TARGET_ALIASES; afterEach(() => { process.argv = originalArgv; @@ -15,6 +16,12 @@ describe('resolveTargetType', () => { } else { process.env.CCS_DROID_ALIASES = originalDroidAliases; } + + if (originalTargetAliases === undefined) { + delete process.env.CCS_TARGET_ALIASES; + } else { + process.env.CCS_TARGET_ALIASES = originalTargetAliases; + } }); it('should return claude as default', () => { @@ -52,6 +59,23 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should detect built-in ccs-droid argv[0] alias', () => { + process.argv = ['node', 'ccs-droid']; + expect(resolveTargetType([])).toBe('droid'); + }); + + it('should detect custom target aliases from CCS_TARGET_ALIASES', () => { + process.env.CCS_TARGET_ALIASES = 'droid=droidx,my-droid'; + process.argv = ['node', 'my-droid']; + expect(resolveTargetType([])).toBe('droid'); + }); + + it('should ignore unsupported targets in CCS_TARGET_ALIASES', () => { + process.env.CCS_TARGET_ALIASES = 'codex=ccsx;droid=ccs-droid-custom'; + process.argv = ['node', 'ccsx']; + expect(resolveTargetType([])).toBe('claude'); + }); + it('should detect custom argv[0] aliases from CCS_DROID_ALIASES', () => { process.env.CCS_DROID_ALIASES = 'droidx,my-droid'; process.argv = ['node', 'my-droid']; @@ -75,6 +99,11 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should strip .cmd extension on built-in explicit alias', () => { + process.argv = ['node', 'ccs-droid.cmd']; + expect(resolveTargetType([])).toBe('droid'); + }); + it('should strip .bat extension on Windows argv[0]', () => { process.argv = ['node', 'ccsd.bat']; expect(resolveTargetType([])).toBe('droid'); @@ -95,6 +124,11 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should handle full path argv[0] for ccs-droid', () => { + process.argv = ['node', '/usr/local/bin/ccs-droid']; + expect(resolveTargetType([])).toBe('droid'); + }); + it('should prioritize --target over argv[0]', () => { process.argv = ['node', 'ccsd']; expect(resolveTargetType(['--target', 'claude'])).toBe('claude'); diff --git a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx index d2e7f4b8..8cb6835d 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx @@ -90,11 +90,7 @@ export function ProviderInfoTab({
- {isDroidTarget - ? `ccsd ${profileName} "prompt"` - : `ccs ${profileName} --target droid "prompt"`} + {`ccs-droid ${profileName} "prompt"`} diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 09a63560..fe32c183 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -524,11 +524,25 @@ export function ProfileCreateDialog({

- Run with{' '} - - {targetValue === 'droid' ? 'ccsd' : 'ccs'} - {' '} - by default. You can still override each run with{' '} + {targetValue === 'droid' ? ( + <> + Preferred explicit alias:{' '} + ccs-droid. + + ) : ( + <> + Run with ccs by + default. + + )} + {targetValue === 'droid' ? ( + <> + {' '} + Legacy ccsd still + works for Droid. + + ) : null}{' '} + You can still override each run with{' '} --target.

diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index de2bdd5f..237be3e4 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1451,10 +1451,8 @@ const resources = { openrouterModelsBadge: '{{modelCountLabel}} ไธช OpenRouter ๆจกๅž‹', runtimeProviderBadge: '่ฟ่กŒๆ—ถๆไพ›ๅ•†่ฎพ็ฝฎ', runtimeProviderTitle: 'ๆ‰“ๅผ€ AI Providers', - runtimeProviderDescription: - 'ๅ…ˆ็ฎก็†ๅ…ฑไบซ็š„ๆไพ›ๅ•†ๅฏ†้’ฅๅ’Œ่ฟ่กŒๆ—ถ่ฟžๆŽฅๅ™จ๏ผŒๅ†ๅˆ›ๅปบ็‹ฌ็ซ‹็š„ API ้…็ฝฎใ€‚', - runtimeProviderFeatureConnectors: - 'Geminiใ€Codexใ€Claudeใ€Vertex ไปฅๅŠ OpenAI ๅ…ผๅฎน่ฟžๆŽฅๅ™จ', + runtimeProviderDescription: 'ๅ…ˆ็ฎก็†ๅ…ฑไบซ็š„ๆไพ›ๅ•†ๅฏ†้’ฅๅ’Œ่ฟ่กŒๆ—ถ่ฟžๆŽฅๅ™จ๏ผŒๅ†ๅˆ›ๅปบ็‹ฌ็ซ‹็š„ API ้…็ฝฎใ€‚', + runtimeProviderFeatureConnectors: 'Geminiใ€Codexใ€Claudeใ€Vertex ไปฅๅŠ OpenAI ๅ…ผๅฎน่ฟžๆŽฅๅ™จ', runtimeProviderFeatureSecrets: 'ๅ…ฑไบซๅฏ†้’ฅไธŽๅ•ไธช API ้…็ฝฎๅˆ†็ฆปๅญ˜ๆ”พ', runtimeProviderFooter: 'ๅฝ“ๅคšไธช้…็ฝฎ้œ€่ฆๅ…ฑไบซๅŒไธ€ๆไพ›ๅ•†่ฎพ็ฝฎๆ—ถๆœ€้€‚ๅˆไฝฟ็”จใ€‚', createOpenRouterProfile: 'ๅˆ›ๅปบ OpenRouter ้…็ฝฎ', @@ -3837,8 +3835,7 @@ const resources = { runtimeProviderTitle: 'AI Providers ใ‚’้–‹ใ', runtimeProviderDescription: 'ๅ€‹ๅˆฅใฎ API ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’ไฝœๆˆใ™ใ‚‹ๅ‰ใซใ€ๅ…ฑๆœ‰ใƒ—ใƒญใƒใ‚คใƒ€ใƒผใ‚ญใƒผใจใƒฉใƒณใ‚ฟใ‚คใƒ ใ‚ณใƒใ‚ฏใ‚ฟใ‚’็ฎก็†ใ—ใพใ™ใ€‚', - runtimeProviderFeatureConnectors: - 'Geminiใ€Codexใ€Claudeใ€Vertexใ€OpenAI ไบ’ๆ›ใ‚ณใƒใ‚ฏใ‚ฟ', + runtimeProviderFeatureConnectors: 'Geminiใ€Codexใ€Claudeใ€Vertexใ€OpenAI ไบ’ๆ›ใ‚ณใƒใ‚ฏใ‚ฟ', runtimeProviderFeatureSecrets: 'ๅ…ฑๆœ‰ใ‚ทใƒผใ‚ฏใƒฌใƒƒใƒˆใ‚’ๅ€‹ๅˆฅใฎ API ใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‹ใ‚‰ๅˆ‡ใ‚Š้›ขใ—ใฆ็ฎก็†', runtimeProviderFooter: diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts index aa9cee80..7a7097c1 100644 --- a/ui/src/lib/support-updates-catalog.ts +++ b/ui/src/lib/support-updates-catalog.ts @@ -68,7 +68,7 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ highlights: [ 'Set default target to Droid when creating or editing API Profiles.', 'Set default target to Droid for CLIProxy variants, including Codex and Antigravity flows.', - 'Use ccsd alias or --target droid for one-off target overrides.', + 'Use ccs-droid as the explicit alias, with ccsd kept as the legacy shortcut.', ], actions: [ { @@ -88,11 +88,11 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ path: '/cliproxy', }, { - id: 'copy-ccsd-command', - label: 'Run once with Droid alias', - description: 'Use ccsd to force Droid target with your current profile.', + id: 'copy-ccs-droid-command', + label: 'Run once with explicit Droid alias', + description: 'Use ccs-droid to force the Droid target with your current profile.', type: 'command', - command: 'ccsd glm', + command: 'ccs-droid glm', }, { id: 'copy-target-override', @@ -107,7 +107,7 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ { label: 'CLIProxy', path: '/cliproxy' }, ], commands: [ - 'ccsd glm', + 'ccs-droid glm', 'ccs codex --target droid "your prompt"', 'ccs cliproxy create mycodex --provider codex --target droid', ], @@ -182,8 +182,8 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ { label: 'API Profiles', path: '/providers' }, { label: 'CLIProxy', path: '/cliproxy' }, ], - commands: ['ccsd glm', 'ccs km --target droid', 'ccs codex --target droid'], - notes: 'Use ccsd alias for automatic Droid target selection.', + commands: ['ccs-droid glm', 'ccs km --target droid', 'ccs codex --target droid'], + notes: 'Use ccs-droid as the explicit runtime alias. Legacy ccsd still works.', }, { id: 'codex-cliproxy', From 1f667b5953297189dfe8e88112088b11641ba676 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 14:15:30 -0400 Subject: [PATCH 13/24] fix: harden droid runtime alias resolution --- README.md | 16 ++--- docs/system-architecture/index.md | 2 +- docs/system-architecture/target-adapters.md | 23 +++---- package.json | 4 +- scripts/add-shebang.js | 53 +++++++++------- src/bin/droid-runtime.ts | 2 + src/targets/target-resolver.ts | 36 ++++++++--- tests/npm/cross-platform.test.js | 18 +++++- tests/unit/targets/target-resolver.test.ts | 60 ++++++++++++++++++- .../provider-editor/provider-info-tab.tsx | 4 +- .../profiles/editor/info-section.tsx | 6 +- .../profiles/profile-create-dialog.tsx | 12 ++-- ui/src/lib/i18n.ts | 20 +++++++ 13 files changed, 192 insertions(+), 64 deletions(-) create mode 100644 src/bin/droid-runtime.ts diff --git a/README.md b/README.md index 62012ab1..905f8e36 100644 --- a/README.md +++ b/README.md @@ -194,24 +194,24 @@ ccs api export glm --out ./glm.ccs-profile.json # Export for cross-device trans ccs api import ./glm.ccs-profile.json # Import exported profile bundle ``` -### Runtime Aliases (`argv[0]` pattern) +### Runtime Aliases (built-in bins / `argv[0]` pattern) -Built-in Droid runtime aliases: +Built-in Droid runtime aliases are installed with the package: ```bash -ln -s "$(command -v ccs)" /usr/local/bin/ccs-droid -ln -s "$(command -v ccs)" /usr/local/bin/ccsd ccs-droid glm # explicit alias ccsd glm # legacy shortcut ``` -Need additional alias names? Use `CCS_TARGET_ALIASES` (preferred) or keep using -legacy `CCS_DROID_ALIASES`: +Need additional alias names? First create the matching symlink or another launcher that +preserves the invoked basename, then map that name with `CCS_TARGET_ALIASES` (preferred) or legacy +`CCS_DROID_ALIASES`: ```bash -CCS_TARGET_ALIASES='droid=mydroid,team-droid' +ln -s "$(command -v ccs)" /usr/local/bin/mydroid +CCS_TARGET_ALIASES='droid=mydroid' # Legacy fallback still supported: -CCS_DROID_ALIASES='mydroid,team-droid' +CCS_DROID_ALIASES='mydroid' ``` For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hint diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index 6c4e1647..1fba949e 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -86,7 +86,7 @@ Spawn Target Process - Spawns: `droid -m custom:ccs- ` - Model config includes baseUrl, apiKey, provider -**Runtime alias pattern (argv[0]-style):** +**Runtime alias pattern (built-in bins / argv[0]-style):** ``` ccs โ†’ Target: claude (default) diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index 5d81005e..23a29a97 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -355,27 +355,28 @@ ccs --target droid glm ### Runtime Alias Pattern ```bash -# Create aliases/symlinks to auto-select droid target -ln -s /path/to/ccs /path/to/ccs-droid -ln -s /path/to/ccs /path/to/ccsd - -# Usage +# Built-in package bin aliases ccs-droid glm -โ†’ Target: droid (detected from argv[0]) +โ†’ Target: droid (forced by runtime alias) โ†’ droid -m custom:ccs-glm "args..." # Legacy shortcut still works ccsd glm -โ†’ Target: droid (detected from argv[0]) +โ†’ Target: droid (forced by runtime alias) โ†’ droid -m custom:ccs-glm "args..." ``` On Windows, `ccs-droid.cmd`, `ccsd.cmd`, `ccsd.bat`, `ccsd.ps1`, and `ccsd.exe` wrappers are also recognized. -Additional alias names can be configured at runtime via `CCS_TARGET_ALIASES` -(preferred, `target=alias1,alias2;...`) or legacy `CCS_DROID_ALIASES` -(comma-separated). Example: -`CCS_TARGET_ALIASES=droid=mydroid,team-droid` +Additional alias names can be configured at runtime after you create a matching +symlink or another launcher that preserves the invoked basename. Use `CCS_TARGET_ALIASES` (preferred, +`target=alias1,alias2;...`) or legacy `CCS_DROID_ALIASES` (comma-separated). +Example: + +```bash +ln -s /path/to/ccs /path/to/mydroid +CCS_TARGET_ALIASES=droid=mydroid +``` --- diff --git a/package.json b/package.json index ef687955..c0dd9ea1 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "types": "dist/ccs.d.ts", "bin": { "ccs": "dist/ccs.js", - "ccs-droid": "dist/ccs.js", - "ccsd": "dist/ccs.js" + "ccs-droid": "dist/bin/droid-runtime.js", + "ccsd": "dist/bin/droid-runtime.js" }, "files": [ "dist/", diff --git a/scripts/add-shebang.js b/scripts/add-shebang.js index 96055446..e58074e8 100755 --- a/scripts/add-shebang.js +++ b/scripts/add-shebang.js @@ -4,36 +4,49 @@ const fs = require('fs'); const path = require('path'); +function getExecutablePaths() { + const packageJsonPath = path.join(__dirname, '../package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const binEntries = packageJson.bin || {}; + const uniqueRelativePaths = [...new Set(Object.values(binEntries))]; + + return uniqueRelativePaths.map((relativePath) => path.join(__dirname, '..', relativePath)); +} + /** - * Add shebang to dist/ccs.js and make executable + * Add shebangs to all published bin entrypoints and make them executable. * Run after: tsc */ function addShebang() { - const ccsPath = path.join(__dirname, '../dist/ccs.js'); - - if (!fs.existsSync(ccsPath)) { - console.error('[X] dist/ccs.js not found. Run tsc first.'); + const executablePaths = getExecutablePaths(); + const missingPaths = executablePaths.filter((executablePath) => !fs.existsSync(executablePath)); + if (missingPaths.length > 0) { + console.error(`[X] Missing built executable(s): ${missingPaths.join(', ')}. Run tsc first.`); process.exit(1); } - let content = fs.readFileSync(ccsPath, 'utf8'); + for (const executablePath of executablePaths) { + let content = fs.readFileSync(executablePath, 'utf8'); - // Add shebang if missing - if (!content.startsWith('#!/usr/bin/env node')) { - content = '#!/usr/bin/env node\n' + content; - fs.writeFileSync(ccsPath, content); - console.log('[OK] Shebang added to dist/ccs.js'); - } + if (!content.startsWith('#!/usr/bin/env node')) { + content = '#!/usr/bin/env node\n' + content; + fs.writeFileSync(executablePath, content); + console.log(`[OK] Shebang added to ${path.relative(path.join(__dirname, '..'), executablePath)}`); + } - // Make executable (Unix-like systems) - if (process.platform !== 'win32') { - try { - fs.chmodSync(ccsPath, 0o755); - console.log('[OK] dist/ccs.js is now executable'); - } catch (err) { - console.warn('[!] Could not chmod dist/ccs.js:', err.message); + if (process.platform !== 'win32') { + try { + fs.chmodSync(executablePath, 0o755); + console.log( + `[OK] ${path.relative(path.join(__dirname, '..'), executablePath)} is now executable` + ); + } catch (err) { + console.warn( + `[!] Could not chmod ${path.relative(path.join(__dirname, '..'), executablePath)}: ${err.message}` + ); + } } } } -addShebang(); \ No newline at end of file +addShebang(); diff --git a/src/bin/droid-runtime.ts b/src/bin/droid-runtime.ts new file mode 100644 index 00000000..de641d2a --- /dev/null +++ b/src/bin/droid-runtime.ts @@ -0,0 +1,2 @@ +process.env.CCS_INTERNAL_ENTRY_TARGET = 'droid'; +require('../ccs'); diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts index eced5f8a..a7a5999d 100644 --- a/src/targets/target-resolver.ts +++ b/src/targets/target-resolver.ts @@ -3,8 +3,8 @@ * * Resolves which CLI target to use based on: * 1. --target flag (highest priority) - * 2. Per-profile config - * 3. argv[0] detection (busybox/symlink pattern) + * 2. Runtime alias entrypoint / argv[0] detection + * 3. Per-profile config * 4. Default: 'claude' */ @@ -20,14 +20,20 @@ const BUILTIN_ARGV0_TARGET_MAP: Record = { ccsd: 'droid', }; const ALIAS_NAME_REGEX = /^[a-z0-9._-]+$/; +const INTERNAL_ENTRY_TARGET_ENV_VAR = 'CCS_INTERNAL_ENTRY_TARGET'; const GENERIC_TARGET_ALIAS_ENV_VAR = 'CCS_TARGET_ALIASES'; const LEGACY_TARGET_ALIAS_ENV_VARS: Partial> = { droid: 'CCS_DROID_ALIASES', }; +const RESERVED_BIN_NAMES = new Set(['ccs', ...Object.keys(BUILTIN_ARGV0_TARGET_MAP)]); function addAliasToMap(map: Record, alias: string, target: TargetType): void { const normalizedAlias = alias.trim().toLowerCase(); - if (!normalizedAlias || !ALIAS_NAME_REGEX.test(normalizedAlias)) { + if ( + !normalizedAlias || + !ALIAS_NAME_REGEX.test(normalizedAlias) || + RESERVED_BIN_NAMES.has(normalizedAlias) + ) { return; } @@ -87,6 +93,16 @@ function buildArgv0TargetMap(): Record { return map; } +function resolveEntrypointTarget(): TargetType | null { + const rawTarget = process.env[INTERNAL_ENTRY_TARGET_ENV_VAR]; + if (!rawTarget) { + return null; + } + + const normalizedTarget = rawTarget.trim().toLowerCase(); + return isValidTarget(normalizedTarget) ? normalizedTarget : null; +} + /** * Valid target types for --target flag validation. */ @@ -172,13 +188,12 @@ export function resolveTargetType( return parsed.targetOverride; } - // 2. Check per-profile config - if (profileConfig?.target !== undefined) { - return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude'; + // 2. Check runtime alias entrypoint / argv[0] + const entrypointTarget = resolveEntrypointTarget(); + if (entrypointTarget) { + return entrypointTarget; } - // 3. Check argv[0] (busybox pattern) - // Strip common wrapper extensions for Windows shims/wrappers const rawBin = path.basename(process.argv[1] || process.argv0 || ''); const binName = rawBin.replace(/\.(cmd|bat|ps1|exe)$/i, '').toLowerCase(); const argv0TargetMap = buildArgv0TargetMap(); @@ -187,6 +202,11 @@ export function resolveTargetType( return argv0Target; } + // 3. Check per-profile config + if (profileConfig?.target !== undefined) { + return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude'; + } + // 4. Default return 'claude'; } diff --git a/tests/npm/cross-platform.test.js b/tests/npm/cross-platform.test.js index f99c56a8..ed2c68d8 100644 --- a/tests/npm/cross-platform.test.js +++ b/tests/npm/cross-platform.test.js @@ -129,7 +129,23 @@ describe('cross-platform', () => { assert(packageJson.bin, 'package.json should have bin field'); assert(packageJson.bin.ccs, 'bin field should specify ccs command'); + assert(packageJson.bin['ccs-droid'], 'bin field should specify ccs-droid command'); + assert(packageJson.bin.ccsd, 'bin field should specify ccsd command'); + assert.notStrictEqual( + packageJson.bin['ccs-droid'], + packageJson.bin.ccs, + 'ccs-droid should use a dedicated runtime entrypoint' + ); + assert.strictEqual( + packageJson.bin['ccs-droid'], + packageJson.bin.ccsd, + 'legacy ccsd alias should share the dedicated droid runtime entrypoint' + ); + assert( + fs.existsSync(path.join(__dirname, '..', '..', packageJson.bin['ccs-droid'])), + 'dedicated droid runtime entrypoint should exist' + ); assert(packageJson.scripts, 'package.json should have scripts field'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index 7628ed63..6e60a177 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -8,6 +8,7 @@ describe('resolveTargetType', () => { const originalArgv = process.argv; const originalDroidAliases = process.env.CCS_DROID_ALIASES; const originalTargetAliases = process.env.CCS_TARGET_ALIASES; + const originalInternalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET; afterEach(() => { process.argv = originalArgv; @@ -22,6 +23,12 @@ describe('resolveTargetType', () => { } else { process.env.CCS_TARGET_ALIASES = originalTargetAliases; } + + if (originalInternalEntryTarget === undefined) { + delete process.env.CCS_INTERNAL_ENTRY_TARGET; + } else { + process.env.CCS_INTERNAL_ENTRY_TARGET = originalInternalEntryTarget; + } }); it('should return claude as default', () => { @@ -82,12 +89,29 @@ describe('resolveTargetType', () => { expect(resolveTargetType([])).toBe('droid'); }); + it('should merge CCS_TARGET_ALIASES and CCS_DROID_ALIASES', () => { + process.env.CCS_TARGET_ALIASES = 'droid=team-droid'; + process.env.CCS_DROID_ALIASES = 'legacy-droid'; + + process.argv = ['node', 'team-droid']; + expect(resolveTargetType([])).toBe('droid'); + + process.argv = ['node', 'legacy-droid']; + expect(resolveTargetType([])).toBe('droid'); + }); + it('should ignore invalid custom alias entries', () => { process.env.CCS_DROID_ALIASES = 'valid_alias,../bad,'; process.argv = ['node', '../bad']; expect(resolveTargetType([])).toBe('claude'); }); + it('should detect internal entry target for dedicated package bin entrypoints', () => { + process.env.CCS_INTERNAL_ENTRY_TARGET = 'droid'; + process.argv = ['node', 'ccs']; + expect(resolveTargetType([])).toBe('droid'); + }); + it('should normalize argv[0] and custom aliases case-insensitively', () => { process.env.CCS_DROID_ALIASES = 'DroidCaps'; process.argv = ['node', 'DROIDCAPS']; @@ -134,9 +158,41 @@ describe('resolveTargetType', () => { expect(resolveTargetType(['--target', 'claude'])).toBe('claude'); }); - it('should prioritize profile config over argv[0]', () => { + it('should prioritize --target over internal entry target', () => { + process.env.CCS_INTERNAL_ENTRY_TARGET = 'droid'; + process.argv = ['node', 'ccs']; + expect(resolveTargetType(['--target', 'claude'])).toBe('claude'); + }); + + it('should prioritize runtime alias over profile config', () => { process.argv = ['node', 'ccsd']; - expect(resolveTargetType([], { target: 'claude' })).toBe('claude'); + expect(resolveTargetType([], { target: 'claude' })).toBe('droid'); + }); + + it('should prioritize internal entry target over profile config', () => { + process.env.CCS_INTERNAL_ENTRY_TARGET = 'droid'; + process.argv = ['node', 'ccs']; + expect(resolveTargetType([], { target: 'claude' })).toBe('droid'); + }); + + it('should keep reserved command names authoritative', () => { + process.env.CCS_TARGET_ALIASES = 'claude=ccs,ccs-droid,ccsd;droid=mydroid'; + process.env.CCS_DROID_ALIASES = 'ccs,ccs-droid,ccsd,legacy-droid'; + + process.argv = ['node', 'ccs']; + expect(resolveTargetType([])).toBe('claude'); + + process.argv = ['node', 'ccs-droid']; + expect(resolveTargetType([])).toBe('droid'); + + process.argv = ['node', 'ccsd']; + expect(resolveTargetType([])).toBe('droid'); + + process.argv = ['node', 'mydroid']; + expect(resolveTargetType([])).toBe('droid'); + + process.argv = ['node', 'legacy-droid']; + expect(resolveTargetType([])).toBe('droid'); }); it('should throw for invalid --target value', () => { diff --git a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx index 8cb6835d..bc6c8f5f 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx @@ -93,8 +93,8 @@ export function ProviderInfoTab({ command={`ccs-droid ${provider} "your prompt"`} /> diff --git a/ui/src/components/profiles/editor/info-section.tsx b/ui/src/components/profiles/editor/info-section.tsx index 535b4b4e..6c6122c0 100644 --- a/ui/src/components/profiles/editor/info-section.tsx +++ b/ui/src/components/profiles/editor/info-section.tsx @@ -103,14 +103,14 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
- ccs {profileName} --target claude "prompt" + ccs {profileName} --target {isDroidTarget ? 'claude' : 'droid'} "prompt" diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index fe32c183..ca256fe2 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -526,23 +526,23 @@ export function ProfileCreateDialog({

{targetValue === 'droid' ? ( <> - Preferred explicit alias:{' '} + {t('profileEditor.targetHintPreferredAlias')}{' '} ccs-droid. ) : ( <> - Run with ccs by - default. + {t('profileEditor.targetHintClaudeDefault')}{' '} + ccs. )} {targetValue === 'droid' ? ( <> {' '} - Legacy ccsd still - works for Droid. + {t('profileEditor.targetHintLegacyAlias')}{' '} + ccsd. ) : null}{' '} - You can still override each run with{' '} + {t('profileEditor.targetHintOverride')}{' '} --target.

diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 237be3e4..099156d6 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -619,9 +619,14 @@ const resources = { quickUsage: 'Quick Usage', runWithProfile: 'Run with profile', runOnDroid: 'Run on Droid', + runOnDroidWithFlag: 'Run on Droid (--target)', droidAliasExplicit: 'Droid alias (explicit)', overrideToClaude: 'Override to Claude', overrideToClaudeExplicit: 'Override to Claude (explicit)', + targetHintPreferredAlias: 'Preferred explicit alias:', + targetHintClaudeDefault: 'Default command:', + targetHintLegacyAlias: 'Legacy shortcut still works:', + targetHintOverride: 'You can still override each run with', setAsDefault: 'Set as default', provider: 'Provider', custom: 'Custom', @@ -1790,9 +1795,14 @@ const resources = { quickUsage: 'ๅฟซ้€Ÿไฝฟ็”จ', runWithProfile: 'ไฝฟ็”จ้…็ฝฎ่ฟ่กŒ', runOnDroid: 'ๅœจ Droid ไธŠ่ฟ่กŒ', + runOnDroidWithFlag: '้€š่ฟ‡ --target ๅœจ Droid ไธŠ่ฟ่กŒ', droidAliasExplicit: 'Droid ๅˆซๅ๏ผˆๆ˜พๅผ๏ผ‰', overrideToClaude: 'ๅˆ‡ๆขไธบ Claude', overrideToClaudeExplicit: 'ๅˆ‡ๆขไธบ Claude๏ผˆๆ˜พๅผ๏ผ‰', + targetHintPreferredAlias: 'ๆŽจ่ๆ˜พๅผๅˆซๅ๏ผš', + targetHintClaudeDefault: '้ป˜่ฎคๅ‘ฝไปค๏ผš', + targetHintLegacyAlias: 'ๆ—งๅฟซๆทๆ–นๅผไปๅฏ็”จ๏ผš', + targetHintOverride: 'ไฝ ไปๅฏๅœจๆฏๆฌก่ฟ่กŒๆ—ถ็”จ', setAsDefault: '่ฎพไธบ้ป˜่ฎค', provider: 'ๆไพ›ๅ•†', custom: '่‡ชๅฎšไน‰', @@ -2986,9 +2996,14 @@ const resources = { quickUsage: 'Sแปญ dแปฅng nhanh', runWithProfile: 'Chแบกy vแป›i hแป“ sฦก', runOnDroid: 'Chแบกy trรชn Droid', + runOnDroidWithFlag: 'Chแบกy trรชn Droid (--target)', droidAliasExplicit: 'Bรญ danh Droid (rรต rร ng)', overrideToClaude: 'Ghi ฤ‘รจ lรชn Claude', overrideToClaudeExplicit: 'Ghi ฤ‘รจ lรชn Claude (rรต rร ng)', + targetHintPreferredAlias: 'Bรญ danh rรต rร ng nรชn dรนng:', + targetHintClaudeDefault: 'Lแป‡nh mแบทc ฤ‘แป‹nh:', + targetHintLegacyAlias: 'Lแป‘i tแบฏt cลฉ vแบซn dรนng ฤ‘ฦฐแปฃc:', + targetHintOverride: 'Bแบกn vแบซn cรณ thแปƒ ghi ฤ‘รจ mแป—i lแบงn chแบกy bแบฑng', setAsDefault: 'ฤแบทt lร m mแบทc ฤ‘แป‹nh', provider: 'Nhร  cung cแบฅp', custom: 'Tรนy chแป‰nh', @@ -4206,9 +4221,14 @@ const resources = { quickUsage: 'ใ‚ฏใ‚คใƒƒใ‚ฏๅฎŸ่กŒ', runWithProfile: 'ใ“ใฎใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใงๅฎŸ่กŒ', runOnDroid: 'Droid ใงๅฎŸ่กŒ', + runOnDroidWithFlag: '--target ใง Droid ๅฎŸ่กŒ', droidAliasExplicit: 'Droid ใ‚จใ‚คใƒชใ‚ขใ‚น๏ผˆๆ˜Ž็คบ๏ผ‰', overrideToClaude: 'Claude ใซๅˆ‡ใ‚Šๆ›ฟใˆ', overrideToClaudeExplicit: 'Claude ใซๅˆ‡ใ‚Šๆ›ฟใˆ๏ผˆๆ˜Ž็คบ๏ผ‰', + targetHintPreferredAlias: 'ๆŽจๅฅจใฎๆ˜Ž็คบใ‚จใ‚คใƒชใ‚ขใ‚น:', + targetHintClaudeDefault: 'ใƒ‡ใƒ•ใ‚ฉใƒซใƒˆใ‚ณใƒžใƒณใƒ‰:', + targetHintLegacyAlias: 'ๅพ“ๆฅใ‚ทใƒงใƒผใƒˆใ‚ซใƒƒใƒˆใ‚‚ๅˆฉ็”จๅฏ่ƒฝ:', + targetHintOverride: 'ๅฎŸ่กŒใ”ใจใซๆฌกใงใ‚‚ไธŠๆ›ธใใงใใพใ™', setAsDefault: 'ใƒ‡ใƒ•ใ‚ฉใƒซใƒˆใซ่จญๅฎš', provider: 'ใƒ—ใƒญใƒใ‚คใƒ€ใƒผ', custom: 'ใ‚ซใ‚นใ‚ฟใƒ ', From 0d59c7a3af35ff5af6768d5d4083abf1ab29d8af Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 14:40:54 -0400 Subject: [PATCH 14/24] fix(ui): remove stale dashboard footer gap --- ui/src/pages/accounts.tsx | 2 +- ui/src/pages/api.tsx | 2 +- ui/src/pages/claude-extension.tsx | 2 +- ui/src/pages/cliproxy-ai-providers.tsx | 6 +-- ui/src/pages/cliproxy-control-panel.tsx | 2 +- ui/src/pages/cliproxy.tsx | 2 +- ui/src/pages/copilot.tsx | 2 +- ui/src/pages/cursor.tsx | 2 +- ui/src/pages/droid.tsx | 2 +- ui/src/pages/settings/index.tsx | 2 +- ui/src/pages/updates.tsx | 2 +- .../dashboard-page-height-contract.test.ts | 38 +++++++++++++++++++ 12 files changed, 51 insertions(+), 13 deletions(-) create mode 100644 ui/tests/unit/ui/pages/dashboard-page-height-contract.test.ts diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index 877b04b4..71de35b2 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -55,7 +55,7 @@ export function AccountsPage() { return ( <> -
+
{/* Left action column */}
diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index b1474d57..028121c3 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -206,7 +206,7 @@ export function ApiPage() { }; return ( -
+
setCreateDialogOpen(true)} />
diff --git a/ui/src/pages/claude-extension.tsx b/ui/src/pages/claude-extension.tsx index 00bed2c0..c17057ac 100644 --- a/ui/src/pages/claude-extension.tsx +++ b/ui/src/pages/claude-extension.tsx @@ -405,7 +405,7 @@ export function ClaudeExtensionPage() { } return ( -
+
diff --git a/ui/src/pages/cliproxy-ai-providers.tsx b/ui/src/pages/cliproxy-ai-providers.tsx index 78deca16..37998ca0 100644 --- a/ui/src/pages/cliproxy-ai-providers.tsx +++ b/ui/src/pages/cliproxy-ai-providers.tsx @@ -1462,7 +1462,7 @@ export function CliproxyAiProvidersPage() { if (isLoading) { return ( -
+
@@ -1476,7 +1476,7 @@ export function CliproxyAiProvidersPage() { : 'Failed to load CLIProxy AI providers. Check the local server and try again.'; return ( -
+
@@ -1549,7 +1549,7 @@ export function CliproxyAiProvidersPage() {
); return ( -
+
diff --git a/ui/src/pages/cliproxy-control-panel.tsx b/ui/src/pages/cliproxy-control-panel.tsx index 80cfa80e..776c33ca 100644 --- a/ui/src/pages/cliproxy-control-panel.tsx +++ b/ui/src/pages/cliproxy-control-panel.tsx @@ -8,7 +8,7 @@ import { ControlPanelEmbed } from '@/components/cliproxy/control-panel-embed'; export function CliproxyControlPanelPage() { return ( -
+
); diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index f1d8c557..ce4e2a05 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -338,7 +338,7 @@ export function CliproxyPage() { }; return ( -
+
{/* Left Sidebar */}
{/* Header */} diff --git a/ui/src/pages/copilot.tsx b/ui/src/pages/copilot.tsx index 211a240b..23a58578 100644 --- a/ui/src/pages/copilot.tsx +++ b/ui/src/pages/copilot.tsx @@ -116,7 +116,7 @@ export function CopilotPage() { } = useCopilot(); return ( -
+
{/* Left Sidebar - Status Overview */}
{/* Header */} diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index 2c4b7679..e788ecb0 100644 --- a/ui/src/pages/cursor.tsx +++ b/ui/src/pages/cursor.tsx @@ -633,7 +633,7 @@ export function CursorPage() { return ( <> -
+
diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 382791c3..0166ab23 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -694,7 +694,7 @@ export function DroidPage() { }; return ( -
+
{renderOverview()}
diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index 57da2d80..bb317ff9 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -121,7 +121,7 @@ function SettingsPageInner() { }; return ( -
+
{/* Mobile View - Stacked vertically */}
diff --git a/ui/src/pages/updates.tsx b/ui/src/pages/updates.tsx index 770c3d61..1a4048db 100644 --- a/ui/src/pages/updates.tsx +++ b/ui/src/pages/updates.tsx @@ -102,7 +102,7 @@ export function UpdatesPage() { ); return ( -
+
diff --git a/ui/tests/unit/ui/pages/dashboard-page-height-contract.test.ts b/ui/tests/unit/ui/pages/dashboard-page-height-contract.test.ts new file mode 100644 index 00000000..170f3890 --- /dev/null +++ b/ui/tests/unit/ui/pages/dashboard-page-height-contract.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(testDir, '../../../../'); + +const layoutManagedRouteFiles = [ + 'src/pages/index.tsx', + 'src/pages/analytics/index.tsx', + 'src/pages/updates.tsx', + 'src/pages/api.tsx', + 'src/pages/cliproxy.tsx', + 'src/pages/cliproxy-ai-providers.tsx', + 'src/pages/cliproxy-control-panel.tsx', + 'src/pages/copilot.tsx', + 'src/pages/cursor.tsx', + 'src/pages/claude-extension.tsx', + 'src/pages/droid.tsx', + 'src/pages/accounts.tsx', + 'src/pages/settings/index.tsx', + 'src/pages/health.tsx', + 'src/pages/shared.tsx', +] as const; + +const forbiddenViewportHeightPattern = /\b(?:h-screen|min-h-screen)\b|calc\(100(?:d|l|s)?vh/i; + +describe('dashboard route height contract', () => { + it.each(layoutManagedRouteFiles)( + '%s relies on the shared layout for viewport height', + (relativePath) => { + const source = readFileSync(path.join(projectRoot, relativePath), 'utf8'); + + expect(source).not.toMatch(forbiddenViewportHeightPattern); + } + ); +}); From 15b0bdae05aea9371e62f2c7c40fc0b00a147486 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 14:42:13 -0400 Subject: [PATCH 15/24] chore: link issue 768 for main release close Closes #768 From d8a034bf2db92d4fd814b521204a8ad290105081 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 19:10:21 +0000 Subject: [PATCH 16/24] chore(release): 7.56.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c0dd9ea1..65be4129 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.56.0-dev.2", + "version": "7.56.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 84fd2b001c39eba40c002e7c85314a5bdb563252 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 19:19:24 +0000 Subject: [PATCH 17/24] chore(release): 7.56.0-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 65be4129..b05318a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.56.0-dev.3", + "version": "7.56.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 6c7d215ecc7d2a986e922400eb6787f1b402931d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 15:26:05 -0400 Subject: [PATCH 18/24] feat(websearch): add real provider chain --- README.md | 40 +- docs/project-overview-pdr.md | 11 +- docs/websearch.md | 247 ++-- lib/hooks/websearch-transformer.cjs | 1010 +++++++++-------- src/config/unified-config-loader.ts | 81 +- src/config/unified-config-types.ts | 88 +- src/utils/websearch-manager.ts | 9 +- src/utils/websearch/hook-env.ts | 24 +- src/utils/websearch/status.ts | 229 ++-- src/utils/websearch/types.ts | 69 +- src/web-server/health/websearch-checks.ts | 20 +- src/web-server/routes/websearch-routes.ts | 65 +- .../unit/hooks/websearch-transformer.test.ts | 54 + tests/unit/utils/websearch/status.test.ts | 127 +++ .../settings/hooks/use-websearch-config.ts | 61 +- .../settings/sections/websearch/index.tsx | 796 ++++++++++--- .../sections/websearch/provider-card.tsx | 352 +++--- ui/src/pages/settings/types.ts | 25 +- 18 files changed, 2105 insertions(+), 1203 deletions(-) create mode 100644 tests/unit/hooks/websearch-transformer.test.ts create mode 100644 tests/unit/utils/websearch/status.test.ts diff --git a/README.md b/README.md index 905f8e36..a2f11dbd 100644 --- a/README.md +++ b/README.md @@ -539,24 +539,26 @@ Without Developer Mode, CCS falls back to copying directories. ## WebSearch -Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically provides web search via CLI tools with automatic fallback. +Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS intercepts those requests and resolves them through real local search backends instead of depending on another model CLI to do the search. ### How It Works | Profile Type | WebSearch Method | |--------------|------------------| | Claude (native) | Anthropic WebSearch API | -| Third-party profiles | CLI Tool Fallback Chain | +| Third-party profiles | Local Search Backend Chain | -### CLI Tool Fallback Chain +### Local Search Backend Chain -CCS intercepts WebSearch requests and routes them through available CLI tools: +CCS intercepts WebSearch requests and routes them through deterministic search providers: -| Priority | Tool | Auth | Install | -|----------|------|------|---------| -| 1st | Gemini CLI | OAuth (free) | `npm install -g @google/gemini-cli` | -| 2nd | OpenCode | OAuth (free) | `curl -fsSL https://opencode.ai/install \| bash` | -| 3rd | Grok CLI | API Key | `npm install -g @vibe-kit/grok-cli` | +| Priority | Provider | Setup | Notes | +|----------|----------|-------|-------| +| 1st | Exa | `EXA_API_KEY` | API-backed search with extracted content | +| 2nd | Tavily | `TAVILY_API_KEY` | Agent-oriented search API | +| 3rd | Brave Search | `BRAVE_API_KEY` | Cleaner API-backed results | +| 4th | DuckDuckGo | None | Built-in default fallback | +| 5th | Gemini / OpenCode / Grok | Optional | Legacy compatibility fallback only | ### Configuration @@ -565,17 +567,21 @@ Configure via dashboard (**Settings** page) or `~/.ccs/config.yaml`: ```yaml websearch: enabled: true # Enable/disable (default: true) - gemini: - enabled: true # Use Gemini CLI (default: true) - model: gemini-2.5-flash # Model to use - opencode: - enabled: true # Use OpenCode as fallback - grok: - enabled: false # Requires XAI_API_KEY + providers: + exa: + enabled: false # Enable when EXA_API_KEY is set + tavily: + enabled: false # Enable when TAVILY_API_KEY is set + duckduckgo: + enabled: true # Built-in zero-setup fallback + brave: + enabled: false # Enable when BRAVE_API_KEY is set + gemini: + enabled: false # Optional legacy fallback ``` > [!TIP] -> **Gemini CLI** is recommended - free OAuth authentication with 1000 requests/day. Just run `gemini` once to authenticate via browser. +> **DuckDuckGo** still works out of the box. Add **Exa**, **Tavily**, or **Brave Search** if you want API-backed results, then keep Gemini/OpenCode/Grok only if you explicitly want legacy fallback behavior. See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting. diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 2849246f..9f698758 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -35,7 +35,7 @@ CCS provides: 3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families 4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API 5. **Visual Dashboard**: React SPA for configuration management -6. **Automatic WebSearch**: MCP fallback for third-party providers +6. **Automatic WebSearch**: Real backend fallback chain for third-party providers 7. **Usage Analytics**: Token tracking, cost analysis, model breakdown --- @@ -92,8 +92,9 @@ CCS provides: - Validate symlinks and permissions ### FR-007: WebSearch Fallback -- Auto-configure MCP web search for third-party profiles -- Support Gemini CLI, OpenCode, Grok providers +- Intercept WebSearch for third-party profiles that cannot reach Anthropic's native tool +- Support Exa, Tavily, Brave, and DuckDuckGo real search backends +- Keep Gemini CLI, OpenCode, and Grok as optional legacy fallback - Graceful fallback chain ### FR-008: Remote CLIProxy Support @@ -169,8 +170,8 @@ CCS provides: ### TR-002: Optional Dependencies - CLIProxyAPI binary (auto-managed) -- Gemini CLI for WebSearch -- Additional MCP servers +- Exa/Tavily/Brave API keys for higher-quality WebSearch +- Gemini CLI for legacy WebSearch fallback ### TR-003: Configuration - YAML-based config (`~/.ccs/config.yaml`) diff --git a/docs/websearch.md b/docs/websearch.md index c1b44841..6a6803c9 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -1,97 +1,71 @@ # WebSearch Configuration Guide -Last Updated: 2026-02-26 +Last Updated: 2026-03-23 -CCS provides automatic web search capability for all profiles, including third-party providers that cannot access Anthropic's native WebSearch API. +CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API. ## How WebSearch Works ### Native Claude Accounts -When using a native Claude subscription account, WebSearch is handled by Anthropic's server-side API ($10/1000 searches, usage-based billing). +Native Claude subscription accounts still use Anthropic's server-side WebSearch directly. ### Third-Party Profiles -Third-party profiles (OAuth and API-based) cannot use Anthropic's WebSearch because: -- Claude Code CLI executes tools locally -- CLIProxyAPI only receives conversation messages -- Tool execution never reaches the third-party backend - -CCS solves this with a hybrid fallback approach: - -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 +Third-party profiles cannot execute Anthropic's server-side WebSearch because the tool never reaches their backend. CCS now solves that by intercepting WebSearch and running real local search providers directly. ## Architecture ``` โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Claude Code CLI โ”‚ -โ”‚ โ”‚ -โ”‚ WebSearch Tool Request โ”‚ -โ”‚ โ”‚ โ”‚ -โ”‚ โ”œโ”€โ”€ Native Claude Account? โ†’ Anthropic WebSearch API โ”‚ -โ”‚ โ”‚ ($10/1000 searches) โ”‚ -โ”‚ โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€ Third-party Profile? โ†’ PreToolUse Hook โ”‚ -โ”‚ โ”‚ โ”‚ -โ”‚ โ”œโ”€โ”€ 1. Gemini CLI โ”‚ -โ”‚ โ”‚ (google_web_search) โ”‚ -โ”‚ โ”‚ No API key needed! โ”‚ -โ”‚ โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€ 2. MCP Fallback Chain โ”‚ -โ”‚ โ”œโ”€โ”€ web-search-prime โ”‚ -โ”‚ โ”œโ”€โ”€ Brave Search โ”‚ -โ”‚ โ””โ”€โ”€ Tavily โ”‚ +โ”‚ Claude Code CLI โ”‚ +โ”‚ โ”‚ +โ”‚ WebSearch Tool Request โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ Native Claude Account? โ†’ Anthropic WebSearch API โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€ Third-party Profile? โ†’ PreToolUse Hook โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ 1. Exa Search API โ”‚ +โ”‚ โ”œโ”€โ”€ 2. Tavily Search API โ”‚ +โ”‚ โ”œโ”€โ”€ 3. Brave Search API โ”‚ +โ”‚ โ”œโ”€โ”€ 4. DuckDuckGo HTML โ”‚ +โ”‚ โ””โ”€โ”€ 5. Legacy CLI fallback โ”‚ +โ”‚ (Gemini/OpenCode/Grok) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` -## Gemini CLI Integration (Primary) +## Why This Changed -The **ultimate solution** for third-party WebSearch. Uses `gemini` CLI with OAuth authentication - **no API key needed!** +The previous design asked another model CLI to perform web search and summarize the answer. That was brittle: -### How It Works +- CLI syntax changed upstream +- auth state varied per tool +- prompt/tool behavior drifted across releases -1. A PreToolUse hook intercepts WebSearch tool calls -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 +The new flow matches the `goclaw` model more closely: web search is treated as a first-class deterministic capability, not an LLM-to-LLM workaround. -### Requirements +## Providers -- `gemini` CLI installed and authenticated (run `gemini` to authenticate via browser) -- OAuth authentication (no GEMINI_API_KEY needed) - -### Installation - -The Gemini CLI is installed via npm: -```bash -npm install -g @google/gemini-cli -``` - -Then authenticate by running gemini once (opens browser): -```bash -gemini -``` - -## MCP Providers - -| Provider | Type | Cost | API Key Required | Notes | -|----------|------|------|------------------|-------| -| web-search-prime | HTTP MCP | z.ai subscription | No | Requires z.ai coding plan | -| Brave Search | stdio MCP | Free tier | `BRAVE_API_KEY` | 15k queries/month | -| Tavily | stdio MCP | Paid | `TAVILY_API_KEY` | AI-optimized search | +| Provider | Type | Setup | Default | Notes | +|----------|------|-------|---------|-------| +| Exa | HTTP API | `EXA_API_KEY` | No | High-quality API search with extracted content | +| Tavily | HTTP API | `TAVILY_API_KEY` | No | Agent-oriented search API | +| DuckDuckGo | HTML fetch | None | Yes | Built-in zero-setup fallback | +| Brave Search | HTTP API | `BRAVE_API_KEY` | No | Cleaner snippets and metadata | +| Gemini CLI | Legacy CLI | `npm i -g @google/gemini-cli` | No | Optional compatibility fallback | +| OpenCode | Legacy CLI | `curl -fsSL https://opencode.ai/install \| bash` | No | Optional compatibility fallback | +| Grok CLI | Legacy CLI | `npm i -g @vibe-kit/grok-cli` + `GROK_API_KEY` | No | Optional compatibility fallback | ## Configuration ### Via Dashboard -1. Open dashboard: `ccs config` -2. Navigate to **Settings** page -3. Configure WebSearch options: - - **Enable/Disable**: Toggle auto-configuration - - **Provider**: Choose preferred provider - - **Fallback**: Enable/disable fallback chain +Open `ccs config` โ†’ `Settings` โ†’ `WebSearch`. + +- Enable Exa, Tavily, Brave, or DuckDuckGo in the backend chain +- Export the matching API key first for Exa, Tavily, or Brave +- Review whether any legacy fallback CLIs are still enabled in config ### Via Config File @@ -99,111 +73,74 @@ Edit `~/.ccs/config.yaml`: ```yaml websearch: - enabled: true # Enable auto-config (default: true) - provider: auto # auto | web-search-prime | brave | tavily - fallback: true # Enable fallback chain (default: true) - webSearchPrimeUrl: "https://..." # Optional: custom endpoint - - # Gemini CLI configuration (new!) - gemini: - enabled: true # Use Gemini CLI for WebSearch (default: true) - timeout: 55 # Timeout in seconds (default: 55) + enabled: true + providers: + exa: + enabled: false + max_results: 5 + tavily: + enabled: false + max_results: 5 + duckduckgo: + enabled: true + max_results: 5 + brave: + enabled: false + max_results: 5 + gemini: + enabled: false + model: gemini-2.5-flash + timeout: 55 + opencode: + enabled: false + model: opencode/grok-code + timeout: 90 + grok: + enabled: false + timeout: 55 ``` -### Environment Variables +## Environment Variables -The WebSearch hook also respects these environment variables: - -| Variable | Description | Default | -|----------|-------------|---------| -| `CCS_WEBSEARCH_SKIP` | Skip WebSearch hook entirely | `0` | -| `CCS_GEMINI_SKIP` | Skip Gemini CLI, use MCP only | `0` | -| `CCS_GEMINI_TIMEOUT` | Gemini CLI timeout (seconds) | `55` | -| `CCS_DEBUG` | Enable debug output | `0` | - -### Provider Options - -- **auto** (default): Uses web-search-prime, adds Brave/Tavily if API keys available -- **web-search-prime**: Requires z.ai coding plan subscription -- **brave**: Requires `BRAVE_API_KEY` env var -- **tavily**: Requires `TAVILY_API_KEY` env var - -## Setting Up Optional Providers - -### Brave Search (Free Tier) - -1. Get API key: [brave.com/search/api](https://brave.com/search/api) -2. Set environment variable: - ```bash - export BRAVE_API_KEY="your-api-key" - ``` -3. Restart CCS - Brave will be added to fallback chain - -**Free tier limits**: 15,000 queries/month, 1 query/second - -### Tavily (AI-Optimized) - -1. Get API key: [tavily.com](https://tavily.com) -2. Set environment variable: - ```bash - export TAVILY_API_KEY="your-api-key" - ``` -3. Restart CCS - Tavily will be added to fallback chain - -## MCP Configuration - -CCS writes MCP configuration to `~/.claude/.mcp.json`. Example: - -```json -{ - "mcpServers": { - "web-search-prime": { - "type": "http", - "url": "https://api.z.ai/api/mcp/web_search_prime/mcp", - "headers": {} - }, - "brave-search": { - "type": "stdio", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-brave-search"], - "env": { "BRAVE_API_KEY": "..." } - } - } -} -``` +| Variable | Description | +|----------|-------------| +| `EXA_API_KEY` | Enables Exa when `providers.exa.enabled: true` | +| `TAVILY_API_KEY` | Enables Tavily when `providers.tavily.enabled: true` | +| `BRAVE_API_KEY` | Enables Brave Search when `providers.brave.enabled: true` | +| `GROK_API_KEY` | Required only for legacy Grok CLI fallback | +| `CCS_WEBSEARCH_SKIP` | Skip hook entirely | +| `CCS_DEBUG` | Verbose hook logging | ## Troubleshooting -### Gemini CLI Issues +### WebSearch says "Ready (DuckDuckGo)" -1. **Not installed**: Install with `npm install -g @google/gemini-cli` -2. **Not authenticated**: Run `gemini` to open browser for OAuth login -3. **Timeout**: Increase timeout in config or via `CCS_GEMINI_TIMEOUT=90` -4. **Skip Gemini**: Set `CCS_GEMINI_SKIP=1` to use MCP fallback only +That is expected. DuckDuckGo is the default zero-setup backend. -### WebSearch Not Working +### Exa, Tavily, or Brave is enabled but not ready -1. **Check config**: Ensure `websearch.enabled: true` in config -2. **Verify MCP**: Check `~/.claude/.mcp.json` exists -3. **Debug mode**: Run with `CCS_DEBUG=1 ccs gemini` for verbose output +Export the matching API key in the environment that launches CCS, then refresh status: -### MCP Server Errors +```bash +export EXA_API_KEY="your-api-key" +# or: export TAVILY_API_KEY="your-api-key" +# or: export BRAVE_API_KEY="your-api-key" +ccs config +``` -1. **Network issues**: web-search-prime requires internet access -2. **npx failures**: Brave/Tavily require Node.js and npx -3. **API key issues**: Verify env vars are set correctly +### I still want Gemini/OpenCode/Grok fallback -### Existing MCP Config +Those providers remain supported, but they are no longer the primary path. Enable them explicitly in `config.yaml` if you want them as last-resort fallback. -CCS respects existing web search MCP configuration. If you have manually configured web search MCPs, CCS will not overwrite them. +### WebSearch returns no results -To reset: -1. Remove web search entries from `~/.claude/.mcp.json` -2. Run any CCS third-party profile to regenerate +1. Check `websearch.enabled: true` +2. Keep DuckDuckGo enabled unless you have a strong reason to disable it +3. If using Exa, Tavily, or Brave, verify the matching API key +4. Run with `CCS_DEBUG=1` for hook logs ## Security Considerations -- API keys are stored in environment variables only +- API keys stay in environment variables, not in dashboard state - Never commit API keys to version control -- Use `.env` files with proper permissions (chmod 600) -- Dashboard settings are stored in `~/.ccs/config.yaml` (no API keys) +- Use shell profile or `.env` tooling with proper permissions diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 8d8a99ab..0e09d48f 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -1,51 +1,32 @@ #!/usr/bin/env node /** - * CCS WebSearch Hook - CLI Tool Executor with Fallback Chain + * CCS WebSearch Hook - deterministic search backends with legacy CLI fallback * - * Intercepts Claude's WebSearch tool and executes search via CLI tools. - * Respects provider enabled states from config.yaml. - * Supports automatic fallback: Gemini CLI โ†’ OpenCode โ†’ Grok CLI + * Primary providers: + * - Exa Search API + * - Tavily Search API + * - Brave Search API + * - DuckDuckGo HTML search * - * Environment Variables (set by CCS): - * CCS_WEBSEARCH_SKIP=1 - Skip this hook entirely (for official Claude) - * CCS_WEBSEARCH_ENABLED=1 - Enable WebSearch (default: 1) - * CCS_WEBSEARCH_TIMEOUT=55 - Timeout in seconds (default: 55) - * CCS_WEBSEARCH_GEMINI=1 - Enable Gemini CLI provider - * CCS_WEBSEARCH_GEMINI_MODEL - Gemini model (default: gemini-2.5-flash) - * CCS_WEBSEARCH_OPENCODE=1 - Enable OpenCode provider - * CCS_WEBSEARCH_GROK=1 - Enable Grok CLI provider - * CCS_WEBSEARCH_OPENCODE_MODEL - OpenCode model (default: opencode/grok-code) - * CCS_DEBUG=1 - Enable debug output - * - * Exit codes: - * 0 - Allow tool (pass-through to native WebSearch) - * 2 - Block tool (deny with results/message) - * - * @module hooks/websearch-transformer + * Legacy compatibility fallback: + * - Gemini CLI + * - OpenCode + * - Grok CLI */ const { spawnSync } = require('child_process'); -// ============================================================================ -// PLATFORM DETECTION -// ============================================================================ - -/** - * Windows platform flag - used for shell option in spawnSync calls. - * On Windows, globally installed CLI tools via npm/pnpm are .cmd/.bat files, - * which require shell: true to execute properly. - * @see https://github.com/kaitranntt/ccs/issues/378 - */ const isWindows = process.platform === 'win32'; +const DEFAULT_TIMEOUT_SEC = 55; +const DEFAULT_RESULT_COUNT = 5; +const MIN_VALID_RESPONSE_LENGTH = 20; +const EXA_URL = 'https://api.exa.ai/search'; +const TAVILY_URL = 'https://api.tavily.com/search'; +const DDG_URL = 'https://html.duckduckgo.com/html/'; +const BRAVE_URL = 'https://api.search.brave.com/res/v1/web/search'; +const USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; -// ============================================================================ -// CONFIGURATION - Edit these for prompt engineering -// ============================================================================ - -/** - * SHARED INSTRUCTIONS - Applied to ALL providers - * Edit here to change behavior across all CLI tools at once. - */ const SHARED_INSTRUCTIONS = `Instructions: 1. Search the web for current, up-to-date information 2. Provide a comprehensive summary of the search results @@ -55,119 +36,45 @@ const SHARED_INSTRUCTIONS = `Instructions: 6. If results conflict, note the discrepancy 7. Format output clearly with sections if the topic is complex`; -/** - * PROVIDER-SPECIFIC CONFIG - Only tool-use differences and quirks - * Each provider may have unique capabilities or invocation methods. - */ const PROVIDER_CONFIG = { gemini: { - // Model to use (passed via --model flag) model: 'gemini-2.5-flash', - // Alternative free models: gemini-2.0-flash, gemini-1.5-flash - - // Provider-specific: How to invoke web search (Gemini has google_web_search tool) toolInstruction: 'Use the google_web_search tool to find current information.', - - // Optional quirks (null if none) quirks: null, }, - opencode: { - // Model to use (can be overridden via CCS_WEBSEARCH_OPENCODE_MODEL env var) model: 'opencode/grok-code', - // Alternative models: opencode/gpt-4o, opencode/claude-3.5-sonnet, opencode/gpt-5-nano - - // Provider-specific: OpenCode has built-in web search via Zen toolInstruction: 'Search the web using your built-in capabilities.', - - // Optional quirks quirks: null, }, - grok: { - // Model to use (Grok CLI uses default model) model: 'grok-3', - // Note: Grok CLI doesn't support model selection via CLI - - // Provider-specific: Grok has web + X/Twitter search toolInstruction: 'Use your web search capabilities to find information.', - - // Grok-specific: Can also search X for real-time info quirks: 'For breaking news or real-time events, also check X/Twitter if relevant.', }, }; -/** - * Build the complete prompt for a provider - * Combines: query + tool instruction + shared instructions + quirks - */ -function buildPrompt(providerId, query) { - const config = PROVIDER_CONFIG[providerId]; - const parts = [ - `Search the web for: ${query}`, - '', - config.toolInstruction, - '', - SHARED_INSTRUCTIONS, - ]; +const ddgLinkRe = /]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g; +const ddgSnippetRe = /([\s\S]*?)<\/a>/g; +const htmlTagRe = /<[^>]+>/g; - if (config.quirks) { - parts.push('', `Note: ${config.quirks}`); +function debug(message) { + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] ${message}`); } - - return parts.join('\n'); } -// Minimum response length to consider valid -const MIN_VALID_RESPONSE_LENGTH = 20; - -// Default timeout in seconds -const DEFAULT_TIMEOUT_SEC = 55; - -// ============================================================================ -// HOOK LOGIC - Generally no need to edit below -// ============================================================================ - -/** - * Determine if hook should skip and pass through to native WebSearch. - * Returns true for native Claude accounts where WebSearch works server-side. - */ function shouldSkipHook() { - // Explicit skip signal (set by CCS for account profiles) if (process.env.CCS_WEBSEARCH_SKIP === '1') return true; - - // Account/default profiles - use native WebSearch const profileType = process.env.CCS_PROFILE_TYPE; if (profileType === 'account' || profileType === 'default') return true; - - // Explicit disable if (process.env.CCS_WEBSEARCH_ENABLED === '0') return true; - return false; } -// Read input from stdin -let input = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', (chunk) => { - input += chunk; -}); -process.stdin.on('end', () => { - processHook(); -}); - -// Handle stdin not being available -process.stdin.on('error', () => { - process.exit(0); -}); - -/** - * Check if a CLI tool is available - */ function isCliAvailable(cmd) { try { const whichCmd = isWindows ? 'where.exe' : 'which'; - const result = spawnSync(whichCmd, [cmd], { encoding: 'utf8', timeout: 2000, @@ -179,124 +86,330 @@ function isCliAvailable(cmd) { } } -/** - * Check if provider is enabled via environment variable - */ function isProviderEnabled(provider) { - const envVar = `CCS_WEBSEARCH_${provider.toUpperCase()}`; - const value = process.env[envVar]; - - // If env var not set, provider is disabled by default - // This ensures we respect config.yaml settings - return value === '1'; + return process.env[`CCS_WEBSEARCH_${provider.toUpperCase()}`] === '1'; } -/** - * Main hook processing logic with fallback chain - */ -async function processHook() { - try { - // Skip for native accounts (account, default profiles) or explicit disable - if (shouldSkipHook()) { - process.exit(0); +function hasEnvValue(name) { + return (process.env[name] || '').trim().length > 0; +} + +function getFirstEnvValue(names) { + for (const name of names) { + if (hasEnvValue(name)) { + return process.env[name].trim(); } + } + return ''; +} - const data = JSON.parse(input); - - // Only handle WebSearch tool - if (data.tool_name !== 'WebSearch') { - process.exit(0); - } - - const query = data.tool_input?.query || ''; - - if (!query) { - process.exit(0); - } - - const timeout = parseInt(process.env.CCS_WEBSEARCH_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); - - // Fallback chain: Gemini โ†’ OpenCode โ†’ Grok - // Only include providers that are BOTH installed AND enabled in config - const providers = [ - { name: 'Gemini CLI', cmd: 'gemini', id: 'gemini', fn: tryGeminiSearch }, - { name: 'OpenCode', cmd: 'opencode', id: 'opencode', fn: tryOpenCodeSearch }, - { name: 'Grok CLI', cmd: 'grok', id: 'grok', fn: tryGrokSearch }, - ]; - - // Filter to only enabled AND available providers - const enabledProviders = providers.filter((p) => { - const enabled = isProviderEnabled(p.id); - const available = isCliAvailable(p.cmd); - - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] ${p.name}: enabled=${enabled}, available=${available}`); - } - - return enabled && available; - }); - - const errors = []; - - if (process.env.CCS_DEBUG) { - const names = enabledProviders.map((p) => p.name).join(', ') || 'none'; - console.error(`[CCS Hook] Enabled providers: ${names}`); - } - - // Try each enabled provider in order - for (const provider of enabledProviders) { - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] Trying ${provider.name}...`); - } - - const result = provider.fn(query, timeout); - - if (result.success) { - outputSuccess(query, result.content, provider.name); - return; - } - - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] ${provider.name} failed: ${result.error}`); - } - - errors.push({ provider: provider.name, error: result.error }); - } - - // All providers failed or none enabled - if (enabledProviders.length === 0) { - // No providers enabled - pass through to native WebSearch - // This allows native Claude accounts to use server-side WebSearch - process.exit(0); - } else { - outputAllFailedMessage(query, errors); - } - } catch (err) { - if (process.env.CCS_DEBUG) { - console.error('[CCS Hook] Parse error:', err.message); - } - process.exit(0); +function getProviderApiKey(providerId) { + switch (providerId) { + case 'brave': + return getFirstEnvValue(['BRAVE_API_KEY', 'CCS_WEBSEARCH_BRAVE_API_KEY']); + case 'exa': + return getFirstEnvValue(['EXA_API_KEY', 'CCS_WEBSEARCH_EXA_API_KEY']); + case 'tavily': + return getFirstEnvValue(['TAVILY_API_KEY', 'CCS_WEBSEARCH_TAVILY_API_KEY']); + default: + return ''; } } -/** - * Execute search via Gemini CLI - */ -function shouldRetryGeminiWithLegacyPrompt(errorMessage) { - const lowerError = (errorMessage || '').toLowerCase(); +function getResultCount(provider) { + const raw = process.env[`CCS_WEBSEARCH_${provider.toUpperCase()}_MAX_RESULTS`]; + const parsed = Number.parseInt(raw || '', 10); + return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 10) : DEFAULT_RESULT_COUNT; +} +function buildPrompt(providerId, query) { + const config = PROVIDER_CONFIG[providerId]; + const parts = [ + `Search the web for: ${query}`, + '', + config.toolInstruction, + '', + SHARED_INSTRUCTIONS, + ]; + if (config.quirks) { + parts.push('', `Note: ${config.quirks}`); + } + return parts.join('\n'); +} + +function decodeHtml(value) { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +function compactText(value, maxLength = 280) { + const text = String(value || '') + .replace(/\s+/g, ' ') + .trim(); + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength - 3).trimEnd()}...`; +} + +function extractDuckDuckGoResults(html, count) { + const links = [...html.matchAll(ddgLinkRe)].slice(0, count + 5); + const snippets = [...html.matchAll(ddgSnippetRe)].slice(0, count + 5); + + return links.slice(0, count).map((match, index) => { + let url = match[1]; + if (url.includes('uddg=')) { + try { + const decoded = decodeURIComponent(url); + const uddgIndex = decoded.indexOf('uddg='); + if (uddgIndex !== -1) { + url = decoded.slice(uddgIndex + 5).split('&')[0]; + } + } catch { + // keep original url + } + } + + return { + title: decodeHtml(match[2].replace(htmlTagRe, '').trim()), + url, + description: decodeHtml((snippets[index]?.[1] || '').replace(htmlTagRe, '').trim()), + }; + }); +} + +function formatStructuredSearchResults(query, providerName, results) { + if (!results.length) { + return `No search results found for "${query}" via ${providerName}.`; + } + + const lines = [`Search results for "${query}" via ${providerName}:`, '']; + for (const [index, result] of results.entries()) { + lines.push(`${index + 1}. ${result.title}`); + lines.push(` ${result.url}`); + if (result.description) { + lines.push(` ${result.description}`); + } + lines.push(''); + } + lines.push('Use these results to answer the user directly.'); + return lines.join('\n'); +} + +async function fetchWithTimeout(url, options, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +async function tryBraveSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + const apiKey = getProviderApiKey('brave'); + if (!apiKey) { + return { success: false, error: 'BRAVE_API_KEY is not set' }; + } + + const params = new URLSearchParams({ + q: query, + count: String(getResultCount('brave')), + }); + + try { + const response = await fetchWithTimeout( + `${BRAVE_URL}?${params.toString()}`, + { + headers: { + Accept: 'application/json', + 'User-Agent': USER_AGENT, + 'X-Subscription-Token': apiKey, + }, + }, + timeoutSec * 1000 + ); + + if (!response.ok) { + const body = await response.text(); + return { + success: false, + error: `Brave Search returned ${response.status}: ${body.slice(0, 160)}`, + }; + } + + const body = await response.json(); + const results = (body.web?.results || []).map((result) => ({ + title: result.title || 'Untitled', + url: result.url || '', + description: result.description || '', + })); + + return { + success: true, + content: formatStructuredSearchResults(query, 'Brave Search', results), + }; + } catch (error) { + return { + success: false, + error: error.name === 'AbortError' ? 'Brave Search timed out' : error.message, + }; + } +} + +async function tryExaSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + const apiKey = getProviderApiKey('exa'); + if (!apiKey) { + return { success: false, error: 'EXA_API_KEY is not set' }; + } + + try { + const response = await fetchWithTimeout( + EXA_URL, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, + 'x-api-key': apiKey, + }, + body: JSON.stringify({ + query, + type: 'auto', + numResults: getResultCount('exa'), + text: true, + }), + }, + timeoutSec * 1000 + ); + + if (!response.ok) { + const body = await response.text(); + return { success: false, error: `Exa returned ${response.status}: ${body.slice(0, 160)}` }; + } + + const body = await response.json(); + const results = (body.results || []).map((result) => ({ + title: compactText(result.title || result.url || 'Untitled', 120), + url: result.url || '', + description: compactText(result.text || result.summary || '', 240), + })); + + return { + success: true, + content: formatStructuredSearchResults(query, 'Exa', results), + }; + } catch (error) { + return { + success: false, + error: error.name === 'AbortError' ? 'Exa timed out' : error.message, + }; + } +} + +async function tryTavilySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + const apiKey = getProviderApiKey('tavily'); + if (!apiKey) { + return { success: false, error: 'TAVILY_API_KEY is not set' }; + } + + try { + const response = await fetchWithTimeout( + TAVILY_URL, + { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, + }, + body: JSON.stringify({ + query, + search_depth: 'basic', + max_results: getResultCount('tavily'), + include_answer: false, + include_raw_content: false, + }), + }, + timeoutSec * 1000 + ); + + if (!response.ok) { + const body = await response.text(); + return { success: false, error: `Tavily returned ${response.status}: ${body.slice(0, 160)}` }; + } + + const body = await response.json(); + const results = (body.results || []).map((result) => ({ + title: compactText(result.title || result.url || 'Untitled', 120), + url: result.url || '', + description: compactText(result.content || '', 240), + })); + + return { + success: true, + content: formatStructuredSearchResults(query, 'Tavily', results), + }; + } catch (error) { + return { + success: false, + error: error.name === 'AbortError' ? 'Tavily timed out' : error.message, + }; + } +} + +async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + try { + const params = new URLSearchParams({ q: query }); + const response = await fetchWithTimeout( + `${DDG_URL}?${params.toString()}`, + { + headers: { + Accept: 'text/html', + 'User-Agent': USER_AGENT, + }, + }, + timeoutSec * 1000 + ); + + if (!response.ok) { + return { success: false, error: `DuckDuckGo returned ${response.status}` }; + } + + const html = await response.text(); + const results = extractDuckDuckGoResults(html, getResultCount('duckduckgo')); + return { + success: true, + content: formatStructuredSearchResults(query, 'DuckDuckGo', results), + }; + } catch (error) { + return { + success: false, + error: error.name === 'AbortError' ? 'DuckDuckGo timed out' : error.message, + }; + } +} + +function shouldRetryGeminiWithLegacyPrompt(errorMessage) { + const lower = (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') + lower.includes('unknown option') || + lower.includes('unknown argument') || + lower.includes('unrecognized option') || + lower.includes('usage: gemini') || + lower.includes('use --prompt') || + lower.includes('using the --prompt option') ); } function runGeminiCommand(args, timeoutMs) { - const spawnResult = spawnSync('gemini', args, { + const result = spawnSync('gemini', args, { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 1024 * 1024 * 2, @@ -304,241 +417,130 @@ function runGeminiCommand(args, timeoutMs) { shell: isWindows, }); - if (spawnResult.error) { - if (spawnResult.error.code === 'ENOENT') { + if (result.error) { + if (result.error.code === 'ENOENT') return { success: false, error: 'Gemini CLI not installed' }; - } - throw spawnResult.error; + throw result.error; } - - if (spawnResult.status !== 0) { - const stderr = (spawnResult.stderr || '').trim(); + if (result.status !== 0) { return { success: false, - error: stderr || `Gemini CLI exited with code ${spawnResult.status}`, + error: (result.stderr || '').trim() || `Gemini CLI exited with code ${result.status}`, }; } - const result = (spawnResult.stdout || '').trim(); - - if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) { + const output = (result.stdout || '').trim(); + if (!output || output.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 }; + return { success: true, content: output }; } function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { try { const timeoutMs = timeoutSec * 1000; - const config = PROVIDER_CONFIG.gemini; + const model = process.env.CCS_WEBSEARCH_GEMINI_MODEL || PROVIDER_CONFIG.gemini.model; const prompt = buildPrompt('gemini', query); - - // 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) { + debug(`Executing Gemini legacy fallback with model ${model}`); + const positionalResult = runGeminiCommand([...baseArgs, prompt], timeoutMs); + if (positionalResult.success || !shouldRetryGeminiWithLegacyPrompt(positionalResult.error)) { 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 legacyPromptArgs = [...baseArgs, '-p', prompt]; - return runGeminiCommand(legacyPromptArgs, timeoutMs); - } catch (err) { - if (err.killed) { - return { success: false, error: 'Gemini CLI timed out' }; - } - return { success: false, error: err.message || 'Unknown Gemini error' }; + return runGeminiCommand([...baseArgs, '-p', prompt], timeoutMs); + } catch (error) { + return { + success: false, + error: error.killed ? 'Gemini CLI timed out' : error.message || 'Unknown Gemini error', + }; } } -/** - * Execute search via OpenCode CLI - */ function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { try { - const timeoutMs = timeoutSec * 1000; - const config = PROVIDER_CONFIG.opencode; - - // Allow model override via env var - const model = process.env.CCS_WEBSEARCH_OPENCODE_MODEL || config.model; - const prompt = buildPrompt('opencode', query); - - if (process.env.CCS_DEBUG) { - 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, - }); - - if (spawnResult.error) { - if (spawnResult.error.code === 'ENOENT') { - return { success: false, error: 'OpenCode not installed' }; + const model = process.env.CCS_WEBSEARCH_OPENCODE_MODEL || PROVIDER_CONFIG.opencode.model; + const result = spawnSync( + 'opencode', + ['run', buildPrompt('opencode', query), '--model', model], + { + encoding: 'utf8', + timeout: timeoutSec * 1000, + maxBuffer: 1024 * 1024 * 2, + stdio: ['pipe', 'pipe', 'pipe'], + shell: isWindows, } - throw spawnResult.error; - } + ); - if (spawnResult.status !== 0) { - const stderr = (spawnResult.stderr || '').trim(); + if (result.error) { + if (result.error.code === 'ENOENT') + return { success: false, error: 'OpenCode not installed' }; + throw result.error; + } + if (result.status !== 0) { return { success: false, - error: stderr || `OpenCode exited with code ${spawnResult.status}`, + error: (result.stderr || '').trim() || `OpenCode exited with code ${result.status}`, }; } - const result = (spawnResult.stdout || '').trim(); - - if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) { + const output = (result.stdout || '').trim(); + if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) { return { success: false, error: 'Empty or too short response from OpenCode' }; } - - const lowerResult = result.toLowerCase(); - if ( - lowerResult.includes('error:') || - lowerResult.includes('failed to') || - lowerResult.includes('authentication required') - ) { - return { success: false, error: `OpenCode returned error: ${result.substring(0, 100)}` }; - } - - return { success: true, content: result }; - } catch (err) { - if (err.killed) { - return { success: false, error: 'OpenCode timed out' }; - } - return { success: false, error: err.message || 'Unknown OpenCode error' }; + return { success: true, content: output }; + } catch (error) { + return { + success: false, + error: error.killed ? 'OpenCode timed out' : error.message || 'Unknown OpenCode error', + }; } } -/** - * Execute search via Grok CLI - */ function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { try { - const timeoutMs = timeoutSec * 1000; - const prompt = buildPrompt('grok', query); - - if (process.env.CCS_DEBUG) { - console.error('[CCS Hook] Executing: grok "..."'); - } - - const spawnResult = spawnSync('grok', [prompt], { + const result = spawnSync('grok', [buildPrompt('grok', query)], { encoding: 'utf8', - timeout: timeoutMs, + timeout: timeoutSec * 1000, maxBuffer: 1024 * 1024 * 2, stdio: ['pipe', 'pipe', 'pipe'], shell: isWindows, }); - if (spawnResult.error) { - if (spawnResult.error.code === 'ENOENT') { + if (result.error) { + if (result.error.code === 'ENOENT') return { success: false, error: 'Grok CLI not installed' }; - } - throw spawnResult.error; + throw result.error; } - - if (spawnResult.status !== 0) { - const stderr = (spawnResult.stderr || '').trim(); + if (result.status !== 0) { return { success: false, - error: stderr || `Grok CLI exited with code ${spawnResult.status}`, + error: (result.stderr || '').trim() || `Grok CLI exited with code ${result.status}`, }; } - const result = (spawnResult.stdout || '').trim(); - - if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) { + const output = (result.stdout || '').trim(); + if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) { return { success: false, error: 'Empty or too short response from Grok' }; } - - const lowerResult = result.toLowerCase(); - if ( - lowerResult.includes('error:') || - lowerResult.includes('failed to') || - lowerResult.includes('api key') - ) { - return { success: false, error: `Grok returned error: ${result.substring(0, 100)}` }; - } - - return { success: true, content: result }; - } catch (err) { - if (err.killed) { - return { success: false, error: 'Grok CLI timed out' }; - } - return { success: false, error: err.message || 'Unknown Grok error' }; + return { success: true, content: output }; + } catch (error) { + return { + success: false, + error: error.killed ? 'Grok CLI timed out' : error.message || 'Unknown Grok error', + }; } } -/** - * Format search results for Claude - */ -function formatSearchResults(query, content, providerName) { - return [ - `[WebSearch Result via ${providerName}]`, - '', - `Query: "${query}"`, - '', - content, - '', - '---', - 'Use this information to answer the user.', - ].join('\n'); -} - -/** - * Output success response and exit - * - * Key insight from Claude Code docs: - * - permissionDecisionReason (with deny) โ†’ shown to CLAUDE (AI reads this) - * - systemMessage โ†’ shown to USER only (nice styling but AI doesn't see) - * - * So we MUST put results in permissionDecisionReason for Claude to use them. - * systemMessage provides the nice UI for the user. - */ function outputSuccess(query, content, providerName) { - const formattedResults = formatSearchResults(query, content, providerName); - const output = { decision: 'block', - reason: `WebSearch completed via ${providerName}`, - // Nice message for user (shows as "says:" - info style) - systemMessage: `[WebSearch via ${providerName}] Results retrieved successfully. See below.`, + reason: `WebSearch handled via ${providerName}`, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', - // Full results here - Claude reads this - permissionDecisionReason: formattedResults, + permissionDecisionReason: `[WebSearch Result via ${providerName}]\n\nQuery: "${query}"\n\n${content}`, }, }; @@ -546,113 +548,137 @@ function outputSuccess(query, content, providerName) { process.exit(2); } -/** - * Output error message - */ -function outputError(query, error, providerName) { - const message = [ - `[WebSearch - ${providerName} Error]`, - '', - `Error: ${error}`, - '', - `Query: "${query}"`, - '', - 'Troubleshooting:', - ' - Check ~/.gemini/oauth_creds.json exists', - ' - Re-authenticate: run `gemini` (opens browser)', - ].join('\n'); - - const output = { - decision: 'block', - reason: `WebSearch failed: ${error}`, - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: message, - }, - }; - - console.log(JSON.stringify(output)); - process.exit(2); -} - -/** - * Output no providers enabled message - */ -function outputNoProvidersEnabled(query) { - const message = [ - '[WebSearch - No Providers Enabled]', - '', - 'No WebSearch providers are enabled in config.', - '', - 'To enable: Run `ccs config` and enable a provider.', - '', - 'Or install one of the following CLI tools:', - '', - '1. Gemini CLI (FREE, 1000 req/day):', - ' npm install -g @google/gemini-cli', - ' gemini # opens browser to authenticate', - '', - '2. OpenCode (FREE via Zen):', - ' curl -fsSL https://opencode.ai/install | bash', - '', - '3. Grok CLI (requires XAI_API_KEY):', - ' npm install -g @vibe-kit/grok-cli', - '', - `Query: "${query}"`, - ].join('\n'); - - const output = { - decision: 'block', - reason: 'WebSearch unavailable - no providers enabled', - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: message, - }, - }; - - console.log(JSON.stringify(output)); - process.exit(2); -} - -/** - * Output no tools message (legacy - kept for backwards compatibility) - */ -function outputNoToolsMessage(query) { - outputNoProvidersEnabled(query); -} - -/** - * Output all providers failed message - */ function outputAllFailedMessage(query, errors) { - const errorDetails = errors.map((e) => ` - ${e.provider}: ${e.error}`).join('\n'); - - const message = [ - '[WebSearch - All Providers Failed]', - '', - 'Tried all enabled CLI tools but all failed:', - errorDetails, - '', - `Query: "${query}"`, - '', - 'Troubleshooting:', - ' - Gemini: run `gemini` to authenticate (opens browser)', - ' - OpenCode: opencode --version', - ' - Grok: Check XAI_API_KEY environment variable', - ].join('\n'); - + const detail = errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | '); const output = { decision: 'block', - reason: 'WebSearch failed - all providers failed', + reason: 'WebSearch fallback failed', hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', - permissionDecisionReason: message, + permissionDecisionReason: `WebSearch could not be completed for "${query}". ${detail}`, }, }; console.log(JSON.stringify(output)); process.exit(2); } + +async function processHook(input) { + try { + if (shouldSkipHook()) { + process.exit(0); + } + + const data = JSON.parse(input); + if (data.tool_name !== 'WebSearch') { + process.exit(0); + } + + const query = data.tool_input?.query || ''; + if (!query) { + process.exit(0); + } + + const timeout = Number.parseInt( + process.env.CCS_WEBSEARCH_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`, + 10 + ); + const providers = [ + { + name: 'Exa', + id: 'exa', + available: () => isProviderEnabled('exa') && Boolean(getProviderApiKey('exa')), + fn: tryExaSearch, + }, + { + name: 'Tavily', + id: 'tavily', + available: () => isProviderEnabled('tavily') && Boolean(getProviderApiKey('tavily')), + fn: tryTavilySearch, + }, + { + name: 'Brave Search', + id: 'brave', + available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')), + fn: tryBraveSearch, + }, + { + name: 'DuckDuckGo', + id: 'duckduckgo', + available: () => isProviderEnabled('duckduckgo'), + fn: tryDuckDuckGoSearch, + }, + { + name: 'Gemini CLI', + id: 'gemini', + available: () => isProviderEnabled('gemini') && isCliAvailable('gemini'), + fn: tryGeminiSearch, + }, + { + name: 'OpenCode', + id: 'opencode', + available: () => isProviderEnabled('opencode') && isCliAvailable('opencode'), + fn: tryOpenCodeSearch, + }, + { + name: 'Grok CLI', + id: 'grok', + available: () => isProviderEnabled('grok') && isCliAvailable('grok'), + fn: tryGrokSearch, + }, + ]; + + const activeProviders = providers.filter((provider) => provider.available()); + debug( + `Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}` + ); + + if (activeProviders.length === 0) { + process.exit(0); + } + + const errors = []; + for (const provider of activeProviders) { + debug(`Trying ${provider.name}`); + const result = await provider.fn(query, timeout); + if (result.success) { + outputSuccess(query, result.content, provider.name); + return; + } + errors.push({ provider: provider.name, error: result.error }); + } + + outputAllFailedMessage(query, errors); + } catch (error) { + debug(`Hook error: ${error.message}`); + process.exit(0); + } +} + +function startFromStdin() { + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + input += chunk; + }); + process.stdin.on('end', () => { + processHook(input); + }); + process.stdin.on('error', () => { + process.exit(0); + }); +} + +if (require.main === module) { + startFromStdin(); +} + +module.exports = { + extractDuckDuckGoResults, + formatStructuredSearchResults, + tryExaSearch, + tryTavilySearch, + tryDuckDuckGoSearch, + tryBraveSearch, +}; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 108f9f20..a95cb2c5 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -337,11 +337,27 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { websearch: { enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true, providers: { + exa: { + enabled: partial.websearch?.providers?.exa?.enabled ?? false, + max_results: partial.websearch?.providers?.exa?.max_results ?? 5, + }, + tavily: { + enabled: partial.websearch?.providers?.tavily?.enabled ?? false, + max_results: partial.websearch?.providers?.tavily?.max_results ?? 5, + }, + duckduckgo: { + enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true, + max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5, + }, + brave: { + enabled: partial.websearch?.providers?.brave?.enabled ?? false, + max_results: partial.websearch?.providers?.brave?.max_results ?? 5, + }, gemini: { enabled: partial.websearch?.providers?.gemini?.enabled ?? partial.websearch?.gemini?.enabled ?? // Legacy fallback - true, + false, model: partial.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash', timeout: partial.websearch?.providers?.gemini?.timeout ?? @@ -618,25 +634,25 @@ function generateYamlWithComments(config: UnifiedConfig): string { // WebSearch section if (config.websearch) { lines.push('# ----------------------------------------------------------------------------'); - lines.push('# WebSearch: CLI-based web search for third-party profiles'); + lines.push('# WebSearch: real search backends for third-party profiles'); lines.push('# Dashboard (`ccs config`) is the source of truth for provider selection.'); lines.push('#'); lines.push('# Third-party providers (gemini, codex, agy, etc.) do not have access to'); - lines.push("# Anthropic's WebSearch tool. These CLI tools provide fallback web search."); - lines.push('#'); - lines.push('# Fallback chain: Gemini -> OpenCode -> Grok (tries in order until success)'); + lines.push("# Anthropic's WebSearch tool. CCS intercepts that tool and runs local search."); lines.push('#'); lines.push( - '# Gemini models: gemini-2.5-flash (default), gemini-2.5-pro, gemini-2.5-flash-lite' - ); - lines.push( - '# OpenCode models: opencode/grok-code (default), opencode/gpt-4o, opencode/claude-3.5-sonnet' + '# Priority: Exa -> Tavily -> Brave -> DuckDuckGo -> optional legacy AI CLI fallbacks' ); lines.push('#'); - lines.push('# Install commands:'); - lines.push('# gemini: npm i -g @google/gemini-cli (FREE - 1000 req/day)'); - lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash (FREE via Zen)'); - lines.push('# grok: npm i -g @vibe-kit/grok-cli (requires GROK_API_KEY)'); + lines.push('# Exa requires EXA_API_KEY in your environment.'); + lines.push('# Tavily requires TAVILY_API_KEY in your environment.'); + lines.push('# Brave requires BRAVE_API_KEY in your environment.'); + lines.push('# DuckDuckGo works with zero extra setup and is enabled by default.'); + lines.push('#'); + lines.push('# Legacy LLM fallbacks remain optional if you still want them:'); + lines.push('# gemini: npm i -g @google/gemini-cli'); + lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash'); + lines.push('# grok: npm i -g @vibe-kit/grok-cli'); lines.push('# ----------------------------------------------------------------------------'); lines.push( yaml @@ -978,6 +994,10 @@ export interface GeminiWebSearchInfo { export function getWebSearchConfig(): { enabled: boolean; providers?: { + exa?: { enabled?: boolean; max_results?: number }; + tavily?: { enabled?: boolean; max_results?: number }; + duckduckgo?: { enabled?: boolean; max_results?: number }; + brave?: { enabled?: boolean; max_results?: number }; gemini?: GeminiWebSearchInfo; opencode?: { enabled?: boolean; model?: string; timeout?: number }; grok?: { enabled?: boolean; timeout?: number }; @@ -988,9 +1008,29 @@ export function getWebSearchConfig(): { const config = loadOrCreateUnifiedConfig(); // Build provider configs + const exaConfig = { + enabled: config.websearch?.providers?.exa?.enabled ?? false, + max_results: config.websearch?.providers?.exa?.max_results ?? 5, + }; + + const tavilyConfig = { + enabled: config.websearch?.providers?.tavily?.enabled ?? false, + max_results: config.websearch?.providers?.tavily?.max_results ?? 5, + }; + + const duckDuckGoConfig = { + enabled: config.websearch?.providers?.duckduckgo?.enabled ?? true, + max_results: config.websearch?.providers?.duckduckgo?.max_results ?? 5, + }; + + const braveConfig = { + enabled: config.websearch?.providers?.brave?.enabled ?? false, + max_results: config.websearch?.providers?.brave?.max_results ?? 5, + }; + const geminiConfig: GeminiWebSearchInfo = { enabled: - config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? true, + config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? false, model: config.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash', timeout: config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55, @@ -1008,12 +1048,23 @@ export function getWebSearchConfig(): { }; // Auto-enable master switch if ANY provider is enabled - const anyProviderEnabled = geminiConfig.enabled || opencodeConfig.enabled || grokConfig.enabled; + const anyProviderEnabled = + exaConfig.enabled || + tavilyConfig.enabled || + duckDuckGoConfig.enabled || + braveConfig.enabled || + geminiConfig.enabled || + opencodeConfig.enabled || + grokConfig.enabled; const enabled = anyProviderEnabled && (config.websearch?.enabled ?? true); return { enabled, providers: { + exa: exaConfig, + tavily: tavilyConfig, + duckduckgo: duckDuckGoConfig, + brave: braveConfig, gemini: geminiConfig, opencode: opencodeConfig, grok: grokConfig, diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 89ed7d9f..bf96e4a3 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -22,8 +22,10 @@ import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; * Version 6 = Customizable auth tokens (API key and management secret) * Version 7 = Quota management for hybrid auto+manual account control * Version 8 = Thinking/reasoning budget configuration + * Version 9 = Real WebSearch backends (DuckDuckGo/Brave) with legacy CLI fallback + * Version 10 = Exa + Tavily WebSearch backends */ -export const UNIFIED_CONFIG_VERSION = 8; +export const UNIFIED_CONFIG_VERSION = 10; /** * Supported CLIProxy providers. @@ -235,11 +237,51 @@ export interface PreferencesConfig { auto_update?: boolean; } +/** + * DuckDuckGo WebSearch configuration. + */ +export interface DuckDuckGoWebSearchConfig { + /** Enable DuckDuckGo HTML search fallback (default: true) */ + enabled?: boolean; + /** Number of results to fetch (default: 5) */ + max_results?: number; +} + +/** + * Brave WebSearch configuration. + */ +export interface BraveWebSearchConfig { + /** Enable Brave Search when BRAVE_API_KEY is available (default: false) */ + enabled?: boolean; + /** Number of results to fetch (default: 5) */ + max_results?: number; +} + +/** + * Exa WebSearch configuration. + */ +export interface ExaWebSearchConfig { + /** Enable Exa Search when EXA_API_KEY is available (default: false) */ + enabled?: boolean; + /** Number of results to fetch (default: 5) */ + max_results?: number; +} + +/** + * Tavily WebSearch configuration. + */ +export interface TavilyWebSearchConfig { + /** Enable Tavily Search when TAVILY_API_KEY is available (default: false) */ + enabled?: boolean; + /** Number of results to fetch (default: 5) */ + max_results?: number; +} + /** * Gemini CLI WebSearch configuration. */ export interface GeminiWebSearchConfig { - /** Enable Gemini CLI for WebSearch (default: true) */ + /** Enable Gemini CLI legacy fallback (default: false) */ enabled?: boolean; /** Model to use (default: gemini-2.5-flash) */ model?: string; @@ -251,7 +293,7 @@ export interface GeminiWebSearchConfig { * Grok CLI WebSearch configuration. */ export interface GrokWebSearchConfig { - /** Enable Grok CLI for WebSearch (default: false - requires GROK_API_KEY) */ + /** Enable Grok CLI legacy fallback (default: false - requires GROK_API_KEY) */ enabled?: boolean; /** Timeout in seconds (default: 55) */ timeout?: number; @@ -261,7 +303,7 @@ export interface GrokWebSearchConfig { * OpenCode CLI WebSearch configuration. */ export interface OpenCodeWebSearchConfig { - /** Enable OpenCode CLI for WebSearch (default: false) */ + /** Enable OpenCode CLI legacy fallback (default: false) */ enabled?: boolean; /** Model to use (default: opencode/grok-code) */ model?: string; @@ -271,14 +313,22 @@ export interface OpenCodeWebSearchConfig { /** * WebSearch providers configuration. - * Supports Gemini CLI, Grok CLI, and OpenCode. + * Uses deterministic search backends first, with optional legacy CLI fallback. */ export interface WebSearchProvidersConfig { - /** Gemini CLI - uses google_web_search tool (FREE tier: 1000 req/day) */ + /** Exa Search API - API-backed search with strong relevance and content extraction */ + exa?: ExaWebSearchConfig; + /** Tavily Search API - API-backed search optimized for agent/tool usage */ + tavily?: TavilyWebSearchConfig; + /** DuckDuckGo HTML search - zero setup default backend */ + duckduckgo?: DuckDuckGoWebSearchConfig; + /** Brave Search API - higher quality results when BRAVE_API_KEY is set */ + brave?: BraveWebSearchConfig; + /** Gemini CLI - optional legacy LLM fallback */ gemini?: GeminiWebSearchConfig; - /** Grok CLI - xAI web search (requires GROK_API_KEY) */ + /** Grok CLI - optional legacy LLM fallback */ grok?: GrokWebSearchConfig; - /** OpenCode - built-in web search (FREE via OpenCode Zen) */ + /** OpenCode - optional legacy LLM fallback */ opencode?: OpenCodeWebSearchConfig; } @@ -441,8 +491,8 @@ export const DEFAULT_GLOBAL_ENV: Record = { /** * WebSearch configuration. - * Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles. - * Third-party providers don't have server-side WebSearch access. + * Uses deterministic local backends for third-party profiles. + * Legacy AI CLI fallbacks remain available for compatibility only. */ export interface WebSearchConfig { /** Master switch - enable/disable WebSearch (default: true) */ @@ -825,8 +875,24 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { websearch: { enabled: true, providers: { - gemini: { + exa: { + enabled: false, + max_results: 5, + }, + tavily: { + enabled: false, + max_results: 5, + }, + duckduckgo: { enabled: true, + max_results: 5, + }, + brave: { + enabled: false, + max_results: 5, + }, + gemini: { + enabled: false, model: 'gemini-2.5-flash', timeout: 55, }, diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index 88a916b5..cb2a4569 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -3,12 +3,13 @@ * * WebSearch is a server-side tool executed by Anthropic's API. * Third-party providers (gemini, agy, codex, qwen) don't have access. - * This manager installs a hook that uses CLI tools (Gemini CLI) as fallback. + * This manager installs a hook that uses deterministic local search backends, + * with legacy AI CLI tools kept only as optional fallback. * * Simplified Architecture: - * - No MCP complexity - * - Uses CLI tools (currently Gemini CLI) - * - Easy to extend for future CLI tools (opencode, etc.) + * - No MCP dependency for the default path + * - Uses real search providers first (DuckDuckGo, Brave) + * - Keeps Gemini/OpenCode/Grok as compatibility fallback only * * @module utils/websearch-manager */ diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index ee3cf2b9..ac0e9e1b 100644 --- a/src/utils/websearch/hook-env.ts +++ b/src/utils/websearch/hook-env.ts @@ -29,7 +29,29 @@ export function getWebSearchHookEnv(): Record { env.CCS_WEBSEARCH_ENABLED = '1'; // Pass individual provider enabled states - // Hook will only use providers that are BOTH enabled AND installed + // Hook will only use providers that are BOTH enabled AND ready. + if (wsConfig.providers?.exa?.enabled) { + env.CCS_WEBSEARCH_EXA = '1'; + env.CCS_WEBSEARCH_EXA_MAX_RESULTS = String(wsConfig.providers.exa.max_results || 5); + } + + if (wsConfig.providers?.tavily?.enabled) { + env.CCS_WEBSEARCH_TAVILY = '1'; + env.CCS_WEBSEARCH_TAVILY_MAX_RESULTS = String(wsConfig.providers.tavily.max_results || 5); + } + + if (wsConfig.providers?.duckduckgo?.enabled) { + env.CCS_WEBSEARCH_DUCKDUCKGO = '1'; + env.CCS_WEBSEARCH_DUCKDUCKGO_MAX_RESULTS = String( + wsConfig.providers.duckduckgo.max_results || 5 + ); + } + + if (wsConfig.providers?.brave?.enabled) { + env.CCS_WEBSEARCH_BRAVE = '1'; + env.CCS_WEBSEARCH_BRAVE_MAX_RESULTS = String(wsConfig.providers.brave.max_results || 5); + } + if (wsConfig.providers?.gemini?.enabled) { env.CCS_WEBSEARCH_GEMINI = '1'; if (wsConfig.providers.gemini.model) { diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index 95ddea17..4594b4ad 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -8,70 +8,164 @@ import { ok, warn, fail, info } from '../ui'; import { getWebSearchConfig } from '../../config/unified-config-loader'; -import { getGeminiCliStatus, hasGeminiCli, isGeminiAuthenticated } from './gemini-cli'; -import { getGrokCliStatus, hasGrokCli } from './grok-cli'; -import { getOpenCodeCliStatus, hasOpenCodeCli } from './opencode-cli'; +import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli'; +import { getGrokCliStatus } from './grok-cli'; +import { getOpenCodeCliStatus } from './opencode-cli'; import type { WebSearchCliInfo, WebSearchStatus } from './types'; -/** - * Get all WebSearch CLI providers with their status - */ -export function getWebSearchCliProviders(): WebSearchCliInfo[] { +function hasEnvValue(name: string): boolean { + return (process.env[name] || '').trim().length > 0; +} + +function hasAnyEnvValue(names: string[]): boolean { + return names.some((name) => hasEnvValue(name)); +} + +function getLegacyProviderStatuses(): WebSearchCliInfo[] { + const wsConfig = getWebSearchConfig(); const geminiStatus = getGeminiCliStatus(); const grokStatus = getGrokCliStatus(); const opencodeStatus = getOpenCodeCliStatus(); + const geminiAuthed = geminiStatus.installed && isGeminiAuthenticated(); return [ { id: 'gemini', + kind: 'legacy-cli', name: 'Gemini CLI', command: 'gemini', - installed: geminiStatus.installed, + enabled: wsConfig.providers?.gemini?.enabled ?? false, + available: geminiAuthed, version: geminiStatus.version ?? null, installCommand: 'npm install -g @google/gemini-cli', docsUrl: 'https://github.com/google-gemini/gemini-cli', requiresApiKey: false, - description: 'Google Gemini with web search (FREE tier: 1000 req/day)', - freeTier: true, + description: 'Optional legacy LLM fallback with Google web search.', + detail: geminiStatus.installed + ? geminiAuthed + ? 'Authenticated' + : "Run 'gemini' to login" + : 'Not installed', }, { id: 'opencode', + kind: 'legacy-cli', name: 'OpenCode', command: 'opencode', - installed: opencodeStatus.installed, + enabled: wsConfig.providers?.opencode?.enabled ?? false, + available: opencodeStatus.installed, version: opencodeStatus.version ?? null, installCommand: 'curl -fsSL https://opencode.ai/install | bash', docsUrl: 'https://github.com/sst/opencode', requiresApiKey: false, - description: 'OpenCode with built-in web search (FREE via Zen)', - freeTier: true, + description: 'Optional legacy LLM fallback via OpenCode.', + detail: opencodeStatus.installed ? 'Installed' : 'Not installed', }, { id: 'grok', + kind: 'legacy-cli', name: 'Grok CLI', command: 'grok', - installed: grokStatus.installed, + enabled: wsConfig.providers?.grok?.enabled ?? false, + available: grokStatus.installed && hasEnvValue('GROK_API_KEY'), version: grokStatus.version ?? null, installCommand: 'npm install -g @vibe-kit/grok-cli', docsUrl: 'https://github.com/superagent-ai/grok-cli', requiresApiKey: true, apiKeyEnvVar: 'GROK_API_KEY', - description: 'xAI Grok CLI with AI coding agent capabilities', - freeTier: false, + description: 'Optional legacy LLM fallback with xAI Grok.', + detail: grokStatus.installed + ? hasEnvValue('GROK_API_KEY') + ? 'Ready' + : 'Set GROK_API_KEY' + : 'Not installed', }, ]; } /** - * Check if any WebSearch CLI is available + * Get all WebSearch providers with their current status. */ -export function hasAnyWebSearchCli(): boolean { - return hasGeminiCli() || hasGrokCli() || hasOpenCodeCli(); +export function getWebSearchCliProviders(): WebSearchCliInfo[] { + const wsConfig = getWebSearchConfig(); + const providers: WebSearchCliInfo[] = [ + { + id: 'exa', + kind: 'backend', + name: 'Exa', + enabled: wsConfig.providers?.exa?.enabled ?? false, + available: + (wsConfig.providers?.exa?.enabled ?? false) && + hasAnyEnvValue(['EXA_API_KEY', 'CCS_WEBSEARCH_EXA_API_KEY']), + version: null, + docsUrl: 'https://docs.exa.ai/reference/search', + requiresApiKey: true, + apiKeyEnvVar: 'EXA_API_KEY', + description: 'API-backed search with strong relevance and content extraction.', + detail: hasAnyEnvValue(['EXA_API_KEY', 'CCS_WEBSEARCH_EXA_API_KEY']) + ? `API key detected (${wsConfig.providers?.exa?.max_results ?? 5} results)` + : 'Set EXA_API_KEY', + }, + { + id: 'tavily', + kind: 'backend', + name: 'Tavily', + enabled: wsConfig.providers?.tavily?.enabled ?? false, + available: + (wsConfig.providers?.tavily?.enabled ?? false) && + hasAnyEnvValue(['TAVILY_API_KEY', 'CCS_WEBSEARCH_TAVILY_API_KEY']), + version: null, + docsUrl: 'https://docs.tavily.com/documentation/api-reference/endpoint/search', + requiresApiKey: true, + apiKeyEnvVar: 'TAVILY_API_KEY', + description: 'Search API optimized for agent workflows and concise web result synthesis.', + detail: hasAnyEnvValue(['TAVILY_API_KEY', 'CCS_WEBSEARCH_TAVILY_API_KEY']) + ? `API key detected (${wsConfig.providers?.tavily?.max_results ?? 5} results)` + : 'Set TAVILY_API_KEY', + }, + { + id: 'duckduckgo', + kind: 'backend', + name: 'DuckDuckGo', + enabled: wsConfig.providers?.duckduckgo?.enabled ?? true, + available: wsConfig.providers?.duckduckgo?.enabled ?? true, + version: null, + docsUrl: 'https://duckduckgo.com', + requiresApiKey: false, + description: 'Default built-in HTML search backend. Zero setup.', + detail: `Built-in (${wsConfig.providers?.duckduckgo?.max_results ?? 5} results)`, + }, + { + id: 'brave', + kind: 'backend', + name: 'Brave Search', + enabled: wsConfig.providers?.brave?.enabled ?? false, + available: + (wsConfig.providers?.brave?.enabled ?? false) && + hasAnyEnvValue(['BRAVE_API_KEY', 'CCS_WEBSEARCH_BRAVE_API_KEY']), + version: null, + docsUrl: 'https://brave.com/search/api/', + requiresApiKey: true, + apiKeyEnvVar: 'BRAVE_API_KEY', + description: 'API-backed web search with cleaner result metadata.', + detail: hasAnyEnvValue(['BRAVE_API_KEY', 'CCS_WEBSEARCH_BRAVE_API_KEY']) + ? `API key detected (${wsConfig.providers?.brave?.max_results ?? 5} results)` + : 'Set BRAVE_API_KEY', + }, + ]; + + return [...providers, ...getLegacyProviderStatuses()]; } /** - * Get install hints for CLI-only users when no WebSearch CLI is installed - * Returns raw message strings (without indicator prefix) for display + * Check if any WebSearch provider is currently ready. + */ +export function hasAnyWebSearchCli(): boolean { + return getWebSearchCliProviders().some((provider) => provider.enabled && provider.available); +} + +/** + * Get setup hints when no providers are ready. */ export function getCliInstallHints(): string[] { if (hasAnyWebSearchCli()) { @@ -79,95 +173,58 @@ export function getCliInstallHints(): string[] { } return [ - 'WebSearch: No CLI tools installed', - ' Gemini CLI (FREE): npm i -g @google/gemini-cli', - ' OpenCode (FREE): curl -fsSL https://opencode.ai/install | bash', - ' Grok CLI (paid): npm i -g @vibe-kit/grok-cli', + 'WebSearch: no ready providers', + ' Enable DuckDuckGo in Settings > WebSearch for zero-setup search', + ' Or export EXA_API_KEY, TAVILY_API_KEY, or BRAVE_API_KEY for API-backed search', + ' Optional legacy fallback: npm i -g @google/gemini-cli', ]; } /** - * Get WebSearch readiness status for display - * - * Called on third-party profile startup to inform user. - * Checks both installation AND authentication status for Gemini CLI. + * Get WebSearch readiness status for display. */ export function getWebSearchReadiness(): WebSearchStatus { const wsConfig = getWebSearchConfig(); + const providers = getWebSearchCliProviders(); - // Check if WebSearch is disabled entirely if (!wsConfig.enabled) { return { readiness: 'unavailable', - geminiCli: false, - geminiAuthenticated: false, - grokCli: false, - opencodeCli: false, message: 'Disabled in config', + providers, }; } - // Check all CLIs - const geminiInstalled = hasGeminiCli(); - const geminiAuthed = geminiInstalled && isGeminiAuthenticated(); - const grokInstalled = hasGrokCli(); - const opencodeInstalled = hasOpenCodeCli(); + const enabledProviders = providers.filter((provider) => provider.enabled); + const readyProviders = enabledProviders.filter((provider) => provider.available); - // Build message based on installed + authenticated CLIs - const readyClis: string[] = []; - const needsAuthClis: string[] = []; - - // Gemini requires auth check - if (geminiInstalled) { - if (geminiAuthed) { - readyClis.push('Gemini'); - } else { - needsAuthClis.push('Gemini'); - } - } - - // Other CLIs don't require auth check (for now) - if (grokInstalled) readyClis.push('Grok'); - if (opencodeInstalled) readyClis.push('OpenCode'); - - // Determine overall status - if (readyClis.length > 0) { + if (readyProviders.length > 0) { return { readiness: 'ready', - geminiCli: geminiInstalled, - geminiAuthenticated: geminiAuthed, - grokCli: grokInstalled, - opencodeCli: opencodeInstalled, - message: `Ready (${readyClis.join(' + ')})`, + message: `Ready (${readyProviders.map((provider) => provider.name).join(' + ')})`, + providers, }; } - if (needsAuthClis.length > 0) { + if (enabledProviders.length > 0) { return { - readiness: 'needs_auth', - geminiCli: geminiInstalled, - geminiAuthenticated: false, - grokCli: grokInstalled, - opencodeCli: opencodeInstalled, - message: `Gemini: run 'gemini' to login`, + readiness: 'needs_setup', + message: enabledProviders + .map((provider) => `${provider.name}: ${provider.detail}`) + .join(' | '), + providers, }; } return { readiness: 'unavailable', - geminiCli: false, - geminiAuthenticated: false, - grokCli: false, - opencodeCli: false, - message: 'Install: npm i -g @google/gemini-cli', + message: 'Enable at least one provider in Settings > WebSearch', + providers, }; } /** - * Display WebSearch status (single line, equilibrium UX) - * - * Only call for third-party profiles. - * Shows detailed install hints when no CLI is installed. + * Display WebSearch status (single line, equilibrium UX). */ export function displayWebSearchStatus(): void { const status = getWebSearchReadiness(); @@ -176,21 +233,13 @@ export function displayWebSearchStatus(): void { case 'ready': console.error(ok(`WebSearch: ${status.message}`)); break; - case 'needs_auth': + case 'needs_setup': console.error(warn(`WebSearch: ${status.message}`)); break; case 'unavailable': console.error(fail(`WebSearch: ${status.message}`)); - const hints = getCliInstallHints(); - if (hints.length > 0) { - // First line gets [i] prefix, rest are continuation (indented, no prefix) - for (let i = 0; i < hints.length; i++) { - if (i === 0) { - console.error(info(hints[i])); - } else { - console.error(hints[i]); - } - } + for (const [index, hint] of getCliInstallHints().entries()) { + console.error(index === 0 ? info(hint) : hint); } break; } diff --git a/src/utils/websearch/types.ts b/src/utils/websearch/types.ts index b8ff474c..8d737617 100644 --- a/src/utils/websearch/types.ts +++ b/src/utils/websearch/types.ts @@ -1,7 +1,7 @@ /** * WebSearch Type Definitions * - * Contains all type definitions for WebSearch CLI providers and status. + * Contains all type definitions for WebSearch providers and status. * * @module utils/websearch/types */ @@ -29,46 +29,64 @@ export type OpenCodeCliStatus = ComponentStatus; /** * WebSearch availability status for third-party profiles */ -export type WebSearchReadiness = 'ready' | 'needs_auth' | 'unavailable'; +export type WebSearchReadiness = 'ready' | 'needs_setup' | 'unavailable'; /** - * WebSearch status for display + * WebSearch provider identifier */ -export interface WebSearchStatus { - readiness: WebSearchReadiness; - geminiCli: boolean; - geminiAuthenticated: boolean; - grokCli: boolean; - opencodeCli: boolean; - message: string; -} +export type WebSearchProviderId = + | 'exa' + | 'tavily' + | 'duckduckgo' + | 'brave' + | 'gemini' + | 'grok' + | 'opencode'; /** - * WebSearch CLI provider information for health checks and UI + * Provider execution class. + */ +export type WebSearchProviderKind = 'backend' | 'legacy-cli'; + +/** + * WebSearch provider information for health checks and UI */ export interface WebSearchCliInfo { /** Provider ID */ - id: 'gemini' | 'grok' | 'opencode'; + id: WebSearchProviderId; + /** Backend vs legacy CLI */ + kind: WebSearchProviderKind; /** Display name */ name: string; - /** CLI command name */ - command: string; - /** Whether CLI is installed */ - installed: boolean; - /** CLI version if installed */ + /** Command name for legacy providers */ + command?: string; + /** Whether the provider is enabled in config */ + enabled: boolean; + /** Whether the provider is ready right now */ + available: boolean; + /** CLI version if applicable */ version: string | null; - /** Install command */ - installCommand: string; + /** Install or setup command when applicable */ + installCommand?: string; /** Docs URL */ - docsUrl: string; + docsUrl?: string; /** Whether this provider requires an API key */ requiresApiKey: boolean; /** API key environment variable name */ apiKeyEnvVar?: string; /** Brief description */ description: string; - /** Free tier available? */ - freeTier: boolean; + /** Summary detail shown in status UIs */ + detail: string; +} + +/** + * WebSearch status for display + */ +export interface WebSearchStatus { + readiness: WebSearchReadiness; + message: string; + providers: WebSearchCliInfo[]; } /** @@ -78,6 +96,7 @@ export interface WebSearchProviderConfig { enabled?: boolean; model?: string; timeout?: number; + max_results?: number; } /** @@ -86,6 +105,10 @@ export interface WebSearchProviderConfig { export interface WebSearchConfig { enabled: boolean; providers?: { + exa?: WebSearchProviderConfig; + tavily?: WebSearchProviderConfig; + duckduckgo?: WebSearchProviderConfig; + brave?: WebSearchProviderConfig; gemini?: WebSearchProviderConfig; opencode?: WebSearchProviderConfig; grok?: WebSearchProviderConfig; diff --git a/src/web-server/health/websearch-checks.ts b/src/web-server/health/websearch-checks.ts index 89a7c2cb..3a4faa7d 100644 --- a/src/web-server/health/websearch-checks.ts +++ b/src/web-server/health/websearch-checks.ts @@ -1,7 +1,7 @@ /** - * WebSearch CLI Health Checks + * WebSearch Health Checks * - * Check WebSearch CLI providers (Gemini CLI, Grok CLI). + * Check WebSearch providers (real backends + legacy fallback). */ import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../../utils/websearch-manager'; @@ -15,37 +15,35 @@ export function checkWebSearchClis(): HealthCheck[] { const checks: HealthCheck[] = []; for (const provider of providers) { - if (provider.installed) { - const freeTag = provider.freeTier ? ' (FREE)' : ''; + if (provider.enabled && provider.available) { checks.push({ id: `websearch-${provider.id}`, name: provider.name, status: 'ok', - message: `v${provider.version || 'unknown'}${freeTag}`, + message: provider.detail, details: provider.description, }); } else { - const keyNote = provider.requiresApiKey ? ` (needs ${provider.apiKeyEnvVar})` : ' (FREE)'; checks.push({ id: `websearch-${provider.id}`, name: provider.name, status: 'info', - message: `Not installed${keyNote}`, + message: provider.enabled ? provider.detail : 'Disabled', fix: provider.installCommand, details: provider.description, }); } } - // Add summary check if no providers installed + // Add summary check if no providers are ready if (!hasAnyWebSearchCli()) { checks.push({ id: 'websearch-summary', name: 'WebSearch Status', status: 'warning', - message: 'No CLI tools installed', - fix: 'npm install -g @google/gemini-cli (FREE)', - details: 'Install a WebSearch CLI for real-time web access', + message: 'No ready provider', + fix: 'Enable DuckDuckGo or set EXA_API_KEY, TAVILY_API_KEY, or BRAVE_API_KEY', + details: 'Third-party profiles need a local WebSearch backend.', }); } diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index ae8e9054..d53e2735 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -5,18 +5,13 @@ import { Router, Request, Response } from 'express'; import { mutateUnifiedConfig, getWebSearchConfig } from '../../config/unified-config-loader'; import type { WebSearchConfig } from '../../config/unified-config-types'; -import { - getWebSearchReadiness, - getGeminiCliStatus, - getGrokCliStatus, - getOpenCodeCliStatus, -} from '../../utils/websearch-manager'; +import { getWebSearchReadiness, getWebSearchCliProviders } from '../../utils/websearch-manager'; const router = Router(); /** * GET /api/websearch - Get WebSearch configuration - * Returns: WebSearchConfig with enabled, provider, fallback + * Returns: normalized WebSearch configuration */ router.get('/', (_req: Request, res: Response): void => { try { @@ -30,7 +25,6 @@ router.get('/', (_req: Request, res: Response): void => { /** * PUT /api/websearch - Update WebSearch configuration * Body: WebSearchConfig fields (enabled, providers) - * Dashboard is the source of truth for provider selection. */ router.put('/', (req: Request, res: Response): void => { const { enabled, providers } = req.body as Partial; @@ -53,9 +47,40 @@ router.put('/', (req: Request, res: Response): void => { enabled: enabled ?? config.websearch?.enabled ?? true, providers: providers ? { + exa: { + enabled: providers.exa?.enabled ?? config.websearch?.providers?.exa?.enabled ?? false, + max_results: + providers.exa?.max_results ?? config.websearch?.providers?.exa?.max_results ?? 5, + }, + tavily: { + enabled: + providers.tavily?.enabled ?? config.websearch?.providers?.tavily?.enabled ?? false, + max_results: + providers.tavily?.max_results ?? + config.websearch?.providers?.tavily?.max_results ?? + 5, + }, + duckduckgo: { + enabled: + providers.duckduckgo?.enabled ?? + config.websearch?.providers?.duckduckgo?.enabled ?? + true, + max_results: + providers.duckduckgo?.max_results ?? + config.websearch?.providers?.duckduckgo?.max_results ?? + 5, + }, + brave: { + enabled: + providers.brave?.enabled ?? config.websearch?.providers?.brave?.enabled ?? false, + max_results: + providers.brave?.max_results ?? + config.websearch?.providers?.brave?.max_results ?? + 5, + }, gemini: { enabled: - providers.gemini?.enabled ?? config.websearch?.providers?.gemini?.enabled ?? true, + providers.gemini?.enabled ?? config.websearch?.providers?.gemini?.enabled ?? false, model: providers.gemini?.model ?? config.websearch?.providers?.gemini?.model ?? @@ -99,31 +124,15 @@ router.put('/', (req: Request, res: Response): void => { /** * GET /api/websearch/status - Get WebSearch status - * Returns: { geminiCli, grokCli, opencodeCli, readiness } + * Returns: provider readiness + normalized provider status list */ router.get('/status', (_req: Request, res: Response): void => { try { - const geminiCli = getGeminiCliStatus(); - const grokCli = getGrokCliStatus(); - const opencodeCli = getOpenCodeCliStatus(); const readiness = getWebSearchReadiness(); + const providers = getWebSearchCliProviders(); res.json({ - geminiCli: { - installed: geminiCli.installed, - path: geminiCli.path, - version: geminiCli.version, - }, - grokCli: { - installed: grokCli.installed, - path: grokCli.path, - version: grokCli.version, - }, - opencodeCli: { - installed: opencodeCli.installed, - path: opencodeCli.path, - version: opencodeCli.version, - }, + providers, readiness: { status: readiness.readiness, message: readiness.message, diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts new file mode 100644 index 00000000..ee91863f --- /dev/null +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'bun:test'; + +const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { + extractDuckDuckGoResults: (html: string, count: number) => Array<{ + title: string; + url: string; + description: string; + }>; + formatStructuredSearchResults: ( + query: string, + providerName: string, + results: Array<{ title: string; url: string; description: string }> + ) => string; +}; + +describe('websearch-transformer hook helpers', () => { + it('extracts DuckDuckGo results and unwraps uddg redirect URLs', () => { + const html = ` + Example title + Example snippet + Second title + Second snippet + `; + + const results = hook.extractDuckDuckGoResults(html, 2); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + title: 'Example title', + url: 'https://example.com/article', + description: 'Example snippet', + }); + expect(results[1]).toEqual({ + title: 'Second title', + url: 'https://second.example.com/post', + description: 'Second snippet', + }); + }); + + it('formats structured search results for hook deny output', () => { + const formatted = hook.formatStructuredSearchResults('ccs websearch', 'DuckDuckGo', [ + { + title: 'Result title', + url: 'https://example.com', + description: 'Result snippet', + }, + ]); + + expect(formatted).toContain('Search results for "ccs websearch" via DuckDuckGo'); + expect(formatted).toContain('1. Result title'); + expect(formatted).toContain('https://example.com'); + expect(formatted).toContain('Result snippet'); + }); +}); diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts new file mode 100644 index 00000000..4c79f83b --- /dev/null +++ b/tests/unit/utils/websearch/status.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getWebSearchReadiness } from '../../../../src/utils/websearch/status'; + +function writeWebSearchConfig(tempRoot: string, lines: string[]): void { + fs.writeFileSync(path.join(tempRoot, '.ccs', 'config.yaml'), lines.join('\n'), 'utf8'); +} + +describe('websearch readiness', () => { + const originalCcsHome = process.env.CCS_HOME; + const originalBraveKey = process.env.BRAVE_API_KEY; + const originalExaKey = process.env.EXA_API_KEY; + const originalTavilyKey = process.env.TAVILY_API_KEY; + let tempRoot = ''; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-status-')); + process.env.CCS_HOME = tempRoot; + delete process.env.BRAVE_API_KEY; + delete process.env.EXA_API_KEY; + delete process.env.TAVILY_API_KEY; + fs.mkdirSync(path.join(tempRoot, '.ccs'), { recursive: true }); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalBraveKey !== undefined) process.env.BRAVE_API_KEY = originalBraveKey; + else delete process.env.BRAVE_API_KEY; + + if (originalExaKey !== undefined) process.env.EXA_API_KEY = originalExaKey; + else delete process.env.EXA_API_KEY; + + if (originalTavilyKey !== undefined) process.env.TAVILY_API_KEY = originalTavilyKey; + else delete process.env.TAVILY_API_KEY; + + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('is ready by default because DuckDuckGo is enabled', () => { + const readiness = getWebSearchReadiness(); + + expect(readiness.readiness).toBe('ready'); + expect(readiness.message).toContain('DuckDuckGo'); + }); + + it('reports setup required when only Tavily is enabled without an API key', () => { + writeWebSearchConfig(tempRoot, [ + 'version: 10', + 'websearch:', + ' enabled: true', + ' providers:', + ' exa:', + ' enabled: false', + ' max_results: 5', + ' tavily:', + ' enabled: true', + ' max_results: 5', + ' duckduckgo:', + ' enabled: false', + ' max_results: 5', + ' brave:', + ' enabled: false', + ' max_results: 5', + ' gemini:', + ' enabled: false', + ' model: "gemini-2.5-flash"', + ' timeout: 55', + ' opencode:', + ' enabled: false', + ' model: "opencode/grok-code"', + ' timeout: 90', + ' grok:', + ' enabled: false', + ' timeout: 55', + '', + ]); + + const readiness = getWebSearchReadiness(); + + expect(readiness.readiness).toBe('needs_setup'); + expect(readiness.message).toContain('Tavily'); + expect(readiness.message).toContain('TAVILY_API_KEY'); + }); + + it('prefers API-backed readiness when Exa is enabled and configured', () => { + process.env.EXA_API_KEY = 'exa-test-key'; + writeWebSearchConfig(tempRoot, [ + 'version: 10', + 'websearch:', + ' enabled: true', + ' providers:', + ' exa:', + ' enabled: true', + ' max_results: 5', + ' tavily:', + ' enabled: false', + ' max_results: 5', + ' duckduckgo:', + ' enabled: false', + ' max_results: 5', + ' brave:', + ' enabled: false', + ' max_results: 5', + ' gemini:', + ' enabled: false', + ' model: "gemini-2.5-flash"', + ' timeout: 55', + ' opencode:', + ' enabled: false', + ' model: "opencode/grok-code"', + ' timeout: 90', + ' grok:', + ' enabled: false', + ' timeout: 55', + '', + ]); + + const readiness = getWebSearchReadiness(); + + expect(readiness.readiness).toBe('ready'); + expect(readiness.message).toContain('Exa'); + }); +}); diff --git a/ui/src/pages/settings/hooks/use-websearch-config.ts b/ui/src/pages/settings/hooks/use-websearch-config.ts index 6fc5e05b..9d5a381c 100644 --- a/ui/src/pages/settings/hooks/use-websearch-config.ts +++ b/ui/src/pages/settings/hooks/use-websearch-config.ts @@ -2,17 +2,13 @@ * WebSearch Config Hook */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback } from 'react'; import { useSettingsContext, useSettingsActions } from './context-hooks'; import type { WebSearchConfig } from '../types'; export function useWebSearchConfig() { const { state } = useSettingsContext(); const actions = useSettingsActions(); - const [geminiModelInput, setGeminiModelInput] = useState(''); - const [opencodeModelInput, setOpencodeModelInput] = useState(''); - const [geminiModelSaved, setGeminiModelSaved] = useState(false); - const [opencodeModelSaved, setOpencodeModelSaved] = useState(false); const fetchConfig = useCallback(async () => { try { @@ -46,9 +42,13 @@ export function useWebSearchConfig() { const saveConfig = useCallback( async (updates: Partial) => { const config = state.webSearchConfig; - if (!config) return; + if (!config) return false; - const optimisticConfig = { ...config, ...updates }; + const optimisticConfig = { + ...config, + ...updates, + providers: { ...config.providers, ...updates.providers }, + }; actions.setWebSearchConfig(optimisticConfig); try { @@ -68,52 +68,21 @@ export function useWebSearchConfig() { const data = await res.json(); actions.setWebSearchConfig(data.websearch); + await fetchStatus(); actions.setWebSearchSuccess(true); setTimeout(() => actions.setWebSearchSuccess(false), 1500); + return true; } catch (err) { actions.setWebSearchConfig(config); actions.setWebSearchError((err as Error).message); + return false; } finally { actions.setWebSearchSaving(false); } }, - [state.webSearchConfig, actions] + [state.webSearchConfig, actions, fetchStatus] ); - // Sync model inputs with config - useEffect(() => { - if (state.webSearchConfig) { - setGeminiModelInput(state.webSearchConfig.providers?.gemini?.model ?? 'gemini-2.5-flash'); - setOpencodeModelInput( - state.webSearchConfig.providers?.opencode?.model ?? 'opencode/grok-code' - ); - } - }, [state.webSearchConfig]); - - const saveGeminiModel = useCallback(async () => { - const currentModel = state.webSearchConfig?.providers?.gemini?.model ?? 'gemini-2.5-flash'; - if (geminiModelInput !== currentModel) { - const providers = state.webSearchConfig?.providers || {}; - await saveConfig({ - providers: { ...providers, gemini: { ...providers.gemini, model: geminiModelInput } }, - }); - setGeminiModelSaved(true); - setTimeout(() => setGeminiModelSaved(false), 2000); - } - }, [geminiModelInput, state.webSearchConfig, saveConfig]); - - const saveOpencodeModel = useCallback(async () => { - const currentModel = state.webSearchConfig?.providers?.opencode?.model ?? 'opencode/grok-code'; - if (opencodeModelInput !== currentModel) { - const providers = state.webSearchConfig?.providers || {}; - await saveConfig({ - providers: { ...providers, opencode: { ...providers.opencode, model: opencodeModelInput } }, - }); - setOpencodeModelSaved(true); - setTimeout(() => setOpencodeModelSaved(false), 2000); - } - }, [opencodeModelInput, state.webSearchConfig, saveConfig]); - return { config: state.webSearchConfig, status: state.webSearchStatus, @@ -122,16 +91,8 @@ export function useWebSearchConfig() { saving: state.webSearchSaving, error: state.webSearchError, success: state.webSearchSuccess, - geminiModelInput, - setGeminiModelInput, - opencodeModelInput, - setOpencodeModelInput, - geminiModelSaved, - opencodeModelSaved, fetchConfig, fetchStatus, saveConfig, - saveGeminiModel, - saveOpencodeModel, }; } diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index 537b38fa..f8a92803 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -1,16 +1,329 @@ /** * WebSearch Section - * Settings section for WebSearch providers (Gemini, OpenCode, Grok) + * Settings section for real WebSearch backends and legacy fallbacks. */ -import { useState, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; +import { useEffect, useMemo, useState } from 'react'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react'; -import { useWebSearchConfig, useRawConfig } from '../../hooks'; -import { ProviderCard } from './provider-card'; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronUp, + Globe, + KeyRound, + RefreshCw, +} from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; +import { useRawConfig, useWebSearchConfig } from '../../hooks'; +import type { CliStatus, WebSearchProvidersConfig } from '../../types'; +import { ProviderCard, type ProviderFieldConfig } from './provider-card'; + +type ProviderId = 'exa' | 'tavily' | 'brave' | 'duckduckgo' | 'gemini' | 'opencode' | 'grok'; +type ProviderFieldKey = 'model' | 'timeout' | 'max_results'; + +interface ProviderFieldDefinition { + key: ProviderFieldKey; + label: string; + type: 'text' | 'number'; + placeholder: string; + helpText: string; + defaultValue: string | number; + min?: number; + max?: number; +} + +interface ProviderDefinition { + id: ProviderId; + title: string; + description: string; + badge: string; + badgeTone: 'green' | 'blue' | 'amber' | 'cyan' | 'slate'; + defaultEnabled: boolean; + fallbackDetail: string; + footerNote: string; + fields?: ProviderFieldDefinition[]; +} + +const CHAIN_STEPS = [ + { id: 'exa', title: 'Exa', defaultEnabled: false }, + { id: 'tavily', title: 'Tavily', defaultEnabled: false }, + { id: 'brave', title: 'Brave', defaultEnabled: false }, + { id: 'duckduckgo', title: 'DuckDuckGo', defaultEnabled: true }, + { id: 'legacy', title: 'Legacy CLI', defaultEnabled: false }, +] as const; + +const BACKEND_PROVIDERS: ProviderDefinition[] = [ + { + id: 'exa', + title: 'Exa', + description: + 'Highest-priority API backend when enabled. Best for strong relevance and extracted text.', + badge: 'EXA_API_KEY', + badgeTone: 'amber', + defaultEnabled: false, + fallbackDetail: 'Set EXA_API_KEY', + footerNote: 'Runs before every other provider in the chain when enabled and ready.', + fields: [ + { + key: 'max_results', + label: 'Max results', + type: 'number', + placeholder: '5', + helpText: 'Clamp between 1 and 10 results.', + defaultValue: 5, + min: 1, + max: 10, + }, + ], + }, + { + id: 'tavily', + title: 'Tavily', + description: 'Agent-oriented API backend with concise web result content.', + badge: 'TAVILY_API_KEY', + badgeTone: 'cyan', + defaultEnabled: false, + fallbackDetail: 'Set TAVILY_API_KEY', + footerNote: 'Runs after Exa and before Brave when enabled and ready.', + fields: [ + { + key: 'max_results', + label: 'Max results', + type: 'number', + placeholder: '5', + helpText: 'Clamp between 1 and 10 results.', + defaultValue: 5, + min: 1, + max: 10, + }, + ], + }, + { + id: 'brave', + title: 'Brave Search', + description: 'API-backed search with clean metadata and broad web coverage.', + badge: 'BRAVE_API_KEY', + badgeTone: 'blue', + defaultEnabled: false, + fallbackDetail: 'Set BRAVE_API_KEY', + footerNote: 'Runs after Exa and Tavily, before DuckDuckGo.', + fields: [ + { + key: 'max_results', + label: 'Max results', + type: 'number', + placeholder: '5', + helpText: 'Clamp between 1 and 10 results.', + defaultValue: 5, + min: 1, + max: 10, + }, + ], + }, + { + id: 'duckduckgo', + title: 'DuckDuckGo', + description: 'Zero-setup floor. Keep this on unless you want no built-in fallback at all.', + badge: 'DEFAULT', + badgeTone: 'green', + defaultEnabled: true, + fallbackDetail: 'Built-in', + footerNote: 'Last real backend in the chain. No API key needed.', + fields: [ + { + key: 'max_results', + label: 'Max results', + type: 'number', + placeholder: '5', + helpText: 'Clamp between 1 and 10 results.', + defaultValue: 5, + min: 1, + max: 10, + }, + ], + }, +]; + +const LEGACY_PROVIDERS: ProviderDefinition[] = [ + { + id: 'gemini', + title: 'Gemini CLI', + description: 'Optional legacy LLM fallback. Used only if every enabled real backend fails.', + badge: 'LEGACY', + badgeTone: 'slate', + defaultEnabled: false, + fallbackDetail: 'Optional fallback', + footerNote: 'Legacy path. Useful for compatibility, not the preferred search backend.', + fields: [ + { + key: 'model', + label: 'Model', + type: 'text', + placeholder: 'gemini-2.5-flash', + helpText: 'CLI model passed to Gemini when this fallback runs.', + defaultValue: 'gemini-2.5-flash', + }, + { + key: 'timeout', + label: 'Timeout (seconds)', + type: 'number', + placeholder: '55', + helpText: 'Clamp between 5 and 300 seconds.', + defaultValue: 55, + min: 5, + max: 300, + }, + ], + }, + { + id: 'opencode', + title: 'OpenCode', + description: 'Optional legacy LLM fallback via OpenCode.', + badge: 'LEGACY', + badgeTone: 'slate', + defaultEnabled: false, + fallbackDetail: 'Optional fallback', + footerNote: 'Legacy path. Runs after Gemini in the fallback portion of the chain.', + fields: [ + { + key: 'model', + label: 'Model', + type: 'text', + placeholder: 'opencode/grok-code', + helpText: 'OpenCode model passed to the CLI runner.', + defaultValue: 'opencode/grok-code', + }, + { + key: 'timeout', + label: 'Timeout (seconds)', + type: 'number', + placeholder: '90', + helpText: 'Clamp between 5 and 300 seconds.', + defaultValue: 90, + min: 5, + max: 300, + }, + ], + }, + { + id: 'grok', + title: 'Grok CLI', + description: 'Optional legacy xAI fallback. Requires the Grok CLI and GROK_API_KEY.', + badge: 'LEGACY', + badgeTone: 'slate', + defaultEnabled: false, + fallbackDetail: 'Optional fallback', + footerNote: 'Last step in the full fallback chain.', + fields: [ + { + key: 'timeout', + label: 'Timeout (seconds)', + type: 'number', + placeholder: '55', + helpText: 'Clamp between 5 and 300 seconds.', + defaultValue: 55, + min: 5, + max: 300, + }, + ], + }, +]; + +function getStatusTone( + provider: CliStatus | undefined, + enabled: boolean +): 'ready' | 'setup' | 'idle' { + if (enabled && provider?.available) { + return 'ready'; + } + if (enabled) { + return 'setup'; + } + return 'idle'; +} + +function getStatusLabel(provider: CliStatus | undefined, enabled: boolean): string { + if (enabled && provider?.available) { + return 'Ready'; + } + if (enabled) { + return 'Needs setup'; + } + return 'Disabled'; +} + +function getToneChipClass(tone: 'ready' | 'setup' | 'idle'): string { + switch (tone) { + case 'ready': + return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200'; + case 'setup': + return 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200'; + case 'idle': + return 'border-border/80 bg-background/85 text-muted-foreground'; + } +} + +function getToneIndicatorClass(tone: 'ready' | 'setup' | 'idle'): string { + switch (tone) { + case 'ready': + return 'bg-emerald-500'; + case 'setup': + return 'bg-amber-500'; + case 'idle': + return 'bg-border'; + } +} + +function getToneRailDotClass(tone: 'ready' | 'setup' | 'idle'): string { + switch (tone) { + case 'ready': + return 'bg-emerald-500 shadow-[0_0_0_6px_rgba(16,185,129,0.12)]'; + case 'setup': + return 'bg-amber-500 shadow-[0_0_0_6px_rgba(245,158,11,0.12)]'; + case 'idle': + return 'bg-border'; + } +} + +function getToneLineClass(tone: 'ready' | 'setup' | 'idle'): string { + switch (tone) { + case 'ready': + return 'bg-gradient-to-b from-emerald-500/55 to-border'; + case 'setup': + return 'bg-gradient-to-b from-amber-500/55 to-border'; + case 'idle': + return 'bg-border'; + } +} + +function normalizeFieldValue(field: ProviderFieldDefinition, rawValue: string): string | number { + if (field.type === 'number') { + const parsed = Number.parseInt(rawValue.trim(), 10); + if (!Number.isFinite(parsed)) { + return field.defaultValue; + } + + const min = field.min ?? parsed; + const max = field.max ?? parsed; + return Math.min(max, Math.max(min, parsed)); + } + + const trimmed = rawValue.trim(); + return trimmed || String(field.defaultValue); +} + +function getConfiguredValue( + config: WebSearchProvidersConfig | undefined, + providerId: ProviderId, + field: ProviderFieldDefinition +): string { + const configured = config?.[providerId]?.[field.key]; + return String(configured ?? field.defaultValue); +} export default function WebSearchSection() { const { t } = useTranslation(); @@ -22,69 +335,136 @@ export default function WebSearchSection() { saving, error, success, - geminiModelInput, - setGeminiModelInput, - opencodeModelInput, - setOpencodeModelInput, - geminiModelSaved, - opencodeModelSaved, fetchConfig, fetchStatus, saveConfig, - saveGeminiModel, - saveOpencodeModel, } = useWebSearchConfig(); - const { fetchRawConfig } = useRawConfig(); + const [fieldDrafts, setFieldDrafts] = useState>({}); + const [savedFieldId, setSavedFieldId] = useState(null); + const [legacyExpanded, setLegacyExpanded] = useState(false); - // Collapsible install hints state - const [showGeminiHint, setShowGeminiHint] = useState(false); - const [showOpencodeHint, setShowOpencodeHint] = useState(false); - const [showGrokHint, setShowGrokHint] = useState(false); - - // Load data on mount useEffect(() => { fetchConfig(); fetchStatus(); fetchRawConfig(); }, [fetchConfig, fetchStatus, fetchRawConfig]); - const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false; - const isGrokEnabled = config?.providers?.grok?.enabled ?? false; - const isOpenCodeEnabled = config?.providers?.opencode?.enabled ?? false; + useEffect(() => { + if (!config?.providers) { + return; + } - const toggleGemini = () => { - const providers = config?.providers || {}; - const currentState = providers.gemini?.enabled ?? false; - saveConfig({ - enabled: !currentState || isGrokEnabled || isOpenCodeEnabled, - providers: { ...providers, gemini: { ...providers.gemini, enabled: !currentState } }, + const nextDrafts: Record = {}; + for (const provider of [...BACKEND_PROVIDERS, ...LEGACY_PROVIDERS]) { + for (const field of provider.fields ?? []) { + nextDrafts[`${provider.id}.${field.key}`] = getConfiguredValue( + config.providers, + provider.id, + field + ); + } + } + setFieldDrafts(nextDrafts); + }, [config]); + + const providerStatus = useMemo( + () => new Map((status?.providers ?? []).map((provider) => [provider.id, provider])), + [status?.providers] + ); + const legacyEnabled = LEGACY_PROVIDERS.filter( + (provider) => config?.providers?.[provider.id]?.enabled ?? provider.defaultEnabled + ); + const legacyReady = legacyEnabled.some((provider) => providerStatus.get(provider.id)?.available); + + const legacySummary = + legacyEnabled.length === 0 + ? 'Off' + : legacyEnabled.length === 1 + ? `${legacyEnabled[0].title} enabled` + : `${legacyEnabled.length} enabled`; + + const toggleProvider = async (providerId: ProviderId, enabled: boolean) => { + const currentProviders = (config?.providers ?? {}) as WebSearchProvidersConfig; + await saveConfig({ + providers: { + ...currentProviders, + [providerId]: { + ...currentProviders[providerId], + enabled: !enabled, + }, + }, }); }; - const toggleGrok = () => { - const providers = config?.providers || {}; - const currentState = providers.grok?.enabled ?? false; - saveConfig({ - enabled: isGeminiEnabled || !currentState || isOpenCodeEnabled, - providers: { ...providers, grok: { ...providers.grok, enabled: !currentState } }, - }); + const updateDraft = (providerId: ProviderId, fieldKey: ProviderFieldKey, value: string) => { + setFieldDrafts((current) => ({ ...current, [`${providerId}.${fieldKey}`]: value })); }; - const toggleOpenCode = () => { - const providers = config?.providers || {}; - const currentState = providers.opencode?.enabled ?? false; - saveConfig({ - enabled: isGeminiEnabled || isGrokEnabled || !currentState, - providers: { ...providers, opencode: { ...providers.opencode, enabled: !currentState } }, + const commitField = async (providerId: ProviderId, field: ProviderFieldDefinition) => { + if (!config) { + return; + } + + const fieldId = `${providerId}.${field.key}`; + const currentProviders = (config.providers ?? {}) as WebSearchProvidersConfig; + const currentProviderConfig = currentProviders[providerId] ?? {}; + const currentValue = currentProviderConfig[field.key] ?? field.defaultValue; + const normalized = normalizeFieldValue(field, fieldDrafts[fieldId] ?? String(currentValue)); + + setFieldDrafts((current) => ({ ...current, [fieldId]: String(normalized) })); + + if (String(currentValue) === String(normalized)) { + return; + } + + const saved = await saveConfig({ + providers: { + ...currentProviders, + [providerId]: { + ...currentProviderConfig, + [field.key]: normalized, + }, + }, }); + + if (saved) { + setSavedFieldId(fieldId); + setTimeout(() => { + setSavedFieldId((current) => (current === fieldId ? null : current)); + }, 1200); + } }; + const buildFields = (provider: ProviderDefinition): ProviderFieldConfig[] => + (provider.fields ?? []).map((field) => { + const fieldId = `${provider.id}.${field.key}`; + + return { + id: fieldId, + label: field.label, + value: fieldDrafts[fieldId] ?? String(field.defaultValue), + placeholder: field.placeholder, + type: field.type, + helpText: field.helpText, + saved: savedFieldId === fieldId, + onChange: (value) => updateDraft(provider.id, field.key, value), + onBlur: () => { + void commitField(provider.id, field); + }, + onKeyDown: (event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }, + }; + }); + if (loading) { return ( -
+
- + {t('settings.loading')}
@@ -93,12 +473,11 @@ export default function WebSearchSection() { return ( <> - {/* Toast-style alerts */}
{error && ( @@ -108,133 +487,248 @@ export default function WebSearchSection() { )} {success && ( -
+
{t('settings.saved')}
)}
- {/* Scrollable Content */} -
-

{t('settingsWebsearch.description')}

- - {/* Status Summary */} -
-
-

- {isGeminiEnabled ? t('settingsWebsearch.enabled') : t('settingsWebsearch.disabled')} -

- {statusLoading ? ( -

{t('settingsWebsearch.checking')}

- ) : status?.readiness ? ( -

{status.readiness.message}

- ) : null} -
- -
- - {/* CLI Providers */} -
-

{t('settingsWebsearch.providers')}

- - {/* Empty state when no providers available */} - {!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && ( -
- -

- {t('settingsWebsearch.noneConfigured')} -

-

- {t('settingsWebsearch.noneConfiguredHint')} -

-
+
+ {CHAIN_STEPS.map((step, index) => { + const stepEnabled = + step.id === 'legacy' + ? legacyEnabled.length > 0 + : (config?.providers?.[step.id]?.enabled ?? step.defaultEnabled); + const tone = + step.id === 'legacy' + ? legacyReady + ? 'ready' + : legacyEnabled.length > 0 + ? 'setup' + : 'idle' + : getStatusTone(providerStatus.get(step.id), stepEnabled); + const label = + step.id === 'legacy' + ? legacySummary + : getStatusLabel(providerStatus.get(step.id), stepEnabled); + + return ( +
+
+ + + {step.title} ยท {label} + +
+ {index < CHAIN_STEPS.length - 1 && ( + + )} +
+ ); + })} +
+
+
+ +
+
+
+
+
+ +
+
+

Primary backends

+

+ Real backends run top-down before any legacy CLI fallback. +

+
+
+ +
+ {BACKEND_PROVIDERS.map((provider, index) => { + const currentStatus = providerStatus.get(provider.id); + const enabled = + config?.providers?.[provider.id]?.enabled ?? provider.defaultEnabled; + const tone = getStatusTone(currentStatus, enabled); + + return ( +
+
+
+ + {index < BACKEND_PROVIDERS.length - 1 && ( + + )} +
+
+ { + void toggleProvider(provider.id, enabled); + }} + fields={buildFields(provider)} + /> +
+ ); + })} +
+
+
+ +
+ + + {legacyExpanded && ( +
+
+
+ {LEGACY_PROVIDERS.map((provider, index) => { + const currentStatus = providerStatus.get(provider.id); + const enabled = + config?.providers?.[provider.id]?.enabled ?? provider.defaultEnabled; + const tone = getStatusTone(currentStatus, enabled); + + return ( +
+
+
+ + {index < LEGACY_PROVIDERS.length - 1 && ( + + )} +
+
+ { + void toggleProvider(provider.id, enabled); + }} + fields={buildFields(provider)} + docsUrl={currentStatus?.docsUrl} + installCommand={currentStatus?.installCommand} + footerNote={provider.footerNote} + /> +
+ ); + })} +
+
)} - - - - - -
- {/* Footer */} -
+
diff --git a/ui/src/pages/settings/sections/websearch/provider-card.tsx b/ui/src/pages/settings/sections/websearch/provider-card.tsx index c28977de..5e16d51e 100644 --- a/ui/src/pages/settings/sections/websearch/provider-card.tsx +++ b/ui/src/pages/settings/sections/websearch/provider-card.tsx @@ -1,176 +1,240 @@ -/** - * Provider Card Component - * Reusable card for CLI provider configuration in WebSearch section - */ - -import { Switch } from '@/components/ui/switch'; +import type { KeyboardEvent } from 'react'; +import { ExternalLink } from 'lucide-react'; import { Input } from '@/components/ui/input'; -import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; + +export interface ProviderFieldConfig { + id: string; + label: string; + value: string; + placeholder?: string; + type?: 'text' | 'number'; + helpText?: string; + saved?: boolean; + onBlur: () => void; + onChange: (value: string) => void; + onKeyDown?: (event: KeyboardEvent) => void; +} export interface ProviderCardProps { - name: string; - label: string; + title: string; + description: string; + detail: string; badge: string; - badgeColor: 'green' | 'blue' | 'purple'; + badgeTone: 'green' | 'blue' | 'amber' | 'cyan' | 'slate'; + statusLabel: string; + statusTone: 'ready' | 'setup' | 'idle'; enabled: boolean; - installed: boolean; - statusLoading: boolean; saving: boolean; onToggle: () => void; - modelInput?: string; - setModelInput?: (v: string) => void; - onModelBlur?: () => void; - modelSaved?: boolean; - modelPlaceholder?: string; - showHint: boolean; - setShowHint: (v: boolean) => void; - installCmd: string; - docsUrl: string; - hintColor: 'amber' | 'blue' | 'purple'; + fields?: ProviderFieldConfig[]; + docsUrl?: string; + installCommand?: string; + footerNote?: string; +} + +const PROVIDER_TONE_STYLES = { + green: { + accent: 'from-emerald-500 via-lime-400 to-transparent', + badge: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200', + glow: 'bg-emerald-500/18 dark:bg-emerald-500/22', + surface: + 'border-emerald-500/30 bg-gradient-to-br from-emerald-500/10 via-background to-background dark:from-emerald-500/14', + switchFrame: 'border-emerald-500/20 bg-emerald-500/10', + }, + blue: { + accent: 'from-sky-500 via-blue-500 to-transparent', + badge: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200', + glow: 'bg-sky-500/18 dark:bg-sky-500/22', + surface: + 'border-sky-500/30 bg-gradient-to-br from-sky-500/10 via-background to-background dark:from-sky-500/14', + switchFrame: 'border-sky-500/20 bg-sky-500/10', + }, + amber: { + accent: 'from-amber-500 via-orange-500 to-transparent', + badge: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200', + glow: 'bg-amber-500/18 dark:bg-amber-500/22', + surface: + 'border-amber-500/30 bg-gradient-to-br from-amber-500/10 via-background to-background dark:from-amber-500/14', + switchFrame: 'border-amber-500/20 bg-amber-500/10', + }, + cyan: { + accent: 'from-cyan-500 via-teal-400 to-transparent', + badge: 'border-cyan-500/25 bg-cyan-500/10 text-cyan-800 dark:text-cyan-200', + glow: 'bg-cyan-500/18 dark:bg-cyan-500/22', + surface: + 'border-cyan-500/30 bg-gradient-to-br from-cyan-500/10 via-background to-background dark:from-cyan-500/14', + switchFrame: 'border-cyan-500/20 bg-cyan-500/10', + }, + slate: { + accent: 'from-slate-500 via-slate-400 to-transparent', + badge: 'border-slate-500/20 bg-slate-500/10 text-slate-700 dark:text-slate-300', + glow: 'bg-slate-500/18 dark:bg-slate-500/22', + surface: + 'border-slate-400/30 bg-gradient-to-br from-slate-500/8 via-background to-background dark:from-slate-500/12', + switchFrame: 'border-slate-400/20 bg-slate-500/10', + }, +} as const; + +function getStatusToneStyles(tone: ProviderCardProps['statusTone']) { + switch (tone) { + case 'ready': + return { + chip: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200', + dot: 'bg-emerald-500', + }; + case 'setup': + return { + chip: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200', + dot: 'bg-amber-500', + }; + case 'idle': + return { + chip: 'border-border/80 bg-muted/60 text-muted-foreground', + dot: 'bg-muted-foreground/70', + }; + } } export function ProviderCard({ - name, - label, + title, + description, + detail, badge, - badgeColor, + badgeTone, + statusLabel, + statusTone, enabled, - installed, - statusLoading, saving, onToggle, - modelInput, - setModelInput, - onModelBlur, - modelSaved, - modelPlaceholder, - showHint, - setShowHint, - installCmd, + fields = [], docsUrl, - hintColor, + installCommand, + footerNote, }: ProviderCardProps) { - const { t } = useTranslation(); - const badgeClass = - badgeColor === 'green' - ? 'bg-green-500/10 text-green-600' - : badgeColor === 'blue' - ? 'bg-blue-500/10 text-blue-600' - : 'bg-purple-500/10 text-purple-600'; - - const hintBgClass = - hintColor === 'amber' - ? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300' - : hintColor === 'blue' - ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300' - : 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300'; - - const hintCodeClass = - hintColor === 'amber' - ? 'bg-amber-100 dark:bg-amber-900/40' - : hintColor === 'blue' - ? 'bg-blue-100 dark:bg-blue-900/40' - : 'bg-purple-100 dark:bg-purple-900/40'; - - const hintTextClass = - hintColor === 'amber' - ? 'text-amber-600 dark:text-amber-400' - : hintColor === 'blue' - ? 'text-blue-600 dark:text-blue-400' - : 'text-purple-600 dark:text-purple-400'; - - const displayName = - name === 'opencode' ? 'OpenCode' : name.charAt(0).toUpperCase() + name.slice(1); + const tone = PROVIDER_TONE_STYLES[badgeTone]; + const status = getStatusToneStyles(statusTone); return (
-
-
- -
-
-

{name}

- - {badge} - - {installed ? ( - - {t('settingsWebsearch.installed')} - - ) : ( - - {t('settingsWebsearch.notInstalled')} - +
+
+
+ +
+
+
+

{title}

+ -

{label}

+ > + {badge} +
+ + + {statusLabel} + +
+
+

{description}

+ {detail &&

{detail}

}
- +
+ +
- {/* Model input when enabled */} - {enabled && modelInput !== undefined && setModelInput && onModelBlur && ( -
-
- - setModelInput(e.target.value)} - onBlur={onModelBlur} - placeholder={modelPlaceholder} - className="h-8 text-sm font-mono" - disabled={saving} - /> - {modelSaved && ( - - - {t('settings.saved')} - - )} + {enabled && fields.length > 0 && ( +
+
+ {fields.map((field) => ( +
+
+ + {field.saved && ( + + Saved + + )} +
+ field.onChange(event.target.value)} + onKeyDown={field.onKeyDown} + className="h-8 border-border/70 bg-background/80 text-sm" + disabled={saving} + /> + {field.helpText && ( +

{field.helpText}

+ )} +
+ ))}
)} - {/* Installation hint when not installed */} - {!installed && !statusLoading && ( -
- - {showHint && ( -
-

- {t('settingsWebsearch.installGlobally')}{' '} - {badge === 'GROK_API_KEY' - ? t('settingsWebsearch.requiresKey') - : t('settingsWebsearch.freeTier')} - : -

- - {installCmd} - - - - {t('settingsWebsearch.viewDocs')} - -
+ {(footerNote || installCommand || docsUrl) && ( +
+ {footerNote &&

{footerNote}

} + {installCommand && ( + + {installCommand} + + )} + {docsUrl && ( + + + View docs + )}
)} diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index c6ea66b9..606d3c00 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -11,9 +11,14 @@ export interface ProviderConfig { enabled?: boolean; model?: string; timeout?: number; + max_results?: number; } export interface WebSearchProvidersConfig { + exa?: ProviderConfig; + tavily?: ProviderConfig; + duckduckgo?: ProviderConfig; + brave?: ProviderConfig; gemini?: ProviderConfig; grok?: ProviderConfig; opencode?: ProviderConfig; @@ -25,17 +30,25 @@ export interface WebSearchConfig { } export interface CliStatus { - installed: boolean; - path: string | null; + id: 'exa' | 'tavily' | 'duckduckgo' | 'brave' | 'gemini' | 'grok' | 'opencode'; + kind: 'backend' | 'legacy-cli'; + name?: string; + enabled: boolean; + available: boolean; + command?: string; version: string | null; + installCommand?: string; + docsUrl?: string; + requiresApiKey: boolean; + apiKeyEnvVar?: string; + description: string; + detail: string; } export interface WebSearchStatus { - geminiCli: CliStatus; - grokCli: CliStatus; - opencodeCli: CliStatus; + providers: CliStatus[]; readiness: { - status: 'ready' | 'unavailable'; + status: 'ready' | 'needs_setup' | 'unavailable'; message: string; }; } From 83dab7fea7ed906da93ea4946fb41c1cec829547 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Mar 2026 15:38:10 -0400 Subject: [PATCH 19/24] style(websearch): format routes after rebase --- src/web-server/routes/websearch-routes.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index d53e2735..bbdcd557 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -48,13 +48,16 @@ router.put('/', (req: Request, res: Response): void => { providers: providers ? { exa: { - enabled: providers.exa?.enabled ?? config.websearch?.providers?.exa?.enabled ?? false, + enabled: + providers.exa?.enabled ?? config.websearch?.providers?.exa?.enabled ?? false, max_results: providers.exa?.max_results ?? config.websearch?.providers?.exa?.max_results ?? 5, }, tavily: { enabled: - providers.tavily?.enabled ?? config.websearch?.providers?.tavily?.enabled ?? false, + providers.tavily?.enabled ?? + config.websearch?.providers?.tavily?.enabled ?? + false, max_results: providers.tavily?.max_results ?? config.websearch?.providers?.tavily?.max_results ?? @@ -80,7 +83,9 @@ router.put('/', (req: Request, res: Response): void => { }, gemini: { enabled: - providers.gemini?.enabled ?? config.websearch?.providers?.gemini?.enabled ?? false, + providers.gemini?.enabled ?? + config.websearch?.providers?.gemini?.enabled ?? + false, model: providers.gemini?.model ?? config.websearch?.providers?.gemini?.model ?? From d62b2b9602a2bb5a9c06f362bd6799aac412aaf5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 19:41:27 +0000 Subject: [PATCH 20/24] chore(release): 7.56.0-dev.5 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b05318a1..cdb879a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.56.0-dev.4", + "version": "7.56.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 09eb01f16e0a374198031079d22eeae2a3135810 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Mar 2026 09:15:05 -0400 Subject: [PATCH 21/24] fix(auth): verify polled oauth account persistence --- src/web-server/routes/cliproxy-auth-routes.ts | 142 +++++++++++++++++- ...iproxy-auth-routes-manual-callback.test.ts | 120 +++++++++++++++ 2 files changed, 258 insertions(+), 4 deletions(-) diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index b6a96a88..86b1e706 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs'; +import * as path from 'path'; import { Router, Request, Response } from 'express'; import { getAllAuthStatus, @@ -22,6 +24,7 @@ import { pauseAccount as pauseAccountFn, resumeAccount as resumeAccountFn, touchAccount, + extractAccountIdFromTokenFile, hasAccountNameConflict, PROVIDERS_WITHOUT_EMAIL, validateNickname, @@ -34,7 +37,11 @@ import { import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; -import { getProviderTokenDir, registerAccountFromToken } from '../../cliproxy/auth/token-manager'; +import { + getProviderTokenDir, + isTokenFileForProvider, + registerAccountFromToken, +} from '../../cliproxy/auth/token-manager'; import { CLIPROXY_CALLBACK_PROVIDER_MAP, CLIPROXY_AUTH_URL_PROVIDER_MAP, @@ -56,9 +63,20 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar const router = Router(); const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000; +type ProviderTokenSnapshot = { + file: string; + mtimeMs: number; + email?: string; +}; + const pendingManualAuthState = new Map< string, - { nickname?: string; expectedAccountId?: string; createdAt: number } + { + nickname?: string; + expectedAccountId?: string; + createdAt: number; + knownTokenFiles: ProviderTokenSnapshot[]; + } >(); // Valid providers list - derived from canonical CLIPROXY_PROFILES @@ -88,7 +106,11 @@ function pruneExpiredManualAuthState(now = Date.now()): void { function rememberManualAuthState( state: string, - pending: { nickname?: string; expectedAccountId?: string } + pending: { + nickname?: string; + expectedAccountId?: string; + knownTokenFiles: ProviderTokenSnapshot[]; + } ): void { pruneExpiredManualAuthState(); pendingManualAuthState.set(state, { @@ -99,7 +121,12 @@ function rememberManualAuthState( function getManualAuthState( state: string | undefined -): { nickname?: string; expectedAccountId?: string } | null { +): { + nickname?: string; + expectedAccountId?: string; + createdAt: number; + knownTokenFiles: ProviderTokenSnapshot[]; +} | null { if (!state) { return null; } @@ -113,9 +140,62 @@ function getManualAuthState( return { nickname: pending.nickname, expectedAccountId: pending.expectedAccountId, + createdAt: pending.createdAt, + knownTokenFiles: pending.knownTokenFiles, }; } +function listProviderTokenSnapshots(provider: CLIProxyProvider): ProviderTokenSnapshot[] { + const tokenDir = getProviderTokenDir(provider); + if (!fs.existsSync(tokenDir)) { + return []; + } + + return fs + .readdirSync(tokenDir) + .filter((file) => file.endsWith('.json')) + .map((file): ProviderTokenSnapshot | null => { + const filePath = path.join(tokenDir, file); + if (!isTokenFileForProvider(filePath, provider)) { + return null; + } + + let email: string | undefined; + try { + const content = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(content) as { email?: string }; + email = typeof parsed.email === 'string' ? parsed.email : undefined; + } catch { + email = undefined; + } + + const stats = fs.statSync(filePath); + return { + file, + mtimeMs: stats.mtimeMs, + email, + }; + }) + .filter((snapshot): snapshot is ProviderTokenSnapshot => snapshot !== null) + .sort((left, right) => right.mtimeMs - left.mtimeMs); +} + +function findNewTokenSnapshotForPendingAuth( + provider: CLIProxyProvider, + pending: { knownTokenFiles: ProviderTokenSnapshot[] } +): ProviderTokenSnapshot | null { + const knownTokenMtimes = new Map( + pending.knownTokenFiles.map((snapshot) => [snapshot.file, snapshot.mtimeMs]) + ); + + return ( + listProviderTokenSnapshots(provider).find((snapshot) => { + const knownMtime = knownTokenMtimes.get(snapshot.file); + return knownMtime === undefined || snapshot.mtimeMs > knownMtime + 1; + }) || null + ); +} + function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } { if (raw === undefined || raw === null) { return { method: normalizeKiroAuthMethod(), invalid: false }; @@ -795,6 +875,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise if (oauthState) { rememberManualAuthState(oauthState, { nickname: nickname || undefined, + knownTokenFiles: listProviderTokenSnapshots(provider as CLIProxyProvider), }); } @@ -838,6 +919,59 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise { }); } + async function getJson(route: string) { + return await new Promise<{ status: number; body: unknown }>((resolve, reject) => { + const url = new URL(`${baseUrl}${route}`); + const request = http.request( + { + method: 'GET', + hostname: url.hostname, + port: url.port, + path: `${url.pathname}${url.search}`, + }, + (response) => { + let responseBody = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + responseBody += chunk; + }); + response.on('end', () => { + resolve({ + status: response.statusCode || 0, + body: responseBody ? JSON.parse(responseBody) : null, + }); + }); + } + ); + + request.on('error', reject); + request.end(); + }); + } + it('persists the supplied nickname for Kiro social start-url flows after callback submission', async () => { mockFetch([ { @@ -175,4 +205,94 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { 'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.', }); }); + + it('returns 409 when status polling completes upstream but no new local token was written', async () => { + const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(tokenDir, { recursive: true }); + fs.writeFileSync( + path.join(tokenDir, 'codex-existing@example.com.json'), + JSON.stringify({ type: 'codex', email: 'existing@example.com' }), + 'utf8' + ); + + mockFetch([ + { + url: /\/v0\/management\/codex-auth-url\?is_webui=true$/, + response: { + auth_url: 'https://auth.example.com/authorize?state=state-status-missing', + state: 'state-status-missing', + }, + }, + { + url: /\/v0\/management\/get-auth-status\?state=state-status-missing$/, + response: { status: 'ok' }, + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {}); + expect(startResponse.status).toBe(200); + + const statusResponse = await getJson( + '/api/cliproxy/auth/codex/status?state=state-status-missing' + ); + + expect(statusResponse.status).toBe(409); + expect(statusResponse.body).toEqual({ + status: 'error', + error: + 'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.', + }); + }); + + it('registers the new account before reporting polled auth success', async () => { + mockFetch([ + { + url: /\/v0\/management\/codex-auth-url\?is_webui=true$/, + response: { + auth_url: 'https://auth.example.com/authorize?state=state-status-ok', + state: 'state-status-ok', + }, + }, + { + url: /\/v0\/management\/get-auth-status\?state=state-status-ok$/, + response: { status: 'ok' }, + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {}); + expect(startResponse.status).toBe(200); + + const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(tokenDir, { recursive: true }); + fs.writeFileSync( + path.join(tokenDir, 'codex-new@example.com.json'), + JSON.stringify({ type: 'codex', email: 'new@example.com' }), + 'utf8' + ); + + const statusResponse = await getJson('/api/cliproxy/auth/codex/status?state=state-status-ok'); + + expect(statusResponse.status).toBe(200); + expect(statusResponse.body).toEqual({ + status: 'ok', + account: { + id: 'new@example.com', + email: 'new@example.com', + nickname: 'new', + provider: 'codex', + isDefault: true, + }, + }); + + const registryPath = path.join(tempHome, '.ccs', 'cliproxy', 'accounts.json'); + const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as { + providers: { + codex: { + accounts: Record; + }; + }; + }; + + expect(registry.providers.codex.accounts['new@example.com']?.email).toBe('new@example.com'); + }); }); From a3478ace447d0a9c99c8266e3c0723c0ae29d5f5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Mar 2026 09:15:06 -0400 Subject: [PATCH 22/24] fix(ui): guard auth success on registered account --- ui/src/hooks/use-cliproxy-auth-flow.ts | 26 +++++++++++- .../hooks/use-cliproxy-auth-flow.test.tsx | 42 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index a2a9b3bd..34431809 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -133,6 +133,7 @@ export function useCliproxyAuthFlow() { const data = (await response.json()) as { status?: string; error?: string; + account?: unknown; url?: string; auth_url?: string; verification_url?: string; @@ -141,8 +142,22 @@ export function useCliproxyAuthFlow() { pollFailureCountRef.current = 0; if (data.status === 'ok') { + const hasAccount = typeof data.account === 'object' && data.account !== null; + if (!hasAccount) { + stopPolling(); + const errorMsg = 'Authenticated account could not be registered'; + toast.error(errorMsg); + setState((prev) => ({ + ...prev, + isAuthenticating: false, + error: errorMsg, + })); + return; + } + stopPolling(); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); toast.success(`${provider} authentication successful`); openedAuthUrlRef.current = false; @@ -270,8 +285,10 @@ export function useCliproxyAuthFlow() { return; } const success = data.success === true; - if (response.ok && success) { + const hasAccount = typeof data.account === 'object' && data.account !== null; + if (response.ok && success && hasAccount) { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); // Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast // via deviceCodeCompleted WebSocket event to avoid duplicate toasts @@ -279,7 +296,11 @@ export function useCliproxyAuthFlow() { setState(INITIAL_STATE); } else { const errorMsg = - typeof data.error === 'string' ? data.error : 'Authentication failed'; + typeof data.error === 'string' + ? data.error + : success + ? 'Authenticated account could not be registered' + : 'Authentication failed'; toast.error(errorMsg); setState((prev) => ({ ...prev, @@ -416,6 +437,7 @@ export function useCliproxyAuthFlow() { if (response.ok && success && hasAccount) { stopPolling(); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); toast.success(`${currentProvider} authentication successful`); setState(INITIAL_STATE); diff --git a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx index 8498ce9f..1ad319b1 100644 --- a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx +++ b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx @@ -196,4 +196,46 @@ describe('useCliproxyAuthFlow', () => { expect(toast.error).toHaveBeenCalledWith('Authenticated account could not be registered'); expect(toast.success).not.toHaveBeenCalled(); }); + + it('treats status ok responses without an account as failures', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/start-url')) { + return Promise.resolve( + createJsonResponse({ + success: true, + authUrl: 'https://auth.example/status-only', + state: 'state-status-only', + }) + ); + } + + if (url.includes('/status?state=state-status-only')) { + return Promise.resolve(createJsonResponse({ status: 'ok' })); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('codex', { startEndpoint: 'start-url' }); + }); + + await act(async () => { + vi.advanceTimersByTime(3000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.error).toBe('Authenticated account could not be registered'); + expect(result.current.isAuthenticating).toBe(false); + expect(toast.error).toHaveBeenCalledWith('Authenticated account could not be registered'); + expect(toast.success).not.toHaveBeenCalled(); + }); }); From 06d098ae0e981abd9b0dd15397cdac297e7bfc22 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Mar 2026 09:26:15 -0400 Subject: [PATCH 23/24] style(auth): format cliproxy auth route --- src/web-server/routes/cliproxy-auth-routes.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 86b1e706..9db43f01 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -119,9 +119,7 @@ function rememberManualAuthState( }); } -function getManualAuthState( - state: string | undefined -): { +function getManualAuthState(state: string | undefined): { nickname?: string; expectedAccountId?: string; createdAt: number; From b5a1bccf6ae843cfb538f4c1894b889c413d838b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 24 Mar 2026 15:26:13 +0000 Subject: [PATCH 24/24] chore(release): 7.56.0-dev.6 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cdb879a4..3e1b8132 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.56.0-dev.5", + "version": "7.56.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",