Merge pull request #1343 from kaitranntt/dev

feat(release): promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-05-23 14:54:26 -04:00
commit 8c8c22a6b6
200 files changed
+18564 -997

No files matched your search

+284
View File
@@ -0,0 +1,284 @@
name: Breaking Change Guard – Docker Compose Contract
# Guards against accidental breaking changes to the stable ccs-net contract
# defined in docker/compose.yaml. The following fields are considered stable
# API — changing them breaks existing sibling-container setups:
#
# services.ccs.image (the image *name*, not the tag)
# networks.ccs-net.name
# services.ccs.container_name (if set)
# services keys (Docker DNS uses the service key; renaming "ccs:" changes http://ccs:8317)
#
# If any of these fields change in a PR, the workflow fails unless at least
# one commit in the PR includes a breaking-change marker (feat!: or fix!:).
# This enforces a deliberate, visible decision to change the contract.
on:
pull_request:
branches: [main, dev]
paths:
- "docker/compose.yaml"
jobs:
guard:
name: Verify breaking changes are intentional
# Exception to self-hosted-first policy (documented in CLAUDE.md "Self-Hosted Runner Policy"):
# This workflow MUST cover ALL PRs including forks — a forked contributor can rename
# services.ccs or change the image namespace without a breaking-change marker, which
# silently breaks sibling-container setups for every user. Gating on trusted-author
# association would let forked PRs bypass the check entirely.
#
# Safety justification: this workflow does ONLY pure YAML diff parsing (git show / git diff
# + shell + awk). It checks out the code with persist-credentials: false and runs no build,
# install, or arbitrary scripts from the PR branch. There is no untrusted code execution,
# so running on a GitHub-hosted runner is safe and required for universal coverage.
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Fetch base branch
run: git fetch origin ${{ github.base_ref }} --depth=50
- name: Check for contract-breaking changes
id: contract
run: |
set -euo pipefail
BASE="origin/${{ github.base_ref }}"
# Guard: if docker/compose.yaml does not exist in the base branch, this
# is a new file — no contract existed before, so no regression is possible.
if ! git cat-file -e "${BASE}:docker/compose.yaml" 2>/dev/null; then
echo "[i] docker/compose.yaml is new in this PR (not present on ${BASE}) — skipping contract check"
echo "breaking=0" >> "$GITHUB_OUTPUT"
exit 0
fi
BREAKING=0
# 1. Image name change (repo path, not tag — tags change every release)
#
# compose.yaml uses ${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest}. A naive
# sed 's/:.*//' truncates at the FIRST colon, yielding "${CCS_IMAGE" instead
# of the actual image name. We strip the ${VAR:-default} wrapper first, then
# strip only the trailing :tag suffix (preserving internal colons such as
# those in registry:port/owner/repo).
extract_image_name() {
# $1 = raw image line content (everything after "image: ")
local raw="$1"
raw=$(printf '%s' "$raw" | sed -E "s/^[\"']//; s/[\"']$//")
# Strip ${VAR:-default} wrapper if present
raw=$(printf '%s' "$raw" | sed -E 's/^\$\{[A-Za-z_][A-Za-z0-9_]*:-//; s/\}$//')
# Strip only the trailing :tag — preserve internal colons (e.g. registry:5000/owner/repo)
printf '%s' "$raw" | sed 's|:[^:/]*$||'
}
service_field_value() {
local file="$1" service="$2" field="$3"
awk -v service="$service" -v field="$field" '
function trim(s) {
sub(/^[[:space:]]+/, "", s)
sub(/[[:space:]]+$/, "", s)
return s
}
/^services:[[:space:]]*$/ { in_services = 1; next }
in_services && /^[^[:space:]]/ { in_services = 0; in_service = 0 }
in_services && $0 ~ "^ " service ":[[:space:]]*$" { in_service = 1; next }
in_service && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { in_service = 0 }
in_service {
prefix = "^ " field ":[[:space:]]*"
if ($0 ~ prefix) {
line = $0
sub(prefix, "", line)
print trim(line)
exit
}
}
' "$file"
}
service_network_refs() {
local file="$1" service="$2"
awk -v service="$service" '
function trim(s) {
sub(/^[[:space:]]+/, "", s)
sub(/[[:space:]]+$/, "", s)
return s
}
function emit_inline(value, parts, count, i) {
gsub(/[\[\]]/, "", value)
count = split(value, parts, ",")
for (i = 1; i <= count; i++) {
if (trim(parts[i]) != "") print trim(parts[i])
}
}
/^services:[[:space:]]*$/ { in_services = 1; next }
in_services && /^[^[:space:]]/ { in_services = 0; in_service = 0; in_networks = 0 }
in_services && $0 ~ "^ " service ":[[:space:]]*$" { in_service = 1; in_networks = 0; next }
in_service && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { in_service = 0; in_networks = 0 }
in_service {
if ($0 ~ /^ networks:[[:space:]]*\[/) {
line = $0
sub(/^ networks:[[:space:]]*/, "", line)
emit_inline(line)
next
}
if ($0 ~ /^ networks:[[:space:]]*$/) {
in_networks = 1
next
}
if ($0 ~ /^ networks:[[:space:]]+/) {
line = $0
sub(/^ networks:[[:space:]]+/, "", line)
print trim(line)
next
}
if (in_networks && $0 ~ /^ [A-Za-z0-9_-]+:/) {
in_networks = 0
}
if (in_networks && $0 ~ /^ -[[:space:]]*/) {
line = $0
sub(/^ -[[:space:]]*/, "", line)
print trim(line)
next
}
if (in_networks && $0 ~ /^ [A-Za-z0-9_-]+:/) {
line = $0
sub(/^ /, "", line)
sub(/:.*/, "", line)
print trim(line)
next
}
}
' "$file"
}
network_effective_name() {
local file="$1" ref="$2"
awk -v ref="$ref" '
function trim(s) {
sub(/^[[:space:]]+/, "", s)
sub(/[[:space:]]+$/, "", s)
return s
}
/^networks:[[:space:]]*$/ { in_networks = 1; next }
in_networks && /^[^[:space:]]/ { in_networks = 0; in_target = 0 }
in_networks && $0 ~ "^ " ref ":[[:space:]]*$" {
in_target = 1
found = 1
effective = ref
next
}
in_target && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { in_target = 0 }
in_target && /^ name:[[:space:]]*/ {
line = $0
sub(/^ name:[[:space:]]*/, "", line)
effective = trim(line)
}
END { if (found) print effective }
' "$file"
}
has_service_network_effective_name() {
local file="$1" service="$2" expected="$3" ref effective
while IFS= read -r ref; do
[[ -z "$ref" ]] && continue
effective=$(network_effective_name "$file" "$ref")
if [[ "$effective" == "$expected" ]]; then
return 0
fi
done < <(service_network_refs "$file" "$service")
return 1
}
BASE_COMPOSE="$(mktemp)"
trap 'rm -f "$BASE_COMPOSE"' EXIT
git show "${BASE}:docker/compose.yaml" > "$BASE_COMPOSE"
OLD_RAW=$(service_field_value "$BASE_COMPOSE" ccs image)
NEW_RAW=$(service_field_value docker/compose.yaml ccs image)
OLD_IMAGE=$(extract_image_name "$OLD_RAW")
NEW_IMAGE=$(extract_image_name "$NEW_RAW")
if [[ "${OLD_IMAGE}" != "${NEW_IMAGE}" ]]; then
echo "[!] BREAKING: image name changed: '${OLD_IMAGE}' -> '${NEW_IMAGE}'"
BREAKING=1
fi
# 2. ccs-net network attachment — positive assertion: services.ccs MUST
# attach to a network whose effective Compose name is ccs-net. Merely
# keeping an unused top-level ccs-net definition is not enough.
if ! has_service_network_effective_name docker/compose.yaml ccs ccs-net; then
echo "[!] BREAKING: services.ccs is not attached to effective network name ccs-net"
BREAKING=1
fi
# 3. container_name change (if present in either version)
OLD_CN=$(service_field_value "$BASE_COMPOSE" ccs container_name | tr -d ' ')
NEW_CN=$(service_field_value docker/compose.yaml ccs container_name | tr -d ' ')
if [[ "${OLD_CN}" != "${NEW_CN}" ]]; then
echo "[!] BREAKING: container_name changed: '${OLD_CN}' -> '${NEW_CN}'"
BREAKING=1
fi
# 4. Service key contract — Docker DNS on ccs-net uses the compose service
# KEY as the hostname (e.g. http://ccs:8317). Renaming or removing
# "ccs:" breaks every sibling container in the wild. Adding new service
# keys (e.g. a sidecar) does NOT affect the "ccs" DNS contract and is
# therefore allowed without a breaking-change marker.
#
# Extraction logic: find lines that look like top-level service keys
# (two-space indent + identifier + colon, NOT sub-keys like "image:").
# This matches YAML service keys without requiring an external YAML parser.
NEW_KEYS=$(awk '/^services:/{s=1;next} s && /^ [a-zA-Z0-9_-]+:/{print $1} /^[^ ]/{s=0}' \
docker/compose.yaml \
| tr -d ':' | sort | tr '\n' ' ' | sed 's/ $//')
if ! echo " ${NEW_KEYS} " | grep -q " ccs "; then
echo "[!] BREAKING: services.ccs key missing from docker/compose.yaml — sibling containers on ccs-net rely on DNS hostname 'ccs'"
BREAKING=1
fi
echo "breaking=${BREAKING}" >> "$GITHUB_OUTPUT"
- name: Require breaking-change commit marker if contract changed
if: steps.contract.outputs.breaking == '1'
run: |
set -euo pipefail
BASE="origin/${{ github.base_ref }}"
# Check all commit messages in the PR for feat! or fix! marker
HAS_BREAKING_MARKER=$(
git log "${BASE}"...HEAD --format="%s" \
| grep -cE "^(feat|fix)(\([^)]+\))?!:" || true
)
if [[ "${HAS_BREAKING_MARKER}" -eq 0 ]]; then
echo ""
echo "[X] Breaking change guard FAILED"
echo ""
echo " docker/compose.yaml was modified in a way that changes the stable"
echo " ccs-net contract (image name, network name, or container_name)."
echo ""
echo " These fields are relied upon by sibling containers. Changing them"
echo " without a breaking-change marker is a silent breaking change."
echo ""
echo " To proceed, rename at least one commit to use the feat! or fix!"
echo " breaking-change format:"
echo ""
echo " feat!: rename ccs service image to ghcr.io/owner/newname"
echo " fix!: align container_name with new naming convention"
echo ""
echo " See: https://www.conventionalcommits.org/en/v1.0.0/#specification"
exit 1
fi
echo "[OK] Breaking-change commit marker found — contract change is intentional"
- name: No contract-breaking changes detected
if: steps.contract.outputs.breaking == '0'
run: echo "[OK] No contract-breaking changes in docker/compose.yaml"
+14
View File
@@ -146,3 +146,17 @@ jobs:
env:
CCS_E2E_SKIP_BUILD: '1'
run: bun run test:e2e
compose-parity:
if: >-
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
runs-on: [self-hosted, linux, x64, cliproxy]
name: compose-parity
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Assert compose parity (compose.yaml vs docker-compose.integrated.yml)
run: bash tests/docker/compose-parity.sh
+362 -17
View File
@@ -10,20 +10,40 @@ on:
description: Stable tag to publish manually, for example v7.55.0
required: true
type: string
promote_to_latest:
description: >
Promote mutable :latest tags (and major/minor aliases) after rc soak.
Skipped by default so the first publish of a tag only pushes the
immutable version tag. Use only after rc.N soak confirms stability.
required: false
type: boolean
default: false
# Prevent stale parallel runs for the same tag; never cancel in-progress so
# a publish + sign + promote sequence always completes atomically.
concurrency:
group: docker-release-${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag || github.ref }}
group: docker-release-${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag || github.ref || github.run_id }}
cancel-in-progress: false
jobs:
publish:
# ---------------------------------------------------------------------------
# Job 1: Legacy ccs-dashboard image (2-release sunset window)
# ---------------------------------------------------------------------------
publish-dashboard:
name: Publish legacy ccs-dashboard image
# Skip on prerelease release events — only stable vX.Y.Z GitHub releases
# publish the legacy image. workflow_dispatch always passes through.
if: ${{ github.event_name != 'release' || !github.event.release.prerelease }}
runs-on: [self-hosted, linux, x64]
runs-on: [self-hosted, linux, x64, cliproxy]
permissions:
contents: read
packages: write
env:
DEPRECATION_BASELINE_VERSION: "7.80.0"
STABLE_RELEASE_WINDOW: "2"
steps:
- name: Resolve target tag
id: target
@@ -36,7 +56,6 @@ jobs:
else
TARGET_TAG="${MANUAL_TAG}"
fi
echo "tag=${TARGET_TAG}" >> "$GITHUB_OUTPUT"
- name: Validate stable semver tag
@@ -46,7 +65,6 @@ jobs:
echo "publish=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "publish=false" >> "$GITHUB_OUTPUT"
echo "Skipping non-stable semver tag ${{ steps.target.outputs.tag }}"
@@ -55,20 +73,27 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ steps.target.outputs.tag }}
fetch-depth: 0
persist-credentials: false
- name: Check legacy dashboard sunset
if: steps.tag.outputs.publish == 'true'
id: sunset
env:
TARGET_TAG: ${{ steps.target.outputs.tag }}
run: node scripts/docker-dashboard-sunset-guard.js
- name: Set up QEMU
if: steps.tag.outputs.publish == 'true'
if: steps.tag.outputs.publish == 'true' && steps.sunset.outputs.publish == 'true'
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: steps.tag.outputs.publish == 'true'
if: steps.tag.outputs.publish == 'true' && steps.sunset.outputs.publish == 'true'
uses: docker/setup-buildx-action@v3
- name: Derive image metadata
if: steps.tag.outputs.publish == 'true'
if: steps.tag.outputs.publish == 'true' && steps.sunset.outputs.publish == 'true'
id: meta
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.target.outputs.tag }}"
VERSION="${VERSION#v}"
@@ -97,6 +122,131 @@ jobs:
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
if: steps.tag.outputs.publish == 'true' && steps.sunset.outputs.publish == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push legacy dashboard image
if: steps.tag.outputs.publish == 'true' && steps.sunset.outputs.publish == 'true'
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: |
org.opencontainers.image.title=ccs-dashboard
org.opencontainers.image.description=CCS Dashboard container image (deprecated - migrate to ghcr.io/kaitranntt/ccs:latest)
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=${{ steps.meta.outputs.revision }}
annotations: |
index:org.opencontainers.image.source=https://github.com/${{ github.repository }}
index:org.opencontainers.image.description=CCS Dashboard container image (deprecated - migrate to ghcr.io/kaitranntt/ccs:latest)
cache-from: type=gha,scope=dashboard
cache-to: type=gha,mode=max,scope=dashboard
# ---------------------------------------------------------------------------
# Job 2: Integrated image — CCS + CLIProxy (single build, no :full variant)
# Publishes ONLY the immutable :<ver> tag here on every release event.
# Mutable :latest / major / minor aliases are added by promote-mutable-tags
# ONLY via explicit workflow_dispatch (promote_to_latest=true) after the
# operator has verified the immutable image is stable. This keeps the rc.1
# soak window entirely in the Docker promotion path — npm @latest is always
# published immediately by semantic-release on the stable release.
# ---------------------------------------------------------------------------
publish-integrated:
name: Publish integrated image
runs-on: [self-hosted, linux, x64, cliproxy]
permissions:
contents: read
packages: write
id-token: write # required for keyless cosign signing
outputs:
version: ${{ steps.meta.outputs.version }}
image_ref: ${{ steps.meta.outputs.image_ref }}
publish: ${{ steps.tag.outputs.publish }}
steps:
- 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 semver tag (stable or rc)
id: tag
run: |
TAG="${{ steps.target.outputs.tag }}"
# Accept stable (vX.Y.Z) and prerelease rc (vX.Y.Z-rc.N)
if [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "publish=false" >> "$GITHUB_OUTPUT"
echo "Skipping unrecognised tag format: ${TAG}"
- name: Checkout release tag
if: steps.tag.outputs.publish == 'true'
uses: actions/checkout@v4
with:
ref: ${{ steps.target.outputs.tag }}
persist-credentials: false
- name: Set up QEMU
if: steps.tag.outputs.publish == 'true'
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
if: steps.tag.outputs.publish == 'true'
uses: docker/setup-buildx-action@v3
- name: Derive image metadata
if: steps.tag.outputs.publish == 'true'
id: meta
run: |
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"
REVISION=$(git rev-parse HEAD)
# Only the immutable version-pinned tag is pushed here.
# Mutable tags (:latest, :MAJOR, :MINOR) are added by promote-mutable-tags.
TAGS="${IMAGE}:${VERSION}"
IMAGE_REF="${IMAGE}:${VERSION}"
{
echo "version=${VERSION}"
echo "minor=${MINOR}"
echo "major=${MAJOR}"
echo "image=${IMAGE}"
echo "revision=${REVISION}"
echo "image_ref=${IMAGE_REF}"
echo "tags<<EOF"
echo "${TAGS}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
if: steps.tag.outputs.publish == 'true'
uses: docker/login-action@v3
@@ -105,24 +255,219 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push release image
- name: Build and push integrated image
if: steps.tag.outputs.publish == 'true'
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
file: docker/Dockerfile.integrated
platforms: linux/amd64,linux/arm64
push: true
provenance: mode=max
sbom: true
build-args: |
CCS_NPM_VERSION=${{ steps.meta.outputs.version }}
tags: ${{ steps.meta.outputs.tags }}
labels: |
org.opencontainers.image.title=ccs-dashboard
org.opencontainers.image.description=CCS Dashboard container image
org.opencontainers.image.title=ccs
org.opencontainers.image.description=CCS integrated image — CLIProxy + CCS CLI
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=${{ steps.meta.outputs.revision }}
annotations: |
index:org.opencontainers.image.source=https://github.com/${{ github.repository }}
index:org.opencontainers.image.description=CCS Dashboard container image
cache-from: type=gha
cache-to: type=gha,mode=max
index:org.opencontainers.image.description=CCS integrated image — CLIProxy + CCS CLI
cache-from: type=gha,scope=integrated
cache-to: type=gha,mode=max,scope=integrated
- name: Install cosign
if: steps.tag.outputs.publish == 'true'
uses: sigstore/cosign-installer@v3
- name: Sign image with cosign (keyless OIDC)
if: steps.tag.outputs.publish == 'true'
env:
COSIGN_EXPERIMENTAL: "1"
run: |
DIGEST="${{ steps.build.outputs.digest }}"
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
cosign sign --yes "ghcr.io/${OWNER_LOWER}/ccs@${DIGEST}"
echo "[OK] Signed ghcr.io/${OWNER_LOWER}/ccs@${DIGEST}"
# ---------------------------------------------------------------------------
# Job 3: Smoke test — pull the immutable :<ver> tag and verify it boots
# Runs after publish-integrated; mutable tags are only promoted if this passes.
# ---------------------------------------------------------------------------
smoke-test:
name: Smoke test integrated image
# Depends only on publish-integrated, NOT publish-dashboard.
# publish-dashboard is skipped on GitHub prerelease events — if it were
# listed here, GitHub Actions would also skip smoke-test when that condition
# is not met, preventing the immutable tag from being verified.
needs: [publish-integrated]
if: ${{ needs.publish-integrated.outputs.publish == 'true' }}
runs-on: [self-hosted, linux, x64, cliproxy]
steps:
- name: Checkout release tag (for test scripts)
uses: actions/checkout@v4
with:
ref: ${{ needs.publish-integrated.outputs.version != '' && format('v{0}', needs.publish-integrated.outputs.version) || github.ref }}
persist-credentials: false
- name: Derive image reference
id: image
run: |
echo "ref=${{ needs.publish-integrated.outputs.image_ref }}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull image
run: docker pull "${{ steps.image.outputs.ref }}"
- name: Assert image size budget (amd64)
run: |
chmod +x tests/docker/image-size.sh
tests/docker/image-size.sh "${{ steps.image.outputs.ref }}" "367001600" "--platform" "linux/amd64"
- name: Assert image size budget (arm64)
run: |
tests/docker/image-size.sh "${{ steps.image.outputs.ref }}" "367001600" "--platform" "linux/arm64"
- name: Boot container and wait for healthcheck
id: boot
run: |
CONTAINER_NAME="ccs-smoke-${{ github.run_id }}"
echo "container=${CONTAINER_NAME}" >> "$GITHUB_OUTPUT"
docker run -d \
--name "${CONTAINER_NAME}" \
--rm \
-p 13000:3000 \
-p 18317:8317 \
"${{ steps.image.outputs.ref }}"
echo "[i] Waiting for container healthcheck (up to 60s)..."
HEALTHY=0
for i in $(seq 1 12); do
STATUS=$(docker inspect "${CONTAINER_NAME}" --format='{{.State.Health.Status}}' 2>/dev/null || echo "missing")
if [[ "${STATUS}" == "healthy" ]]; then
echo "[OK] Container is healthy after $((i * 5))s"
HEALTHY=1
break
fi
if [[ "${STATUS}" == "unhealthy" ]]; then
echo "[X] Container marked unhealthy"
docker logs "${CONTAINER_NAME}" --tail 50 >&2
exit 1
fi
echo " [${i}/12] status=${STATUS}, waiting 5s..."
sleep 5
done
if [[ "${HEALTHY}" -ne 1 ]]; then
echo "[X] Container did not become healthy within 60s (last status: ${STATUS})" >&2
docker logs "${CONTAINER_NAME}" --tail 100 >&2
docker inspect "${CONTAINER_NAME}" --format='{{json .State}}' >&2 || true
exit 1
fi
- name: Run network-contract test
run: |
# network-contract.sh signature: <compose-file> [image-ref]
# Pass compose.yaml as $1 and the pinned image ref as $2 so the
# smoke test exercises the canonical compose file with the exact
# image that was just published, not whatever tag is in the file.
bash tests/docker/network-contract.sh docker/compose.yaml "${{ steps.image.outputs.ref }}"
- name: Probe dashboard port 3000
run: |
# Single-shot probe by design: healthcheck and network-contract already
# handle readiness waits, and curl --retry would not retry HTTP errors.
HTTP_CODE=$(curl -o /dev/null -s -w "%{http_code}" --max-time 10 http://127.0.0.1:13000/ || echo "000")
if [[ "${HTTP_CODE}" == "000" ]]; then
echo "[X] Could not reach dashboard on :3000 (connection failed)"
exit 1
fi
echo "[OK] Dashboard port 3000 responded with HTTP ${HTTP_CODE}"
- name: Probe CLIProxy port 8317
run: |
# Single-shot probe by design: healthcheck and network-contract already
# handle readiness waits, and curl --retry would not retry HTTP errors.
HTTP_CODE=$(curl -o /dev/null -s -w "%{http_code}" --max-time 10 http://127.0.0.1:18317/ || echo "000")
if [[ "${HTTP_CODE}" == "000" ]]; then
echo "[X] Could not reach CLIProxy on :8317 (connection failed)"
exit 1
fi
echo "[OK] CLIProxy port 8317 responded with HTTP ${HTTP_CODE}"
- name: Stop smoke test container
if: always()
run: |
docker stop "ccs-smoke-${{ github.run_id }}" 2>/dev/null || true
# ---------------------------------------------------------------------------
# Job 4: Promote mutable tags — runs ONLY after smoke tests pass
# Adds :latest, :<major>, :<minor> aliases pointing to the immutable digest.
#
# INTENTIONAL DESIGN (issue #1251 rc.1 soak — see docs/release-process.md):
# GitHub release events publish ONLY the immutable :<ver> tag. The mutable
# :latest / :<major> / :<minor> aliases STAY POINTED AT THE PRIOR STABLE
# RELEASE until an operator manually promotes after a soak window (~24h).
# Trade-off: zero-install users (curl ccs.kaitran.ca/docker-compose.yaml &&
# docker compose up -d) keep receiving last-known-good :latest during the
# soak; operators wanting the new version pull :<ver> directly.
#
# Operator promotion:
# gh workflow run promote-release.yml -f tag=v<X.Y.Z>
# (or equivalently: gh workflow run "Publish Docker Image" -f tag=v<X.Y.Z>
# -f promote_to_latest=true)
# ---------------------------------------------------------------------------
promote-mutable-tags:
name: Promote mutable tags (:latest / major / minor)
needs: [smoke-test, publish-integrated]
# Only promote on explicit operator dispatch — never on automatic release events.
# Release events publish the immutable :<ver> tag only (see publish-integrated).
if: |
needs.publish-integrated.outputs.publish == 'true' &&
github.event_name == 'workflow_dispatch' && inputs.promote_to_latest == true
runs-on: [self-hosted, linux, x64, cliproxy]
permissions:
contents: read
packages: write
id-token: write # for cosign re-sign of mutable aliases
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Promote :latest / major / minor aliases
env:
IMAGE_REF: ${{ needs.publish-integrated.outputs.image_ref }}
VERSION: ${{ needs.publish-integrated.outputs.version }}
run: |
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
IMAGE="ghcr.io/${OWNER_LOWER}/ccs"
MINOR="${VERSION%.*}"
MAJOR="${VERSION%%.*}"
echo "[i] Pointing mutable tags to ${IMAGE_REF}"
docker buildx imagetools create \
--tag "${IMAGE}:latest" \
--tag "${IMAGE}:${MINOR}" \
--tag "${IMAGE}:${MAJOR}" \
"${IMAGE_REF}"
echo "[OK] Promoted: :latest :${MINOR} :${MAJOR} → ${IMAGE_REF}"
+34
View File
@@ -0,0 +1,34 @@
name: Docs – Quickstart Snippet Parity
on:
push:
paths:
- "docs/quickstart-snippet.md"
- "README.md"
- "docker/README.md"
- "tests/docs/quickstart-parity.sh"
- ".github/workflows/docs-parity.yml"
pull_request:
paths:
- "docs/quickstart-snippet.md"
- "README.md"
- "docker/README.md"
- "tests/docs/quickstart-parity.sh"
- ".github/workflows/docs-parity.yml"
jobs:
quickstart-parity:
name: Assert quickstart snippet matches in README.md and docker/README.md
if: >-
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
|| github.event_name == 'push'
runs-on: [self-hosted, linux, x64, cliproxy]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Run quickstart parity check
run: bash tests/docs/quickstart-parity.sh
+98
View File
@@ -0,0 +1,98 @@
name: Promote Stable Release to Docker Latest
# Manual workflow to promote a stable vX.Y.Z release to Docker mutable tags
# (:latest, :MAJOR, :MINOR) after the rc.1 soak window.
#
# Flow:
# 1. Validate that the input tag exists as a stable (non-prerelease) GitHub release.
# 2. Dispatch docker-release.yml with promote_to_latest=true for the given tag.
# This triggers the promote-mutable-tags job (gated on workflow_dispatch
# with promote_to_latest=true) which adds :latest/:MAJOR/:MINOR Docker aliases.
#
# Pre-conditions:
# - The stable tag must already exist as a GitHub release (created automatically
# by semantic-release when the PR merges to main).
# - The immutable :<ver> Docker image must already be published and smoke-tested
# (done automatically by docker-release.yml on the release: published event).
# - Operator has verified the immutable image is stable (24h soak recommended).
#
# See docs/release-process.md for the full soak + promote procedure.
on:
workflow_dispatch:
inputs:
tag:
description: >
Stable tag to promote, e.g. v7.80.0.
Must already exist as a GitHub stable release with the immutable
Docker :<ver> image already published and smoke-tested.
required: true
type: string
jobs:
promote:
name: Promote ${{ inputs.tag }} Docker mutable tags
runs-on: [self-hosted, linux, x64, cliproxy]
permissions:
contents: read # to verify the release
actions: write # to dispatch docker-release.yml
steps:
- name: Validate stable semver tag format
run: |
if [[ "${{ inputs.tag }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "[OK] Tag format valid: ${{ inputs.tag }}"
else
echo "[X] Expected vX.Y.Z format, got: ${{ inputs.tag }}"
echo " For rc tags use docker-release.yml directly with promote_to_latest=true."
exit 1
fi
- name: Verify release exists and is stable (not a prerelease)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
IS_PRERELEASE=$(gh release view "${{ inputs.tag }}" \
--repo "${{ github.repository }}" \
--json isPrerelease --jq '.isPrerelease')
if [[ "${IS_PRERELEASE}" == "true" ]]; then
echo "[X] Release ${{ inputs.tag }} is still a prerelease (isPrerelease=true)"
echo " Semantic-release publishes stable releases to main automatically."
echo " Check that the tag was created from a main merge, not a dev prerelease."
exit 1
fi
if [[ "${IS_PRERELEASE}" != "false" ]]; then
echo "[X] Could not read release state for ${{ inputs.tag }} (got: ${IS_PRERELEASE})"
echo " Verify the tag exists: gh release view ${{ inputs.tag }} --repo ${{ github.repository }}"
exit 1
fi
echo "[OK] Release ${{ inputs.tag }} is stable — proceeding with Docker mutable tag promotion"
- name: Verify immutable Docker image exists
run: |
TAG_SANS_V="${{ inputs.tag }}"
TAG_SANS_V="${TAG_SANS_V#v}"
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
IMAGE_REF="ghcr.io/${OWNER_LOWER}/ccs:${TAG_SANS_V}"
echo "[i] Verifying immutable image: ${IMAGE_REF}"
if ! docker manifest inspect "${IMAGE_REF}" > /dev/null 2>&1; then
echo "[X] Immutable image ${IMAGE_REF} not found in ghcr.io"
echo " Run docker-release.yml for this tag first, or wait for smoke tests to complete."
exit 1
fi
echo "[OK] Immutable image ${IMAGE_REF} confirmed in registry"
- name: Dispatch Docker mutable tag promotion
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RUN_URL=$(gh workflow run "Publish Docker Image" \
--repo "${{ github.repository }}" \
--field "tag=${{ inputs.tag }}" \
--field "promote_to_latest=true" \
2>&1)
echo "[OK] Dispatched docker-release.yml with tag=${{ inputs.tag }} promote_to_latest=true"
echo "[i] Monitor the run at: https://github.com/${{ github.repository }}/actions/workflows/docker-release.yml"
echo "[i] After the run completes, verify with:"
echo " docker buildx imagetools inspect ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/ccs:latest"
@@ -0,0 +1,52 @@
name: Smoke Test – ccs.kaitran.ca/docker-compose.yaml
on:
release:
types:
- published
schedule:
# Nightly at 07:00 UTC
- cron: "0 7 * * *"
workflow_dispatch:
inputs:
do_up:
description: >
Also run `docker compose up -d`, wait for healthcheck, run the
network-contract test against the downloaded URL compose file, then
tear down. Skipped by default (parse-only).
required: false
type: boolean
default: false
jobs:
smoke-test:
name: curl + parse (+ optional up/down + network-contract)
runs-on: [self-hosted, linux, x64, cliproxy]
steps:
- name: Checkout (for test scripts)
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Download compose from canonical URL
run: |
# Keep this as a plain fetch. curl --retry only covers transport-like
# failures by default and can overstate HTTP 5xx resilience.
curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o /tmp/ccs-compose.yaml
echo "--- downloaded compose ---"
cat /tmp/ccs-compose.yaml
- name: Validate compose parses (docker compose config)
run: docker compose -f /tmp/ccs-compose.yaml config
- name: Run network-contract test against downloaded compose
if: ${{ github.event_name == 'workflow_dispatch' && inputs.do_up == true }}
run: |
set -euo pipefail
# network-contract.sh accepts the compose file as first argument.
# This exercises the exact file served at the canonical URL rather
# than the repo copy, ensuring URL-delivered compose works end-to-end.
# The script handles up, healthcheck wait, sibling DNS probes, and
# teardown (via EXIT trap) — no separate up/down steps needed.
bash tests/docker/network-contract.sh /tmp/ccs-compose.yaml
+17 -1
View File
@@ -3,7 +3,13 @@
*
* Branch-aware config:
* - dev branch: Uses dev release configuration (prerelease)
* - main branch: Uses production release configuration
* - main branch: Uses production release configuration (stable, npm @latest)
*
* RC soak window for Docker mutable tags is handled entirely in docker-release.yml:
* every release event publishes the immutable :<ver> Docker tag immediately;
* mutable :latest/:MAJOR/:MINOR tags require an explicit operator action via
* `gh workflow run promote-release.yml -f tag=vX.Y.Z` (workflow_dispatch).
* npm @latest is always set immediately on stable release — no rc soak needed.
*/
const currentBranch =
@@ -37,6 +43,9 @@ const releaseNotesGenerator = [
{ type: 'feat', section: 'Features' },
{ type: 'fix', section: 'Bug Fixes' },
{ type: 'hotfix', section: 'Hotfixes' },
// Breaking changes (feat! / fix!) surface under Features/Bug Fixes
// with a BREAKING CHANGE footer note — no separate section needed.
{ type: 'revert', section: 'Reverts' },
{ type: 'docs', section: 'Documentation' },
{ type: 'style', section: 'Styles' },
{ type: 'refactor', section: 'Code Refactoring' },
@@ -44,6 +53,9 @@ const releaseNotesGenerator = [
{ type: 'test', section: 'Tests' },
{ type: 'build', section: 'Build System' },
{ type: 'ci', section: 'CI' },
// chore commits are intentionally hidden from release notes (no section).
// "### Removed" sections come from feat!/fix! BREAKING CHANGE footers,
// not from a separate commit type.
],
},
},
@@ -87,6 +99,10 @@ const devConfig = {
};
// Production release configuration
// Every merge to main publishes a stable vX.Y.Z release immediately to npm @latest.
// Docker immutable :<ver> tag is pushed by docker-release.yml on the release: published event.
// Docker mutable :latest/:MAJOR/:MINOR tags require a separate manual promote step — see
// docs/release-process.md and promote-release.yml for the soak + promote procedure.
const productionConfig = {
branches: ['main'],
plugins: [
+20
View File
@@ -1,3 +1,23 @@
## [Unreleased]
### Added
* **docker:** Stable Docker network contract — external network `ccs-net` with service DNS `ccs` resolving to the CCS container. CLIProxy reachable at `http://ccs:8317`; dashboard at `http://ccs:3000`. Sibling containers can attach via `--network ccs-net` or by declaring `ccs-net` as an external network in their own compose file. Changing the network name or service name is a **SemVer-major breaking change**. See [docker/README.md](docker/README.md#connect-your-app-to-cliproxy) for usage patterns and troubleshooting. Verified by `tests/docker/network-contract.sh`.
* **docker/ci:** Published images are signed with cosign (keyless OIDC) and include a provenance attestation and SBOM. Verification command documented in [docker/README.md](docker/README.md#image-signatures-and-sbom).
* **release:** Every merge to `main` now auto-cuts a `vX.Y.Z-rc.N` pre-release. A manual `promote-release` workflow promotes the rc to stable after the soak period. See [docs/release-process.md](docs/release-process.md).
### Removed
* **docker:** Dropped Docker image variant `ccs:full`. AI CLIs (claude-code, gemini-cli, grok-cli, opencode) are no longer bundled. Use sibling containers attached to `ccs-net` instead — see [docker/README.md#connect-your-app-to-cliproxy](docker/README.md#connect-your-app-to-cliproxy). Rationale: smaller surface area, fewer supply-chain dependencies, simpler tag taxonomy. The `:latest` image now covers the only published integrated variant.
### Deprecated
* **docker:** `ghcr.io/kaitranntt/ccs-dashboard:latest` Docker image is deprecated — migrate to `ghcr.io/kaitranntt/ccs:latest` (CCS + CLIProxy). The legacy image continues publishing for 2 more releases and emits a startup warning. See [#1251](https://github.com/kaitranntt/ccs/issues/1251).
### Bug Fixes
* **codex:** Pass native `ccsx` Codex subcommands such as `exec`, `apply`, `mcp`, `plugin`, `completion`, and `resume` plus upstream aliases such as `e` and `a` through before CCS profile detection while keeping CCS-owned commands reserved.
## [7.79.1](https://github.com/kaitranntt/ccs/compare/v7.79.0...v7.79.1) (2026-05-14)
### Hotfixes
+86 -525
View File
@@ -1,563 +1,124 @@
# Agent Guidelines
# CCS CLI Agent Guide
AI-facing guidance for agent tooling when working with this repository.
Canonical agent instructions for `/Users/kaitran/CloudPersonal/ccs/cli`.
`AGENTS.md` must stay a symlink to this file.
## Critical Constraints (NEVER VIOLATE)
## Scope
### Test Isolation (MANDATORY)
CCS is a TypeScript/Bun CLI and dashboard for managing Claude Code, Codex,
Factory Droid, CLIProxy, and compatible provider profiles.
**NEVER touch the user's real `~/.ccs/` or `~/.claude/` directories during tests.**
## Non-Negotiables
- All code accessing CCS paths MUST use `getCcsDir()` from `src/utils/config-manager.ts`
- This function respects `CCS_HOME` env var for test isolation
- **WRONG:** `path.join(os.homedir(), '.ccs', ...)`
- **CORRECT:** `path.join(getCcsDir(), ...)`
- Default branch is `dev`. Feature/fix branches start from `dev`; production
hotfixes start from `main` only when explicitly needed.
- Never touch the user's real `~/.ccs/` or `~/.claude/` in tests. Use
`getCcsDir()` from `src/utils/config-manager.ts`; it respects `CCS_HOME`.
- Do not commit directly to `dev` or `main`.
- Do not manually bump versions or create release tags. Semantic-release owns
versions, changelog, tags, npm publish, and GitHub releases.
- CLI terminal output must be ASCII only: `[OK]`, `[!]`, `[X]`, `[i]`.
- Respect `NO_COLOR` and TTY-aware output.
Tests set `process.env.CCS_HOME` to a temp directory. Code using `os.homedir()` directly will modify the user's real files.
## Architecture
## CI-First Protocol (MANDATORY)
- `src/` - TypeScript CLI/server source.
- `lib/ccs`, `lib/ccs.ps1` - bootstrap wrappers; no help text here.
- `ui/src/` - React dashboard.
- `dist/` and `dist/ui/` - build outputs.
- `docs/` - local development and architecture docs.
- Docker support lives under `docker/` and related commands.
**A task is NOT complete until CI is green. After every `git push`, the AI agent MUST block on CI until it passes.**
Profile resolution priority:
### Required Sequence
1. `git push`
2. **Immediately** run `gh pr checks --watch` (or `gh run watch`) and block until all checks complete.
3. If **green** → task may proceed to next step / be declared done.
4. If **red**:
- Pull failing logs: `gh run view --log-failed` (or `gh pr checks <n>` to identify the failing job, then `gh run view <run-id> --log-failed`).
- Fix the root cause locally. Do NOT retry blindly.
- Commit and push again. Re-watch CI.
5. Applies to initial `gh pr create` AND every subsequent push on an open PR.
1. Built-in CLIProxy providers: Gemini, Codex, Antigravity.
2. User-defined `config.cliproxy` providers.
3. Settings-based `config.profiles`.
4. Account-based `profiles.json` with isolated `CLAUDE_CONFIG_DIR`.
### Fallback (when `--watch` is unavailable or flaky)
Poll with short sleep until no check is `pending` / `in_progress`:
```bash
until [ "$(gh pr checks <n> --json state -q '[.[] | select(.state == "IN_PROGRESS" or .state == "PENDING" or .state == "QUEUED")] | length')" = "0" ]; do
sleep 10
done
gh pr checks <n>
```
All env values written into settings must be strings.
### Absolute rule
AI MUST NOT declare a task done, close a session, or move to the next task while CI is red or still running. Leaving a PR red and moving on is the primary failure mode this protocol prevents.
## User-Facing Change Checklist
### Dev Release vs Push CI
- Update the matching `--help` handler when CLI behavior changes.
- Keep README concise; do not remove `## Community Projects` or
`## Star History` unless explicitly asked.
- Use neutral broad examples such as `ccs`, `ccs codex`, `ccs glm`, or
`ccs <provider>` unless the page is provider-specific.
- If CLI commands, config, providers, install steps, or user workflows change,
update the public CCS docs in `/Users/kaitran/CloudPersonal/ccs/docs`.
- `CI` is the pull-request quality gate for contributor branches.
- `Push CI` is the post-merge quality signal for `dev`.
- `Dev Release` publishes the `@dev` package after `dev` changes land.
- A red `Dev Release` does **not** automatically mean contributor code failed. Check `Push CI` first.
- Verified on `2026-04-22` via `gh api repos/kaitranntt/ccs/branches/dev/protection`: `dev` currently requires `typecheck`, `lint`, `format`, `build`, and `test`, has no branch restrictions, and has no required PR-review gate.
- `dev-release.yml` currently pushes with `PAT_TOKEN` because `dev` is protected by those required status checks. Do not switch it back to `github.token` unless branch protection changes with it.
Help locations:
### Self-Hosted Runner Policy
- `ccs --help`: `src/commands/help-command.ts`
- `ccs api --help`: `src/commands/api-command.ts`
- `ccs cleanup --help`: `src/commands/cleanup-command.ts`
- `ccs cliproxy --help`: `src/commands/cliproxy-command.ts`
- `ccs config --help`: `src/commands/config-command.ts`
- `ccs copilot --help`: `src/commands/copilot-command.ts`
- `ccs cursor --help`: `src/commands/cursor-command.ts`
- `ccs doctor --help`: `src/commands/doctor-command.ts`
- `ccs docker --help`: `src/commands/docker/help-subcommand.ts`
- `ccs env --help`: `src/commands/env-command.ts`
- `ccs migrate --help`: `src/commands/migrate-command.ts`
- `ccs persist --help`: `src/commands/persist-command.ts`
- `ccs setup --help`: `src/commands/setup-command.ts`
- Keep active CCS workflows on local self-hosted runners by default. Do **not** move jobs to `ubuntu-latest`, `macos-latest`, or other GitHub-hosted runners as the first security fix.
- For this public repo, standard GitHub-hosted runners may not consume billable minutes, but the project policy is still local-runner-first to keep compute predictable and avoid future quota surprises across repos.
- Make self-hosted use secure by gating untrusted PR triggers, avoiding checkout of untrusted code in privileged `pull_request_target` jobs, setting `persist-credentials: false`, and scoping PAT-backed credentials to the exact release/sync push step.
- If a workflow truly needs GitHub-hosted runners, document the exception in this file and add a regression test for the exception.
## Validation
## Core Function
Multi-provider profile and runtime manager for Claude Code, Factory Droid,
Codex CLI, and other compatible targets. See README.md for user documentation.
## README Preservation
When editing `README.md`, keep the file concise and funnel detailed usage into
the docs site, but **do not remove the `## Community Projects` section** or the
`## Star History` section unless the user explicitly asks for those sections to
be deleted. Treat both as protected README content.
When a contributor adds a useful community integration section to `README.md`,
prefer preserving the attribution in `## Community Projects` and moving the
setup substance into a docs page, rather than deleting the contribution.
Outside provider-specific Gemini and Antigravity docs, avoid using `ccs gemini`
or `ccs agy` as the primary hero example, default starter route, or generic
workflow example. Prefer `ccs`, `ccs codex`, `ccs kiro`, `ccs glm`, Droid
examples, or neutral `ccs <provider>` placeholders when the page is about a
broader topic.
## Design Principles (ENFORCE STRICTLY)
### Technical Excellence
- **YAGNI**: No features "just in case"
- **KISS**: Simple bash/PowerShell/Node.js only
- **DRY**: One source of truth (config.yaml)
### User Experience (EQUALLY IMPORTANT)
- **CLI-Complete**: All features MUST have CLI interface
- **Dashboard-Parity**: Configuration features MUST also have Dashboard interface
- **Execution is CLI**: Running profiles happens via terminal, not dashboard buttons
- **UX > Brevity**: Error messages and help text prioritize user success over terseness
- **Progressive Disclosure**: Simple by default, power features accessible but not overwhelming
### When Principles Conflict
- **UX > YAGNI** for user-facing features (if users need it, it's not "just in case")
- **KISS applies to BOTH** code AND user experience (simple journey, not just simple code)
- **DRY applies to BOTH** code AND interface patterns (consistent behavior across CLI/Dashboard)
## Common Mistakes (AVOID)
| Mistake | Consequence | Correct Action |
|---------|-------------|----------------|
| Running `validate` without `format` first | format:check fails | Run `bun run format` BEFORE validate |
| Treating `Dev Release` as the contributor quality signal | Publish failures on `dev` look like broken code | Check PR `CI` on the branch and `Push CI` on `dev` first |
| Using `chore:` for dev→main PR | No npm release triggered | Use `feat:` or `fix:` prefix |
| Committing directly to `main` or `dev` | Bypasses CI/review | Always use PRs |
| Manual version bump or git tag | Conflicts with semantic-release | Let CI handle versioning |
| Forgetting `--help` update | CLI docs out of sync | Update `src/commands/help-command.ts` |
| Forgetting docs update | User docs out of sync | Update `docs/` and CCS docs submodule |
## GitHub Issue Operations (CCS-Specific)
These rules apply when the task is issue triage, backlog cleanup, labels, comments, Projects, or milestones for this repo.
### Scope Boundary
- Treat issue triage as a **GitHub-only workflow** unless the user explicitly asks for implementation.
- Do **NOT** create a worktree, branch, PR, or run `/fix`, `/cook`, or `kai:maintainer` just to tag issues, post follow-up comments, close duplicates, or clean up backlog state.
- Escalate into code workflow only when:
- the user explicitly asks to fix/implement an issue, or
- triage proves the same task now requires code changes.
### Read Before Mutating
- Always inspect live issue state first with `gh issue view <n> --json ...` or `gh api`.
- Never rely on stale memory, screenshots, or issue titles alone.
- Before closing as resolved, cross-check repo evidence in at least one of:
- `README.md`
- `docs/`
- `CHANGELOG.md`
- relevant source/help handlers
- If the `gh` query would touch Projects fields, verify token scope first. Missing `read:project` is a real blocker, not something to hand-wave around.
### Labeling Standard
- Every **open** issue should end triage with:
- one primary type label: `bug`, `enhancement`, `question`, `documentation`, `duplicate`, `invalid`, or `wontfix`
- one area label:
- `area:cli-runtime`
- `area:dashboard-ui`
- `area:config-auth`
- `area:provider-integration`
- `area:install-packaging`
- `area:documentation`
- `area:contributor-workflow`
- Add routing labels only when they materially change handling:
- `upstream-blocked`
- `needs-repro`
- `needs-split`
- `docs-gap`
- Use release-state labels for shipped work:
- `pending-release`
- `released-dev`
- `released`
- Do **NOT** create or use status labels like `todo`, `doing`, `blocked`, `done`.
- Do **NOT** create provider-name labels unless there is a proven long-term need. Provider names belong in titles/issues, not label spam.
### Commenting Rules
- Keep issue comments short, technical, and neutral.
- State the decision plainly: close, keep open, retag, needs repro, duplicate, blocked upstream.
- Include exact evidence when relevant: version, doc path, changelog release, canonical issue, upstream link.
- Do **NOT** reference internal plans, local report files, agent prompts, or private reasoning.
- Post **one** maintainer follow-up comment per triage pass. If accidental duplicates are created, delete them with `gh api repos/<owner>/<repo>/issues/comments/<id> -X DELETE`.
### Closure Rules
- Close immediately when:
- the issue is an obvious duplicate and you can point to the canonical issue
- the feature/fix is clearly shipped and documented
- a previously `pending-release` issue is now clearly past release and no longer needs tracking
- Keep open and retag when:
- upstream dependency still blocks CCS adoption -> `upstream-blocked`
- latest-release behavior is unclear -> `needs-repro`
- issue contains multiple independent asks -> `needs-split`
- feature likely exists but discoverability/docs are weak -> `docs-gap`
- Do **NOT** close just because an issue is old, vague, or inconvenient. Close only with evidence.
### Projects And Milestones
- Preferred project model for this repo: one project, `CCS Backlog`.
- Use Projects for workflow state and priority. Use labels for meaning and routing.
- Milestones are for real ship windows only, not generic categorization buckets.
- If `gh` token lacks `read:project`, say so explicitly and stop short of pretending Projects data is available.
- Active project:
- owner: `kaitranntt`
- number: `3`
- URL: `https://github.com/users/kaitranntt/projects/3`
- Active project fields:
- `Status` -> use for work state (`Todo`, `In Progress`, `Done`)
- `Priority` -> `P1` for bugs, `P2` default backlog, `P3` for broad `needs-split` buckets unless explicitly reprioritized
- `Follow-up` -> `Ready`, `Needs repro`, `Blocked upstream`, `Needs split`, `Docs follow-up`
- `Next review` -> date only for issues that need a follow-up checkpoint
- When triaging an open issue, make sure it exists in `CCS Backlog` and the project fields match the routing labels.
- Do **NOT** create a second backlog project unless the user explicitly wants a project split and gives a reason.
- Current automation path:
- workflow file: `.github/workflows/sync-ccs-backlog-project.yml`
- sync script: `scripts/github/ccs-backlog-sync.mjs`
- required Actions secret: `CCS_PROJECT_AUTOMATION_TOKEN`
- Automation mapping must stay aligned with labels:
- `upstream-blocked` -> `Follow-up=Blocked upstream`
- `needs-repro` -> `Follow-up=Needs repro`
- `needs-split` -> `Follow-up=Needs split`
- `docs-gap` -> `Follow-up=Docs follow-up`
- otherwise -> `Follow-up=Ready`
### New Or Updated Issue Creation
- When creating issues for this repo:
- assign `@kaitranntt`
- use conventional issue titles: `bug: ...`, `feat: ...`, `docs: ...`
- keep bodies factual and technical
- avoid personal info and internal-only context
## Quality Gates (MANDATORY)
Quality gates MUST pass before pushing. **Both projects have identical workflow.**
### Pre-Commit Sequence (FOLLOW THIS ORDER)
Format before validating:
```bash
# Main project (from repo root)
bun run format # Step 1: Fix formatting
bun run lint:fix # Step 2: Fix lint issues
bun run validate # Step 3: Fast gate (typecheck + lint + format + test:fast)
bun run validate:ci-parity # Step 4: PR-CI parity gate (branch check + build + full tests + e2e)
# UI project (if UI changed)
cd ui
bun run format # Step 1: Fix formatting
bun run lint:fix # Step 2: Fix lint issues
bun run validate # Step 3: Final check (must pass)
cd /Users/kaitran/CloudPersonal/ccs/cli && bun run format
cd /Users/kaitran/CloudPersonal/ccs/cli && bun run lint:fix
cd /Users/kaitran/CloudPersonal/ccs/cli && bun run validate
```
**WHY THIS ORDER:**
- `validate` runs `format:check` which only VERIFIES—won't fix
- If format:check fails, you skipped step 1
- `validate` now uses read-only `lint`, so autofix still belongs in step 2
- PR CI and `validate:ci-parity` both run non-mutating checks only
Before requesting review or merge, run:
### What Each Gate Runs
| Project | Command | Runs |
|---------|---------|------|
| Main | `bun run validate` | typecheck + lint + format:check + test:fast |
| Main | `bun run validate:ci-parity` | base branch check + typecheck + lint + format:check + build:all + test:all + test:e2e |
| UI | `bun run validate` | typecheck + lint:fix + format:check |
### ESLint Rules (ALL errors)
| Rule | Level | Notes |
|------|-------|-------|
| `@typescript-eslint/no-unused-vars` | error | Ignore `_` prefix |
| `@typescript-eslint/no-explicit-any` | error | Use proper types or `unknown` |
| `@typescript-eslint/no-non-null-assertion` | error | No `!` assertions |
| `prefer-const`, `no-var`, `eqeqeq` | error | Code quality |
| `react-hooks/*` (UI only) | recommended | Hooks rules |
| `react-refresh/*` (UI only) | vite | Fast refresh |
### TypeScript Options (strict mode)
| Option | Value | Notes |
|--------|-------|-------|
| `strict` | true | All strict flags enabled |
| `noUnusedLocals` | true | No unused variables |
| `noUnusedParameters` | true | No unused params |
| `noImplicitReturns` | true | All paths must return |
| `noFallthroughCasesInSwitch` | true | Explicit case handling |
### Automatic Enforcement
- `prepack` runs `build:all`
- PR `CI` runs `typecheck`, `lint`, `format`, `build`, `test:all`, and `test:e2e`
- `Push CI` runs the same quality suite on `dev` after merge, separate from release publishing
- `Dev Release` still runs build + fast validation + slow tests + e2e before publishing and still requires `PAT_TOKEN` to push back to protected `dev`
- husky `pre-commit` runs quick lint/type/format checks
- husky `pre-push` runs the full `bun run validate:ci-parity` gate on `main`/`dev`/hotfix branches
- husky `pre-push` runs a faster feature-branch gate (`typecheck` + `lint` + `format:check` + `test:fast`) plus targeted checks based on changed files
### Maintainability Gate Status
- The historical maintainability baseline gate is retired from the active CCS workflow.
- `validate`, `validate:ci-parity`, PR `CI`, `Push CI`, and release workflows do **not** invoke `maintainability:check`.
- Older roadmap references to `maintainability:baseline` / `maintainability:check` are historical context, not current repo commands.
## Critical Constraints (NEVER VIOLATE)
1. **NO EMOJIS in CLI output** - Terminal output uses ASCII only: [OK], [!], [X], [i]
- **Scope:** CCS CLI terminal output (`src/` code that prints to stdout/stderr)
- **Does NOT apply to:** PR descriptions, commit messages, documentation, comments, AI conversations
2. **TTY-aware colors** - Respect NO_COLOR env var
3. **Non-invasive** - NEVER modify external tool settings (`~/.claude/settings.json`) without explicit user request and confirmation (exception: `ccs persist` command)
4. **Cross-platform parity** - bash/PowerShell/Node.js must behave identically
5. **CLI documentation** - ALL CLI changes MUST update respective `--help` handler (see table below)
6. **Idempotent** - All install operations safe to run multiple times
7. **Dashboard parity** - Configuration features MUST work in both CLI and Dashboard
### Help Location Reference
| Command | Help Handler Location |
|---------|----------------------|
| `ccs --help` | `src/commands/help-command.ts` |
| `ccs api --help` | `src/commands/api-command.ts` → `showHelp()` |
| `ccs cleanup --help` | `src/commands/cleanup-command.ts` → `printHelp()` |
| `ccs cliproxy --help` | `src/commands/cliproxy-command.ts` → `showHelp()` |
| `ccs config --help` | `src/commands/config-command.ts` → `showHelp()` |
| `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` |
| `ccs cursor --help` | `src/commands/cursor-command.ts` → `handleHelp()` |
| `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` |
| `ccs docker --help` | `src/commands/docker/help-subcommand.ts` → `showHelp()` |
| `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` |
| `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` |
| `ccs persist --help` | `src/commands/persist-command.ts` → `showHelp()` |
| `ccs setup --help` | `src/commands/setup-command.ts` → `showHelp()` |
**Note:** `lib/ccs` and `lib/ccs.ps1` are bootstrap wrappers only—they delegate to Node.js and contain no help text.
## Documentation Requirements (MANDATORY)
**Documentation is a first-class citizen. ALL user-facing changes require docs updates.**
### Local Documentation (`docs/`)
Update local `docs/` folder for:
- Architecture changes
- Internal API documentation
- Development guides
### CCS Docs Submodule (Owner Only)
**For @kaitranntt (repository owner):** When adding/changing CLI commands or config options, you MUST also update the CCS docs submodule at `~/CloudPersonal/ccs/docs/`:
| Change Type | Files to Update |
|-------------|-----------------|
| New CLI command/flag | `reference/cli-commands.mdx` |
| New config option | `reference/config-schema.mdx` |
| Provider feature | `providers/<provider>.mdx` |
| New feature | `features/<feature>.mdx` |
**Workflow for docs submodule:**
```bash
cd ~/CloudPersonal/ccs/docs/
git checkout main && git pull
# Make changes
git add -A && git commit -m "docs: <description>"
git push origin main
cd /Users/kaitran/CloudPersonal/ccs/cli && bun run validate:ci-parity
```
**For external contributors:** Document changes in PR description. Owner will sync to CCS docs.
If UI changed:
### Pre-Commit Docs Checklist
- [ ] Respective `--help` updated (see Help Location Reference table)
- [ ] Local `docs/` updated if architecture changed
- [ ] CCS docs submodule updated (owner) or PR description includes docs (contributor)
## Feature Interface Requirements
| Feature Type | CLI | Dashboard | Example |
|--------------|-----|-----------|---------|
| Profile creation | ✓ | ✓ | `ccs auth create`, Dashboard "Add Account" |
| Profile switching | ✓ | ✓ | `ccs <profile>` (execution is CLI-only) |
| API key config | ✓ | ✓ | `ccs api create`, Dashboard API Profiles |
| Health check | ✓ | ✓ | `ccs doctor`, Dashboard Live Monitor |
| OAuth auth flow | ✓ | ✓ | Browser opens from CLI or Dashboard |
| Analytics/monitoring | ✗ | ✓ | Dashboard Analytics (visual by nature) |
| WebSearch config | ✓ | ✓ | CLI flags, Dashboard Settings |
| Remote proxy config | ✓ | ✓ | CLI flags, Dashboard Settings |
## File Structure
```
src/ → TypeScript source (main project)
dist/ → Compiled JavaScript (npm package)
lib/ → Native shell scripts (bash, PowerShell)
ui/src/ → React components, hooks, pages
ui/src/components/ui/ → shadcn/ui components
dist/ui/ → Built UI bundle (served by Express)
```
## Key Technical Details
### Profile Mechanisms (Priority Order)
1. **CLIProxy hardcoded**: gemini, codex, agy → OAuth-based, zero config
2. **CLIProxy variants**: `config.cliproxy` section → user-defined providers
3. **Settings-based**: `config.profiles` section → GLM, legacy GLMT compatibility, Kimi
4. **Account-based**: `profiles.json` → isolated instances via `CLAUDE_CONFIG_DIR`
### Settings Format (CRITICAL)
All env values MUST be strings (not booleans/objects) to prevent PowerShell crashes.
```json
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com/anthropic",
"ANTHROPIC_AUTH_TOKEN": "your-api-key",
"ANTHROPIC_MODEL": "model-name"
}
}
```
### Shared Data Architecture
Symlinked from `~/.ccs/shared/`: commands/, skills/, agents/
Profile-specific: settings.json, sessions/, todolists/, logs/
Windows fallback: Copies if symlinks unavailable
## Code Standards
### Architecture
- `lib/ccs`, `lib/ccs.ps1` - Bootstrap scripts (delegate to Node.js via npx)
- `src/*.ts` → `dist/*.js` - Main implementation (TypeScript)
### Bash (lib/*.sh)
- bash 3.2+, `set -euo pipefail`, quote all vars `"$VAR"`, `[[ ]]` tests
- NO external dependencies
### PowerShell (lib/*.ps1)
- PowerShell 5.1+, `$ErrorActionPreference = "Stop"`
- Native JSON only, no external dependencies
### TypeScript (src/*.ts)
- Node.js 18+, Bun 1.0+, TypeScript 5.3, strict mode
- `child_process.spawn`, handle SIGINT/SIGTERM
### Terminal Output
- ASCII only: [OK], [!], [X], [i] (NO emojis in CLI output)
- TTY detect before colors, respect NO_COLOR
- Box borders for errors: ╔═╗║╚╝
## Conventional Commits (MANDATORY)
**ALL commits MUST follow conventional commit format. Non-compliant commits are rejected by husky.**
### Format
```
<type>(<scope>): <description>
```
### Types (determines version bump)
| Type | Version Bump | Use For |
|------|--------------|---------|
| `feat:` | MINOR | New features |
| `fix:` | PATCH | Bug fixes |
| `perf:` | PATCH | Performance |
| `feat!:` | MAJOR | Breaking changes |
| `docs:`, `style:`, `refactor:`, `test:`, `chore:`, `ci:`, `build:` | None | Non-release |
### Examples
```bash
# Good
git commit -m "feat(cliproxy): add OAuth token refresh"
git commit -m "fix(doctor): handle missing config gracefully"
# Bad - REJECTED
git commit -m "added new feature"
git commit -m "Fixed bug"
cd /Users/kaitran/CloudPersonal/ccs/cli/ui && bun run format && bun run validate
```
## Branching Strategy
After every push to a PR, watch CI until it finishes. If checks fail, inspect
logs, fix root cause, push again, and re-watch.
### Hierarchy
```
main (production) ← dev (integration) ← feat/* | fix/* | docs/*
↑
└── hotfix/* (critical only, skips dev)
```
## Issue Triage
Issue triage is GitHub-only unless implementation is explicitly requested.
Always inspect live state first:
### Standard Workflow
```bash
git checkout dev && git pull origin dev
git checkout -b feat/my-feature
# ... develop with conventional commits ...
git push -u origin feat/my-feature
gh pr create --base dev --title "feat(scope): description"
# After testing in @dev:
gh pr create --base main --title "feat(release): promote dev to main"
cd /Users/kaitran/CloudPersonal/ccs/cli && gh issue view <number> --json title,body,state,labels,assignees,comments
```
### Hotfix Workflow (Production Emergencies Only)
```bash
git checkout main && git pull origin main
git checkout -b hotfix/critical-bug
# ... fix ...
gh pr create --base main --title "fix: critical issue"
# Then sync: git checkout dev && git merge main && git push
```
For open issues, prefer one type label and one area label. Use routing labels
only when they affect handling: `upstream-blocked`, `needs-repro`,
`needs-split`, `docs-gap`. Do not close issues on age, intuition, or vague
titles; close only with evidence from README, docs, changelog, source, or a
canonical duplicate.
### Rules
1. **NEVER** commit directly to `main` or `dev`
2. Feature branches from `dev`, hotfixes from `main`
3. dev→main PRs MUST use `feat:` or `fix:` (not `chore:`)
4. Delete branches after merge
## Release Signals
## Automated Releases (DO NOT MANUALLY TAG)
- PR `CI` is the contributor quality gate.
- `Push CI` is the post-merge signal for `dev`.
- `Dev Release` publishes the `@dev` npm package.
- A red `Dev Release` is not automatically contributor failure; check PR `CI`
and `Push CI` first.
**Releases are FULLY AUTOMATED via semantic-release. NEVER manually bump versions or create tags.**
Use `feat:` or `fix:` for dev-to-main promotion PRs so release automation runs.
| Branch | npm Tag | When |
|--------|---------|------|
| `main` | `@latest` | Merge PR to main |
| `dev` | `@dev` | Push to dev branch |
## Design Standards
**CI handles:** version bump, CHANGELOG.md, git tag, npm publish, GitHub release.
## Development
### Testing (REQUIRED before PR)
```bash
bun run test # All tests
bun run test:npm # npm package tests
bun run test:native # Native install tests
bun run test:unit # Unit tests
```
### Local Development
```bash
bun run dev # Build + start config server (http://localhost:3000)
bun run dev:symlink # Symlink global 'ccs' → dev dist/ccs.js (fast iteration)
bun run dev:unlink # Restore original global ccs
./scripts/dev-install.sh # Build, pack, install globally (full install)
rm -rf ~/.ccs # Clean environment
```
**IMPORTANT:** Use `bun run dev` at CCS root for always up-to-date code. Do NOT use `ccs config` during development as it uses the globally installed version.
## Two-Tier Pre-Push Checklist
Optimized for iterative push-then-review workflow. Do NOT run the full gate on every push — CI is the safety net. Run the full gate once before asking for review / merge.
### Tier 1 — Iterative push (feature branch)
Husky `pre-push` auto-runs: `typecheck + lint + format:check + test:fast` plus targeted checks based on changed files. AI does **nothing extra** at push time.
**After push (MANDATORY):** follow the [CI-First Protocol](#ci-first-protocol-mandatory) — watch CI until green. Do not move on while CI is red.
### Tier 2 — Before requesting review / merge
Run ONCE, not per push:
- [ ] `bun run validate:ci-parity` — branch freshness + build + full non-e2e tests + e2e
- [ ] `gh pr checks <n>` — all checks green
- [ ] If UI changed: `cd ui && bun run format && bun run validate`
- [ ] If touching command routing, proxy flows, workflows, or release logic: `bun run test:e2e`
### Code / Docs / Standards (verify before merge)
- [ ] Conventional commit format (`feat:`, `fix:`, etc.)
- [ ] Respective `--help` updated (see Help Location Reference) — if CLI changed
- [ ] Tests added/updated — if behavior changed
- [ ] README.md updated — if user-facing
- [ ] CCS docs updated (owner: `~/CloudPersonal/ccs/docs/`) — if CLI/config changed
- [ ] Local `docs/` updated — if architecture changed
- [ ] CLI output ASCII only (NO emojis in terminal output), NO_COLOR respected
- [ ] YAGNI/KISS/DRY alignment verified
- [ ] No manual version bump or tags
## Error Handling Principles
- Validate early, fail fast with clear messages
- Show available options on mistakes
- Never leave broken state
- YAGNI, KISS, DRY.
- CLI-complete: core configuration features need CLI coverage.
- Dashboard parity: configuration features usually need dashboard coverage too.
- Execution remains CLI-first; dashboard should not replace terminal profile
launch flows.
- Error messages should help users recover, not just report failure.
+12
View File
@@ -217,6 +217,18 @@ If you cannot run the full suite, that is still fine for early or docs-only PRs.
- Update `ui/src/` and any affected tests.
- Run UI validation from `ui/`.
### If you change the Docker network or service name
The `ccs-net` network name and the `ccs` service name in `docker/compose.yaml` are a **public contract**. Users attach their own containers to `ccs-net` and resolve CLIProxy at `http://ccs:8317`.
Changing either of these values is a **SemVer-major breaking change**. Before modifying `services.ccs.name` or `networks.ccs-net.name` in `docker/compose.yaml`:
- Open an issue to discuss the migration path.
- Update `docker/README.md`, `CHANGELOG.md`, and any docs that reference the stable names.
- Bump the major version (via a `feat!:` or `fix!:` commit with a `BREAKING CHANGE:` footer).
If you are unsure whether your change affects the contract, check `tests/docker/network-contract.sh` — the test will fail if the network or service DNS resolution breaks.
### If you change config, providers, or architecture
- Update the relevant docs in `docs/`.
+29 -11
View File
@@ -20,21 +20,25 @@ Anthropic-compatible APIs without config thrash.
</div>
## Why CCS
> **[Docker]** `ghcr.io/kaitranntt/ccs-dashboard:latest` is deprecated. Use `ghcr.io/kaitranntt/ccs:latest` instead. See [#1251](https://github.com/kaitranntt/ccs/issues/1251) and [docker/README.md](docker/README.md#choosing-an-image) for migration details. To wire a sibling container to CLIProxy, see [Connect your app to CLIProxy](docker/README.md#connect-your-app-to-cliproxy).
CCS gives you one stable command surface while letting you switch between:
<!-- quickstart-snippet-start -->
## Quick Start (Docker)
- multiple runtimes such as Claude Code, Factory Droid, and Codex CLI
- multiple Claude subscriptions and isolated account contexts
- OAuth providers like Codex, Kiro, Claude, Qwen, Kimi, and more, with legacy
Copilot compatibility for existing setups
- API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp,
Novita, and Alibaba Coding Plan
With Docker installed:
The goal is simple: stop rewriting config files, stop breaking active sessions,
and move between providers in seconds.
```bash
curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o docker-compose.yaml
docker compose up -d
```
## Quick Start
Dashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
Need a corporate-proxy alternative? Download directly:
`https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml`
<!-- quickstart-snippet-end -->
## Install on Host (npm)
```bash
npm install -g @kaitranntt/ccs
@@ -51,6 +55,20 @@ ccs glm
ccs ollama
```
## Why CCS
CCS gives you one stable command surface while letting you switch between:
- multiple runtimes such as Claude Code, Factory Droid, and Codex CLI
- multiple Claude subscriptions and isolated account contexts
- OAuth providers like Codex, Kiro, Claude, Qwen, Kimi, and more, with legacy
Copilot compatibility for existing setups
- API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp,
Novita, and Alibaba Coding Plan
The goal is simple: stop rewriting config files, stop breaking active sessions,
and move between providers in seconds.
## OpenAI-Compatible Routing
CCS can now bridge Claude Code into OpenAI-compatible providers through a local
+20 -11
View File
@@ -2,6 +2,7 @@
# =============================================================================
# Build stage: compile TypeScript and build UI
# Bun is used here for speed; npm lockfile is generated for the runtime stage.
# =============================================================================
FROM node:20-bookworm-slim AS build
@@ -35,29 +36,37 @@ RUN bun run build:all
# Validate build artifacts exist
RUN test -d dist && test -d lib && echo "[OK] Build artifacts validated"
# Generate a fresh npm lockfile from package.json immediately after build.
# This lockfile is ephemeral — never committed; bun.lock remains the dev lockfile.
# --ignore-scripts: only lockfile generation, no install side-effects needed here.
# postinstall (scripts/postinstall.js) is a user-facing setup script; it must NOT
# run during the Docker build (it writes to ~/.ccs/ which does not exist here).
RUN --mount=type=cache,target=/root/.npm \
npm install --package-lock-only --ignore-scripts
# =============================================================================
# Runtime stage: minimal production image
# Runtime stage: Node-only, no Bun
# =============================================================================
FROM node:20-bookworm-slim AS runtime
SHELL ["/bin/bash", "-lc"]
# Pin bun version for reproducible builds
ARG BUN_VERSION=1.2.21
ENV BUN_INSTALL=/usr/local/bun
ENV PATH="$BUN_INSTALL/bin:/home/node/.opencode/bin:$PATH"
# opencode installs its binary to /home/node/.opencode/bin — keep on PATH
ENV PATH="/home/node/.opencode/bin:$PATH"
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates unzip \
&& rm -rf /var/lib/apt/lists/*
# Install specific bun version
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production --ignore-scripts
# Copy the ephemeral lockfile generated in the build stage (never committed).
# --ignore-scripts: postinstall writes ~/.ccs/ config which is not needed inside
# the Docker image; runtime deps (bcrypt v6+, express, etc.) have no native
# compilation steps requiring postinstall.
COPY --from=build /app/package.json /app/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev --ignore-scripts
COPY docker/entrypoint.sh /usr/local/bin/ccs-entrypoint
RUN chmod +x /usr/local/bin/ccs-entrypoint
@@ -72,7 +81,7 @@ COPY --from=build /app/LICENSE ./LICENSE
# Install AI CLI tools (using latest - pin versions in production if needed)
# These are optional tools for docker exec usage
RUN npm install -g @google/gemini-cli @vibe-kit/grok-cli @anthropic-ai/claude-code \
&& npm install -g @kaitranntt/ccs --force \
&& npm install -g @kaitranntt/ccs --force --ignore-scripts \
&& npm cache clean --force \
&& su -s /bin/bash node -c 'curl -fsSL https://opencode.ai/install | bash -s -- --no-modify-path' \
&& ln -sf /app/dist/ccs.js /usr/local/bin/ccs
+27 -2
View File
@@ -1,7 +1,21 @@
FROM eceasy/cli-proxy-api:latest
# Base image pinned by digest for reproducible builds.
# To refresh: docker pull eceasy/cli-proxy-api:latest && docker inspect --format='{{index .RepoDigests 0}}' eceasy/cli-proxy-api:latest
# Refresh cadence: quarterly OR on security advisory from upstream.
# TODO: pin apk packages once apk-digest pinning workflow exists in CI (apk add nodejs npm are currently unpinned;
# alpine package versions can be pinned but require per-package version discovery and maintenance).
FROM eceasy/cli-proxy-api:latest@sha256:4fa722b9ff83adfbe6e350d433b7dcb754756aa63df95a7a6349767dc13b2cb7
ARG CCS_NPM_VERSION=latest
# CCS integrated image: CCS CLI + CLIProxy + supervisord.
# Design choice (issue #1251 final scope): single-image strategy. The originally-
# proposed `:full` variant bundling claude-code/gemini-cli/grok-cli/opencode was
# DROPPED on maintainer review. Rationale: smaller surface area, fewer supply-
# chain dependencies, simpler tag taxonomy. Users needing AI CLIs run them in
# sibling containers attached to ccs-net — the public DNS contract guarantees
# http://ccs:8317 reachability. See docker/README.md#connect-your-app-to-
# cliproxy. Historical record: CHANGELOG.md "### Removed" entry.
RUN apk add --no-cache \
curl \
jq \
@@ -9,7 +23,18 @@ RUN apk add --no-cache \
npm \
supervisor
RUN npm install -g @kaitranntt/ccs@${CCS_NPM_VERSION} \
# Install CCS CLI.
# Tradeoff: `npm install -g` does not use a lockfile — the package is already
# version-pinned via CCS_NPM_VERSION (e.g. "7.80.0"), so transitive drift is
# bounded to patch-level updates of CCS's own production dependencies between
# publish time and image build time. This is intentional: pinning an exact
# lockfile in a Docker global-install layer is fragile (lockfile format ties to
# npm/Node version in the base image). The --mount=type=cache layer ensures the
# resolved dependency tree is stable within a single build host's cache.
# Regression test: tests/docker/image-size.sh asserts the image stays within
# the size budget, which catches unexpected dep bloat.
RUN --mount=type=cache,target=/root/.npm \
npm install -g @kaitranntt/ccs@${CCS_NPM_VERSION} \
&& ln -sf /usr/local/lib/node_modules/@kaitranntt/ccs/dist/docker/docker-bootstrap.js /usr/local/bin/ccs-docker-bootstrap
COPY supervisord.conf /etc/supervisord.conf
+275 -34
View File
@@ -11,11 +11,45 @@ Persistent config, restart on reboot.
</div>
> **[Deprecation]** `ghcr.io/kaitranntt/ccs-dashboard:latest` is deprecated.
> Migrate to `ghcr.io/kaitranntt/ccs:latest`. See [Migration](#migration-from-ccs-dashboardlatest) below.
<br>
## Preferred: `ccs docker`
<!-- quickstart-snippet-start -->
## Quick Start (Docker)
The CLI now ships a first-class Docker command suite for the integrated CCS + CLIProxy stack:
With Docker installed:
```bash
curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o docker-compose.yaml
docker compose up -d
```
Dashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
Need a corporate-proxy alternative? Download directly:
`https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml`
<!-- quickstart-snippet-end -->
---
## Choosing an image
| Tag | Use | Approx. size | Status |
|---|---|---|---|
| `ghcr.io/kaitranntt/ccs:latest` | CCS + CLIProxy, no AI CLIs bundled | < 350 MB | **Recommended** |
| `ghcr.io/kaitranntt/ccs-dashboard:latest` | Legacy all-in-one image | > 600 MB | **Deprecated** — migrate to `ccs:latest`. Sunset after 2 releases. See [#1251](https://github.com/kaitranntt/ccs/issues/1251) |
`ccs:latest` also publishes pinned version tags (`ccs:<major>.<minor>.<patch>`, `ccs:<major>.<minor>`, `ccs:<major>`) for reproducible deployments.
**Need claude-code, gemini-cli, grok-cli, or opencode?** Run those tools in a sibling container attached to `ccs-net` — see [Connect your app to CLIProxy](#connect-your-app-to-cliproxy). This keeps each tool independently versioned and prevents supply-chain bloat in the CLIProxy image.
---
## Power-user: `ccs docker`
The CLI ships a first-class Docker command suite for the integrated CCS + CLIProxy stack:
```bash
ccs docker up
@@ -45,7 +79,22 @@ The `ccs docker` flow uses the integrated assets in this directory:
- `docker/supervisord.conf`
- `docker/entrypoint-integrated.sh`
### Post-Deployment: Enable Dashboard Auth (Required for Remote Access)
### Network Binding and Dashboard Auth
The integrated Docker stack publishes the dashboard and CLIProxy ports on `127.0.0.1` by default. This keeps the services reachable from the Docker host and SSH tunnels without exposing them on every host interface.
For remote hosts, prefer an SSH tunnel:
```bash
ssh -L 3000:localhost:3000 my-server
# Then open http://localhost:3000 in browser
```
Only bind publicly when you have enabled dashboard authentication and have intentionally placed the host behind trusted network controls:
```bash
CCS_DOCKER_BIND_HOST=0.0.0.0 ccs docker up --host my-server
```
When accessing the dashboard from a different machine (not `localhost`), the API blocks requests with **403 Forbidden** unless authentication is configured. Without auth, the dashboard appears empty (no providers, no version).
@@ -78,13 +127,22 @@ After configuring auth, restart the dashboard:
docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart ccs-dashboard
```
If accessing from `localhost` only (e.g., via SSH tunnel), auth is not required:
### Docker CLIProxy Secrets
On first startup, the integrated container generates per-install CLIProxy API and management secrets when the config is missing custom values. If you have already configured `cliproxy.auth.api_key` or `cliproxy.auth.management_secret`, Docker preserves those custom values.
If you upgraded from an older Docker deployment that used the historical `ccs-internal-managed` API key, CCS keeps that legacy key valid beside the new per-install key for 14 days by default. During the grace period, every `ccs docker up` prints the masked new key and expiry date to stderr. Reveal the full key only with `ccs docker show-key --full`. Override the window with `CCS_DOCKER_LEGACY_KEY_GRACE_DAYS`.
```bash
ssh -L 3000:localhost:3000 my-server
# Then open http://localhost:3000 in browser
ccs docker show-key # masked
ccs docker show-key --full # reveal the current key
ccs docker finalize-key-rotation
```
Run `finalize-key-rotation` after updating clients to remove the legacy key immediately.
If a previous upgrade already replaced the old key before this grace logic was available, run once with `CCS_DOCKER_RESTORE_LEGACY_API_KEY=1` to explicitly restore the temporary legacy-key grace window. CCS does not infer this from random-looking custom keys.
### Post-Deployment: Migrate Existing Auth Tokens
If you have existing CLIProxy OAuth tokens from a previous deployment, copy them into the Docker volume:
@@ -145,28 +203,26 @@ Expected healthy output:
- CLIProxy health: `cliproxy-port: ok, CLIProxy running`
- Client count matches number of auth token files
---
## Prebuilt Image Quick Start
This existing image still runs the CCS dashboard and its locally managed CLIProxy inside one
container. It does not provide the remote staging and in-container self-update flow exposed by
`ccs docker`.
Pull the latest stable release image from GitHub Container Registry:
Pull the recommended minimal image (CCS + CLIProxy, no AI CLIs):
```bash
docker run -d \
--name ccs-dashboard \
--name ccs \
--restart unless-stopped \
-p 3000:3000 \
-p 8317:8317 \
-e CCS_PORT=3000 \
-v ccs_home:/home/node/.ccs \
ghcr.io/kaitranntt/ccs-dashboard:latest
-v ccs_home:/root/.ccs \
ghcr.io/kaitranntt/ccs:latest
```
Release-tag images are also published as `ghcr.io/kaitranntt/ccs-dashboard:<version>`.
Release-tag images are published as `ghcr.io/kaitranntt/ccs:<version>` for reproducible deployments.
## Prebuilt Image Build Locally
### Build Locally
```bash
docker build -f docker/Dockerfile -t ccs-dashboard:latest .
@@ -184,6 +240,187 @@ Open `http://localhost:3000` (Dashboard).
CCS also starts CLIProxy on `http://localhost:8317` (used by Dashboard features and OAuth providers).
---
## Connect Your App to CLIProxy
The CCS container joins a Docker network named `ccs-net`. This network name is a **stable, public contract** — it will not change without a SemVer-major release.
### Network Contract
| Resource | Stable name | Notes |
|---|---|---|
| Network | `ccs-net` | Attach any sibling container to this network |
| Service DNS | `ccs` | Resolves to the CCS container from inside `ccs-net` |
| CLIProxy port | `8317` | OAuth proxy — use as `OPENAI_BASE_URL` / `CLIPROXY_URL` |
| Dashboard port | `3000` | Web UI |
| Env-friendly URL | `http://ccs:8317` | Drop into your app's env without port-mapping on the host |
### Pattern A — Same Compose File
Declare `ccs-net` as external in your own compose file and add your service to it:
```yaml
services:
my-app:
image: my-app:latest
environment:
CLIPROXY_URL: http://ccs:8317
networks:
- ccs-net
networks:
ccs-net:
external: true
```
Start CCS first so the network exists:
```bash
docker compose -f docker/compose.yaml up -d # or: ccs docker up
docker compose -f my-app/compose.yaml up -d
```
### Pattern B — `docker run`
Attach a container at runtime without modifying any compose file:
```bash
docker run --rm \
--network ccs-net \
-e CLIPROXY_URL=http://ccs:8317 \
my-app:latest
```
### Troubleshooting Network Issues
**Service not resolvable from sibling container**
Verify both containers are on `ccs-net`:
```bash
docker network inspect ccs-net
```
The output should list both `ccs` and your app container under `Containers`.
**Network not found**
The `ccs-net` network is created when the CCS stack starts. Run:
```bash
docker compose -f docker/compose.yaml up -d
# or: ccs docker up
```
**Conflict with an existing `ccs-net`**
If you already have a network named `ccs-net` from unrelated tooling, either rename yours or scope
the CCS project via `COMPOSE_PROJECT_NAME`:
```bash
COMPOSE_PROJECT_NAME=myproject docker compose -f docker/compose.yaml up -d
# Network becomes: myproject_ccs-net
```
Note: scoping changes the network name, so sibling compose files must use the same project name.
**Podman / rootless containers**
On rootless Podman, network names and DNS resolution may behave differently. Verify your Podman
version supports `--network` with named networks (`podman network ls`) and that `aardvark-dns` or
equivalent is installed for container-name resolution.
**Low MTU on Hetzner and other cloud providers**
Some cloud environments set a low MTU (e.g., 1450) on their overlay networks. If you see packet
fragmentation or stalled requests, add a custom MTU to the network in `compose.yaml`:
```yaml
networks:
ccs-net:
name: ccs-net
driver_opts:
com.docker.network.driver.mtu: "1450"
```
---
## Migration from `ccs-dashboard:latest`
`ghcr.io/kaitranntt/ccs-dashboard:latest` is deprecated and will stop publishing after 2 more
releases. Migrate to `ghcr.io/kaitranntt/ccs:latest` now.
### Steps
1. **Stop the old stack.**
```bash
docker compose down
# or if running via docker run:
docker stop ccs-dashboard && docker rm ccs-dashboard
```
2. **Preserve your data.**
Existing `~/.ccs` data on the host is not affected by the container change. If you were using
a named volume (`ccs_home`), it persists automatically. If you were bind-mounting your host
`~/.ccs`, continue doing so — just update the compose file path below.
3. **Get the new compose file.**
```bash
curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o docker-compose.yaml
```
Or download manually from:
`https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml`
4. **If you were bind-mounting `~/.ccs`** (instead of using a named volume), edit the downloaded
`docker-compose.yaml` and replace the `ccs_home` named volume with your bind mount:
```yaml
volumes:
- ~/.ccs:/root/.ccs
```
Otherwise the default named volume (`ccs_home`) works out of the box. Let compose create it
automatically, or create it manually first:
```bash
docker volume create ccs_home
```
5. **Start the new stack.**
```bash
docker compose up -d
```
Dashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
> **Warning:** Use `docker compose down` (without `-v`) to stop the stack.
> `docker compose down -v` deletes named volumes including `ccs_home`, which
> permanently removes your CCS configuration and auth tokens. Always omit
> `-v` unless you intentionally want a clean wipe.
6. **Verify.**
```bash
curl -fsS http://localhost:8317/
```
### What changes
| Old | New |
|---|---|
| `ghcr.io/kaitranntt/ccs-dashboard:latest` | `ghcr.io/kaitranntt/ccs:latest` |
| > 600 MB image | < 350 MB image |
| Monolithic all-in-one | CCS + CLIProxy (AI CLIs via sibling containers on `ccs-net`) |
| No stable network contract | `ccs-net` network, `ccs` service DNS |
---
## Environment Variables
Common CCS environment variables (from the docs):
@@ -227,23 +464,6 @@ docker start ccs-dashboard
docker rm -f ccs-dashboard
```
## Prebuilt Image Docker Compose (Optional)
Using the included `docker/docker-compose.yml`:
```bash
docker-compose -f docker/docker-compose.yml up --build -d
docker-compose -f docker/docker-compose.yml logs -f
```
Stop:
```bash
docker-compose -f docker/docker-compose.yml down
```
For the integrated CCS + CLIProxy stack managed by the CLI, use `ccs docker up` instead.
## Persistence
- CCS stores data in `/home/node/.ccs` inside the container.
@@ -302,10 +522,12 @@ lsof -i :3000
lsof -i :8317
# Use different ports
docker run -p 4000:3000 -p 9317:8317 ...
docker run -p 127.0.0.1:4000:3000 -p 127.0.0.1:9317:8317 ...
# Or with compose
CCS_DASHBOARD_PORT=4000 CCS_CLIPROXY_PORT=9317 docker-compose up -d
# Public bind is opt-in:
CCS_DOCKER_BIND_HOST=0.0.0.0 docker-compose up -d
```
### Container Keeps Restarting
@@ -389,3 +611,22 @@ docker exec -it ccs-dashboard gemini --help
- **Secrets**: For sensitive values like `CCS_PROXY_AUTH_TOKEN`, consider using Docker secrets or a `.env` file (not committed to git).
- **Network**: The container exposes ports 3000 and 8317. In production, use a reverse proxy (nginx, traefik) with TLS.
- **Updates**: Regularly rebuild the image to get security patches: `docker-compose build --pull`
### Image Signatures and SBOM
All `ghcr.io/kaitranntt/ccs` images are signed with [cosign](https://docs.sigstore.dev/cosign/overview/) using keyless OIDC signing tied to the GitHub Actions workflow identity. A software bill of materials (SBOM) is attached to every image at publish time.
**Verify a specific image digest:**
```bash
cosign verify \
--certificate-identity-regexp "https://github.com/kaitranntt/ccs/.github/workflows/docker-release.yml" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/kaitranntt/ccs:<version>
```
**Inspect the SBOM:**
```bash
cosign download sbom ghcr.io/kaitranntt/ccs:<version>
```
+68
View File
@@ -0,0 +1,68 @@
# Canonical CCS docker-compose. Served at https://ccs.kaitran.ca/docker-compose.yaml.
# Source of truth: kaitranntt/ccs:docker/compose.yaml
#
# Quick start:
# curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o compose.yaml
# docker compose up -d
#
# Ports:
# 3000 — CCS Dashboard
# 8317 — CLIProxy API
#
# restart: unless-stopped ensures the container restarts automatically on
# system reboot or crash, but honours a deliberate `docker compose stop`.
services:
ccs:
# image uses Docker Compose env-var-with-default syntax so CI smoke tests can
# override via CCS_IMAGE=ghcr.io/.../ccs:<rc-tag> docker compose up -d while
# end users get the documented :latest by default. The Cloudflare Worker that
# serves this file at ccs.kaitran.ca validates that the default part starts
# with the canonical ghcr.io/kaitranntt/ccs prefix.
image: ${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest}
restart: unless-stopped
environment:
CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}"
CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}"
ports:
- "3000:3000"
- "8317:8317"
volumes:
# /root/.ccs matches the HOME used inside the integrated image.
# entrypoint-integrated.sh runs as root (supervisord user=root) and
# explicitly mkdir -p /root/.ccs, so state always lands here.
# Note: docker/entrypoint.sh (legacy dashboard image) uses a different
# default — it does NOT apply to this integrated image.
- ccs_home:/root/.ccs
- ccs_logs:/var/log/ccs
networks:
- ccs-net
healthcheck:
# Probes both Dashboard (:3000) and CLIProxy (:8317) before reporting
# healthy — a container with only one service running is not considered
# ready. Uses a 4.5s internal timeout to stay well within the 5s Docker
# timeout budget.
test:
- "CMD-SHELL"
- >
node -e "
const http = require('http');
let pending = 2; let ok = true;
const check = (port) => http.get('http://127.0.0.1:'+port+'/', r => {
if (r.statusCode >= 400) ok = false;
if (--pending === 0) process.exit(ok ? 0 : 1);
}).on('error', () => { ok = false; if (--pending === 0) process.exit(1); });
check(3000); check(8317);
setTimeout(() => process.exit(1), 4500);
"
interval: 30s
timeout: 5s
retries: 3
volumes:
ccs_home:
ccs_logs:
networks:
ccs-net:
name: ccs-net
+4 -2
View File
@@ -10,13 +10,15 @@ services:
restart: unless-stopped
init: true
ports:
- "${CCS_DASHBOARD_PORT:-3000}:3000"
- "${CCS_CLIPROXY_PORT:-8317}:8317"
- "${CCS_DOCKER_BIND_HOST:-127.0.0.1}:${CCS_DASHBOARD_PORT:-3000}:3000"
- "${CCS_DOCKER_BIND_HOST:-127.0.0.1}:${CCS_CLIPROXY_PORT:-8317}:8317"
environment:
CCS_PORT: 3000
NODE_ENV: production
NO_COLOR: "${NO_COLOR:-}"
CCS_DEBUG: "${CCS_DEBUG:-}"
CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}"
CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}"
volumes:
- ccs_home:/root/.ccs
- ccs_logs:/var/log/ccs
+2 -2
View File
@@ -9,8 +9,8 @@ services:
restart: unless-stopped
init: true
ports:
- "${CCS_DASHBOARD_PORT:-3000}:3000"
- "${CCS_CLIPROXY_PORT:-8317}:8317"
- "${CCS_DOCKER_BIND_HOST:-127.0.0.1}:${CCS_DASHBOARD_PORT:-3000}:3000"
- "${CCS_DOCKER_BIND_HOST:-127.0.0.1}:${CCS_CLIPROXY_PORT:-8317}:8317"
environment:
CCS_PORT: 3000
CCS_DEBUG: "${CCS_DEBUG:-}"
+2
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[WARN] ghcr.io/kaitranntt/ccs-dashboard is deprecated. Migrate to ghcr.io/kaitranntt/ccs:latest. See https://github.com/kaitranntt/ccs/issues/1251" >&2
ccs_home_dir="${CCS_HOME_DIR:-/home/node/.ccs}"
mkdir -p "$ccs_home_dir"
+8
View File
@@ -127,12 +127,18 @@ CCS still supports environment-variable overrides for backward compatibility.
| `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir |
| `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory |
| `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override |
| `CCS_BROWSER_INTERCEPT_FULFILL_MODE=enabled` | Dangerous local-testing opt-in for Browser MCP response fulfillment; disabled by default |
| `CCS_BROWSER_UPLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for local files that browser upload tools may read |
| `CCS_BROWSER_DOWNLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for caller-provided browser download directories |
If an override is active, Browser status surfaces should report that the current session is being
managed externally by environment variables.
Browser MCP request interception can continue or fail matched requests by default. Synthetic response
fulfillment (`action: fulfill`) is more sensitive because it can serve caller-supplied response
content inside the attached browser's target origin. CCS therefore hides and blocks fulfillment unless
`CCS_BROWSER_INTERCEPT_FULFILL_MODE=enabled` is set for a trusted local test session.
The saved browser policy still controls default exposure. Env overrides change the effective attach
path/port for the current shell; they do not bypass `policy: manual`.
@@ -263,3 +269,5 @@ If CCS reports `unsupported_build`, upgrade Codex and rerun `ccs browser status`
- Prefer a dedicated automation user-data dir instead of your everyday browser profile
- Do not commit browser paths, secrets, or generated session state to version control
- Treat `~/.ccs/config.yaml`, `~/.claude.json`, and the browser user-data directory as local machine state
- `browser_wait_for_event` requires explicit scoping for network request events (`urlIncludes`) and download events (`urlIncludes` or `suggestedFilenameIncludes`)
- Event details redact observed navigation, request, and download URLs before returning them to the MCP caller so observed browser metadata does not expose query strings, fragments, or path-scoped bearer values
+3 -2
View File
@@ -1,6 +1,6 @@
# CCS Codebase Summary
Last Updated: 2026-04-26
Last Updated: 2026-05-18
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, native Codex runtime target support, native Codex/Droid usage collectors, and models.dev-backed model pricing metadata.
@@ -273,7 +273,8 @@ src/
### Native Codex Runtime Target
- Dedicated runtime entrypoints: `ccs-codex` and `ccsx` resolve through `src/bin/codex-runtime.ts`, while `ccsxp` resolves through `src/bin/ccsxp-runtime.ts`; all three set `CCS_INTERNAL_ENTRY_TARGET=codex` before delegating to `src/targets/target-resolver.ts`.
- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. On launch, CCS repairs the native `[model_providers.cliproxy]` stanza in `config.toml`, reads that provider's configured `env_key` (default `CLIPROXY_API_KEY`), and injects the effective CLIProxy auth token into that key for the child Codex process.
- Native Codex passthrough: `ccsx --help`, `ccsx --version`, and known upstream Codex subcommands such as `ccsx exec ...`, `ccsx apply ...`, `ccsx mcp ...`, `ccsx plugin ...`, `ccsx completion ...`, and `ccsx resume ...` short-circuit before CCS profile detection; upstream aliases such as `ccsx e ...` and `ccsx a ...` are included. CCS-owned `ccsx auth`, `ccsx doctor`, and `ccsx update` remain reserved for CCS.
- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. On launch, CCS repairs the native `[model_providers.cliproxy]` stanza in `config.toml`, preserves a valid custom `base_url`, reads that provider's configured `env_key` (default `CLIPROXY_API_KEY`), and injects the effective CLIProxy auth token into that key for the child Codex process.
- Implicit Codex launches such as `ccs --target codex` and `ccsxp` use native Codex default mode even when the CCS default profile is a Claude account. Explicit unsupported profiles such as `ccs work --target codex` still fail fast with native-vs-pool guidance.
- `argv[0]` alias mapping still exists in `src/targets/target-resolver.ts` for same-binary/custom alias scenarios, but the built-in npm bins above do not depend on that map at runtime.
- Metadata boundary: `src/targets/target-metadata.ts` keeps Codex runtime-only in v1, so persisted default targets remain `claude | droid`.
+206
View File
@@ -0,0 +1,206 @@
# Codex Auth Profile Isolation (`ccsx auth`)
Run two Codex accounts simultaneously — one per terminal — with full auth isolation.
## Why
Codex stores its OAuth credentials in a single directory (`~/.codex/`). When you run two
`codex` sessions in separate terminals, they both write to the same `auth.json`. A token
refresh in one session overwrites the other's credentials.
`ccsx auth` solves this by giving each account its own profile directory under
`~/.ccs/codex-instances/<name>/`. Each profile holds its own `auth.json` and
`history.jsonl`. Shared `config.toml`, `agents/`, and `skills/` resources are linked
via symlink so model/provider settings and relative agent role config files stay in sync.
## Quick start (4 commands)
```bash
# Create and authenticate two profiles
ccsx auth create work # creates ~/.ccs/codex-instances/work/ and prompts for login
ccsx auth create personal # same for personal account
# Activate per terminal (ephemeral — only this shell)
# Terminal A:
eval "$(ccsx auth use work)"
codex
# Terminal B:
eval "$(ccsx auth use personal)"
codex
# Or launch a named profile directly through ccsx
ccsx work
```
## Two-terminal example
```bash
# Terminal A — work account
eval "$(ccsx auth use work)"
codex # runs with CODEX_HOME=~/.ccs/codex-instances/work
# Terminal B — personal account (simultaneously)
eval "$(ccsx auth use personal)"
codex # runs with CODEX_HOME=~/.ccs/codex-instances/personal
# No token clobbering. Each session refreshes its own auth.json only.
```
## Command reference
| Command | Description |
|---------|-------------|
| `ccsx auth create <name>` | Create profile dir + auto-login |
| `ccsx <name>` | Launch a named Codex auth profile |
| `ccsx auth login <name>` | (Re-)authenticate an existing profile |
| `ccsx auth switch <name>` | Set the persistent default profile for future `ccsx` launches |
| `ccsx auth use <name>` | Emit shell exports for this shell only (use with `eval`) |
| `ccsx auth show [name]` | List all profiles or show details for one |
| `ccsx auth remove <name>` | Delete profile dir + registry entry |
| `ccsx auth import-default <name>` | Migrate legacy `~/.codex/auth.json` into a new profile |
## Persistent vs ephemeral switching
| Method | Scope | How |
|--------|-------|-----|
| `ccsx <name>` | One launch | Resolves `<name>` from the Codex profile registry |
| `ccsx auth switch <name>` | Future `ccsx` launches | Writes to `~/.ccs/codex-profiles.yaml` |
| `eval "$(ccsx auth use <name>)"` | Current shell only | Sets `CODEX_HOME` + `CCS_CODEX_PROFILE` in your shell |
Native `codex` shells only see the persistent default when launched through the `ccsx`
Codex runtime. For an already-open shell or a plain native `codex` binary, use `auth use`.
Do not use `ccs persist codex` for Claude Code or the Claude Code Extension. That path
would persist Claude settings that send Claude traffic through the Codex translator. CCS
blocks Codex CLIProxy profiles from Claude extension setup; use `ccsxp` or
`ccs codex --target codex` for ChatGPT/Codex subscriptions. If old settings were already
persisted, clear them with:
```bash
ccs persist default --yes
```
The command prints a config receipt after writing settings: cleared managed keys,
written managed keys, whether and where any `/api/provider/codex` translator URL
remains, and the native Codex targets to use next.
Shell syntax for `use`:
```bash
# bash / zsh
eval "$(ccsx auth use work)"
# fish
ccsx auth use work | source
# PowerShell
ccsx auth use work | Invoke-Expression
```
## Migration from `~/.codex`
If you already have a logged-in session in `~/.codex/auth.json`, import it without
disturbing the original:
```bash
# Auth only (default — recommended)
ccsx auth import-default legacy
# Auth + history + sessions (opt-in)
ccsx auth import-default legacy --with-history
# Make it the default
ccsx auth switch legacy
```
The source `~/.codex/` directory is **never modified**. If `import-default` is not run,
`codex` continues to work exactly as before.
### Torn-write safety
Codex writes `auth.json` with truncate+write (not atomic rename). Running
`import-default` while a token refresh is in flight can produce a corrupt copy.
The command detects a running `codex` process via `pgrep` and refuses unless you
pass `--force-while-running`. The safest approach is to quit Codex before
importing.
## Dashboard
The CCS dashboard shows active profile metadata at the **Auth Profiles** tab on the
Codex page:
- Profile name and whether it is the current default
- Decoded email address (from `id_token` — no signature verification; display only)
- Plan tier (Plus, Pro, Free) when present in the token
- Last-used timestamp
No OAuth tokens are ever returned by the API endpoint or shown in the UI.
## Profile disk layout
```
~/.ccs/
├── codex-profiles.yaml # Registry: version, default, profiles metadata
└── codex-instances/
└── <name>/
├── auth.json # OAuth credentials (Codex writes here)
├── history.jsonl # Per-profile prompt history (optional)
├── sessions/ # Per-profile chat session dirs (optional)
├── config.toml -> ~/.codex/config.toml (symlink — shared)
├── agents/ -> ~/.codex/agents/ (symlink — shared)
└── skills/ -> ~/.codex/skills/ (symlink — shared)
~/.codex/
├── config.toml # Single shared model/provider config
├── agents/ # Shared Codex agent role config files
└── skills/ # Shared Codex skills
```
`ccsx auth create <name>` and `ccsx <name>` both repair these links idempotently.
This keeps relative Codex config entries such as `agents/foo.toml` valid inside
each isolated `CODEX_HOME`.
## Caveats
### Windows symlinks
On Windows, creating symlinks requires Developer Mode or elevated privileges.
If symlink creation fails, CCS falls back to copying `config.toml`, `agents/`,
and `skills/`. In this case, changes to `~/.codex/` resources are **not**
automatically reflected in the profile; re-run `ccsx auth create <name> --force`
to refresh the copy.
### Native Codex project-local config warnings
`ccsx` preserves your current working directory. If you launch from your home directory,
native Codex can also see `~/.codex/config.toml` as `./.codex/config.toml`, a
project-local config file. Codex rejects user-level-only keys such as `model_providers`
and `notify` in project-local config. That warning comes from native Codex config
layering, not from the `ccsx auth` profile resource links. Launch from a project
directory or move project-local Codex config out of `$HOME/.codex/config.toml` if the
warning is noisy.
### `ccsx` vs `ccsxp`
`ccsx auth` profiles apply only to the **native `codex`** CLI. They have no effect on
`ccsxp` (the CLIProxy round-robin pool). `ccsxp` unconditionally sets its own
`CODEX_HOME` on startup and ignores `CCS_CODEX_PROFILE`.
If you run `eval "$(ccsx auth use work)"` and then invoke `ccsxp`, a notice is emitted
to stderr:
```
[i] CCS_CODEX_PROFILE is ignored by ccsxp; profile applies to native 'codex' only
```
### cmd.exe
`ccsx auth use` emits `set FOO=bar` syntax for cmd.exe. Native `eval` is not available
in legacy cmd — use PowerShell (`Invoke-Expression`) instead.
### Backup files from `--force`
When re-importing with `--force`, the existing `auth.json` is backed up as
`auth.json.bak-<timestamp>` in the profile directory. These accumulate over time; remove
them manually when no longer needed.
+14 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap
Last Updated: 2026-05-09
Last Updated: 2026-05-22
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,6 +41,19 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-05-22**: **#1337** CLIProxy quota preflight now reconciles the entire
active provider account pool before local proxy startup. Known-exhausted
non-default accounts move out of live `auth/` rotation when another healthy
account exists, preventing round-robin requests from stalling on already
exhausted accounts while preserving manual pause/resume and cooldown
auto-resume behavior.
- **2026-05-22**: **#1332** `ccsx auth` profiles now link native Codex
`agents/` and `skills/` resources alongside `config.toml`, and `ccsx <profile>`
repairs missing resource links at launch so relative agent role `config_file`
entries do not warn on existing profiles.
- **2026-05-20**: **#1285** Codex model picker aliases now include `minimal` and `low` reasoning effort suffixes alongside `medium`, `high`, and `xhigh`, and the dashboard separates base recommendations, reasoning variants, and fast variants so low-effort selections such as `gpt-5.5-low` are visible.
- **2026-05-19**: **#1252** Claude extension persistence now rejects Codex CLIProxy profiles instead of writing `ANTHROPIC_BASE_URL=/api/provider/codex` into Claude Code settings. Native Codex subscription use stays on `ccsxp` or `ccs codex --target codex`, and `ccs doctor` warns when stale Claude settings still point at the Codex translator.
- **2026-05-18**: **#1287** `ccsx resume` now passes through to the native Codex `resume` subcommand before CCS profile detection, preserving Codex session continuation while keeping `ccsx auth` on the managed Codex profile namespace.
- **2026-05-09**: **#1199** Existing Claude auth accounts now have dashboard-visible Shared Resources controls separate from History Sync. Accounts exposes a dedicated Resources action for `shared` vs `profile-local`, `/shared` now inventories commands, skills, agents, plugins, and `settings.json`, plugin directories without docs show factual directory contents, and shared settings content is inspectable read-only through the localhost-gated shared-content API.
- **2026-05-07**: **#760** Codex GPT fast mode is now a first-class CLIProxy model tuning suffix. CCS accepts `gpt-5.4-fast`, `gpt-5.4-high-fast`, and equivalent canonicalized forms in raw env configs, CLI variant creation, and the dashboard model picker; runtime requests now send the base upstream model with `reasoning.effort` plus `service_tier: "priority"` instead of leaking the suffixed alias to CLIProxy upstream routing.
- **2026-05-07**: **#1103** GitHub Copilot is now treated as a deprecated compatibility bridge. The dashboard moves Copilot out of the active Identity & Access section and into Deprecated, quick setup no longer offers `ghcp` for new onboarding, CLI/help/config copy marks Copilot as deprecated, and existing `ccs copilot` / `ghcp` compatibility paths remain available for current setups.
+15
View File
@@ -0,0 +1,15 @@
<!-- quickstart-snippet-start -->
## Quick Start (Docker)
With Docker installed:
```bash
curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o docker-compose.yaml
docker compose up -d
```
Dashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
Need a corporate-proxy alternative? Download directly:
`https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml`
<!-- quickstart-snippet-end -->
+106
View File
@@ -0,0 +1,106 @@
# CCS Release Process
CCS uses a decoupled release model: every merge to `main` immediately publishes
a stable npm `@latest` release and an immutable Docker `:<ver>` tag. Docker
mutable tags (`:latest`, `:<MAJOR>`, `:<MINOR>`) require a separate manual
promote step after an operator-verified soak window. This decouples the npm
ecosystem from the Docker stability gate.
## Phase 1 — Automatic stable release (on every merge to `main`)
1. A PR is merged into `main` with a conventional commit (`feat:`, `fix:`, etc.).
2. `release.yml` triggers semantic-release, which reads `.releaserc.cjs`.
3. Because `main` is a stable channel, semantic-release cuts a GitHub release
tagged `vX.Y.Z` and publishes the npm package to the `@latest` dist-tag
immediately. No rc channel, no soak delay on npm.
4. `docker-release.yml` triggers on the `release: published` event and:
- Validates the tag as stable semver (`vX.Y.Z`).
- Builds the integrated image for `linux/amd64` and `linux/arm64`.
- Pushes **only the immutable** `ghcr.io/kaitranntt/ccs:X.Y.Z` tag.
- Signs the image with cosign (keyless OIDC).
- Runs smoke tests (`smoke-test` job).
- Mutable tags (`:latest`, `:<MAJOR>`, `:<MINOR>`) are **not** added at
this stage — `promote-mutable-tags` only runs on explicit
`workflow_dispatch` with `promote_to_latest=true`.
## Phase 2 — Manual promotion to Docker mutable tags (rc.1 soak window)
After the immutable `:<ver>` Docker image has soaked (typically 24 h with no
reported issues), the operator promotes mutable tags:
1. Verify the immutable image is healthy:
```bash
docker pull ghcr.io/kaitranntt/ccs:X.Y.Z
docker run --rm -p 3000:3000 -p 8317:8317 ghcr.io/kaitranntt/ccs:X.Y.Z
# check http://localhost:3000 and http://localhost:8317
```
2. Optionally verify the cosign signature:
```bash
cosign verify \
--certificate-identity-regexp "https://github.com/kaitranntt/ccs/.github/workflows/docker-release.yml" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/kaitranntt/ccs:X.Y.Z
```
3. Run the `promote-release` workflow via GitHub Actions UI or CLI:
```bash
gh workflow run promote-release.yml \
--field tag=vX.Y.Z
```
This dispatches `docker-release.yml` with `promote_to_latest=true`, which
triggers the `promote-mutable-tags` job to add `:latest`, `:<MAJOR>`, and
`:<MINOR>` via `docker buildx imagetools create`.
Alternatively, dispatch `docker-release.yml` directly:
```bash
gh workflow run "Publish Docker Image" \
--field tag=vX.Y.Z \
--field promote_to_latest=true
```
## Why npm and Docker have different soak windows
- **npm `@latest`**: Published immediately on every `main` merge. npm users who
pin a version are unaffected; users who run `npm install -g @kaitranntt/ccs`
get the latest immediately. Rollback is `npm install -g @kaitranntt/ccs@X.Y.Z`.
- **Docker `:latest`**: Promoted only after operator confirmation. Users who
pull `:latest` or run `docker pull` without a pinned tag are shielded from
a bad image. The immutable `:<ver>` tag is always available for pinned usage
from the moment of release.
## Verifying the promotion
```bash
# Confirm :latest points to the promoted digest
docker buildx imagetools inspect ghcr.io/kaitranntt/ccs:latest
# Confirm npm @latest updated (happens automatically at Phase 1)
npm view @kaitranntt/ccs dist-tags
```
## Rollback
If a promoted release is found to be bad:
```bash
# Repoint :latest to the previous known-good immutable tag
docker buildx imagetools create \
--tag ghcr.io/kaitranntt/ccs:latest \
ghcr.io/kaitranntt/ccs:PREVIOUS.VERSION
# For npm, publish a fix as a new patch release (do not unpublish)
# Unpublishing npm packages causes downstream breakage for pinned consumers.
```
## Branch / tag taxonomy
| Branch | Semantic-release channel | npm dist-tag | Docker tag (on release event) | Docker mutable (on promote) |
|--------|--------------------------|--------------|-------------------------------|------------------------------|
| `main` | stable | `@latest` | `:<ver>` (immutable, immediate) | `:latest`, `:<MAJOR>`, `:<MINOR>` (after soak) |
| `dev` | `dev` prerelease | `@dev` | not published | not published |
+3 -1
View File
@@ -269,7 +269,7 @@ async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise<bool
### Overview
Hybrid quota management enables automatic detection of exhausted accounts and failover to next available account.
When CCS detects exhaustion and a healthy fallback exists, it temporarily pauses the exhausted account out of CLIProxy rotation and automatically resumes that pause after the configured cooldown expires. This durable self-pause uses the same account registry and token movement path as dashboard/manual pause, so the dashboard shows the account as paused and CLIProxy cannot rediscover its token from the live `auth/` folder.
Before local CLIProxy startup, CCS reconciles the whole active account pool for the provider, not only the default account. When CCS detects any quota-exhausted account and a healthy fallback exists, it temporarily pauses the exhausted account out of CLIProxy rotation and automatically resumes that pause after the configured cooldown expires. This durable self-pause uses the same account registry and token movement path as dashboard/manual pause, so the dashboard shows the account as paused and CLIProxy cannot rediscover its token from the live `auth/` folder.
```
+===========================================================================+
@@ -305,6 +305,8 @@ When CCS detects exhaustion and a healthy fallback exists, it temporarily pauses
| |
| +---> Check exhaustion status
|
+---> Pause exhausted non-default accounts when another healthy account exists
|
+---> Select best account (not paused, not exhausted)
|
+---> Auto-failover to next account if current exhausted
+4 -1
View File
@@ -1,6 +1,6 @@
# Target Adapters
Last Updated: 2026-03-28
Last Updated: 2026-05-18
Detailed documentation of the target adapter pattern and implementations.
@@ -515,6 +515,8 @@ ccs-codex
ccsx
→ dist/bin/codex-runtime.js
→ CCS_INTERNAL_ENTRY_TARGET=codex
→ passes native Codex diagnostics plus known upstream Codex subcommands and aliases through before CCS profile detection
→ reserves CCS-owned `auth`, `doctor`, and `update`
ccsxp
→ dist/bin/ccsxp-runtime.js
@@ -522,6 +524,7 @@ ccsxp
→ injects native `model_provider="cliproxy"` override
→ pins CODEX_HOME to native `~/.codex` unless `CCSXP_CODEX_HOME` is set
→ repairs `[model_providers.cliproxy]` in the active Codex `config.toml`
→ preserves valid custom `base_url` values for remote or non-default CLIProxy endpoints
→ injects the effective CCS CLIProxy auth token into the provider's configured `env_key`
→ ignores the configured CCS default account/profile and stays in native Codex default mode
```
+12 -3
View File
@@ -874,7 +874,10 @@ function runGeminiCommand(args, timeoutMs) {
timeout: timeoutMs,
maxBuffer: 1024 * 1024 * 2,
stdio: ['pipe', 'pipe', 'pipe'],
shell: isWindows,
// Never route query-derived prompts through a shell. Node concatenates
// arguments for shell-backed Windows spawns, which lets shell metacharacters
// in WebSearch queries escape the intended CLI invocation.
shell: false,
});
if (result.error) {
@@ -929,7 +932,10 @@ function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
timeout: timeoutSec * 1000,
maxBuffer: 1024 * 1024 * 2,
stdio: ['pipe', 'pipe', 'pipe'],
shell: isWindows,
// Never route query-derived prompts through a shell. Node concatenates
// arguments for shell-backed Windows spawns, which lets shell metacharacters
// in WebSearch queries escape the intended CLI invocation.
shell: false,
}
);
@@ -965,7 +971,10 @@ function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
timeout: timeoutSec * 1000,
maxBuffer: 1024 * 1024 * 2,
stdio: ['pipe', 'pipe', 'pipe'],
shell: isWindows,
// Never route query-derived prompts through a shell. Node concatenates
// arguments for shell-backed Windows spawns, which lets shell metacharacters
// in WebSearch queries escape the intended CLI invocation.
shell: false,
});
if (result.error) {
+217 -12
View File
@@ -83,6 +83,16 @@ const TOOL_HOVER = 'browser_hover';
const TOOL_QUERY = 'browser_query';
const TOOL_TAKE_ELEMENT_SCREENSHOT = 'browser_take_element_screenshot';
const TOOL_WAIT_FOR_EVENT = 'browser_wait_for_event';
const SENSITIVE_INTERCEPT_HEADER_NAMES = new Set([
'authorization',
'cookie',
'cookie2',
'proxy-authorization',
'x-api-key',
'x-api-token',
'x-auth-token',
]);
const TOOL_NAMES = [
TOOL_SESSION_INFO,
TOOL_URL_TITLE,
@@ -612,10 +622,15 @@ function getTools() {
urlRegex: { type: 'string' },
headerMatchers: {
type: 'array',
description:
'Match non-sensitive request headers. Cookie, Authorization, and token headers are not allowed.',
items: {
type: 'object',
properties: {
name: { type: 'string' },
name: {
type: 'string',
description: 'Non-sensitive request header name to match.',
},
valueIncludes: { type: 'string' },
valueRegex: { type: 'string' },
},
@@ -624,7 +639,7 @@ function getTools() {
},
},
priority: { type: 'integer' },
action: { type: 'string', enum: ['continue', 'fail', 'fulfill'] },
action: { type: 'string', enum: getInterceptActionEnum() },
statusCode: { type: 'integer', minimum: 100, maximum: 599 },
responseHeaders: {
type: 'array',
@@ -1194,7 +1209,18 @@ function getTools() {
},
event: {
type: 'object',
description: 'Required event selector.',
description:
'Required event selector. request events require urlIncludes; download events require urlIncludes or suggestedFilenameIncludes. URLs in returned details are redacted.',
properties: {
kind: { type: 'string', enum: ['dialog', 'navigation', 'request', 'download'] },
dialogType: { type: 'string' },
messageIncludes: { type: 'string' },
urlIncludes: { type: 'string' },
method: { type: 'string' },
suggestedFilenameIncludes: { type: 'string' },
},
required: ['kind'],
additionalProperties: false,
},
},
required: ['event'],
@@ -1277,9 +1303,30 @@ function parseOptionalPageId(toolArgs) {
: '';
}
function getBrowserInterceptFulfillMode() {
return String(process.env.CCS_BROWSER_INTERCEPT_FULFILL_MODE || 'disabled').trim() === 'enabled'
? 'enabled'
: 'disabled';
}
function isBrowserInterceptFulfillEnabled() {
return getBrowserInterceptFulfillMode() === 'enabled';
}
function getInterceptActionEnum() {
return isBrowserInterceptFulfillEnabled()
? ['continue', 'fail', 'fulfill']
: ['continue', 'fail'];
}
function parseInterceptAction(value) {
if (value === 'fulfill' && !isBrowserInterceptFulfillEnabled()) {
throw new Error(
'action fulfill is disabled by CCS_BROWSER_INTERCEPT_FULFILL_MODE=disabled; set it to enabled only for trusted local testing'
);
}
if (value !== 'continue' && value !== 'fail' && value !== 'fulfill') {
throw new Error('action must be one of: continue, fail, fulfill');
throw new Error(`action must be one of: ${getInterceptActionEnum().join(', ')}`);
}
return value;
}
@@ -1379,6 +1426,10 @@ function parseOptionalPriority(value) {
return value;
}
function isSensitiveInterceptHeaderName(name) {
return SENSITIVE_INTERCEPT_HEADER_NAMES.has(name.toLowerCase());
}
function parseOptionalHeaderMatchers(value) {
if (value === undefined) {
return [];
@@ -1391,6 +1442,9 @@ function parseOptionalHeaderMatchers(value) {
throw new Error('headerMatchers entries must be objects');
}
const name = requireNonEmptyString(entry.name, 'headerMatchers.name');
if (isSensitiveInterceptHeaderName(name)) {
throw new Error(`headerMatchers.name cannot target sensitive request header: ${name}`);
}
const valueIncludes =
entry.valueIncludes === undefined
? ''
@@ -3305,24 +3359,80 @@ function parseEventCondition(value) {
};
}
if (value.kind === 'request') {
const urlIncludes = value.urlIncludes ? String(value.urlIncludes) : undefined;
if (!urlIncludes) {
throw new Error('request events require urlIncludes to limit network metadata exposure');
}
return {
kind: 'request',
urlIncludes: value.urlIncludes ? String(value.urlIncludes) : undefined,
urlIncludes,
method: value.method ? String(value.method) : undefined,
};
}
if (value.kind === 'download') {
const urlIncludes = value.urlIncludes ? String(value.urlIncludes) : undefined;
const suggestedFilenameIncludes = value.suggestedFilenameIncludes
? String(value.suggestedFilenameIncludes)
: undefined;
if (!urlIncludes && !suggestedFilenameIncludes) {
throw new Error(
'download events require urlIncludes or suggestedFilenameIncludes to limit metadata exposure'
);
}
return {
kind: 'download',
urlIncludes: value.urlIncludes ? String(value.urlIncludes) : undefined,
suggestedFilenameIncludes: value.suggestedFilenameIncludes
? String(value.suggestedFilenameIncludes)
: undefined,
urlIncludes,
suggestedFilenameIncludes,
};
}
throw new Error(`unknown event kind: ${String(value.kind || '')}`);
}
function redactUrlForModel(value, options = {}) {
const rawUrl = String(value || '');
if (!rawUrl) {
return '';
}
try {
const url = new URL(rawUrl);
if (options.originOnly) {
return url.origin;
}
url.username = '';
url.password = '';
url.search = '';
url.hash = '';
return url.toString();
} catch {
return '[redacted-url]';
}
}
function redactObservedEvent(event, observed) {
if (!observed || typeof observed !== 'object') {
return observed;
}
if (event.kind === 'request') {
return {
url: redactUrlForModel(observed.url, { originOnly: true }),
method: observed.method || '',
};
}
if (event.kind === 'download') {
return {
url: redactUrlForModel(observed.url, { originOnly: true }),
suggestedFilename: observed.suggestedFilename || '',
};
}
if (event.kind === 'navigation' && Object.prototype.hasOwnProperty.call(observed, 'url')) {
return {
...observed,
url: redactUrlForModel(observed.url, { originOnly: true }),
};
}
return observed;
}
function matchesObservedEvent(event, observed) {
if (event.kind === 'dialog') {
return (
@@ -4776,7 +4886,8 @@ async function handleWaitForEvent(toolArgs) {
);
const event = parseEventCondition(toolArgs.event);
const observed = await waitForMatchingEvent({ page, pageIndex, timeoutMs, event });
return `pageIndex: ${pageIndex}\nevent: ${event.kind}\nstatus: observed\ndetail: ${JSON.stringify(observed)}`;
const safeObserved = redactObservedEvent(event, observed);
return `pageIndex: ${pageIndex}\nevent: ${event.kind}\nstatus: observed\ndetail: ${JSON.stringify(safeObserved)}`;
}
function findInterceptRuleIndex(ruleId) {
@@ -5381,7 +5492,10 @@ function buildRecordingInstallExpression(recordingPayload) {
const recordingPayload = JSON.parse(${JSON.stringify(JSON.stringify(recordingPayload))});
const existing = globalThis.__CCS_BROWSER_RECORDING_RECORDER__;
if (existing && existing.installed === true) {
return { installed: true };
if (typeof existing.teardown === 'function') {
existing.teardown();
}
delete globalThis.__CCS_BROWSER_RECORDING_RECORDER__;
}
const events = Array.isArray(recordingPayload.events) ? [...recordingPayload.events] : [];
@@ -5413,8 +5527,42 @@ function buildRecordingInstallExpression(recordingPayload) {
timestamp: Date.now(),
});
};
const sensitiveInputTypes = new Set(['password', 'hidden']);
const sensitiveAttributePattern = /(?:pass(?:word|code|phrase)?|pwd|secret|token|api[-_ ]?key|access[-_ ]?key|private[-_ ]?key|credential|otp|one[-_ ]?time[-_ ]?(?:code|password)|verif(?:ication)?[-_ ]?code|security[-_ ]?code|pin|auth(?:orization)?[-_ ]?(?:code|token)|mfa|2fa)/i;
const sensitiveAutocompleteValues = new Set([
'current-password',
'new-password',
'one-time-code',
'cc-number',
'cc-csc',
]);
const isSensitiveTextTarget = (target) => {
if (!target || !(target instanceof Element)) {
return false;
}
if (target instanceof HTMLInputElement) {
const inputType = String(target.type || '').toLowerCase();
if (sensitiveInputTypes.has(inputType)) {
return true;
}
}
const autocompleteTokens = String(target.getAttribute('autocomplete') || '')
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (autocompleteTokens.some((token) => sensitiveAutocompleteValues.has(token))) {
return true;
}
const attributesToInspect = ['id', 'name', 'placeholder', 'aria-label', 'data-testid'];
return attributesToInspect.some((attributeName) =>
sensitiveAttributePattern.test(String(target.getAttribute(attributeName) || ''))
);
};
const onInput = (event) => {
const target = event.target;
if (isSensitiveTextTarget(target)) {
return;
}
let text = '';
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
text = target.value;
@@ -5425,7 +5573,19 @@ function buildRecordingInstallExpression(recordingPayload) {
}
pushEvent({ kind: 'type', selector: getSelector(target), text, timestamp: Date.now() });
};
const isRecordableKey = (event) => {
if (isSensitiveTextTarget(event.target)) {
return false;
}
if (typeof event.key !== 'string' || event.key.length !== 1) {
return true;
}
return event.altKey === true || event.ctrlKey === true || event.metaKey === true;
};
const onKeyDown = (event) => {
if (!isRecordableKey(event)) {
return;
}
const modifiers = [];
if (event.altKey) modifiers.push('Alt');
if (event.ctrlKey) modifiers.push('Control');
@@ -5493,7 +5653,19 @@ async function finalizeRecordingCapture(session) {
};
const response = await sendCdpCommand(page, 'Runtime.evaluate', {
expression: `(() => globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] })()`,
expression: `(() => {
const recorder = globalThis.__CCS_BROWSER_RECORDING_RECORDER__;
if (!recorder) {
return { events: [], warnings: [] };
}
const events = Array.isArray(recorder.events) ? [...recorder.events] : [];
const warnings = Array.isArray(recorder.warnings) ? [...recorder.warnings] : [];
if (typeof recorder.teardown === 'function') {
recorder.teardown();
}
delete globalThis.__CCS_BROWSER_RECORDING_RECORDER__;
return { events, warnings };
})()`,
returnByValue: true,
awaitPromise: true,
});
@@ -5513,6 +5685,28 @@ async function finalizeRecordingCapture(session) {
session.warnings = warnings.map((warning) => String(warning.message || warning));
}
async function teardownRecordingCapture(session) {
if (!session || !session.pageWebSocketDebuggerUrl) {
return;
}
const page = {
id: session.pageId,
webSocketDebuggerUrl: session.pageWebSocketDebuggerUrl,
};
await sendCdpCommand(page, 'Runtime.evaluate', {
expression: `(() => {
const recorder = globalThis.__CCS_BROWSER_RECORDING_RECORDER__;
if (recorder && typeof recorder.teardown === 'function') {
recorder.teardown();
}
delete globalThis.__CCS_BROWSER_RECORDING_RECORDER__;
return { installed: false };
})()`,
returnByValue: true,
awaitPromise: true,
});
}
async function handleStartRecording(toolArgs) {
if (activeRecordingSession) {
throw new Error('recording already active');
@@ -5563,6 +5757,11 @@ async function handleStopRecording() {
} catch (error) {
finalizeError = error instanceof Error ? error : new Error(String(error));
session.warnings.push(`recording capture finalization failed: ${finalizeError.message}`);
try {
await teardownRecordingCapture(session);
} catch {
// Preserve the original finalization failure for the caller.
}
}
session.status = 'stopped';
session.stoppedAt = new Date().toISOString();
@@ -5583,6 +5782,12 @@ async function handleClearRecording() {
if (!latestRecordingSession && !activeRecordingSession) {
throw new Error('no recording available');
}
const session = activeRecordingSession || latestRecordingSession;
try {
await teardownRecordingCapture(session);
} catch {
// Clearing session-local recording state should still succeed if the page is already gone.
}
activeRecordingSession = null;
latestRecordingSession = null;
return 'status: cleared';
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.79.1-dev.2",
"version": "7.79.1-dev.42",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const { execFileSync } = require('node:child_process');
function parseStableVersion(value) {
const match = String(value || '').match(/^v?([0-9]+)\.([0-9]+)\.([0-9]+)$/);
if (!match) {
return null;
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
raw: `v${Number(match[1])}.${Number(match[2])}.${Number(match[3])}`,
};
}
function compareVersions(left, right) {
for (const key of ['major', 'minor', 'patch']) {
if (left[key] !== right[key]) {
return left[key] - right[key];
}
}
return 0;
}
function stableVersionKey(version) {
return `${version.major}.${version.minor}.${version.patch}`;
}
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith('--')) {
throw new Error(`Unexpected argument: ${arg}`);
}
const key = arg.slice(2);
if (key === 'tags-stdin') {
args.tagsStdin = true;
continue;
}
const value = argv[i + 1];
if (!value || value.startsWith('--')) {
throw new Error(`Missing value for --${key}`);
}
args[key] = value;
i += 1;
}
return args;
}
function readStableTagsFromGit() {
const output = execFileSync('git', ['tag', '--list', 'v*'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return output.split(/\r?\n/).filter(Boolean);
}
function parseStableTags(tags) {
const byKey = new Map();
for (const tag of tags) {
const version = parseStableVersion(tag.trim());
if (version) {
byKey.set(stableVersionKey(version), version);
}
}
return Array.from(byKey.values()).sort(compareVersions);
}
function evaluateDashboardSunset({ targetTag, baselineVersion, releaseWindow, stableTags }) {
const target = parseStableVersion(targetTag);
if (!target) {
throw new Error(`Target tag must be stable semver like v1.2.3, got: ${targetTag}`);
}
const baseline = parseStableVersion(baselineVersion);
if (!baseline) {
throw new Error(`DEPRECATION_BASELINE_VERSION must be stable semver, got: ${baselineVersion}`);
}
if (!Number.isInteger(releaseWindow) || releaseWindow < 1) {
throw new Error(`STABLE_RELEASE_WINDOW must be a positive integer, got: ${releaseWindow}`);
}
if (compareVersions(target, baseline) < 0) {
return {
publish: true,
elapsed: 0,
reason: `${target.raw} is before dashboard deprecation baseline ${baseline.raw}`,
};
}
const versions = parseStableTags(stableTags);
const hasBaseline = versions.some((version) => compareVersions(version, baseline) === 0);
if (compareVersions(target, baseline) > 0 && !hasBaseline) {
throw new Error(
`Cannot count dashboard sunset releases: baseline tag ${baseline.raw} is missing from git tags`,
);
}
if (!versions.some((version) => compareVersions(version, target) === 0)) {
versions.push(target);
versions.sort(compareVersions);
}
const elapsed = versions.filter(
(version) => compareVersions(version, baseline) > 0 && compareVersions(version, target) <= 0,
).length;
const publish = elapsed < releaseWindow;
const reason = publish
? `legacy dashboard publish is still inside sunset window (${elapsed}/${releaseWindow} stable releases elapsed since ${baseline.raw})`
: `legacy dashboard sunset reached (${elapsed}/${releaseWindow} stable releases elapsed since ${baseline.raw})`;
return { publish, elapsed, reason };
}
function appendGithubOutput(values) {
if (!process.env.GITHUB_OUTPUT) {
return;
}
const lines = Object.entries(values).map(([key, value]) => `${key}=${String(value)}`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
}
function main() {
const args = parseArgs(process.argv.slice(2));
const targetTag = args.target || process.env.TARGET_TAG;
const baselineVersion = args.baseline || process.env.DEPRECATION_BASELINE_VERSION;
const windowValue = args.window || process.env.STABLE_RELEASE_WINDOW || '2';
const releaseWindow = Number(windowValue);
const stableTags = args.tagsStdin
? fs.readFileSync(0, 'utf8').split(/\r?\n/).filter(Boolean)
: readStableTagsFromGit();
const result = evaluateDashboardSunset({
targetTag,
baselineVersion,
releaseWindow,
stableTags,
});
appendGithubOutput({
publish: result.publish ? 'true' : 'false',
elapsed: result.elapsed,
reason: result.reason,
});
const status = result.publish ? '[OK]' : '[i]';
console.log(`${status} ${result.reason}`);
if (!result.publish) {
console.log('[i] Skipping ghcr.io/kaitranntt/ccs-dashboard publish; use ghcr.io/kaitranntt/ccs.');
}
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(`[X] ${error.message}`);
process.exit(1);
}
}
module.exports = {
evaluateDashboardSunset,
parseStableVersion,
};
+1
View File
@@ -22,6 +22,7 @@ const slowTests = [
'tests/integration/cursor-daemon-lifecycle.test.ts',
'tests/integration/logging-request-context.test.ts',
'tests/integration/proxy/daemon-lifecycle.test.ts',
'tests/integration/web-server/codex-profiles-endpoint.test.ts',
'tests/unit/commands/persist-command-handler.test.ts',
'tests/unit/hooks/browser-mcp-advanced-interactions.test.ts',
'tests/unit/hooks/browser-mcp-downloads-and-files.test.ts',
+10
View File
@@ -68,6 +68,16 @@ function resolveCcsxpCodexHome() {
return path.join(os.homedir(), '.codex');
}
// H5: CCS_CODEX_PROFILE is ignored by ccsxp. The ccsx auth profile system
// (src/codex-auth/) is intentionally NOT consulted here — ccsxp serves the
// cliproxy round-robin pool, not per-user-account profiles. Emit a one-line
// notice so users who set CCS_CODEX_PROFILE in their shell don't get confused
// when ccsxp silently ignores it and overwrites CODEX_HOME below.
if (process.env.CCS_CODEX_PROFILE) {
process.stderr.write(
"[i] CCS_CODEX_PROFILE is ignored by ccsxp; profile applies to native 'codex' only.\n"
);
}
process.env.CODEX_HOME = resolveCcsxpCodexHome();
// ccsxp is the Codex + cliproxy shortcut. Keep the native Codex history root,
+146
View File
@@ -0,0 +1,146 @@
/**
* Codex runtime router — testable logic for src/bin/codex-runtime.ts.
*
* All inter-module deps are resolved via require() at call-time so tests can
* inject stubs via require.cache before calling main().
*
* Routing:
* argv[2] === 'auth' → delegate to runCodexAuth(argv.slice(3)), exit with code
* else → resolve active profile, set CODEX_HOME, load ccs
* CCS manages the process lifecycle; entry MUST NOT
* call process.exit() when main returns -1.
*
* Return value contract:
* ≥ 0 → auth branch: caller should process.exit(code)
* -1 → CCS branch: CCS has taken over the process; caller must NOT exit
*/
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
function isCodexAuthProfileResolutionError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'name' in err &&
(err as { name?: unknown }).name === 'CodexAuthProfileResolutionError'
);
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === 'object' && err !== null && 'message' in err) {
const message = (err as { message?: unknown }).message;
if (typeof message === 'string') return message;
}
return String(err);
}
function maybeSelectPositionalCodexProfile(argv: string[]): void {
const candidate = (argv[2] ?? '').trim();
if (!candidate || candidate.startsWith('-') || candidate === 'auth') {
return;
}
const { getNativeCodexPassthroughArgs } = require('../dispatcher/cli-argument-parser') as {
getNativeCodexPassthroughArgs: (args: string[]) => string[] | null;
};
if (getNativeCodexPassthroughArgs(argv.slice(2)) !== null) {
return;
}
const { CodexProfileRegistry } = require('../codex-auth/codex-profile-registry') as {
CodexProfileRegistry: new () => { hasProfile: (name: string) => boolean };
};
const registry = new CodexProfileRegistry();
if (!registry.hasProfile(candidate)) {
return;
}
process.env.CCS_CODEX_PROFILE = candidate;
argv[2] = 'default';
}
/**
* Main entry-point for the ccsx / codex-runtime binary.
*
* @param argv - process.argv (or test-supplied equivalent)
* @returns ≥0 exit code for auth branch; -1 for CCS branch (no exit needed)
*/
export async function main(argv: string[]): Promise<number> {
const subcommand = argv[2];
// ── auth branch ─────────────────────────────────────────────────────────
if (subcommand === 'auth') {
const { runCodexAuth } = require('../codex-auth/codex-auth-router') as {
runCodexAuth: (args: string[]) => Promise<number>;
};
return runCodexAuth(argv.slice(3));
}
// ── non-auth branch: profile resolution ─────────────────────────────────
try {
maybeSelectPositionalCodexProfile(argv);
// F1: respect explicit CODEX_HOME unless CCS_CODEX_PROFILE asks for a managed profile.
const explicit = (process.env.CODEX_HOME ?? '').trim();
const profileOverride = (process.env.CCS_CODEX_PROFILE ?? '').trim();
if (!explicit || profileOverride) {
const { resolveActiveProfile } = require('../codex-auth/resolve-active-profile') as {
resolveActiveProfile: (
env: NodeJS.ProcessEnv
) => { name: string; dir: string; source: string } | null;
};
const resolved = resolveActiveProfile(process.env);
if (resolved) {
if (explicit && explicit !== resolved.dir) {
process.stderr.write(
`[!] codex-auth: CCS_CODEX_PROFILE=${profileOverride} overrides existing CODEX_HOME.\n`
);
}
process.env.CODEX_HOME = resolved.dir;
try {
const { ensureSharedConfigSymlink } = require('../codex-auth/codex-config-symlink') as {
ensureSharedConfigSymlink: (dir: string) => void;
};
ensureSharedConfigSymlink(resolved.dir);
} catch (symlinkErr) {
const msg = symlinkErr instanceof Error ? symlinkErr.message : String(symlinkErr);
process.stderr.write(
`[!] codex-auth: shared config symlink failed (${msg}), continuing\n`
);
}
try {
const { ensureCodexProfileResources } =
require('../codex-auth/codex-profile-resources') as {
ensureCodexProfileResources: (dir: string) => void;
};
ensureCodexProfileResources(resolved.dir);
} catch (resourceErr) {
const msg = resourceErr instanceof Error ? resourceErr.message : String(resourceErr);
process.stderr.write(
`[!] codex-auth: shared resource repair failed (${msg}), continuing\n`
);
}
}
}
} catch (resolverErr) {
const msg = errorMessage(resolverErr);
if (isCodexAuthProfileResolutionError(resolverErr)) {
process.stderr.write(`[X] codex-auth: ${msg}\n`);
return 1;
}
process.stderr.write(`[X] codex-auth: profile resolution failed (${msg})\n`);
return 1;
}
// ── delegate to CCS ─────────────────────────────────────────────────────
// require() is evaluated AFTER env mutations above. CCS manages its own
// process lifecycle (spawns codex, pipes stdio, calls process.exit).
// Return -1 so the entry script knows NOT to call process.exit().
require('../ccs');
return -1; // CCS is in control — entry must not call process.exit()
}
+5 -2
View File
@@ -1,2 +1,5 @@
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
require('../ccs');
import { main } from './codex-runtime-router';
// -1 means CCS has taken over the process lifecycle; do not exit.
main(process.argv).then((code) => {
if (code >= 0) process.exit(code);
});
+2 -1
View File
@@ -6,6 +6,7 @@ import { fail } from './utils/ui';
import { handleError, runCleanup } from './errors';
import { createLogger, runWithRequestId } from './services/logging';
import { redactArgv } from './services/logging/log-redaction';
// Import target adapter system
import { registerTarget, ClaudeAdapter, DroidAdapter, CodexAdapter } from './targets';
@@ -143,7 +144,7 @@ const cliEntryStartedAt = Date.now();
const cliEntryLogger = createLogger('cli:entry');
runWithRequestId(() => {
cliEntryLogger.stage('intake', 'cli.command.start', 'CLI invocation started', {
argv: process.argv.slice(2),
argv: redactArgv(process.argv.slice(2)),
});
return main()
.then(() => {
@@ -152,6 +152,92 @@ describe('ai-provider service stable ids', () => {
).toBe('sk-openrouter');
});
it('redacts custom headers and URL userinfo in provider list views', async () => {
const { listAiProviders } = await loadAiProviderService();
writeCliproxyConfig(tempHome, {
'claude-api-key': [
{
id: 'claude-secret-route',
'api-key': 'sk-ant-provider-secret-1234',
'base-url': 'https://baseuser:basepass@anthropic.example/v1',
'proxy-url': 'http://proxyuser:proxypass@example.internal:8080',
headers: {
Authorization: 'Bearer HEADER-SECRET-123456',
'X-API-Key': 'x-api-key-secret-abcdef',
},
},
],
'openai-compatibility': [
{
id: 'openrouter-secret-route',
name: 'openrouter',
'base-url': 'https://routeruser:routerpass@openrouter.example/api/v1',
headers: {
Authorization: 'Bearer OPENAI-COMPAT-HEADER-SECRET',
},
'api-key-entries': [{ 'api-key': 'sk-openrouter-secret-9999' }],
},
],
});
const listed = await listAiProviders();
const claudeEntry = listed.families.find((entry) => entry.id === 'claude-api-key')?.entries[0];
const openAiEntry = listed.families.find((entry) => entry.id === 'openai-compatibility')
?.entries[0];
expect(claudeEntry?.baseUrl).toBe('https://***:***@anthropic.example/v1');
expect(claudeEntry?.proxyUrl).toBe('http://***:***@example.internal:8080/');
expect(claudeEntry?.headers).toEqual([
{ key: 'Authorization', value: '...3456' },
{ key: 'X-API-Key', value: '...cdef' },
]);
expect(openAiEntry?.baseUrl).toBe('https://***:***@openrouter.example/api/v1');
expect(openAiEntry?.headers).toEqual([{ key: 'Authorization', value: '...CRET' }]);
});
it('preserves stored header and URL secrets when saving unchanged redacted values', async () => {
const { listAiProviders, updateAiProviderEntry } = await loadAiProviderService();
writeCliproxyConfig(tempHome, {
'claude-api-key': [
{
id: 'claude-secret-route',
'api-key': 'sk-ant-provider-secret-1234',
'base-url': 'https://baseuser:basepass@anthropic.example/v1',
'proxy-url': 'http://proxyuser:proxypass@example.internal:8080',
headers: {
Authorization: 'Bearer HEADER-SECRET-123456',
'X-Project': 'public-routing-context',
},
},
],
});
const listed = await listAiProviders();
const entry = listed.families.find((family) => family.id === 'claude-api-key')?.entries[0];
expect(entry).toBeDefined();
await updateAiProviderEntry('claude-api-key', 'claude-secret-route', {
apiKey: 'sk-ant-provider-secret-1234',
baseUrl: entry?.baseUrl,
proxyUrl: entry?.proxyUrl,
headers: entry?.headers,
preserveSecrets: true,
});
const persisted = readCliproxyConfig(tempHome)['claude-api-key'] as Array<
Record<string, unknown>
>;
expect(persisted[0]?.['base-url']).toBe('https://baseuser:basepass@anthropic.example/v1');
expect(persisted[0]?.['proxy-url']).toBe('http://proxyuser:proxypass@example.internal:8080');
expect(persisted[0]?.headers).toEqual({
Authorization: 'Bearer HEADER-SECRET-123456',
'X-Project': 'public-routing-context',
});
});
it('normalizes plain openai-compatible model rules without aliases', async () => {
const { listAiProviders } = await loadAiProviderService();
@@ -56,12 +56,12 @@ function writeRegistry(providers: Record<string, unknown>): void {
// Helper: write unified config
function writeConfig(quotaConfig: unknown): void {
const configDir = path.join(tmpDir, '.ccs', 'config');
const configDir = path.join(tmpDir, '.ccs');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, 'unified-config.json'),
path.join(configDir, 'config.yaml'),
JSON.stringify({
version: 2,
version: 13,
quota_management: quotaConfig,
})
);
@@ -576,6 +576,258 @@ describe('Quota Exhaustion Handlers', () => {
).toBe(true);
});
it('should self-pause exhausted non-default Codex accounts before CLIProxy rotation', async () => {
writeRegistry({
codex: {
default: 'rotation-default@example.com',
accounts: {
'rotation-default@example.com': {
email: 'rotation-default@example.com',
tokenFile: 'codex-rotation-default.json',
},
'rotation-exhausted@example.com': {
email: 'rotation-exhausted@example.com',
tokenFile: 'codex-rotation-exhausted.json',
},
'rotation-healthy@example.com': {
email: 'rotation-healthy@example.com',
tokenFile: 'codex-rotation-healthy.json',
},
},
},
});
writeConfig({
mode: 'auto',
auto: {
tier_priority: ['ultra', 'pro', 'free'],
exhaustion_threshold: 5,
cooldown_minutes: 10,
preflight_check: true,
},
runtime_monitor: {
enabled: true,
normal_interval_seconds: 300,
critical_interval_seconds: 60,
warn_threshold: 20,
exhaustion_threshold: 5,
cooldown_minutes: 10,
},
});
writeCodexAuth(
'codex-rotation-default.json',
'rotation-default@example.com',
'rotation-default-token'
);
writeCodexAuth(
'codex-rotation-exhausted.json',
'rotation-exhausted@example.com',
'rotation-exhausted-token'
);
writeCodexAuth(
'codex-rotation-healthy.json',
'rotation-healthy@example.com',
'rotation-healthy-token'
);
global.fetch = mock((_url: string, options?: RequestInit) => {
const accountHeader = new Headers(options?.headers).get('ChatGPT-Account-Id') ?? '';
const usedPercent = accountHeader === 'chatgpt-rotation-exhausted@example.com' ? 100 : 10;
return Promise.resolve(
Response.json({
plan_type: 'pro',
rate_limit: {
primary_window: { used_percent: usedPercent, reset_after_seconds: 3600 },
secondary_window: { used_percent: usedPercent, reset_after_seconds: 604800 },
},
})
);
}) as typeof fetch;
const { preflightCheck } = await import('../../quota/quota-manager');
const result = await preflightCheck('codex');
const { getAccount, getDefaultAccount } = await import('../account-manager');
expect(result.accountId).toBe('rotation-default@example.com');
expect(result.switchedFrom).toBeUndefined();
expect(getDefaultAccount('codex')?.id).toBe('rotation-default@example.com');
expect(getAccount('codex', 'rotation-default@example.com')?.paused).not.toBe(true);
expect(getAccount('codex', 'rotation-healthy@example.com')?.paused).not.toBe(true);
expect(getAccount('codex', 'rotation-exhausted@example.com')?.paused).toBe(true);
expect(
fs.existsSync(
path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'codex-rotation-exhausted.json')
)
).toBe(true);
expect(
fs.existsSync(
path.join(tmpDir, '.ccs', 'cliproxy', 'auth', 'codex-rotation-exhausted.json')
)
).toBe(false);
});
it('should durably pause non-default Codex accounts already on cooldown', async () => {
writeRegistry({
codex: {
default: 'cooldown-default@example.com',
accounts: {
'cooldown-default@example.com': {
email: 'cooldown-default@example.com',
tokenFile: 'codex-cooldown-default.json',
},
'cooldown-exhausted@example.com': {
email: 'cooldown-exhausted@example.com',
tokenFile: 'codex-cooldown-exhausted.json',
},
},
},
});
writeConfig({
mode: 'auto',
auto: {
tier_priority: ['ultra', 'pro', 'free'],
exhaustion_threshold: 5,
cooldown_minutes: 10,
preflight_check: true,
},
runtime_monitor: {
enabled: true,
normal_interval_seconds: 300,
critical_interval_seconds: 60,
warn_threshold: 20,
exhaustion_threshold: 5,
cooldown_minutes: 10,
},
});
writeCodexAuth(
'codex-cooldown-default.json',
'cooldown-default@example.com',
'cooldown-default-token'
);
writeCodexAuth(
'codex-cooldown-exhausted.json',
'cooldown-exhausted@example.com',
'cooldown-exhausted-token'
);
const { applyCooldown, preflightCheck } = await import('../../quota/quota-manager');
applyCooldown('codex', 'cooldown-exhausted@example.com', 10);
global.fetch = mock((_url: string, options?: RequestInit) => {
const accountHeader = new Headers(options?.headers).get('ChatGPT-Account-Id') ?? '';
const usedPercent = accountHeader === 'chatgpt-cooldown-default@example.com' ? 10 : 100;
return Promise.resolve(
Response.json({
plan_type: 'pro',
rate_limit: {
primary_window: { used_percent: usedPercent, reset_after_seconds: 3600 },
secondary_window: { used_percent: usedPercent, reset_after_seconds: 604800 },
},
})
);
}) as typeof fetch;
const result = await preflightCheck('codex');
const { getAccount } = await import('../account-manager');
expect(result.accountId).toBe('cooldown-default@example.com');
expect(getAccount('codex', 'cooldown-exhausted@example.com')?.paused).toBe(true);
expect(
fs.existsSync(
path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'codex-cooldown-exhausted.json')
)
).toBe(true);
expect(
fs.existsSync(
path.join(tmpDir, '.ccs', 'cliproxy', 'auth', 'codex-cooldown-exhausted.json')
)
).toBe(false);
});
it('should reconcile exhausted rotation accounts before honoring forced default', async () => {
writeRegistry({
codex: {
default: 'forced-default@example.com',
accounts: {
'forced-default@example.com': {
email: 'forced-default@example.com',
tokenFile: 'codex-forced-default.json',
},
'forced-exhausted@example.com': {
email: 'forced-exhausted@example.com',
tokenFile: 'codex-forced-exhausted.json',
},
'forced-healthy@example.com': {
email: 'forced-healthy@example.com',
tokenFile: 'codex-forced-healthy.json',
},
},
},
});
writeConfig({
mode: 'auto',
auto: {
tier_priority: ['ultra', 'pro', 'free'],
exhaustion_threshold: 5,
cooldown_minutes: 10,
preflight_check: true,
},
manual: {
forced_default: 'forced-default@example.com',
},
runtime_monitor: {
enabled: true,
normal_interval_seconds: 300,
critical_interval_seconds: 60,
warn_threshold: 20,
exhaustion_threshold: 5,
cooldown_minutes: 10,
},
});
writeCodexAuth('codex-forced-default.json', 'forced-default@example.com', 'forced-token');
writeCodexAuth(
'codex-forced-exhausted.json',
'forced-exhausted@example.com',
'forced-exhausted-token'
);
writeCodexAuth('codex-forced-healthy.json', 'forced-healthy@example.com', 'healthy-token');
global.fetch = mock((_url: string, options?: RequestInit) => {
const accountHeader = new Headers(options?.headers).get('ChatGPT-Account-Id') ?? '';
const usedPercent = accountHeader === 'chatgpt-forced-exhausted@example.com' ? 100 : 10;
return Promise.resolve(
Response.json({
plan_type: 'pro',
rate_limit: {
primary_window: { used_percent: usedPercent, reset_after_seconds: 3600 },
secondary_window: { used_percent: usedPercent, reset_after_seconds: 604800 },
},
})
);
}) as typeof fetch;
const { preflightCheck } = await import('../../quota/quota-manager');
const result = await preflightCheck('codex');
const { getAccount, getDefaultAccount } = await import('../account-manager');
expect(result.accountId).toBe('forced-default@example.com');
expect(result.reason).toBe('Forced default override');
expect(getDefaultAccount('codex')?.id).toBe('forced-default@example.com');
expect(getAccount('codex', 'forced-default@example.com')?.paused).not.toBe(true);
expect(getAccount('codex', 'forced-healthy@example.com')?.paused).not.toBe(true);
expect(getAccount('codex', 'forced-exhausted@example.com')?.paused).toBe(true);
expect(
fs.existsSync(
path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'codex-forced-exhausted.json')
)
).toBe(true);
});
it('should not durably self-pause during preflight when fallback quota is unknown', async () => {
writeRegistry({
codex: {
@@ -15,8 +15,12 @@ describe('codex plan compatibility', () => {
it('maps paid-only free-plan models to safe fallbacks', () => {
expect(getFreePlanFallbackCodexModel('gpt-5.5')).toBe('gpt-5.4');
expect(getFreePlanFallbackCodexModel('gpt-5.5-minimal')).toBe('gpt-5.4');
expect(getFreePlanFallbackCodexModel('gpt-5.5-low')).toBe('gpt-5.4');
expect(getFreePlanFallbackCodexModel('gpt-5.5-xhigh')).toBe('gpt-5.4');
expect(getFreePlanFallbackCodexModel('gpt-5.5-low-fast')).toBe('gpt-5.4');
expect(getFreePlanFallbackCodexModel('gpt-5.5-high-fast')).toBe('gpt-5.4');
expect(getFreePlanFallbackCodexModel('gpt-5.5-fast-minimal')).toBe('gpt-5.4');
expect(getFreePlanFallbackCodexModel('gpt-5.5-fast-high')).toBe('gpt-5.4');
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5.4');
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5.4');
@@ -201,6 +201,59 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
expect(capturedBodies[1]?.service_tier).toBe('priority');
});
it('translates minimal and low codex effort suffixes before forwarding upstream', async () => {
const capturedBodies: JsonRecord[] = [];
const upstream = http.createServer((req, res) => {
let rawBody = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
rawBody += chunk;
});
req.on('end', () => {
capturedBodies.push(rawBody ? (JSON.parse(rawBody) as JsonRecord) : {});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
cleanupServers.push(upstream);
const upstreamPort = await listenOnRandomPort(upstream);
const proxy = new CodexReasoningProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
modelMap: {
defaultModel: 'gpt-5.4',
},
defaultEffort: 'medium',
});
const proxyPort = await proxy.start();
const minimalResponse = await postJson(
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
{
model: 'gpt-5.5-minimal',
messages: [],
}
);
const lowFastResponse = await postJson(
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
{
model: 'gpt-5.5-fast-low',
messages: [],
}
);
proxy.stop();
expect(minimalResponse.statusCode).toBe(200);
expect(lowFastResponse.statusCode).toBe(200);
expect(capturedBodies[0]?.model).toBe('gpt-5.5');
expect((capturedBodies[0]?.reasoning as JsonRecord | undefined)?.effort).toBe('minimal');
expect(capturedBodies[1]?.model).toBe('gpt-5.5');
expect((capturedBodies[1]?.reasoning as JsonRecord | undefined)?.effort).toBe('low');
expect(capturedBodies[1]?.service_tier).toBe('priority');
});
it('skips reasoning injection when disableEffort is enabled', async () => {
let capturedBody: JsonRecord | null = null;
@@ -12,6 +12,7 @@ import {
normalizeModelIdForProvider,
normalizeModelIdForRouting,
normalizeModelEnvVarsForProvider,
parseCodexModelTuningAlias,
} from '../model-id-normalizer';
describe('model-id-normalizer', () => {
@@ -122,7 +123,10 @@ describe('model-id-normalizer', () => {
it('normalizes legacy codex aliases to the current supported model IDs', () => {
expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-minimal')).toBe('gpt-5.4-minimal');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-low')).toBe('gpt-5.4-low');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high')).toBe('gpt-5.4-high');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-fast-low')).toBe('gpt-5.4-low-fast');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-fast-high')).toBe('gpt-5.4-high-fast');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high[1m]')).toBe('gpt-5.4-high[1m]');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high-fast[1m]')).toBe(
@@ -135,6 +139,30 @@ describe('model-id-normalizer', () => {
'gpt-5.4-high-fast'
);
});
it('parses codex model tuning suffixes', () => {
expect(parseCodexModelTuningAlias('gpt-5.5-minimal')).toEqual({
baseModel: 'gpt-5.5',
effort: 'minimal',
serviceTier: null,
});
expect(parseCodexModelTuningAlias('gpt-5.5-low-fast')).toEqual({
baseModel: 'gpt-5.5',
effort: 'low',
serviceTier: 'fast',
});
expect(parseCodexModelTuningAlias('gpt-5.5-high')).toEqual({
baseModel: 'gpt-5.5',
effort: 'high',
serviceTier: null,
});
expect(parseCodexModelTuningAlias('gpt-5.5-fast-high')).toEqual({
baseModel: 'gpt-5.5',
effort: 'high',
serviceTier: 'fast',
});
expect(parseCodexModelTuningAlias('gpt-5.5[1m]')).toBeNull();
});
});
describe('env normalization', () => {
@@ -11,8 +11,8 @@ export type CodexPlanType = CodexQuotaResult['planType'];
const FREE_SAFE_DEFAULT_MODEL = 'gpt-5.4';
const FREE_SAFE_FAST_MODEL = 'gpt-5.4-mini';
const CODEX_TUNING_SUFFIX_REGEX =
/(?:-(?:xhigh|high|medium)(?:-fast)?|-fast(?:-(?:xhigh|high|medium))?)$/i;
const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i;
/(?:-(?:minimal|low|medium|high|xhigh)(?:-fast)?|-fast(?:-(?:minimal|low|medium|high|xhigh))?)$/i;
const CODEX_PAREN_SUFFIX_REGEX = /\((minimal|low|medium|high|xhigh)\)$/i;
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
const KNOWN_CODEX_MODELS = new Set(
(getProviderCatalog('codex')?.models ?? []).map((model) => model.id.toLowerCase())
@@ -8,7 +8,7 @@ import {
} from './codex-plan-compatibility';
import { getModelMaxLevel } from '../model-catalog';
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
export type CodexReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export type CodexServiceTier = 'fast';
type CodexServiceTierRequestValue = 'priority';
@@ -48,7 +48,7 @@ interface ForwardJsonContext {
}
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(xhigh|high|medium|fast)$/i;
const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(minimal|low|medium|high|xhigh|fast)$/i;
const CODEX_SERVICE_TIER_REQUEST_VALUE: Record<CodexServiceTier, CodexServiceTierRequestValue> = {
fast: 'priority',
};
@@ -108,13 +108,15 @@ function isKnownCodexModelId(
}
const EFFORT_RANK: Record<CodexReasoningEffort, number> = {
medium: 1,
high: 2,
xhigh: 3,
minimal: 1,
low: 2,
medium: 3,
high: 4,
xhigh: 5,
};
/** All valid codex effort levels in rank order */
const EFFORT_BY_RANK: CodexReasoningEffort[] = ['medium', 'high', 'xhigh'];
const EFFORT_BY_RANK: CodexReasoningEffort[] = ['minimal', 'low', 'medium', 'high', 'xhigh'];
function minEffort(a: CodexReasoningEffort, b: CodexReasoningEffort): CodexReasoningEffort {
return EFFORT_RANK[a] <= EFFORT_RANK[b] ? a : b;
@@ -128,7 +130,7 @@ function capEffortAtModelMax(model: string, effort: CodexReasoningEffort): Codex
const maxLevel = getModelMaxLevel('codex', model);
if (!maxLevel) return effort;
// Map maxLevel to CodexReasoningEffort (only medium/high/xhigh are valid)
// Map maxLevel to CodexReasoningEffort.
const maxEffort = EFFORT_BY_RANK.find((e) => e === maxLevel);
if (!maxEffort) return effort;
@@ -238,7 +240,13 @@ export class CodexReasoningProxy {
serviceTier: CodexServiceTier | null;
path: string;
}> = [];
private readonly counts: Record<CodexReasoningEffort, number> = { medium: 0, high: 0, xhigh: 0 };
private readonly counts: Record<CodexReasoningEffort, number> = {
minimal: 0,
low: 0,
medium: 0,
high: 0,
xhigh: 0,
};
constructor(config: CodexReasoningProxyConfig) {
this.config = {
@@ -298,7 +306,7 @@ export class CodexReasoningProxy {
}
/**
* Treat trailing "-high/-medium/-xhigh" and "-fast" as Codex tuning aliases
* Treat trailing effort tokens and "-fast" as Codex tuning aliases
* only for known codex models.
* Prevents stripping legitimate upstream model IDs that happen to end with those tokens.
*/
@@ -26,7 +26,7 @@ const DENIED_ANTIGRAVITY_OPUS_45_REGEX =
const DENIED_ANTIGRAVITY_SONNET_45_REGEX =
/claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi;
const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking';
const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(xhigh|high|medium|fast)$/i;
const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(minimal|low|medium|high|xhigh|fast)$/i;
const CODEX_LEGACY_MODEL_ALIASES: Readonly<Record<string, string>> = Object.freeze({
'gpt-5-codex': 'gpt-5.4',
'gpt-5-codex-mini': 'gpt-5.4-mini',
@@ -45,6 +45,15 @@ const IFLOW_LEGACY_MODEL_ALIASES: Readonly<Record<string, string>> = Object.free
'minimax-m2.5': 'qwen3-coder-plus',
});
export type CodexModelTuningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export type CodexModelServiceTier = 'fast';
export interface CodexModelTuningAlias {
baseModel: string;
effort: CodexModelTuningEffort | null;
serviceTier: CodexModelServiceTier | null;
}
function trimModelId(model: string): string {
return model.trim();
}
@@ -93,6 +102,23 @@ function splitCodexTuningSuffix(model: string): { baseModel: string; suffix: str
};
}
export function parseCodexModelTuningAlias(model: string): CodexModelTuningAlias | null {
const trimmed = trimModelId(model);
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
if (suffix) return null;
const parsed = splitCodexTuningSuffix(baseModel);
if (!parsed.suffix) return null;
const tokens = parsed.suffix.slice(1).split('-');
const effort = tokens.find((token) => token !== 'fast') as CodexModelTuningEffort | undefined;
return {
baseModel: parsed.baseModel.trim(),
effort: effort ?? null,
serviceTier: tokens.includes('fast') ? 'fast' : null,
};
}
/**
* Extract provider segment from `/api/provider/{provider}` request paths.
*
+47 -12
View File
@@ -17,14 +17,39 @@ function maskSecret(value: string | undefined): string | undefined {
return value.length > 8 ? `...${value.slice(-4)}` : '***';
}
function sanitizeUrlForView(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
try {
const parsed = new URL(trimmed);
if (parsed.username) parsed.username = '***';
if (parsed.password) parsed.password = '***';
return parsed.toString();
} catch {
return trimmed;
}
}
function restoreMaskedViewValue(
value: string | undefined,
existing: string | undefined,
sanitizeForView: (value: string | undefined) => string | undefined = maskSecret
): string | undefined {
const next = value?.trim() || undefined;
if (!next || !existing) return next;
return next === sanitizeForView(existing) ? existing : next;
}
function normalizeHeaders(
headers: Array<{ key: string; value: string }> | undefined
headers: Array<{ key: string; value: string }> | undefined,
existing?: Record<string, string>
): Record<string, string> | undefined {
if (!headers) return undefined;
const normalized = headers.reduce<Record<string, string>>((acc, header) => {
const key = header.key.trim();
if (!key) return acc;
acc[key] = header.value;
acc[key] = restoreMaskedViewValue(header.value, existing?.[key]) || '';
return acc;
}, {});
return Object.keys(normalized).length > 0 ? normalized : undefined;
@@ -33,7 +58,10 @@ function normalizeHeaders(
function toHeaderPairs(
headers: Record<string, string> | undefined
): Array<{ key: string; value: string }> {
return Object.entries(headers || {}).map(([key, value]) => ({ key, value }));
return Object.entries(headers || {}).map(([key, value]) => ({
key,
value: maskSecret(value) || '***',
}));
}
function readModelRulePart(model: unknown, key: keyof AiProviderModelAlias) {
@@ -62,9 +90,9 @@ function buildApiKeyEntryView(
return {
id: entry.id || `${family}:${index}`,
index,
label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`,
baseUrl: entry['base-url']?.trim() || undefined,
proxyUrl: entry['proxy-url']?.trim() || undefined,
label: entry.prefix?.trim() || sanitizeUrlForView(entry['base-url']) || `Entry ${index + 1}`,
baseUrl: sanitizeUrlForView(entry['base-url']),
proxyUrl: sanitizeUrlForView(entry['proxy-url']),
prefix: entry.prefix?.trim() || undefined,
headers: toHeaderPairs(entry.headers),
excludedModels: [...(entry['excluded-models'] || [])],
@@ -80,7 +108,7 @@ function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): Ai
index,
name: entry.name,
label: entry.name,
baseUrl: entry['base-url']?.trim() || undefined,
baseUrl: sanitizeUrlForView(entry['base-url']),
headers: toHeaderPairs(entry.headers),
excludedModels: [],
models: normalizeModelAliases(entry.models),
@@ -142,10 +170,14 @@ function toApiKeyEntry(
return {
id: existing?.id,
'api-key': nextSecret,
'base-url': input.baseUrl?.trim() || undefined,
'proxy-url': input.proxyUrl?.trim() || undefined,
'base-url': restoreMaskedViewValue(input.baseUrl, existing?.['base-url'], sanitizeUrlForView),
'proxy-url': restoreMaskedViewValue(
input.proxyUrl,
existing?.['proxy-url'],
sanitizeUrlForView
),
prefix: input.prefix?.trim() || undefined,
headers: normalizeHeaders(input.headers),
headers: normalizeHeaders(input.headers, existing?.headers),
'excluded-models': (input.excludedModels || [])
.map((value) => value.trim())
.filter((value) => value.length > 0),
@@ -167,8 +199,11 @@ function toOpenAiCompatEntry(
return {
id: existing?.id,
name: input.name?.trim() || existing?.name || 'connector',
'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '',
headers: normalizeHeaders(input.headers),
'base-url':
restoreMaskedViewValue(input.baseUrl, existing?.['base-url'], sanitizeUrlForView) ||
existing?.['base-url'] ||
'',
headers: normalizeHeaders(input.headers, existing?.headers),
'api-key-entries': nextApiKeys.map((apiKey) => ({ 'api-key': apiKey })),
models: normalizeModelAliases(input.models),
};
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { OAUTH_CONFIGS } from '../auth-types';
import { diagnoseFailure, formatErrorMessage } from '../oauth-trace/diagnose-failure';
import { OAuthTracePhase, type OAuthTraceEvent } from '../oauth-trace/trace-events';
@@ -195,3 +196,87 @@ describe('formatErrorMessage', () => {
expect(lines.some((l) => l.includes('--verbose'))).toBe(true);
});
});
describe('cross-profile OAuth failure matrix', () => {
const providers = Object.keys(OAUTH_CONFIGS);
const matrix = [
{
name: 'url-not-displayed',
expectedBranch: 'URL_NOT_DISPLAYED',
snapshot: () => [
ev(OAuthTracePhase.BinarySpawn),
ev(OAuthTracePhase.BinaryExit, { data: { code: 0 } }),
],
hint: '--verbose',
},
{
name: 'callback-not-observed',
expectedBranch: 'CALLBACK_NEVER_OBSERVED',
snapshot: () => [
ev(OAuthTracePhase.AuthUrlDisplayed),
ev(OAuthTracePhase.BrowserOpened),
ev(OAuthTracePhase.BinaryExit, { data: { code: 0 } }),
],
hint: '--no-browser',
},
{
name: 'binary-error',
expectedBranch: 'BINARY_ERROR_EXIT',
snapshot: () => [
ev(OAuthTracePhase.BinarySpawn),
ev(OAuthTracePhase.BinaryExit, { data: { code: 2, stderrTail: 'boom' } }),
],
hint: '--verbose',
},
{
name: 'token-exchange-error',
expectedBranch: 'TOKEN_EXCHANGE_REJECTED',
snapshot: () => [
ev(OAuthTracePhase.PasteCallbackSubmitted),
ev(OAuthTracePhase.Error, {
error: { code: 'CALLBACK_REJECTED', message: 'invalid_grant' },
}),
],
hint: '--verbose',
},
{
name: 'session-expired',
expectedBranch: 'TIMEOUT',
snapshot: () => [
ev(OAuthTracePhase.PasteCallbackPrompted),
ev(OAuthTracePhase.Timeout, { data: { timeoutMs: 600000 } }),
],
hint: '--auth',
},
{
name: 'token-file-missing',
expectedBranch: 'TOKEN_FILE_MISSING_POST_EXIT',
snapshot: () => [
ev(OAuthTracePhase.AuthUrlDisplayed),
ev(OAuthTracePhase.BrowserOpened),
ev(OAuthTracePhase.CallbackObservedHeuristic),
ev(OAuthTracePhase.BinaryExit, { data: { code: 0 } }),
ev(OAuthTracePhase.TokenFileMissing),
],
hint: 'ccs update',
},
] as const;
test.each(providers.flatMap((provider) => matrix.map((scenario) => ({ provider, scenario }))))(
'$provider $scenario.name emits branch identifier and remediation hint',
({ provider, scenario }) => {
reset();
const diagnosis = diagnoseFailure(scenario.snapshot());
expect(diagnosis.branchId).toBe(scenario.expectedBranch);
const message = formatErrorMessage(diagnosis, {
verbose: false,
platform: 'linux',
callbackPort: 1455,
provider,
}).join('\n');
expect(message).toContain(scenario.hint);
expect(message.includes(`ccs ${provider}`) || message.includes('ccs update')).toBe(true);
}
);
});
@@ -5,6 +5,8 @@ import * as path from 'path';
import type { ProxyTarget } from '../../proxy/proxy-target-resolver';
import { InteractivePrompt } from '../../../utils/prompt';
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../../../tests/mocks';
import { createOAuthTraceRecorder } from '../oauth-trace/trace-recorder';
import { OAuthTracePhase } from '../oauth-trace/trace-events';
const remoteTarget: ProxyTarget = {
host: 'proxy.example.com',
@@ -291,6 +293,113 @@ describe('resolvePasteCallbackAuthUrl', () => {
});
});
describe('handlePasteCallbackMode traceability', () => {
it('records paste-callback lifecycle events and redacts callback secrets on exchange rejection', async () => {
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: {
auth_url:
'https://auth.example.com/authorize?client_id=public&state=upstream-state-secret',
state: 'upstream-state-secret',
},
},
{
url: /\/v0\/management\/oauth-callback$/,
response: { status: 'error', error: 'invalid_grant: expired code' },
status: 400,
},
]);
const trace = createOAuthTraceRecorder({
sessionId: 'paste-test',
provider: 'codex',
verbose: false,
});
const { handlePasteCallbackMode } = await import(
`../oauth-handler?paste-trace-rejected=${Date.now()}`
);
const result = await handlePasteCallbackMode(
'codex',
{ provider: 'codex', displayName: 'Codex', authUrl: '', scopes: [], authFlag: '' },
false,
fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-paste-trace-rejected-')),
undefined,
undefined,
{
trace,
promptForCallbackUrl: async () =>
'http://localhost:1455/callback?code=oauth-code-secret&state=upstream-state-secret',
}
);
expect(result).toBeNull();
expect(trace.snapshot().map((event) => event.phase)).toEqual([
OAuthTracePhase.AuthUrlDisplayed,
OAuthTracePhase.PasteCallbackPrompted,
OAuthTracePhase.PasteCallbackReceived,
OAuthTracePhase.PasteCallbackSubmitted,
OAuthTracePhase.Error,
]);
expect(JSON.stringify(trace.snapshot())).not.toContain('oauth-code-secret');
expect(JSON.stringify(trace.snapshot())).not.toContain('upstream-state-secret');
});
it('records token exchange and token-file-missing when callback succeeds without a local token', async () => {
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: {
auth_url: 'https://auth.example.com/authorize?client_id=public&state=state-123',
state: 'state-123',
},
},
{
url: /\/v0\/management\/oauth-callback$/,
response: { status: 'ok' },
},
{
url: /\/v0\/management\/get-auth-status\?state=state-123$/,
response: { status: 'ok' },
},
]);
const trace = createOAuthTraceRecorder({
sessionId: 'paste-test-missing-token',
provider: 'codex',
verbose: false,
});
const { handlePasteCallbackMode } = await import(
`../oauth-handler?paste-trace-token-missing=${Date.now()}`
);
const result = await handlePasteCallbackMode(
'codex',
{ provider: 'codex', displayName: 'Codex', authUrl: '', scopes: [], authFlag: '' },
false,
fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-paste-trace-token-missing-')),
undefined,
undefined,
{
trace,
promptForCallbackUrl: async () =>
'http://localhost:1455/callback?code=oauth-code-secret&state=state-123',
timeoutMs: 5,
pollIntervalMs: 1,
}
);
expect(result).toBeNull();
expect(trace.snapshot().map((event) => event.phase)).toContain(
OAuthTracePhase.TokenExchangePending
);
expect(trace.snapshot().map((event) => event.phase)).toContain(
OAuthTracePhase.TokenFileMissing
);
});
});
describe('findNewTokenSnapshotForManualAuth', () => {
it('detects newly created provider token files', async () => {
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-manual-auth-'));
@@ -51,6 +51,40 @@ describe('redactString', () => {
test('empty input passthrough', () => {
expect(redactString('')).toBe('');
});
test('redacts JSON-style token payloads in plain strings', () => {
const out = redactString(
'{"access_token":"AT_SECRET","refresh_token":"RT_SECRET","token":"TOKEN_SECRET"}'
);
expect(out).not.toContain('AT_SECRET');
expect(out).not.toContain('RT_SECRET');
expect(out).not.toContain('TOKEN_SECRET');
expect(out).toContain(REDACTED_PLACEHOLDER);
});
test('preserves JSON-style separator spacing while redacting token payloads', () => {
const out = redactString('{"access_token" : "AT_SECRET"}');
expect(out).not.toContain('AT_SECRET');
expect(out).toContain(`"access_token" : "${REDACTED_PLACEHOLDER}"`);
});
test('redacts line-leading key/value formats', () => {
const out = redactString(
'access_token=AT_SECRET refresh_token=RT_SECRET client_secret: CS_SECRET'
);
expect(out).not.toContain('AT_SECRET');
expect(out).not.toContain('RT_SECRET');
expect(out).not.toContain('CS_SECRET');
expect(out).toContain(`access_token=${REDACTED_PLACEHOLDER}`);
expect(out).toContain(`refresh_token=${REDACTED_PLACEHOLDER}`);
expect(out).toContain(`client_secret: ${REDACTED_PLACEHOLDER}`);
});
test('preserves URL-style suffixes in line-leading key/value snippets', () => {
const out = redactString('access_token=AT_SECRET&keep=1');
expect(out).not.toContain('AT_SECRET');
expect(out).toBe(`access_token=${REDACTED_PLACEHOLDER}&keep=1`);
});
});
describe('redactUrl', () => {
+155 -39
View File
@@ -11,6 +11,7 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import { fail, info, warn, color, ok } from '../../utils/ui';
import { createLogger } from '../../services/logging';
import { ensureCLIProxyBinary, getStoredConfiguredBackend } from '../binary-manager';
@@ -76,6 +77,11 @@ import {
} from '../accounts/account-safety';
import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility';
import { InteractivePrompt } from '../../utils/prompt';
import { getCcsDir } from '../../utils/config-manager';
import { generateSessionId } from './project-selection-handler';
import { createFileSink } from './oauth-trace/sink-file';
import { createOAuthTraceRecorder, OAuthTracePhase, type OAuthTraceRecorder } from './oauth-trace';
import { diagnoseFailure, formatErrorMessage } from './oauth-trace/diagnose-failure';
interface PasteCallbackStartData {
url?: string;
@@ -84,6 +90,13 @@ interface PasteCallbackStartData {
status?: string;
}
interface PasteCallbackTraceOptions {
trace?: OAuthTraceRecorder;
promptForCallbackUrl?: (timeoutMs: number) => Promise<string | null>;
timeoutMs?: number;
pollIntervalMs?: number;
}
const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000;
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
const GEMINI_PLUS_CLIENT_ID_ENV = 'CLIPROXY_GEMINI_OAUTH_CLIENT_ID';
@@ -641,7 +654,81 @@ export function usesKiroLocalCallbackReplay(
* Handle paste-callback mode: show auth URL, prompt for callback paste
* Uses proxy target resolver to connect to correct CLIProxyAPI instance (local or remote)
*/
async function handlePasteCallbackMode(
function createPasteCallbackTraceRecorder(
provider: CLIProxyProvider,
verbose: boolean
): OAuthTraceRecorder {
const fileSink =
process.env['CCS_OAUTH_LOG_FILE'] === '1'
? createFileSink({ dir: path.join(getCcsDir(), 'logs') })
: undefined;
return createOAuthTraceRecorder({
sessionId: generateSessionId(),
provider,
verbose,
fileSink,
});
}
async function promptForPasteCallbackUrl(timeoutMs: number): Promise<string | null> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<string | null>((resolve) => {
let resolved = false;
rl.on('close', () => {
if (!resolved) {
resolved = true;
resolve(null);
}
});
console.log(info('After completing authentication, paste the callback URL here:'));
rl.question('> ', (answer) => {
resolved = true;
rl.close();
resolve(answer.trim() || null);
});
setTimeout(() => {
if (!resolved) {
resolved = true;
rl.close();
console.log('');
console.log(
fail(`Timed out waiting for callback URL (${Math.round(timeoutMs / 60000)} minutes)`)
);
resolve(null);
}
}, timeoutMs);
});
}
function printPasteCallbackTraceDiagnosis(
trace: OAuthTraceRecorder,
provider: CLIProxyProvider,
verbose: boolean
): void {
const diagnosis = diagnoseFailure(trace.snapshot());
if (diagnosis.branchId === 'UNKNOWN') {
return;
}
const callbackPort = OAUTH_PORTS[provider] ?? null;
for (const line of formatErrorMessage(diagnosis, {
verbose,
platform: process.platform,
callbackPort,
provider,
})) {
console.log(line);
}
}
export async function handlePasteCallbackMode(
provider: CLIProxyProvider,
oauthConfig: ProviderOAuthConfig,
verbose: boolean,
@@ -652,12 +739,14 @@ async function handlePasteCallbackMode(
kiroMethod?: OAuthOptions['kiroMethod'];
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
add?: boolean;
}
} & PasteCallbackTraceOptions
): Promise<AccountInfo | null> {
// Resolve CLIProxyAPI target (local or remote based on config)
const target = getProxyTarget();
// OAuth state timeout (10 minutes, matches CLIProxyAPI state TTL)
const OAUTH_STATE_TIMEOUT_MS = 10 * 60 * 1000;
const OAUTH_STATE_TIMEOUT_MS = options?.timeoutMs ?? 10 * 60 * 1000;
const pollIntervalMs = options?.pollIntervalMs ?? PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS;
const trace = options?.trace ?? createPasteCallbackTraceRecorder(provider, verbose);
console.log('');
console.log(info(`Starting ${oauthConfig.displayName} OAuth (paste-callback mode)...`));
@@ -694,19 +783,37 @@ async function handlePasteCallbackMode(
}
}
warnPossible403Ban(provider, startError);
trace.record(OAuthTracePhase.Error, { startPath }, { message: startError });
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null;
}
const authUrl = await resolvePasteCallbackAuthUrl(target, startData, OAUTH_STATE_TIMEOUT_MS);
const authUrl = await resolvePasteCallbackAuthUrl(
target,
startData,
OAUTH_STATE_TIMEOUT_MS,
pollIntervalMs
);
if (!authUrl) {
console.log(fail('No authorization URL received'));
trace.record(OAuthTracePhase.Error, {}, { message: 'No authorization URL received' });
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null;
}
const authUrlCredentialError = getGeminiAuthUrlCredentialError(provider, authUrl);
if (authUrlCredentialError) {
console.log(fail(authUrlCredentialError));
trace.record(
OAuthTracePhase.Error,
{},
{ code: 'GEMINI_PLUS_MISSING_CRED', message: authUrlCredentialError }
);
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null;
}
@@ -721,47 +828,22 @@ async function handlePasteCallbackMode(
console.log('');
console.log(` ${authUrl}`);
console.log('');
trace.record(OAuthTracePhase.AuthUrlDisplayed, { authUrl });
// Prompt for callback URL
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const callbackUrl = await new Promise<string | null>((resolve) => {
let resolved = false;
rl.on('close', () => {
if (!resolved) {
resolved = true;
resolve(null);
}
});
console.log(info('After completing authentication, paste the callback URL here:'));
rl.question('> ', (answer) => {
resolved = true;
rl.close();
resolve(answer.trim() || null);
});
// Timeout after 10 minutes (match state TTL)
setTimeout(() => {
if (!resolved) {
resolved = true;
rl.close();
console.log('');
console.log(fail('Timed out waiting for callback URL (10 minutes)'));
resolve(null);
}
}, OAUTH_STATE_TIMEOUT_MS);
});
trace.record(OAuthTracePhase.PasteCallbackPrompted, { timeoutMs: OAUTH_STATE_TIMEOUT_MS });
const callbackUrl = await (options?.promptForCallbackUrl ?? promptForPasteCallbackUrl)(
OAUTH_STATE_TIMEOUT_MS
);
if (!callbackUrl) {
console.log(info('Cancelled'));
trace.record(OAuthTracePhase.Cancelled, { reason: 'empty_callback' });
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null;
}
trace.record(OAuthTracePhase.PasteCallbackReceived, { callbackUrl });
// Validate callback URL
let code: string | undefined;
@@ -770,11 +852,17 @@ async function handlePasteCallbackMode(
code = parsed.searchParams.get('code') || undefined;
} catch {
console.log(fail('Invalid URL format'));
trace.record(OAuthTracePhase.PasteCallbackInvalid, { reason: 'invalid_url' });
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null;
}
if (!code) {
console.log(fail('Invalid callback URL: missing code parameter'));
trace.record(OAuthTracePhase.PasteCallbackInvalid, { reason: 'missing_code' });
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null;
}
@@ -782,6 +870,7 @@ async function handlePasteCallbackMode(
console.log(info('Submitting callback...'));
const callbackProvider = CLIPROXY_CALLBACK_PROVIDER_MAP[provider] || provider;
trace.record(OAuthTracePhase.PasteCallbackSubmitted, { provider: callbackProvider });
const callbackResponse = await fetch(buildProxyUrl(target, getManagementOAuthCallbackPath()), {
method: 'POST',
@@ -802,10 +891,18 @@ async function handlePasteCallbackMode(
callbackData.error || `OAuth callback failed with status ${callbackResponse.status}`;
console.log(fail(callbackError));
warnPossible403Ban(provider, callbackError);
trace.record(
OAuthTracePhase.Error,
{ status: callbackResponse.status },
{ code: 'CALLBACK_REJECTED', message: callbackError }
);
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null;
}
console.log(info('Callback submitted. Waiting for token exchange...'));
trace.record(OAuthTracePhase.TokenExchangePending, { state: oauthState ?? undefined });
const { tokenSnapshot, error: tokenWaitError } = await waitForManualCallbackToken(
provider,
target,
@@ -813,12 +910,20 @@ async function handlePasteCallbackMode(
oauthState,
knownTokenFiles,
expectedAccountId,
OAUTH_STATE_TIMEOUT_MS
OAUTH_STATE_TIMEOUT_MS,
pollIntervalMs
);
if (tokenWaitError) {
console.log(fail(tokenWaitError));
warnPossible403Ban(provider, tokenWaitError);
trace.record(
OAuthTracePhase.Error,
{},
{ code: 'CALLBACK_REJECTED', message: tokenWaitError }
);
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null;
}
@@ -828,8 +933,12 @@ async function handlePasteCallbackMode(
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.'
)
);
trace.record(OAuthTracePhase.TokenFileMissing, {});
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null;
}
trace.record(OAuthTracePhase.TokenFileAppeared, {});
const account = registerAccountFromToken(
provider,
@@ -843,8 +952,12 @@ async function handlePasteCallbackMode(
console.log(
fail('Authenticated token could not be matched to the requested account. Retry the flow.')
);
trace.record(OAuthTracePhase.TokenFileMissing, { reason: 'account_match_failed' });
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null;
}
await trace.flush();
console.log(ok('Authentication successful!'));
@@ -863,6 +976,9 @@ async function handlePasteCallbackMode(
} else {
console.log(fail('OAuth failed. Use --verbose for details.'));
}
trace.record(OAuthTracePhase.Error, {}, error as Error);
await trace.flush();
printPasteCallbackTraceDiagnosis(trace, provider, verbose);
return null;
}
}
+23 -1
View File
@@ -40,12 +40,34 @@ const QUERY_PARAM_REGEX = new RegExp(
const BEARER_REGEX = /Bearer\s+[A-Za-z0-9._\-~+/=]+/gi;
const KV_SEPARATORS = String.raw`(?:=|:)`;
const VALUE_FRAGMENT = String.raw`[^\s,;&\]}\"']+`;
const STRING_KV_KEYS = [...SENSITIVE_QUERY_KEYS, 'token'].filter((key) => key !== 'authorization');
const SENSITIVE_KEY_GROUP = `(${STRING_KV_KEYS.join('|')})`;
const LEADING_KV_REGEX = new RegExp(
String.raw`(^|\s)${SENSITIVE_KEY_GROUP}(\s*${KV_SEPARATORS}\s*)${VALUE_FRAGMENT}`,
'gi'
);
const QUOTED_JSON_KV_REGEX = new RegExp(
String.raw`([\"'])${SENSITIVE_KEY_GROUP}\1(\s*:\s*)([\"'])(?:\\.|(?!\4).)*?\4`,
'gi'
);
/** Redact sensitive query-param values inside any string. Idempotent. */
export function redactString(s: string): string {
if (!s) return s;
return s
.replace(QUERY_PARAM_REGEX, (_full, key) => `${key}=${REDACTED}`)
.replace(BEARER_REGEX, `Bearer ${REDACTED}`);
.replace(BEARER_REGEX, `Bearer ${REDACTED}`)
.replace(
QUOTED_JSON_KV_REGEX,
(_full, quoteKey, key, separator, quoteVal) =>
`${quoteKey}${key}${quoteKey}${separator}${quoteVal}${REDACTED}${quoteVal}`
)
.replace(
LEADING_KV_REGEX,
(_full, prefix, key, separator) => `${prefix}${key}${separator}${REDACTED}`
);
}
/** Redact a parsed URL by name; returns redacted href or original on parse error. */
+7 -2
View File
@@ -15,6 +15,7 @@ import { getDeniedModelIdReasonForProvider } from '../ai-providers/model-id-norm
import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver';
import { CLIPROXY_DEFAULT_PORT } from './port-manager';
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
import { getActiveDockerLegacyApiKeys } from '../../docker/docker-key-rotation';
/** Internal API key for CCS-managed requests */
export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
@@ -623,8 +624,12 @@ function generateUnifiedConfigContent(
const effectiveApiKey = getEffectiveApiKey();
const effectiveSecret = getEffectiveManagementSecret();
// Build api-keys section with internal key + preserved user keys
const allApiKeys = [effectiveApiKey, ...userApiKeys];
// Build api-keys section with internal key + preserved user keys.
// Docker upgrades may temporarily keep the historical default key as a
// compatibility grace key; the marker file controls when that is active.
const allApiKeys = Array.from(
new Set([effectiveApiKey, ...getActiveDockerLegacyApiKeys(), ...userApiKeys])
);
const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n');
// Unified config with enhanced CLIProxyAPI features
+6 -6
View File
@@ -16,11 +16,11 @@ import { getThinkingConfig } from '../../config/config-loader-facade';
/** Model tier types for thinking budget defaults */
export type ModelTier = 'opus' | 'sonnet' | 'haiku';
const CODEX_EFFORT_REGEX = /^(medium|high|xhigh)$/i;
const CODEX_EFFORT_REGEX = /^(minimal|low|medium|high|xhigh)$/i;
const CODEX_FAST_TUNING_VALUE_REGEX =
/^(?:(medium|high|xhigh)-fast|fast-(medium|high|xhigh)|fast)$/i;
/^(?:(minimal|low|medium|high|xhigh)-fast|fast-(minimal|low|medium|high|xhigh)|fast)$/i;
const CODEX_TUNING_SUFFIX_REGEX =
/(?:-(?:xhigh|high|medium)(?:-fast)?|-fast(?:-(?:xhigh|high|medium))?)$/i;
/(?:-(?:minimal|low|medium|high|xhigh)(?:-fast)?|-fast(?:-(?:minimal|low|medium|high|xhigh))?)$/i;
/**
* Normalize model ID for provider capability lookup.
@@ -32,16 +32,16 @@ function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvid
if (provider !== 'codex') return providerNormalized;
// New codex suffix forms: gpt-5.4-high-fast, gpt-5.4-fast-high -> gpt-5.4
// New codex suffix forms: gpt-5.4-low-fast, gpt-5.4-fast-high -> gpt-5.4
const codexSuffixMatch = providerNormalized.match(
/^(.*?)(?:-(?:xhigh|high|medium)(?:-fast)?|-fast(?:-(?:xhigh|high|medium))?)$/i
/^(.*?)(?:-(?:minimal|low|medium|high|xhigh)(?:-fast)?|-fast(?:-(?:minimal|low|medium|high|xhigh))?)$/i
);
if (codexSuffixMatch?.[1]) {
return codexSuffixMatch[1].trim();
}
// Legacy codex suffix form: gpt-5.3-codex(high) -> gpt-5.3-codex
const codexLegacyMatch = providerNormalized.match(/^(.*)\((xhigh|high|medium)\)$/i);
const codexLegacyMatch = providerNormalized.match(/^(.*)\((minimal|low|medium|high|xhigh)\)$/i);
if (codexLegacyMatch?.[1]) {
return codexLegacyMatch[1].trim();
}
+1 -1
View File
@@ -112,7 +112,7 @@ const defaultCliproxyImageAnalysisDeps: CliproxyImageAnalysisDeps = {
getLocalRuntimeApiKey: getEffectiveApiKey,
};
const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i;
const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(minimal|low|medium|high|xhigh)$/i;
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
function normalizeCodexModelForDirectUpstream(model: string): string {
@@ -563,6 +563,31 @@ describe('Claude Quota Fetcher', () => {
expect(result.windows).toHaveLength(0);
});
it('falls back to status-only message when error payload is too large', async () => {
createClaudeAccount('claude-large-error@example.com', {
access_token: 'oauth-token',
expired: '2099-01-01T00:00:00.000Z',
type: 'claude',
});
global.fetch = mock(() =>
Promise.resolve(
new Response('x'.repeat(9000), {
status: 400,
headers: {
'Content-Type': 'text/plain',
'Content-Length': '9000',
},
})
)
) as typeof fetch;
const result = await fetchClaudeQuota('claude-large-error@example.com');
expect(result.success).toBe(false);
expect(result.error).toBe('Claude OAuth usage API error: 400');
});
it('retries once on transient 500 then succeeds', async () => {
createClaudeAccount('claude-retry@example.com', {
access_token: 'retry-token',
@@ -597,6 +622,64 @@ describe('Claude Quota Fetcher', () => {
expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60);
});
it('clears the request timeout before retrying a retryable HTTP error', async () => {
createClaudeAccount('claude-retry-timeout@example.com', {
access_token: 'retry-timeout-token',
expired: '2099-01-01T00:00:00.000Z',
type: 'claude',
});
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const activeTimers = new Set<number>();
let nextTimerId = 0;
globalThis.setTimeout = mock(((handler: TimerHandler, timeout?: number) => {
void handler;
void timeout;
const timerId = ++nextTimerId;
activeTimers.add(timerId);
return timerId as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout);
globalThis.clearTimeout = mock(((timerId?: ReturnType<typeof setTimeout>) => {
activeTimers.delete(timerId as unknown as number);
}) as typeof clearTimeout);
let attempt = 0;
global.fetch = mock(() => {
attempt += 1;
if (attempt === 1) {
return Promise.resolve(new Response('', { status: 500 }));
}
return Promise.resolve(
new Response(
JSON.stringify({
five_hour: {
utilization: 25,
resets_at: '2026-03-01T01:00:00Z',
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
);
}) as typeof fetch;
try {
const result = await fetchClaudeQuota('claude-retry-timeout@example.com');
expect(result.success).toBe(true);
expect(attempt).toBe(2);
expect(globalThis.setTimeout).toHaveBeenCalledTimes(2);
expect(globalThis.clearTimeout).toHaveBeenCalledTimes(2);
expect(activeTimers.size).toBe(0);
} finally {
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
it('retries once after AbortError and succeeds', async () => {
createClaudeAccount('claude-timeout@example.com', {
access_token: 'timeout-token',
+38 -4
View File
@@ -21,6 +21,7 @@ export const CLAUDE_OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage
const CLAUDE_QUOTA_TIMEOUT_MS = 10000;
const CLAUDE_QUOTA_MAX_ATTEMPTS = 2;
const CLAUDE_OAUTH_BETA_HEADER = 'oauth-2025-04-20';
const CLAUDE_QUOTA_ERROR_BODY_MAX_BYTES = 8192;
interface ClaudeAuthData {
accessToken: string;
@@ -79,8 +80,32 @@ function extractErrorMessage(payload: unknown): string | null {
async function readResponseErrorMessage(response: Response): Promise<string | null> {
try {
const body = await response.text();
if (!body || body.trim().length === 0) return null;
const contentLength = Number(response.headers.get('content-length') ?? '0');
if (Number.isFinite(contentLength) && contentLength > CLAUDE_QUOTA_ERROR_BODY_MAX_BYTES) {
return null;
}
const reader = response.body?.getReader();
if (!reader) return null;
const decoder = new TextDecoder();
const chunks: string[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > CLAUDE_QUOTA_ERROR_BODY_MAX_BYTES) return null;
chunks.push(decoder.decode(value, { stream: true }));
}
chunks.push(decoder.decode());
const body = chunks.join('').trim();
if (!body) return null;
try {
const parsed = JSON.parse(body) as unknown;
@@ -90,7 +115,7 @@ async function readResponseErrorMessage(response: Response): Promise<string | nu
// fall through to plain-text fallback
}
return body.trim();
return body;
} catch {
return null;
}
@@ -230,13 +255,13 @@ export async function fetchClaudeQuota(
},
});
clearTimeout(timeoutId);
if (verbose) {
console.error(`[i] Claude OAuth usage status: ${response.status} (attempt ${attempt})`);
}
if (response.status === 401) {
const errorMessage = await readResponseErrorMessage(response);
clearTimeout(timeoutId);
return buildEmptyResult(
errorMessage || 'Authentication required for Claude OAuth usage',
accountId,
@@ -245,10 +270,12 @@ export async function fetchClaudeQuota(
}
if (response.status === 404) {
clearTimeout(timeoutId);
return buildEmptyResult('Claude OAuth usage endpoint not found', accountId);
}
if (response.status === 403) {
clearTimeout(timeoutId);
return buildEmptyResult('Not authorized for Claude OAuth usage', accountId);
}
@@ -260,8 +287,10 @@ export async function fetchClaudeQuota(
attempt < CLAUDE_QUOTA_MAX_ATTEMPTS &&
(response.status === 429 || response.status >= 500)
) {
clearTimeout(timeoutId);
continue;
}
clearTimeout(timeoutId);
return buildEmptyResult(lastError, accountId);
}
@@ -269,16 +298,20 @@ export async function fetchClaudeQuota(
try {
payload = await response.json();
} catch {
clearTimeout(timeoutId);
return buildEmptyResult('Invalid Claude OAuth usage format', accountId);
}
if (!toObject(payload)) {
clearTimeout(timeoutId);
return buildEmptyResult('Invalid Claude OAuth usage format', accountId);
}
const windows = buildClaudeQuotaWindows(payload as Record<string, unknown>);
const coreUsage = buildClaudeCoreUsageSummary(windows);
clearTimeout(timeoutId);
return {
success: true,
windows,
@@ -304,6 +337,7 @@ export async function fetchClaudeQuota(
}
if (attempt >= CLAUDE_QUOTA_MAX_ATTEMPTS) {
clearTimeout(timeoutId);
return buildEmptyResult(lastError, accountId);
}
}
+72 -1
View File
@@ -476,6 +476,75 @@ export async function findHealthyAccount(
return selectable[0];
}
export async function reconcileExhaustedRotationAccounts(
provider: CLIProxyProvider
): Promise<string[]> {
if (!isManagedQuotaProvider(provider)) {
return [];
}
const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import(
'../accounts/account-safety'
);
restoreExpiredQuotaPauses();
const config = loadOrCreateUnifiedConfig();
const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5;
const cooldownMinutes = config.quota_management?.auto?.cooldown_minutes ?? 5;
const activeAccounts = getProviderAccounts(provider).filter(
(account) => !isAccountPaused(provider, account.id)
);
if (activeAccounts.length < 2) {
return [];
}
const accountsWithQuota = await batchedMap(
activeAccounts,
async (account) => {
let quota = getCachedQuota(provider, account.id);
if (!quota) {
quota = await fetchQuotaWithDedup(provider, account.id);
}
return {
account,
quotaPercent: calculateQuotaPercent(provider, quota),
};
},
10
);
const healthyAccountIds = new Set(
accountsWithQuota
.filter(({ account, quotaPercent }) => {
if (isOnCooldown(provider, account.id)) return false;
return quotaPercent !== null && quotaPercent >= threshold;
})
.map(({ account }) => account.id)
);
if (healthyAccountIds.size === 0) {
return [];
}
const pausedAccountIds: string[] = [];
for (const { account, quotaPercent } of accountsWithQuota) {
const exhaustedByQuota = quotaPercent !== null && quotaPercent < threshold;
const exhaustedByCooldown = isOnCooldown(provider, account.id);
if (!exhaustedByQuota && !exhaustedByCooldown) continue;
const hasOtherHealthyAccount = [...healthyAccountIds].some((id) => id !== account.id);
if (!hasOtherHealthyAccount) continue;
applyCooldown(provider, account.id, cooldownMinutes);
if (pauseAccountForQuotaCooldown(provider, account.id, cooldownMinutes)) {
pausedAccountIds.push(account.id);
}
}
return pausedAccountIds;
}
/**
* Find and switch to a healthy account
*/
@@ -543,11 +612,13 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
return { proceed: false, accountId: '', reason: 'No accounts configured' };
}
await reconcileExhaustedRotationAccounts(provider);
// Check forced_default override (manual mode)
const forcedDefault = quotaConfig.manual?.forced_default;
if (forcedDefault) {
const forcedAccount = getProviderAccounts(provider).find((a) => a.id === forcedDefault);
if (forcedAccount) {
if (forcedAccount && !isAccountPaused(provider, forcedAccount.id)) {
return { proceed: true, accountId: forcedAccount.id, reason: 'Forced default override' };
}
}
@@ -636,6 +636,45 @@ describe('fetchCliproxyUsageRaw', () => {
});
});
it('skips oversized CLIProxy main logs and falls back to API-key totals', async () => {
writeCliproxyMainLog([
'2026-05-05T18:45:00.000Z INFO request_id=req-1 Use OAuth provider=codex auth_file=codex-user@example.com-pro.json for model gpt-5.5',
'x'.repeat(2 * 1024 * 1024 + 64),
'2026-05-05T18:45:01.000Z INFO request_id=req-1 POST "/api/provider/codex/v1/messages?beta=true" status=200',
]);
let apiKeyUsageCalls = 0;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = requestUrl(input);
if (url.endsWith('/v0/management/usage')) {
return jsonResponse({ error: 'not found' }, 404);
}
if (url.includes('/v0/management/usage-queue?count=1000')) {
return jsonResponse([]);
}
if (url.endsWith('/v0/management/api-key-usage')) {
apiKeyUsageCalls++;
return jsonResponse({
codex: {
'oauth|codex-user@example.com-pro.json': {
success: 3,
failed: 0,
},
},
});
}
throw new Error(`unexpected URL: ${url}`);
}) as typeof fetch;
const raw = await runWithScopedConfigDir(ccsDir, () => fetchCliproxyUsageRaw(19221));
expect(apiKeyUsageCalls).toBe(1);
expect(raw?.usage?.total_requests).toBe(3);
expect(raw?.usage?.success_count).toBe(3);
expect(raw?.usage?.apis?.codex.total_requests).toBe(3);
expect(raw?.usage?.apis?.codex.models?.['gpt-5.5']).toBeUndefined();
});
it('parses bracketed CLIProxy main.log request ids and local timestamps', async () => {
writeCliproxyMainLog([
'[2026-05-05 18:45:00] [req-a] [debug] Use OAuth provider=codex auth_file=codex-user-a@example.com-pro.json for model gpt-5.5',
@@ -176,11 +176,18 @@ export function buildUsageResponseFromCliproxyLogLines(lines: string[]): Cliprox
return buildUsageResponseFromQueueRecords(records);
}
const MAX_LOG_BYTES = 2 * 1024 * 1024;
function readLogFile(filePath: string): string | null {
if (!fs.existsSync(filePath)) {
return null;
}
const stats = fs.statSync(filePath);
if (stats.size > MAX_LOG_BYTES) {
return null;
}
return fs.readFileSync(filePath, 'utf-8');
}
+36
View File
@@ -0,0 +1,36 @@
import * as fs from 'fs';
import { createLogger } from '../services/logging';
import { decodeIdToken } from './decode-id-token';
import type { CodexAccountIdentity } from './types';
const logger = createLogger('codex-auth:identity');
interface AuthJson {
tokens?: {
id_token?: string;
};
}
/**
* Read auth.json from disk and extract display-safe identity fields.
* Returns {} on any error (missing file, bad JSON, missing token, decode failure).
* Never throws.
*/
export function decodeAccountIdentity(authJsonPath: string): CodexAccountIdentity {
try {
const raw = fs.readFileSync(authJsonPath, 'utf8');
const parsed = JSON.parse(raw) as AuthJson;
const idToken = parsed?.tokens?.id_token;
if (typeof idToken !== 'string' || idToken.length === 0) {
return {};
}
return decodeIdToken(idToken);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(
'codex-auth.identity.decode-failed',
`Failed to decode account identity from ${authJsonPath}: ${msg}`
);
return {};
}
}
@@ -0,0 +1,261 @@
/**
* Dashboard service for codex-auth profile summary.
*
* Reads the profile registry, decodes each profile's auth.json JWT,
* resolves the active profile via env precedence, and returns the
* API response shape for GET /api/codex/profiles.
*
* Security: tokens NEVER appear in the returned object. Only display-safe
* fields (email, plan, accountId) are extracted from JWT. auth.json is
* read/decoded and then discarded.
*
* Cache: 5-second in-memory cache reduces full registry/auth reads during
* dashboard polling, but each call still stats the registry so corruption or
* edits are surfaced immediately instead of serving stale success.
*/
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../services/logging';
import { decodeAccountIdentity } from './codex-account-identity';
import { hasStructurallyValidIdToken } from './decode-id-token';
import { getCodexAuthRegistryPath, getCodexInstancesDir } from './codex-profile-paths';
import { CODEX_PROFILE_SCHEMA_VERSION } from './types';
import { CodexProfileRegistry } from './codex-profile-registry';
import type { CodexProfileData, CodexProfileMetadata } from './types';
const logger = createLogger('codex-auth:dashboard');
// ── Response types ──────────────────────────────────────────────────────────
export interface CodexAuthProfileEntry {
name: string;
codexHome: string;
email: string | null;
plan: string | null;
accountId: string | null;
lastUsed: string | null;
authValid: boolean;
}
export interface CodexAuthActiveProfile {
name: string | null;
source: 'default' | 'env' | 'explicit-codex-home';
codexHome: string;
}
export interface CodexAuthProfilesSummary {
active: CodexAuthActiveProfile | null;
default: string | null;
profiles: CodexAuthProfileEntry[];
}
// ── Cache ───────────────────────────────────────────────────────────────────
let cache: {
value: CodexAuthProfilesSummary;
expiresAt: number;
registrySignature: string;
} | null = null;
const TTL_MS = 5000;
/**
* Invalidate the in-process cache so the next call re-reads from disk.
* Useful for Phase 2 CLI commands running in the same process as the dashboard.
* Out-of-process invocations rely on the 5s TTL.
*/
export function invalidateCodexAuthProfilesCache(): void {
cache = null;
}
// ── Registry helpers ────────────────────────────────────────────────────────
function getRegistryCacheSignature(): string {
const registryPath = getCodexAuthRegistryPath();
try {
const stat = fs.statSync(registryPath);
return ['present', stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':');
} catch (err) {
if ((err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
return 'missing';
}
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.dashboard.registry-stat-failed', `Registry stat failed: ${msg}`);
throw new Error('Codex auth profile registry could not be checked safely');
}
}
function readRegistry(): CodexProfileData {
const registryPath = getCodexAuthRegistryPath();
if (!fs.existsSync(registryPath)) {
return { version: CODEX_PROFILE_SCHEMA_VERSION, default: null, profiles: {} };
}
try {
const registry = new CodexProfileRegistry(registryPath);
const profiles: Record<string, CodexProfileMetadata> = {};
for (const name of registry.listProfiles()) {
profiles[name] = registry.getProfile(name);
}
return {
version: CODEX_PROFILE_SCHEMA_VERSION,
default: registry.getDefault(),
profiles,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.dashboard.registry-read-failed', `Registry read failed: ${msg}`);
throw new Error(`Codex auth profile registry could not be read safely: ${msg}`);
}
}
// ── Profile entry builder ───────────────────────────────────────────────────
function buildProfileEntry(name: string): CodexAuthProfileEntry {
const codexHome = path.join(getCodexInstancesDir(), name);
const authJsonPath = path.join(codexHome, 'auth.json');
let authValid = false;
let email: string | null = null;
let plan: string | null = null;
let accountId: string | null = null;
try {
if (fs.existsSync(authJsonPath)) {
// decodeAccountIdentity never throws; returns {} on any error
const identity = decodeAccountIdentity(authJsonPath);
authValid = Object.keys(identity).length > 0 || _hasStructurallyValidIdToken(authJsonPath);
email = identity.email ?? null;
plan = identity.plan_type ?? null;
accountId = identity.account_id ?? null;
logger.debug('codex-auth.dashboard.decoded', 'Decoded auth profile summary', {
profileName: name,
hasEmail: email !== null,
hasPlan: plan !== null,
hasAccountId: accountId !== null,
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(
'codex-auth.dashboard.decode-error',
`Failed to decode auth for profile=${name}: ${msg}`
);
}
return {
name,
codexHome,
email,
plan,
accountId,
// lastUsed is set below by caller from registry metadata
lastUsed: null,
authValid,
};
}
/**
* Check whether auth.json has a parseable JWT payload, even if it has no
* display fields. A non-empty but malformed token is not valid auth.
* This sets authValid=true for valid-but-sparse tokens.
*/
function _hasStructurallyValidIdToken(authJsonPath: string): boolean {
try {
const raw = fs.readFileSync(authJsonPath, 'utf8');
const parsed = JSON.parse(raw) as { tokens?: { id_token?: string } };
const idToken = parsed?.tokens?.id_token;
return typeof idToken === 'string' && hasStructurallyValidIdToken(idToken);
} catch {
return false;
}
}
// ── Active resolution ───────────────────────────────────────────────────────
function resolveActive(registry: CodexProfileData): CodexAuthActiveProfile | null {
const instancesDir = getCodexInstancesDir();
// Precedence 1: explicit $CODEX_HOME set by env (ccsxp, manual)
const codexHome = (process.env.CODEX_HOME ?? '').trim();
if (codexHome) {
// Attempt reverse-map: does any registered profile's codexHome match?
const matchedName =
Object.keys(registry.profiles).find((name) => path.join(instancesDir, name) === codexHome) ??
null;
return {
name: matchedName,
source: 'explicit-codex-home',
codexHome,
};
}
// Precedence 2: $CCS_CODEX_PROFILE env var
const profileEnv = (process.env.CCS_CODEX_PROFILE ?? '').trim();
if (profileEnv) {
if (!Object.prototype.hasOwnProperty.call(registry.profiles, profileEnv)) {
logger.warn(
'codex-auth.dashboard.stale-env-profile',
'Ignoring CCS_CODEX_PROFILE because it is not registered',
{ profileName: profileEnv }
);
return null;
}
return {
name: profileEnv,
source: 'env',
codexHome: path.join(instancesDir, profileEnv),
};
}
// Precedence 3: registry default
const defaultProfile = registry.default;
if (defaultProfile) {
return {
name: defaultProfile,
source: 'default',
codexHome: path.join(instancesDir, defaultProfile),
};
}
// Precedence 4: no active profile (legacy ~/.codex mode)
return null;
}
// ── Core builder ────────────────────────────────────────────────────────────
async function buildSummary(): Promise<CodexAuthProfilesSummary> {
const registry = readRegistry();
const active = resolveActive(registry);
const profiles: CodexAuthProfileEntry[] = Object.entries(registry.profiles).map(
([name, meta]) => {
const entry = buildProfileEntry(name);
entry.lastUsed = meta.last_used ?? null;
return entry;
}
);
return {
active,
default: registry.default,
profiles,
};
}
// ── Public API ──────────────────────────────────────────────────────────────
/**
* Returns the codex-auth profiles summary, using a 5s in-memory cache.
* Tokens are never included in the returned object.
*/
export async function getCodexAuthProfilesSummary(): Promise<CodexAuthProfilesSummary> {
const now = Date.now();
const registrySignature = getRegistryCacheSignature();
if (cache && cache.expiresAt > now && cache.registrySignature === registrySignature) {
return cache.value;
}
const value = await buildSummary();
cache = { value, expiresAt: now + TTL_MS, registrySignature };
return value;
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Help text for `ccsx auth` command tree.
* ASCII-only. Includes ccsxp scope clarifier (H5).
*/
export function printCodexAuthHelp(): void {
process.stdout.write(`CCS Concurrent Codex Account Management
Usage
ccsx auth <command> [options]
Commands
create <name> Create a new Codex profile (idempotent)
login <name> Run \`codex login\` against the profile (auto-creates if missing)
switch <name> Set the persistent default Codex profile
use <name> Emit shell-eval exports to activate a profile in this shell only
show [name] List profiles or show details for one
remove <name> Delete a profile (auth.json + profile dir + registry entry)
import-default <name> Migrate legacy ~/.codex/auth.json into a new profile
Shell activation (per terminal)
bash/zsh: eval "$(ccsx auth use work)"
fish: ccsx auth use work | source
pwsh: ccsx auth use work | Invoke-Expression
Examples
ccsx auth create work
ccsx auth login work # OAuth in browser
ccsx auth create personal
ccsx auth login personal
eval "$(ccsx auth use work)" # terminal A
eval "$(ccsx auth use personal)" # terminal B
codex # each terminal uses its own account
ccsx auth show
ccsx auth switch personal # change persistent default
ccsx auth remove old --yes
Options
--yes, -y Skip confirmation (remove)
--force Re-link config.toml (create) | override default check (remove) |
overwrite existing profile (import-default)
--json JSON output (show)
--shell <s> Override shell detection (use): bash|zsh|fish|pwsh|cmd
--with-history Copy history.jsonl + sessions/ too (import-default, default: off)
--force-while-running Allow import-default even if Codex is running (risky)
Notes
Auth state (auth.json) and history.jsonl are isolated per profile.
config.toml is shared via symlink to ~/.codex/config.toml.
Default profile (switch) is persistent across shells.
Active profile (use) is per-terminal via CODEX_HOME / CCS_CODEX_PROFILE.
Note: This feature applies only to native \`codex\`. \`ccsxp\` ignores
CCS_CODEX_PROFILE and uses its own cliproxy pool.
`);
}
export function printCodexAuthUseHelp(): void {
process.stdout.write(`ccsx auth use — Activate a Codex profile in the current shell
Usage
ccsx auth use <name> [--shell <bash|zsh|fish|pwsh|cmd>]
Description
Emits shell-evalable export statements to stdout. Use within eval "$(...)"
only. Output to stdout is shell-evaluatable; do not pipe to other commands.
All errors and informational messages go to stderr so the eval is never
contaminated.
Shell evaluation
bash/zsh: eval "$(ccsx auth use work)"
fish: ccsx auth use work | source
pwsh: ccsx auth use work | Invoke-Expression
cmd: (not supported natively; use PowerShell)
Options
--shell <s> Override auto-detected shell: bash|zsh|fish|pwsh|cmd
Note: This profile applies only to native \`codex\`. \`ccsxp\` ignores
CCS_CODEX_PROFILE and uses its own cliproxy pool.
`);
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Codex auth command router.
*
* Exports runCodexAuth(argv) which routes argv[0] (the subcommand) to
* the appropriate handler. Returns an exit code (0 = success, non-zero = error).
*
* Phase 3 wires this into src/bin/codex-runtime.ts for argv[2]==='auth'.
*/
import { CodexProfileRegistry } from './codex-profile-registry';
import { printCodexAuthHelp } from './codex-auth-help';
import {
handleCreateCodex,
handleLoginCodex,
handleSwitchCodex,
handleUseCodex,
handleShowCodex,
handleRemoveCodex,
handleImportDefaultCodex,
} from './commands/index';
import type { CodexCommandContext } from './commands/types';
const packageJson = require('../../package.json') as { version: string };
/**
* Route a `ccsx auth <subcommand> [...args]` invocation.
*
* @param argv - Arguments after `auth`, e.g. ['create', 'work'] or ['--help']
* @returns Exit code (0 success, 1 user error, 2+ system error)
*/
export async function runCodexAuth(argv: string[]): Promise<number> {
const [subcommand, ...rest] = argv;
// Help / no-arg
if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') {
printCodexAuthHelp();
return 0;
}
// Version passthrough
if (subcommand === '--version' || subcommand === '-v') {
process.stdout.write(`ccsx auth ${packageJson.version}\n`);
return 0;
}
const registry = new CodexProfileRegistry();
const ctx: CodexCommandContext = {
registry,
version: packageJson.version,
};
try {
switch (subcommand) {
case 'create':
await handleCreateCodex(ctx, rest);
return 0;
case 'login':
await handleLoginCodex(ctx, rest);
return 0;
case 'switch':
await handleSwitchCodex(ctx, rest);
return 0;
case 'use':
await handleUseCodex(ctx, rest);
return 0;
case 'show':
await handleShowCodex(ctx, rest);
return 0;
case 'remove':
await handleRemoveCodex(ctx, rest);
return 0;
case 'import-default':
await handleImportDefaultCodex(ctx, rest);
return 0;
default:
process.stderr.write(`[X] Unknown command: ${subcommand}\n`);
process.stderr.write(` ccsx auth --help\n`);
return 1;
}
} catch (err) {
// Unhandled errors from handlers (e.g. process.exit called inside)
// These should be rare — handlers use exitWithError() which calls process.exit
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[X] Unexpected error in ccsx auth ${subcommand}: ${msg}\n`);
return 1;
}
}
+121
View File
@@ -0,0 +1,121 @@
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../services/logging';
import { getSharedCodexConfigPath } from './codex-profile-paths';
const logger = createLogger('codex-auth:symlink');
export interface EnsureSharedConfigSymlinkOptions {
overwriteRegularFile?: boolean;
}
/**
* Ensure <profileDir>/config.toml points at the shared ~/.codex/config.toml.
* Self-healing: recreates stale or missing symlinks. If symlink creation is
* unavailable, copies the shared config so the profile still has settings.
*
* @param profileDir - The per-profile directory (will be created if missing).
* @param sharedConfigPath - Override for the shared target path (used in tests
* to avoid touching real ~/.codex/config.toml). Defaults to getSharedCodexConfigPath().
*/
export function ensureSharedConfigSymlink(
profileDir: string,
sharedConfigPath?: string,
options: EnsureSharedConfigSymlinkOptions = {}
): void {
const targetPath = sharedConfigPath ?? getSharedCodexConfigPath();
const linkPath = path.join(profileDir, 'config.toml');
// Ensure profile directory exists
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
// Ensure shared config target parent directory exists
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
// Create empty shared config if it doesn't exist — Codex will populate on first run
if (!fs.existsSync(targetPath)) {
fs.writeFileSync(targetPath, '', { mode: 0o600 });
logger.stage('dispatch', 'codex.shared-config.created', 'Created empty shared config.toml', {
path: targetPath,
});
}
// Inspect whatever currently exists at the link path
let existingStat: fs.Stats | null = null;
try {
existingStat = fs.lstatSync(linkPath);
} catch {
// ENOENT — nothing there yet, proceed to create
}
if (existingStat !== null) {
if (existingStat.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(linkPath);
if (currentTarget === targetPath) {
// Already correct — idempotent return
logger.stage('dispatch', 'codex.symlink.ok', 'Shared config symlink already correct', {
link: linkPath,
});
return;
}
// Stale symlink — remove and re-create
fs.unlinkSync(linkPath);
logger.stage('dispatch', 'codex.symlink.repaired', 'Replaced stale symlink', {
link: linkPath,
was: currentTarget,
now: targetPath,
});
} else if (
options.overwriteRegularFile ||
isRegularConfigCopyUnmodified(linkPath, targetPath)
) {
// Explicit repair or unchanged fallback copy — replace with a shared symlink when possible.
process.stderr.write(
`[!] codex-auth: overwriting regular file at ${linkPath} with symlink to shared config.toml\n`
);
fs.unlinkSync(linkPath);
} else {
process.stderr.write(
`[!] codex-auth: preserving existing regular config.toml at ${linkPath}; ` +
`use ccsx auth create <name> --force to refresh it.\n`
);
logger.warn('codex-auth.symlink-regular-file-preserved', 'Preserved regular config.toml', {
link: linkPath,
target: targetPath,
});
return;
}
}
try {
fs.symlinkSync(targetPath, linkPath);
logger.stage('dispatch', 'codex.symlink.created', 'Created shared config symlink', {
link: linkPath,
target: targetPath,
});
} catch (err) {
copySharedConfigFallback(targetPath, linkPath, err);
}
}
function isRegularConfigCopyUnmodified(linkPath: string, targetPath: string): boolean {
try {
return fs.readFileSync(linkPath, 'utf8') === fs.readFileSync(targetPath, 'utf8');
} catch {
return false;
}
}
function copySharedConfigFallback(targetPath: string, linkPath: string, err: unknown): void {
fs.copyFileSync(targetPath, linkPath);
fs.chmodSync(linkPath, 0o600);
process.stderr.write(
`[!] codex-auth: symlink unavailable; copied shared config.toml to ${linkPath}. ` +
`Config edits won't propagate automatically.\n`
);
logger.warn('codex-auth.symlink-copy-fallback', 'Copied shared config after symlink failure', {
link: linkPath,
target: targetPath,
error: err instanceof Error ? err.message : String(err),
});
}
+33
View File
@@ -0,0 +1,33 @@
import * as path from 'path';
import * as os from 'os';
import { getCcsDir } from '../utils/config-manager';
import { getCodexProfileNameError } from './types';
export function getCodexAuthRegistryPath(): string {
return path.join(getCcsDir(), 'codex-profiles.yaml');
}
export function getCodexInstancesDir(): string {
return path.join(getCcsDir(), 'codex-instances');
}
export function resolveCodexProfileDir(name: string): string {
const nameError = getCodexProfileNameError(name);
if (nameError) {
throw new Error(nameError);
}
const instancesDir = path.resolve(getCodexInstancesDir());
const profileDir = path.resolve(path.join(instancesDir, name));
if (profileDir !== instancesDir && profileDir.startsWith(`${instancesDir}${path.sep}`)) {
return profileDir;
}
throw new Error('Profile directory resolved outside codex-instances.');
}
// Uses os.homedir() intentionally — this is the upstream Codex location,
// not a CCS-owned path. Tests must override the shared config path explicitly.
export function getSharedCodexConfigPath(): string {
return path.join(os.homedir(), '.codex', 'config.toml');
}
+369
View File
@@ -0,0 +1,369 @@
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import * as lockfile from 'proper-lockfile';
import { createLogger } from '../services/logging';
import { getCodexAuthRegistryPath } from './codex-profile-paths';
import { getCcsDirSource } from '../utils/config-manager';
import { CODEX_PROFILE_SCHEMA_VERSION, getCodexProfileNameError } from './types';
import type { CodexProfileData, CodexProfileMetadata } from './types';
const logger = createLogger('codex-auth:registry');
const REGISTRY_LOCK_STALE_MS = 10000;
const REGISTRY_LOCK_RETRIES = 40;
const REGISTRY_LOCK_RETRY_DELAY_MS = 50;
function emptyRegistry(): CodexProfileData {
return { version: CODEX_PROFILE_SCHEMA_VERSION, default: null, profiles: {} };
}
export class CodexProfileRegistryReadError extends Error {
constructor(message: string) {
super(message);
this.name = 'CodexProfileRegistryReadError';
}
}
export function validateCodexProfileRegistryData(parsed: unknown): CodexProfileData {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('registry YAML root is not an object');
}
const data = parsed as Partial<CodexProfileData>;
if (!data.profiles || typeof data.profiles !== 'object' || Array.isArray(data.profiles)) {
throw new Error('registry YAML is missing an object profiles map');
}
if (data.default !== undefined && data.default !== null && typeof data.default !== 'string') {
throw new Error('registry YAML default must be a string or null');
}
if (typeof data.default === 'string') {
assertValidProfileName(data.default);
}
const profiles: Record<string, CodexProfileMetadata> = {};
for (const [name, profile] of Object.entries(data.profiles)) {
assertValidProfileName(name);
profiles[name] = validateProfileMetadata(name, profile);
}
if (
typeof data.default === 'string' &&
!Object.prototype.hasOwnProperty.call(profiles, data.default)
) {
throw new Error('registry YAML default profile is missing from profiles map');
}
return {
version: typeof data.version === 'string' ? data.version : CODEX_PROFILE_SCHEMA_VERSION,
default: data.default ?? null,
profiles,
};
}
function assertValidProfileName(name: string): void {
const nameError = getCodexProfileNameError(name);
if (nameError) {
throw new Error(`registry YAML contains invalid profile name "${name}": ${nameError}`);
}
}
function validateProfileMetadata(name: string, profile: unknown): CodexProfileMetadata {
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
throw new Error(`registry YAML profile "${name}" must be an object`);
}
const meta = profile as Partial<CodexProfileMetadata>;
if (meta.type !== 'codex') {
throw new Error(`registry YAML profile "${name}" must have type "codex"`);
}
if (typeof meta.created !== 'string') {
throw new Error(`registry YAML profile "${name}" must have a string created timestamp`);
}
if (meta.last_used !== null && typeof meta.last_used !== 'string') {
throw new Error(
`registry YAML profile "${name}" must have a string or null last_used timestamp`
);
}
if (meta.email !== undefined && typeof meta.email !== 'string') {
throw new Error(`registry YAML profile "${name}" email must be a string`);
}
if (
meta.plan_type !== undefined &&
meta.plan_type !== null &&
typeof meta.plan_type !== 'string'
) {
throw new Error(`registry YAML profile "${name}" plan_type must be a string or null`);
}
if (meta.account_id !== undefined && typeof meta.account_id !== 'string') {
throw new Error(`registry YAML profile "${name}" account_id must be a string`);
}
return meta as CodexProfileMetadata;
}
function registryDisplayPath(registryPath: string): string {
const [source] = getCcsDirSource();
const defaultRegistryPath = path.resolve(getCodexAuthRegistryPath());
if (path.resolve(registryPath) !== defaultRegistryPath) {
return path.basename(registryPath);
}
if (source === 'default') {
return process.platform === 'win32'
? '%USERPROFILE%\\.ccs\\codex-profiles.yaml'
: '~/.ccs/codex-profiles.yaml';
}
if (source === 'CCS_HOME' || source === 'scoped:CCS_HOME') {
return '$CCS_HOME/.ccs/codex-profiles.yaml';
}
if (source === 'CCS_DIR' || source === 'scoped:CCS_DIR') {
return '$CCS_DIR/codex-profiles.yaml';
}
return 'codex-profiles.yaml';
}
function safeRegistryReadMessage(err: unknown): string {
if ((err as { name?: unknown } | undefined)?.name === 'YAMLException') {
return 'registry YAML could not be parsed';
}
return err instanceof Error ? err.message : String(err);
}
function sleepSync(ms: number): void {
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
} catch {
const end = Date.now() + ms;
while (Date.now() < end) {
// Fall back for runtimes without Atomics.wait.
}
}
}
function isLockContentionError(err: unknown): boolean {
return (err as NodeJS.ErrnoException | undefined)?.code === 'ELOCKED';
}
/**
* Registry for codex auth profiles stored at ~/.ccs/codex-profiles.yaml.
*
* Writes are guarded by a registry-directory lock around the read-modify-write
* cycle, then persisted atomically with tmp file + POSIX rename.
*
* Constructor accepts an optional registryPath for test isolation.
*/
export class CodexProfileRegistry {
private readonly registryPath: string;
constructor(registryPath?: string) {
this.registryPath = registryPath ?? getCodexAuthRegistryPath();
this._cleanOrphanTmpFiles();
}
// ── private read/write ──────────────────────────────────────────────────
private _read(): CodexProfileData {
if (!fs.existsSync(this.registryPath)) {
return emptyRegistry();
}
try {
const raw = fs.readFileSync(this.registryPath, 'utf8');
return validateCodexProfileRegistryData(yaml.load(raw));
} catch (err) {
const msg = safeRegistryReadMessage(err);
const displayPath = registryDisplayPath(this.registryPath);
logger.warn(
'codex-auth.registry.read-failed',
`Registry at ${displayPath} could not be read safely; refusing empty-state rewrite: ${msg}`
);
throw new CodexProfileRegistryReadError(
`Codex profile registry at ${displayPath} could not be read safely: ${msg}. Refusing to rewrite it.`
);
}
}
private _write(data: CodexProfileData): void {
const dir = path.dirname(this.registryPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Unique tmp suffix avoids collisions on concurrent writes and orphan leaks
const tmpPath = `${this.registryPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
try {
fs.writeFileSync(tmpPath, yaml.dump(data, { indent: 2, lineWidth: -1 }), {
mode: 0o600,
});
fs.renameSync(tmpPath, this.registryPath);
} catch (err) {
if (fs.existsSync(tmpPath)) {
try {
fs.unlinkSync(tmpPath);
} catch {
// best-effort cleanup
}
}
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to write codex profile registry: ${msg}`);
}
}
private _withRegistryWriteLock<T>(callback: () => T): T {
const dir = path.dirname(this.registryPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
let release: (() => void) | undefined;
let lastLockError: unknown;
for (let attempt = 0; attempt <= REGISTRY_LOCK_RETRIES; attempt++) {
try {
release = lockfile.lockSync(dir, { stale: REGISTRY_LOCK_STALE_MS }) as () => void;
break;
} catch (err) {
if (!isLockContentionError(err) || attempt === REGISTRY_LOCK_RETRIES) {
throw err;
}
lastLockError = err;
sleepSync(REGISTRY_LOCK_RETRY_DELAY_MS);
}
}
if (!release) {
const msg = lastLockError instanceof Error ? lastLockError.message : 'unknown lock error';
throw new Error(`Failed to acquire codex profile registry lock: ${msg}`);
}
try {
return callback();
} finally {
try {
release();
} catch {
// Best-effort release.
}
}
}
// Best-effort cleanup of orphan tmp files older than 1 hour (H3 mitigation).
private _cleanOrphanTmpFiles(): void {
const dir = path.dirname(this.registryPath);
const base = path.basename(this.registryPath);
if (!fs.existsSync(dir)) return;
try {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
for (const entry of fs.readdirSync(dir)) {
if (!entry.startsWith(`${base}.tmp.`)) continue;
const full = path.join(dir, entry);
try {
const stat = fs.statSync(full);
if (stat.mtimeMs < oneHourAgo) {
fs.unlinkSync(full);
}
} catch {
// ignore per-file errors
}
}
} catch {
// ignore cleanup failure silently
}
}
// ── CRUD ────────────────────────────────────────────────────────────────
createProfile(name: string, meta: Partial<CodexProfileMetadata> = {}): void {
assertValidProfileName(name);
this._withRegistryWriteLock(() => {
const data = this._read();
if (data.profiles[name]) {
throw new Error(`Profile already exists: ${name}`);
}
data.profiles[name] = {
type: 'codex',
created: new Date().toISOString(),
last_used: null,
...meta,
} as CodexProfileMetadata;
this._write(data);
logger.stage('route', 'codex-auth.profile.created', 'Codex profile created', { name });
});
}
getProfile(name: string): CodexProfileMetadata {
assertValidProfileName(name);
const data = this._read();
const profile = data.profiles[name];
if (!profile) {
throw new Error(`Profile not found: ${name}`);
}
return profile;
}
updateProfile(name: string, partial: Partial<CodexProfileMetadata>): void {
assertValidProfileName(name);
this._withRegistryWriteLock(() => {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.profiles[name] = { ...data.profiles[name], ...partial } as CodexProfileMetadata;
this._write(data);
});
}
removeProfile(name: string, options: { forceDefault?: boolean } = {}): void {
assertValidProfileName(name);
this._withRegistryWriteLock(() => {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
if (data.default === name && Object.keys(data.profiles).length > 1 && !options.forceDefault) {
throw new Error('Cannot remove default profile while other profiles exist without --force');
}
delete data.profiles[name];
if (data.default === name) {
data.default = null;
}
this._write(data);
logger.stage('cleanup', 'codex-auth.profile.deleted', 'Codex profile removed', { name });
});
}
listProfiles(): string[] {
return Object.keys(this._read().profiles);
}
hasProfile(name: string): boolean {
if (getCodexProfileNameError(name)) return false;
return Object.prototype.hasOwnProperty.call(this._read().profiles, name);
}
// ── Default pointer ──────────────────────────────────────────────────────
getDefault(): string | null {
return this._read().default;
}
setDefault(name: string): void {
assertValidProfileName(name);
this._withRegistryWriteLock(() => {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.default = name;
this._write(data);
});
}
clearDefault(): void {
this._withRegistryWriteLock(() => {
const data = this._read();
data.default = null;
this._write(data);
});
}
touchProfile(name: string): void {
this.updateProfile(name, { last_used: new Date().toISOString() });
}
}
+116
View File
@@ -0,0 +1,116 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { createLogger } from '../services/logging';
const logger = createLogger('codex-auth:resources');
export const SHARED_CODEX_RESOURCE_DIRS = ['agents', 'skills'] as const;
export interface EnsureCodexProfileResourcesOptions {
sharedCodexHome?: string;
}
export function ensureCodexProfileResources(
profileDir: string,
options: EnsureCodexProfileResourcesOptions = {}
): void {
const sharedCodexHome = options.sharedCodexHome ?? path.join(os.homedir(), '.codex');
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
fs.mkdirSync(sharedCodexHome, { recursive: true, mode: 0o700 });
for (const resourceName of SHARED_CODEX_RESOURCE_DIRS) {
ensureSharedResourceDir(profileDir, sharedCodexHome, resourceName);
}
}
function ensureSharedResourceDir(
profileDir: string,
sharedCodexHome: string,
resourceName: (typeof SHARED_CODEX_RESOURCE_DIRS)[number]
): void {
const targetPath = path.join(sharedCodexHome, resourceName);
const linkPath = path.join(profileDir, resourceName);
fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 });
let existingStat: fs.Stats | null = null;
try {
existingStat = fs.lstatSync(linkPath);
} catch {
// Missing resource path: create it below.
}
if (existingStat !== null) {
if (existingStat.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(linkPath);
if (currentTarget === targetPath) {
return;
}
fs.unlinkSync(linkPath);
} else if (existingStat.isDirectory()) {
if (isDirectoryEmpty(linkPath)) {
fs.rmSync(linkPath, { recursive: true, force: true });
} else {
copyMissingResourceEntries(targetPath, linkPath);
return;
}
} else {
process.stderr.write(
`[!] codex-auth: preserving existing non-directory ${resourceName} at ${linkPath}\n`
);
return;
}
}
try {
fs.symlinkSync(targetPath, linkPath, 'dir');
logger.stage('dispatch', 'codex.resource.symlink.created', 'Created shared resource symlink', {
link: linkPath,
target: targetPath,
resource: resourceName,
});
} catch (err) {
fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 });
copyMissingResourceEntries(targetPath, linkPath);
process.stderr.write(
`[!] codex-auth: symlink unavailable; copied shared ${resourceName} to ${linkPath}. ` +
`Resource edits won't propagate automatically.\n`
);
logger.warn(
'codex-auth.resource-copy-fallback',
'Copied shared resource after symlink failure',
{
link: linkPath,
target: targetPath,
resource: resourceName,
error: err instanceof Error ? err.message : String(err),
}
);
}
}
function isDirectoryEmpty(dirPath: string): boolean {
try {
return fs.readdirSync(dirPath).length === 0;
} catch {
return false;
}
}
function copyMissingResourceEntries(sourceDir: string, targetDir: string): void {
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (fs.existsSync(targetPath)) {
continue;
}
fs.cpSync(sourcePath, targetPath, {
recursive: true,
force: false,
errorOnExist: false,
preserveTimestamps: true,
});
}
}
+206
View File
@@ -0,0 +1,206 @@
/**
* codex-auth create command.
* Creates a new profile dir + shared config.toml symlink.
* After creation, auto-spawns `codex login` with CODEX_HOME pinned (D11).
* --force: re-link config.toml only, preserve auth.json (D9).
*/
import * as fs from 'fs';
import * as path from 'path';
import * as childProcess from 'child_process';
import { createLogger } from '../../services/logging';
import { initUI, info, ok } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import {
resolveCodexProfileDir,
ensureSharedConfigSymlink,
ensureCodexProfileResources,
} from '../index';
import { decodeAccountIdentity } from '../codex-account-identity';
import { detectCodexCli } from '../../targets/codex-detector';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
const logger = createLogger('codex-auth:cmd:create');
export async function handleCreateCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth create <name> [--force]', { force: true });
const { profileName, force } = parsed;
if (!profileName) {
console.log(`Usage: ccsx auth create <name> [--force]`);
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
const profileDir = resolveCodexProfileDir(profileName);
// Idempotent: profile already exists
if (registry.hasProfile(profileName)) {
if (force) {
// --force: only re-link config.toml, preserve auth.json
console.log(info(`Profile already exists: ${profileName} (repairing shared resources)`));
_ensureProfileResourcesSafe(profileDir, true);
console.log(ok(`Profile shared resources repaired.`));
console.log(` Profile dir: ${profileDir}`);
} else {
_ensureProfileResourcesSafe(profileDir);
console.log(info(`Profile already exists: ${profileName}`));
console.log(ok(`Profile shared resources are ready.`));
console.log(` Profile dir: ${profileDir}`);
console.log(` Run: ccsx auth login ${profileName}`);
}
return;
}
// Create profile dir + symlink FIRST (filesystem is more failure-prone than registry write).
// Avoids registry orphan if mkdir hits EACCES/ENOSPC.
try {
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
_ensureProfileResourcesSafe(profileDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if ((err as NodeJS.ErrnoException).code === 'EACCES') {
exitWithError(msg, ExitCode.GENERAL_ERROR);
return;
}
throw err;
}
// Now register in the profile registry
try {
registry.createProfile(profileName, {
created: new Date().toISOString(),
last_used: null,
email: undefined,
plan_type: undefined,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('corrupt') || msg.includes('Failed to write')) {
exitWithError(
`Profile registry is corrupt. Backup and remove the file to re-init.\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
throw err;
}
const authJsonPath = path.join(profileDir, 'auth.json');
const authExists = fs.existsSync(authJsonPath);
console.log(info(`Creating Codex profile: ${profileName}`));
console.log('');
console.log(` Profile dir : ${profileDir}`);
console.log(` Auth state : ${authExists ? 'authenticated' : 'not authenticated'}`);
console.log('');
console.log(ok('Profile created.'));
// D11: auto-spawn codex login after creating the profile
await _spawnLogin(profileName, profileDir, ctx);
}
function _ensureSymlinkSafe(profileDir: string, overwriteRegularFile = false): void {
try {
ensureSharedConfigSymlink(profileDir, undefined, { overwriteRegularFile });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.create.config-repair-failed', 'Config repair failed', {
profileDir,
error: msg,
});
exitWithError(`Failed to prepare profile config.toml: ${msg}`, ExitCode.CONFIG_ERROR);
}
}
function _ensureProfileResourcesSafe(profileDir: string, overwriteRegularFile = false): void {
_ensureSymlinkSafe(profileDir, overwriteRegularFile);
try {
ensureCodexProfileResources(profileDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.create.resource-repair-failed', 'Resource repair failed', {
profileDir,
error: msg,
});
exitWithError(`Failed to prepare profile resources: ${msg}`, ExitCode.CONFIG_ERROR);
}
}
async function _spawnLogin(
profileName: string,
profileDir: string,
ctx: CodexCommandContext
): Promise<void> {
const codexCli = detectCodexCli();
if (!codexCli) {
process.stderr.write(`[!] codex CLI not found — skipping auto-login.\n`);
process.stderr.write(` Install: npm i -g @openai/codex\n`);
process.stderr.write(` Then run: ccsx auth login ${profileName}\n`);
return;
}
console.log('');
console.log(`Next step: logging in to Codex...`);
console.log(` CODEX_HOME=${profileDir}`);
console.log('');
const loginResult = await new Promise<{ code: number; error?: string }>((resolve) => {
const child = childProcess.spawn(codexCli, ['login'], {
stdio: 'inherit',
env: { ...process.env, CODEX_HOME: profileDir },
windowsHide: true,
});
child.on('error', (err) => {
process.stderr.write(`[X] Failed to execute codex: ${err.message}\n`);
resolve({ code: ExitCode.BINARY_ERROR, error: err.message });
});
child.on('exit', (code) => {
resolve({ code: code ?? 1 });
});
});
if (loginResult.error) {
exitWithError(`codex login failed to start: ${loginResult.error}`, ExitCode.BINARY_ERROR);
return;
}
const authJsonPath = path.join(profileDir, 'auth.json');
if (loginResult.code === 0 && fs.existsSync(authJsonPath)) {
const identity = decodeAccountIdentity(authJsonPath);
ctx.registry.updateProfile(profileName, {
last_used: new Date().toISOString(),
email: identity.email,
plan_type: identity.plan_type ?? null,
account_id: identity.account_id,
});
const emailStr = identity.email ? ` as ${identity.email}` : '';
const planStr = identity.plan_type ? ` (plan: ${identity.plan_type})` : '';
console.log(ok(`Logged in${emailStr}${planStr}`));
} else if (loginResult.code === 0) {
process.stderr.write(
`[!] codex login exited cleanly but no auth.json. Skipping registry update.\n`
);
exitWithError('codex login completed without auth.json', ExitCode.AUTH_ERROR);
} else {
process.stderr.write(
`[!] Login cancelled or failed. Profile ${profileName} remains unauthenticated.\n`
);
process.stderr.write(` Retry: ccsx auth login ${profileName}\n`);
exitWithError('codex login failed', ExitCode.AUTH_ERROR);
}
}
@@ -0,0 +1,426 @@
/**
* codex-auth import-default command.
*
* Migrates legacy ~/.codex/auth.json into a named profile (non-destructive).
* Implements C3 torn-write protection: read-with-retry + JWT-shape validation
* + pgrep Codex-running detection + atomic write.
*
* Usage: ccsx auth import-default <name> [--with-history] [--force] [--force-while-running]
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as childProcess from 'child_process';
import { createLogger } from '../../services/logging';
import { ok } from '../../utils/ui';
import { initUI } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir, ensureSharedConfigSymlink, decodeIdToken } from '../index';
import { hasStructurallyValidIdToken } from '../decode-id-token';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
const logger = createLogger('codex-auth:cmd:import-default');
// Maximum retries for torn-write detection (C3)
const MAX_READ_RETRIES = 3;
const RETRY_DELAY_MS = 100;
// CLIProxy format marker (reject these with a clear message)
const CLIPROXY_TYPE_MARKER = 'type';
const IMPORT_DEFAULT_USAGE =
'ccsx auth import-default <name> [--with-history] [--force] [--force-while-running]';
// ── helpers ──────────────────────────────────────────────────────────────────
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Detect a running `codex` process via pgrep + ps validation (best-effort, never throws).
* Returns the PID string if found, null otherwise.
*/
function detectCodexRunning(): string | null {
try {
const result = childProcess.spawnSync('pgrep', ['-f', 'codex'], {
encoding: 'utf8',
timeout: 2000,
});
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
return null;
}
const pids = parsePgrepPids(result.stdout);
if (pids.length === 0) return null;
const psResult = childProcess.spawnSync(
'ps',
['-p', pids.join(','), '-o', 'pid=', '-o', 'command='],
{
encoding: 'utf8',
timeout: 2000,
}
);
if (psResult.status !== 0 || !psResult.stdout) return null;
return selectCodexPidFromPsOutput(psResult.stdout, pids);
} catch {
return null;
}
}
function parsePgrepPids(stdout: string): string[] {
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((pid) => /^\d+$/.test(pid) && pid !== String(process.pid));
}
function selectCodexPidFromPsOutput(stdout: string, candidatePids: string[]): string | null {
const candidates = new Set(candidatePids);
for (const line of stdout.split(/\r?\n/)) {
const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
if (!match) continue;
const [, pid, command] = match;
if (!pid || !command || !candidates.has(pid)) continue;
if (isLikelyCodexProcessCommand(command)) return pid;
}
return null;
}
function isLikelyCodexProcessCommand(command: string): boolean {
const executable = firstCommandToken(command);
if (isCodexExecutableToken(executable)) {
return true;
}
const normalized = command.replace(/\\/g, '/').toLowerCase();
if (normalized.includes('/@openai/codex/')) {
return true;
}
return command.split(/\s+/).some((token) => {
const tokenName = executableTokenBasename(token);
return tokenName === 'codex.js' || isCodexExecutableName(tokenName);
});
}
function firstCommandToken(command: string): string {
const trimmed = command.trim();
const quoted = trimmed.match(/^"([^"]+)"/);
if (quoted?.[1]) return quoted[1];
return trimmed.split(/\s+/)[0] ?? '';
}
function isCodexExecutableToken(token: string): boolean {
return isCodexExecutableName(executableTokenBasename(token));
}
function executableTokenBasename(token: string): string {
return path.basename(token.replace(/^["']|["']$/g, '').replace(/\\/g, '/')).toLowerCase();
}
function isCodexExecutableName(name: string): boolean {
return ['codex', 'codex.exe', 'codex.cmd', 'codex.ps1'].includes(name);
}
/**
* Read and validate auth.json with retry on torn-write (C3).
* Returns parsed auth JSON or throws on persistent failure.
*/
async function readAuthJsonSafe(authSrcPath: string): Promise<Record<string, unknown>> {
let lastErr: Error | null = null;
for (let attempt = 0; attempt < MAX_READ_RETRIES; attempt++) {
try {
const buf = fs.readFileSync(authSrcPath, 'utf8');
const parsed = JSON.parse(buf) as Record<string, unknown>;
// Reject cliproxy-format auth files
if (
typeof parsed[CLIPROXY_TYPE_MARKER] === 'string' &&
(parsed[CLIPROXY_TYPE_MARKER] === 'codex' ||
parsed[CLIPROXY_TYPE_MARKER] === 'anthropic' ||
parsed[CLIPROXY_TYPE_MARKER] === 'gemini')
) {
throw new Error(
`CLIPROXY_FORMAT: Source is a CLIProxy auth file (type="${parsed[CLIPROXY_TYPE_MARKER]}"); use \`ccs cliproxy ...\` instead.`
);
}
// Validate JWT shape: must have tokens.id_token with a parseable JWT payload.
const tokens = parsed['tokens'] as Record<string, unknown> | undefined;
if (tokens) {
const idToken = tokens['id_token'];
if (typeof idToken === 'string' && idToken.length > 0) {
if (!hasStructurallyValidIdToken(idToken)) {
// Torn write mid-JWT — retry
throw new Error('TORN_JWT: id_token payload is not parseable');
}
}
}
return parsed;
} catch (err) {
if (err instanceof Error && err.message.startsWith('CLIPROXY_FORMAT:')) {
throw err; // never retry CLIProxy format rejection
}
lastErr = err instanceof Error ? err : new Error(String(err));
logger.warn(
'codex-auth.import-default.torn-read',
`Read attempt ${attempt + 1}/${MAX_READ_RETRIES} failed: ${lastErr.message}`
);
if (attempt < MAX_READ_RETRIES - 1) {
await sleep(RETRY_DELAY_MS);
}
}
}
throw new Error(
`Failed to read a valid auth.json after ${MAX_READ_RETRIES} attempts: ${lastErr?.message ?? 'unknown'}`
);
}
/**
* Atomic copy: write to tmp.<pid>.<rand>, fsync, rename to dest.
* Preserves 0600 permissions.
*/
function atomicWriteFile(dest: string, content: string): void {
const tmpPath = `${dest}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
try {
fs.writeFileSync(tmpPath, content, { mode: 0o600 });
// fsync via close-and-reopen pattern (Bun/Node doesn't expose fd fsync easily)
const fd = fs.openSync(tmpPath, 'r');
fs.fsyncSync(fd);
fs.closeSync(fd);
fs.renameSync(tmpPath, dest);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
// best-effort cleanup
}
throw err;
}
}
/**
* Copy a file if it exists; silently skip if not.
* Returns 'copied' | 'missing'.
*/
function copyIfPresent(src: string, dest: string): 'copied' | 'missing' {
if (!fs.existsSync(src)) return 'missing';
const content = fs.readFileSync(src, 'utf8');
atomicWriteFile(dest, content);
return 'copied';
}
/**
* Recursively copy a directory if it exists.
* Returns count of files copied, or -1 if dir missing.
*/
function copyDirIfPresent(srcDir: string, destDir: string): number {
if (!fs.existsSync(srcDir)) return -1;
fs.mkdirSync(destDir, { recursive: true, mode: 0o700 });
let count = 0;
for (const entry of fs.readdirSync(srcDir)) {
const srcEntry = path.join(srcDir, entry);
const destEntry = path.join(destDir, entry);
const stat = fs.lstatSync(srcEntry);
if (stat.isDirectory()) {
count += copyDirIfPresent(srcEntry, destEntry);
} else if (stat.isFile()) {
fs.copyFileSync(srcEntry, destEntry);
count++;
}
}
return count;
}
// ── main command ──────────────────────────────────────────────────────────────
export interface ImportDefaultArgs {
name: string;
withHistory: boolean;
force: boolean;
forceWhileRunning: boolean;
}
function parseImportDefaultArgs(rawArgs: string[]): ImportDefaultArgs | null {
// --with-history, --force, --force-while-running are distinct flags
const parsed = parseArgs(rawArgs);
const unknownFlags = parsed.unknownFlags?.filter(
(flag) => flag !== '--with-history' && flag !== '--force-while-running'
);
rejectUnsupportedOptions({ ...parsed, unknownFlags }, IMPORT_DEFAULT_USAGE, { force: true });
const withHistory = rawArgs.includes('--with-history');
const forceWhileRunning = rawArgs.includes('--force-while-running');
if (!parsed.profileName) return null;
return {
name: parsed.profileName,
withHistory,
force: parsed.force ?? false,
forceWhileRunning,
};
}
export async function handleImportDefaultCodex(
ctx: CodexCommandContext,
rawArgs: string[]
): Promise<void> {
await initUI();
const args = parseImportDefaultArgs(rawArgs);
if (!args) {
console.log(`Usage: ${IMPORT_DEFAULT_USAGE}`);
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(args.name);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
// Resolve legacy Codex home — LEGACY_CODEX_HOME env allows test hermeticity (D decision)
const legacyCodexHome = process.env['LEGACY_CODEX_HOME'] ?? path.join(os.homedir(), '.codex');
const legacyAuthPath = path.join(legacyCodexHome, 'auth.json');
// Check legacy auth.json exists
if (!fs.existsSync(legacyAuthPath)) {
console.log(` Use \`ccsx auth login ${args.name}\` to authenticate a new profile instead.`);
exitWithError('No legacy auth.json', ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
// Profile collision check
if (registry.hasProfile(args.name) && !args.force) {
console.log(` Use --force to overwrite (a .bak-<ts> backup will be created).`);
exitWithError('Profile already exists', ExitCode.PROFILE_ERROR);
return;
}
// Detect Codex running (C3)
const codexPid = detectCodexRunning();
if (codexPid && !args.forceWhileRunning) {
process.stderr.write(
`[!] Codex appears to be running (PID ${codexPid}). A token refresh may be in flight.\n`
);
process.stderr.write(
` Quit Codex first, then re-run import-default. Or pass --force-while-running to proceed anyway.\n`
);
exitWithError('Codex is running', ExitCode.PROFILE_ERROR);
return;
}
if (codexPid && args.forceWhileRunning) {
process.stderr.write(
`[!] Proceeding with Codex running (--force-while-running). Be aware a refresh may race.\n`
);
}
// Read + validate source (C3 torn-write protection)
let authData: Record<string, unknown>;
try {
authData = await readAuthJsonSafe(legacyAuthPath);
} catch (err) {
if (err instanceof Error && err.message.startsWith('CLIPROXY_FORMAT:')) {
exitWithError(err.message, ExitCode.PROFILE_ERROR);
return;
}
const msg = err instanceof Error ? err.message : String(err);
exitWithError(msg, ExitCode.GENERAL_ERROR);
return;
}
const profileDir = resolveCodexProfileDir(args.name);
// Create dir
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
const destAuthPath = path.join(profileDir, 'auth.json');
// Backup existing auth.json if --force overwrite
if (args.force && fs.existsSync(destAuthPath)) {
const bakPath = `${destAuthPath}.bak-${Date.now()}`;
fs.copyFileSync(destAuthPath, bakPath);
process.stderr.write(`[i] Backed up existing auth.json to ${path.basename(bakPath)}\n`);
}
// Atomic write (C3)
atomicWriteFile(destAuthPath, JSON.stringify(authData, null, 2));
logger.stage('dispatch', 'codex-auth.import-default.copied', 'Copied auth.json to profile', {
name: args.name,
dest: destAuthPath,
});
// Optional: copy history + sessions (D8 default false)
let historyStatus: string;
let sessionsStatus: string;
if (args.withHistory) {
const legacyHistoryPath = path.join(legacyCodexHome, 'history.jsonl');
const destHistoryPath = path.join(profileDir, 'history.jsonl');
const historyCopied = copyIfPresent(legacyHistoryPath, destHistoryPath);
historyStatus = historyCopied === 'copied' ? 'copied' : 'not present';
const legacySessionsDir = path.join(legacyCodexHome, 'sessions');
const destSessionsDir = path.join(profileDir, 'sessions');
const sessionCount = copyDirIfPresent(legacySessionsDir, destSessionsDir);
sessionsStatus = sessionCount >= 0 ? `copied ${sessionCount} files` : 'not present';
} else {
historyStatus = 'not requested';
sessionsStatus = 'not requested';
}
// Ensure shared config symlink (reuse Phase 1 helper)
try {
ensureSharedConfigSymlink(profileDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.import-default.symlink-failed', 'Symlink creation failed', {
profileDir,
error: msg,
});
exitWithError(`Failed to prepare profile config.toml: ${msg}`, ExitCode.CONFIG_ERROR);
return;
}
// Decode email for display (best-effort)
const tokens = authData['tokens'] as Record<string, unknown> | undefined;
const idToken = typeof tokens?.['id_token'] === 'string' ? tokens['id_token'] : null;
const identity = idToken ? decodeIdToken(idToken) : {};
const emailDisplay = identity.email ?? '(unknown)';
// Register in registry
if (registry.hasProfile(args.name)) {
registry.updateProfile(args.name, {
last_used: new Date().toISOString(),
email: identity.email,
plan_type: identity.plan_type ?? null,
account_id: identity.account_id,
});
} else {
registry.createProfile(args.name, {
created: new Date().toISOString(),
last_used: new Date().toISOString(),
email: identity.email,
plan_type: identity.plan_type ?? null,
account_id: identity.account_id,
});
}
// Print summary
console.log(ok(`Imported legacy ${legacyAuthPath} -> profile '${args.name}'`));
console.log(` Email : ${emailDisplay}`);
console.log(` History : ${historyStatus}`);
console.log(` Sessions: ${sessionsStatus}`);
console.log(` Next : ccsx auth switch ${args.name}`);
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Barrel export for codex-auth command handlers.
*/
export { handleCreateCodex } from './create-command';
export { handleLoginCodex } from './login-command';
export { handleSwitchCodex } from './switch-command';
export { handleUseCodex } from './use-command';
export { handleShowCodex } from './show-command';
export { handleRemoveCodex } from './remove-command';
export { handleImportDefaultCodex } from './import-default-command';
export type { CodexCommandContext, CodexAuthArgs, CodexProfileOutput } from './types';
export {
parseArgs,
rejectUnsupportedOptions,
isValidCodexProfileName,
getProfileNameError,
formatRelativeTime,
} from './types';
+140
View File
@@ -0,0 +1,140 @@
/**
* codex-auth login command.
* Spawns `codex login` with CODEX_HOME pinned to the profile dir.
* Auto-creates profile if it doesn't exist yet.
* Updates registry with email/plan from JWT after successful login.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as childProcess from 'child_process';
import { createLogger } from '../../services/logging';
import { initUI, info, ok } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir, ensureSharedConfigSymlink } from '../index';
import { decodeAccountIdentity } from '../codex-account-identity';
import { detectCodexCli } from '../../targets/codex-detector';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexProfileMetadata } from '../types';
import type { CodexCommandContext } from './types';
const logger = createLogger('codex-auth:cmd:login');
export async function handleLoginCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth login <name>');
const { profileName } = parsed;
if (!profileName) {
console.log('Usage: ccsx auth login <name>');
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
const profileDir = resolveCodexProfileDir(profileName);
// Auto-create profile if missing
if (!registry.hasProfile(profileName)) {
console.log(info(`Auto-creating profile ${profileName}`));
ensureProfileDirReady(profileDir);
registry.createProfile(profileName, {
created: new Date().toISOString(),
last_used: null,
});
}
const codexCli = detectCodexCli();
if (!codexCli) {
console.log('');
console.log('Install:');
console.log(' npm i -g @openai/codex');
console.log(' # or follow https://github.com/openai/codex#install');
console.log('');
console.log(`After installing, re-run:`);
console.log(` ccsx auth login ${profileName}`);
exitWithError('codex CLI not found', ExitCode.BINARY_ERROR);
return;
}
// Ensure profile dir exists (may have been deleted)
if (!fs.existsSync(profileDir)) {
ensureProfileDirReady(profileDir);
}
const authJsonPath = path.join(profileDir, 'auth.json');
const authJsonExisted = fs.existsSync(authJsonPath);
console.log(info(`Launching codex login for profile: ${profileName}`));
console.log(` CODEX_HOME=${profileDir}`);
console.log('');
const exitCode = await new Promise<number>((resolve) => {
const child = childProcess.spawn(codexCli, ['login'], {
stdio: 'inherit',
env: { ...process.env, CODEX_HOME: profileDir },
windowsHide: true,
});
child.on('error', (err) => {
process.stderr.write(`[X] Failed to execute codex: ${err.message}\n`);
logger.warn('codex-auth.login.spawn-error', 'Spawn failed', { error: err.message });
resolve(ExitCode.BINARY_ERROR);
});
child.on('exit', (code) => {
resolve(code ?? 1);
});
});
if (exitCode === 0 && fs.existsSync(authJsonPath)) {
const identity = decodeAccountIdentity(authJsonPath);
const now = new Date().toISOString();
const metadataUpdate: Partial<CodexProfileMetadata> = { last_used: now };
if (identity.email !== undefined) metadataUpdate.email = identity.email;
if (identity.plan_type !== undefined) metadataUpdate.plan_type = identity.plan_type;
if (identity.account_id !== undefined) metadataUpdate.account_id = identity.account_id;
registry.updateProfile(profileName, metadataUpdate);
const emailStr = identity.email ?? '<unknown>';
const planStr = identity.plan_type ? ` (plan: ${identity.plan_type})` : '';
console.log(ok(`Logged in as ${emailStr}${planStr}`));
console.log(` Profile: ${profileName}`);
console.log(` Updated: ${now}`);
} else if (exitCode === 0) {
process.stderr.write(
'[!] codex login exited cleanly but no auth.json. Skipping registry update.\n'
);
} else {
if (!authJsonExisted) {
process.stderr.write(
`[!] Login cancelled or failed. Profile ${profileName} remains unauthenticated.\n`
);
} else {
process.stderr.write('[!] Login failed. Previous credentials may still be valid.\n');
}
process.exit(ExitCode.AUTH_ERROR);
}
}
function ensureProfileDirReady(profileDir: string): void {
try {
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
ensureSharedConfigSymlink(profileDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.login.config-repair-failed', 'Config repair failed', {
profileDir,
error: msg,
});
exitWithError(`Failed to prepare profile config.toml: ${msg}`, ExitCode.CONFIG_ERROR);
}
}
+241
View File
@@ -0,0 +1,241 @@
/**
* codex-auth remove command.
* Deletes profile dir + registry entry.
* Guards: refuses to remove the default when others exist (unless --force).
* Best-effort warning if CCS_CODEX_PROFILE points to it.
* --yes skips confirmation prompt.
*/
import * as fs from 'fs';
import * as path from 'path';
import { initUI, info, ok } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir } from '../codex-profile-paths';
import { decodeAccountIdentity } from '../codex-account-identity';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
import type { CodexProfileMetadata } from '../types';
export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth remove <name> [--yes|-y] [--force]', {
yes: true,
force: true,
});
const { profileName, yes, force } = parsed;
if (!profileName) {
console.log('Usage: ccsx auth remove <name> [--yes|-y] [--force]');
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
if (!registry.hasProfile(profileName)) {
exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR);
return;
}
const allProfiles = registry.listProfiles();
const isDefault = registry.getDefault() === profileName;
// Default guard: refuse if others exist and no --force
if (isDefault && allProfiles.length > 1 && !force) {
const others = allProfiles.filter((n) => n !== profileName);
console.log(` Switch first: ccsx auth switch ${others[0]}`);
console.log(` Or override : ccsx auth remove ${profileName} --force`);
exitWithError('Cannot remove default profile', ExitCode.PROFILE_ERROR);
return;
}
// Active-env warning (best-effort — can only see current shell)
if (process.env.CCS_CODEX_PROFILE === profileName) {
process.stderr.write(`[!] CCS_CODEX_PROFILE in this shell points to "${profileName}".\n`);
process.stderr.write(` After removal, codex sessions in this shell will fail until you\n`);
const others = allProfiles.filter((n) => n !== profileName);
if (others.length > 0) {
process.stderr.write(
` run: eval "$(ccsx auth use ${others[0]})" or unset CCS_CODEX_PROFILE.\n`
);
} else {
process.stderr.write(` run: unset CCS_CODEX_PROFILE\n`);
}
}
const profileDir = resolveCodexProfileDir(profileName);
const authJsonPath = path.join(profileDir, 'auth.json');
const authExists = fs.existsSync(authJsonPath);
const dirExists = fs.existsSync(profileDir);
// Load cached email for impact summary
const meta = registry.getProfile(profileName);
const originalDefault = registry.getDefault();
let emailStr = meta.email ?? null;
if (!emailStr && authExists) {
const identity = decodeAccountIdentity(authJsonPath);
emailStr = identity.email ?? null;
}
// Ghost case: dir already gone
if (!dirExists) {
process.stderr.write(`[!] Profile dir was already missing; removing registry entry only.\n`);
try {
registry.removeProfile(profileName, { forceDefault: force });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
exitWithError(
`Profile registry update failed; profile dir was already missing.\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
console.log(ok(`Profile removed: ${profileName}`));
return;
}
// Impact summary
console.log(`Profile "${profileName}" will be removed.`);
console.log(` Profile dir : ${profileDir}`);
console.log(` auth.json : ${authExists ? 'present (will be deleted)' : 'not found'}`);
console.log(` Email : ${emailStr ?? '<unknown>'}`);
console.log('');
// Confirm unless --yes
if (!yes) {
const confirmed = await InteractivePrompt.confirm('Delete this profile?', {
default: false,
});
if (!confirmed) {
console.log(info('Cancelled.'));
return;
}
}
const stagedDeleteDir = `${profileDir}.deleting.${process.pid}.${Math.random()
.toString(36)
.slice(2)}`;
const preservationDir = `${profileDir}.preserved.${process.pid}.${Math.random()
.toString(36)
.slice(2)}`;
try {
fs.renameSync(profileDir, stagedDeleteDir);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'EACCES') {
exitWithError('Permission denied', ExitCode.GENERAL_ERROR);
return;
}
throw err;
}
try {
fs.cpSync(stagedDeleteDir, preservationDir, { recursive: true, errorOnExist: true });
} catch (err) {
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
_removePathBestEffort(preservationDir);
const preservedPath = restored ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err);
exitWithError(
`Profile data delete preparation failed; profile data was preserved at ${preservedPath}.\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
try {
registry.removeProfile(profileName, { forceDefault: force });
} catch (err) {
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
_removePathBestEffort(preservationDir);
const preservedPath = restored ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err);
exitWithError(
`Profile registry update failed; profile data was preserved at ${preservedPath}.\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
try {
fs.rmSync(stagedDeleteDir, { recursive: true, force: true });
fs.rmSync(preservationDir, { recursive: true, force: true });
} catch (err) {
const restoreSource = fs.existsSync(preservationDir) ? preservationDir : stagedDeleteDir;
const restoredDir = _restoreProfileDir(restoreSource, profileDir);
const restoredRegistry = _restoreRegistryEntry(registry, profileName, meta, originalDefault);
if (restoredDir && restoreSource === preservationDir) {
_removePathBestEffort(stagedDeleteDir);
}
const preservedPath = restoredDir ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err);
const registryNote = restoredRegistry
? 'Profile registry entry was restored.'
: 'Profile registry entry could not be restored automatically.';
exitWithError(
`Profile data delete failed; profile data was preserved at ${preservedPath}. ${registryNote}\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
console.log(ok(`Profile removed: ${profileName}`));
}
function _removePathBestEffort(targetPath: string): void {
try {
fs.rmSync(targetPath, { recursive: true, force: true });
} catch {
// best-effort cleanup after data has already been preserved elsewhere
}
}
function _restoreRegistryEntry(
registry: CodexCommandContext['registry'],
profileName: string,
meta: CodexProfileMetadata,
originalDefault: string | null
): boolean {
try {
if (registry.hasProfile(profileName)) {
registry.updateProfile(profileName, meta);
} else {
registry.createProfile(profileName, meta);
}
if (originalDefault === profileName) {
registry.setDefault(profileName);
}
return true;
} catch {
process.stderr.write(
`[!] Profile data delete failed and automatic registry restore failed for "${profileName}".\n`
);
return false;
}
}
function _restoreProfileDir(stagedDeleteDir: string, profileDir: string): boolean {
try {
if (fs.existsSync(stagedDeleteDir) && !fs.existsSync(profileDir)) {
fs.renameSync(stagedDeleteDir, profileDir);
return true;
}
return fs.existsSync(profileDir);
} catch {
process.stderr.write(
`[!] Registry update failed and automatic restore failed. Profile data remains at ${stagedDeleteDir}.\n`
);
return false;
}
}
+127
View File
@@ -0,0 +1,127 @@
/**
* codex-auth show command.
* List mode: table of all profiles with STATE column.
* Detail mode: delegated to show-detail-view.ts.
* --json: machine-readable output.
* D14: active(missing) row at top when CCS_CODEX_PROFILE points to deleted profile.
*/
import * as fs from 'fs';
import * as path from 'path';
import { initUI, info, table } from '../../utils/ui';
import { resolveCodexProfileDir } from '../codex-profile-paths';
import { decodeAccountIdentity } from '../codex-account-identity';
import { showProfileDetail } from './show-detail-view';
import { parseArgs, rejectUnsupportedOptions, formatRelativeTime } from './types';
import type { CodexCommandContext, CodexProfileOutput } from './types';
import type { CodexAccountIdentity } from '../types';
export async function handleShowCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth show [name] [--json]', { json: true });
const { profileName, json } = parsed;
if (profileName) {
return showProfileDetail(profileName, ctx, !!json);
}
return _showList(ctx, !!json);
}
// ── List view ─────────────────────────────────────────────────────────────────
function _showList(ctx: CodexCommandContext, json: boolean): void {
const { registry } = ctx;
const names = registry.listProfiles();
const defaultName = registry.getDefault();
const activeName = process.env.CCS_CODEX_PROFILE ?? null;
// D14: check if CCS_CODEX_PROFILE points to a deleted/missing profile
const activeMissing =
activeName !== null && activeName.length > 0 && !registry.hasProfile(activeName);
interface Row {
name: string;
email: string;
plan: string;
accountId: string | null;
lastUsed: string;
state: string;
missing?: boolean;
}
const rows: Row[] = [];
// D14: active(missing) row at top
if (activeMissing) {
rows.push({
name: activeName ?? '',
email: '<unknown>',
plan: '-',
accountId: null,
lastUsed: 'never',
state: 'active(missing)',
missing: true,
});
}
for (const name of names) {
const meta = registry.getProfile(name);
const states: string[] = [];
if (name === defaultName) states.push('default');
if (name === activeName) states.push('active');
const profileDir = resolveCodexProfileDir(name);
const authJsonPath = path.join(profileDir, 'auth.json');
const identity: CodexAccountIdentity = fs.existsSync(authJsonPath)
? decodeAccountIdentity(authJsonPath)
: {};
const email = meta.email ?? identity.email ?? '<unknown>';
const plan = meta.plan_type ?? identity.plan_type ?? '-';
const accountId = meta.account_id ?? identity.account_id ?? null;
const lastUsed = meta.last_used ? formatRelativeTime(new Date(meta.last_used)) : 'never';
rows.push({ name, email, plan, accountId, lastUsed, state: states.join(',') });
}
if (json) {
const profiles: CodexProfileOutput[] = rows.map((r) => {
const meta = r.missing ? null : registry.getProfile(r.name);
const profileDir = r.missing ? '' : resolveCodexProfileDir(r.name);
return {
name: r.name,
is_default: r.name === defaultName,
is_active: r.name === activeName,
created: meta?.created ?? '',
last_used: meta?.last_used ?? null,
email: r.email === '<unknown>' ? null : r.email,
plan: r.plan === '-' ? null : r.plan,
account_id: r.accountId,
profile_dir: profileDir,
auth_json_exists: r.missing ? false : fs.existsSync(path.join(profileDir, 'auth.json')),
auth_json_mtime: null,
config_toml_link_target: null,
};
});
console.log(JSON.stringify({ profiles }, null, 2));
return;
}
if (names.length === 0 && !activeMissing) {
console.log(info('No Codex profiles yet.'));
console.log(' Create one: ccsx auth create <name>');
return;
}
const count = names.length + (activeMissing ? 1 : 0);
console.log(`Codex Profiles (${count})`);
console.log('');
const header = ['NAME', 'EMAIL', 'PLAN', 'LAST_USED', 'STATE'];
const tableRows = [header, ...rows.map((r) => [r.name, r.email, r.plan, r.lastUsed, r.state])];
console.log(table(tableRows, { colWidths: [14, 26, 8, 14, 18] }));
console.log('');
console.log(info('Default persists across shells. Active is current shell only.'));
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Detail view renderer for `ccsx auth show <name>`.
* Extracted from show-command.ts to keep files under 200 lines.
*/
import * as fs from 'fs';
import * as path from 'path';
import { table } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir } from '../codex-profile-paths';
import { decodeAccountIdentity } from '../codex-account-identity';
import type { CodexCommandContext, CodexProfileOutput } from './types';
export function showProfileDetail(
profileName: string,
ctx: CodexCommandContext,
json: boolean
): void {
const { registry } = ctx;
if (!registry.hasProfile(profileName)) {
exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR);
return;
}
const meta = registry.getProfile(profileName);
const profileDir = resolveCodexProfileDir(profileName);
const authJsonPath = path.join(profileDir, 'auth.json');
const configTomlPath = path.join(profileDir, 'config.toml');
const authExists = fs.existsSync(authJsonPath);
let authMtime: string | null = null;
let identity = {
email: undefined as string | undefined,
plan_type: undefined as string | undefined,
account_id: undefined as string | undefined,
};
let authState = 'missing';
if (authExists) {
try {
const stat = fs.statSync(authJsonPath);
authMtime = stat.mtime.toISOString();
identity = decodeAccountIdentity(authJsonPath) as typeof identity;
authState = `present (mtime: ${new Date(authMtime).toLocaleString()})`;
} catch {
authState = 'present (unreadable)';
}
}
// Inspect config.toml symlink
let configTarget: string | null = null;
try {
const lstat = fs.lstatSync(configTomlPath);
if (lstat.isSymbolicLink()) {
configTarget = fs.readlinkSync(configTomlPath);
} else {
configTarget = `${configTomlPath} (regular file, not symlink)`;
}
} catch {
configTarget = null;
}
const isDefault = registry.getDefault() === profileName;
const isActive = process.env.CCS_CODEX_PROFILE === profileName;
const states: string[] = [];
if (isDefault) states.push('default');
if (isActive) states.push('active');
const stateStr = states.join(',');
const accountId = meta.account_id ?? identity.account_id ?? null;
const email = meta.email ?? identity.email ?? null;
const plan = meta.plan_type ?? identity.plan_type ?? null;
if (json) {
const out: CodexProfileOutput = {
name: profileName,
is_default: isDefault,
is_active: isActive,
created: meta.created,
last_used: meta.last_used ?? null,
email,
plan,
account_id: accountId,
profile_dir: profileDir,
auth_json_exists: authExists,
auth_json_mtime: authMtime,
config_toml_link_target: configTarget,
};
console.log(JSON.stringify(out, null, 2));
return;
}
const badge = stateStr ? ` (${stateStr})` : '';
console.log(`Codex Profile: ${profileName}${badge}`);
console.log('');
const rows: [string, string][] = [
['Name', profileName],
['Profile dir', profileDir],
['config.toml', configTarget ? `-> ${configTarget} (symlink)` : '(not linked)'],
['auth.json', authState],
['Email', email ?? (authExists ? '<invalid>' : '<unknown>')],
['Plan', plan ?? (authExists ? '<invalid>' : '<unknown>')],
['Account ID', accountId ?? '-'],
['Created', new Date(meta.created).toLocaleString()],
['Last used', meta.last_used ? new Date(meta.last_used).toLocaleString() : 'never'],
['CODEX_HOME (env)', process.env.CODEX_HOME ?? 'unset'],
['CCS_CODEX_PROFILE', process.env.CCS_CODEX_PROFILE ?? 'unset'],
];
console.log(table(rows, { colWidths: [20, 55] }));
// H4: warn if config.toml is a regular file (not symlink)
if (configTarget && configTarget.includes('regular file')) {
process.stderr.write(
`[!] config.toml is a regular file, not a symlink. Config changes won't propagate.\n`
);
process.stderr.write(` Run: ccsx auth create ${profileName} --force\n`);
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* codex-auth switch command.
* Sets the persistent default Codex profile in the registry.
*/
import { initUI, ok } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
export async function handleSwitchCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth switch <name>');
const { profileName } = parsed;
if (!profileName) {
console.log('Usage: ccsx auth switch <name>');
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
if (!registry.hasProfile(profileName)) {
const available = registry.listProfiles();
const availableStr = available.length > 0 ? available.join(', ') : '<none>';
exitWithError(
`Profile not found: ${profileName}. Available: ${availableStr}`,
ExitCode.PROFILE_ERROR
);
return;
}
registry.setDefault(profileName);
const meta = registry.getProfile(profileName);
const emailStr = meta.email ? `\n Email: ${meta.email}` : '';
const planStr = meta.plan_type ? `\n Plan : ${meta.plan_type}` : '';
console.log(ok(`Default Codex profile: ${profileName}`));
if (emailStr) process.stdout.write(emailStr + '\n');
if (planStr) process.stdout.write(planStr + '\n');
console.log('');
console.log('[i] This is the persistent default. To use a different profile in the');
console.log(` current shell only, run: eval "$(ccsx auth use <other>)"`);
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Shared types and utilities for codex-auth command handlers.
*/
import { color } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import type { CodexProfileRegistry } from '../codex-profile-registry';
import { getCodexProfileNameError, isValidCodexProfileName } from '../types';
// Re-export for convenience in command modules
export { formatRelativeTime } from '../../utils/time';
// ── Context ──────────────────────────────────────────────────────────────────
export interface CodexCommandContext {
registry: CodexProfileRegistry;
version: string;
}
// ── CLI args ─────────────────────────────────────────────────────────────────
export interface CodexAuthArgs {
profileName?: string;
yes?: boolean;
json?: boolean;
force?: boolean;
shell?: string;
unknownFlags?: string[];
seenOptions?: string[];
extraPositionals?: string[];
}
// ── Profile output shape (JSON mode) ─────────────────────────────────────────
export interface CodexProfileOutput {
name: string;
is_default: boolean;
is_active: boolean;
created: string;
last_used: string | null;
email: string | null;
plan: string | null;
account_id: string | null;
profile_dir: string;
auth_json_exists: boolean;
auth_json_mtime: string | null;
config_toml_link_target: string | null;
}
// ── Name validation ───────────────────────────────────────────────────────────
export { isValidCodexProfileName };
export const getProfileNameError = getCodexProfileNameError;
// ── Arg parsing ───────────────────────────────────────────────────────────────
export function parseArgs(args: string[]): CodexAuthArgs {
const result: CodexAuthArgs = { unknownFlags: [], seenOptions: [] };
const positional: string[] = [];
const markSeen = (flag: string) => result.seenOptions?.push(flag);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--yes' || arg === '-y') {
markSeen('--yes');
result.yes = true;
} else if (arg === '--json') {
markSeen('--json');
result.json = true;
} else if (arg === '--force') {
markSeen('--force');
result.force = true;
} else if (arg === '--shell') {
markSeen('--shell');
result.shell = args[++i] ?? '';
} else if (arg.startsWith('--shell=')) {
markSeen('--shell');
result.shell = arg.slice('--shell='.length);
} else if (arg.startsWith('-') && arg !== '--') {
if (result.unknownFlags) result.unknownFlags.push(arg);
} else if (arg !== '--') {
positional.push(arg);
}
}
if (positional.length > 0) {
result.profileName = positional[0];
}
if (positional.length > 1) {
result.extraPositionals = positional.slice(1);
}
return result;
}
export interface AllowedCodexAuthOptions {
yes?: boolean;
json?: boolean;
force?: boolean;
shell?: boolean;
}
export function rejectUnsupportedOptions(
parsed: CodexAuthArgs,
usage: string,
allowed: AllowedCodexAuthOptions = {}
): void {
const unsupported = new Set(parsed.unknownFlags ?? []);
const seen = new Set(parsed.seenOptions ?? []);
if (seen.has('--yes') && !allowed.yes) unsupported.add('--yes');
if (seen.has('--json') && !allowed.json) unsupported.add('--json');
if (seen.has('--force') && !allowed.force) unsupported.add('--force');
if (seen.has('--shell') && !allowed.shell) unsupported.add('--shell');
const extraPositionals = parsed.extraPositionals ?? [];
if (unsupported.size > 0 || extraPositionals.length > 0) {
const flags = [...unsupported].join(', ');
process.stderr.write(`Usage: ${color(usage, 'command')}\n`);
const details = [
flags ? `Unknown options: ${flags}` : null,
extraPositionals.length > 0
? `Unexpected arguments: ${extraPositionals.map((arg) => `"${arg}"`).join(', ')}`
: null,
].filter(Boolean);
exitWithError(details.join('; '), ExitCode.GENERAL_ERROR);
}
}
+104
View File
@@ -0,0 +1,104 @@
/**
* codex-auth use command.
*
* STDOUT DISCIPLINE (C2, R4): stdout contains ONLY shell-evalable export
* statements. ALL errors, hints, and info messages go to STDERR so that
* `eval "$(ccsx auth use <name>)"` is never contaminated.
*
* Belt-and-suspenders for C2: the primary protection is
* `src/bin/codex-runtime-router.ts`, which dispatches `auth` subcommands
* BEFORE pre-dispatch runs at all. This IIFE is a fallback for any future
* code path (e.g., direct import from a different bin entry) that bypasses
* the router and would otherwise let pre-dispatch banners hit stdout.
*/
(function guardPreDispatch() {
const argv = process.argv;
// argv[2] is the first user arg to ccsx (e.g. "auth"), argv[3] is the subcommand
for (let i = 2; i < argv.length - 1; i++) {
if (argv[i] === 'auth' && argv[i + 1] === 'use') {
process.env.CCS_NO_PRE_DISPATCH = '1';
break;
}
}
})();
// ── End guard — safe to import CCS modules now ───────────────────────────────
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir } from '../codex-profile-paths';
import { detectShell, formatExport } from '../shell-detect';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { Shell } from '../shell-detect';
import type { CodexCommandContext } from './types';
const VALID_SHELLS = new Set<string>(['bash', 'zsh', 'fish', 'pwsh', 'cmd']);
export async function handleUseCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth use <name> [--shell <bash|zsh|fish|pwsh|cmd>]', {
shell: true,
});
const { profileName, shell: shellOverride } = parsed;
// All errors → stderr, empty stdout
if (!profileName) {
process.stderr.write('[X] Profile name is required.\n');
process.stderr.write('Usage: ccsx auth use <name> [--shell <bash|zsh|fish|pwsh|cmd>]\n');
process.exit(ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
process.stderr.write(`[X] ${nameError}\n`);
process.exit(ExitCode.PROFILE_ERROR);
return;
}
if (shellOverride !== undefined && !VALID_SHELLS.has(shellOverride)) {
process.stderr.write(
`[X] Unsupported --shell value: "${shellOverride}". Valid: bash, zsh, fish, pwsh, cmd\n`
);
process.exit(ExitCode.GENERAL_ERROR);
return;
}
const { registry } = ctx;
if (!registry.hasProfile(profileName)) {
const available = registry.listProfiles();
const availableStr = available.length > 0 ? available.join(', ') : '<none>';
process.stderr.write(`[X] Profile not found: ${profileName}. Available: ${availableStr}\n`);
process.exit(ExitCode.PROFILE_ERROR);
return;
}
const profileDir = resolveCodexProfileDir(profileName);
const shell: Shell =
shellOverride !== undefined
? (shellOverride as Shell)
: detectShell(process.env, process.platform);
// ── STDOUT: only export statements ──────────────────────────────────────────
process.stdout.write(formatExport(shell, 'CODEX_HOME', profileDir) + '\n');
process.stdout.write(formatExport(shell, 'CCS_CODEX_PROFILE', profileName) + '\n');
// ── STDERR: human-readable hint ─────────────────────────────────────────────
process.stderr.write(`[i] Codex profile "${profileName}" active in this shell. Run: codex\n`);
if (shell === 'cmd') {
process.stderr.write('[i] Note: cmd.exe cannot eval output from a subprocess natively.\n');
process.stderr.write(
' Use PowerShell: ccsx auth use ' + profileName + ' | Invoke-Expression\n'
);
}
// Note: This profile applies only to native `codex`.
// `ccsxp` ignores CCS_CODEX_PROFILE and uses its own cliproxy pool.
}
// suppress unused import warning — exitWithError is available but we use process.exit
// for stdout purity in this command
void exitWithError;
+104
View File
@@ -0,0 +1,104 @@
import type { CodexAccountIdentity } from './types';
// JWT claim URI for OpenAI-specific auth data (nested object).
// Verified against real auth.json: chatgpt_plan_type and chatgpt_account_id
// live under this key, NOT at top level.
const OPENAI_AUTH_CLAIM = 'https://api.openai.com/auth';
const OPENAI_PROFILE_CLAIM = 'https://api.openai.com/profile';
const BASE64URL_SEGMENT_RE = /^[A-Za-z0-9_-]+$/;
interface OpenAIAuthClaim {
chatgpt_plan_type?: string;
chatgpt_account_id?: string;
}
interface OpenAIProfileClaim {
email?: string;
}
interface JwtPayload {
email?: string;
[OPENAI_AUTH_CLAIM]?: OpenAIAuthClaim;
[OPENAI_PROFILE_CLAIM]?: OpenAIProfileClaim;
[key: string]: unknown;
}
function base64urlDecode(str: string): string {
// Convert base64url to standard base64
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
return Buffer.from(padded, 'base64').toString('utf8');
}
function isBase64UrlSegment(str: string): boolean {
return str.length > 0 && str.length % 4 !== 1 && BASE64URL_SEGMENT_RE.test(str);
}
function decodeJsonSegment(str: string): unknown {
return JSON.parse(base64urlDecode(str));
}
/**
* Decode the payload of a JWT id_token without signature verification.
* Returns only the display-safe fields: email, plan_type, account_id.
* Returns {} on any parse failure — never throws.
*
* Security note: signature is NOT verified. This is purely cosmetic data
* for dashboard display. Auth boundary is OS file perms on auth.json.
*/
export function decodeIdToken(idToken: string): CodexAccountIdentity {
try {
const payload = decodeJwtPayload(idToken);
if (!payload) return {};
const authClaim = payload[OPENAI_AUTH_CLAIM];
const profileClaim = payload[OPENAI_PROFILE_CLAIM];
// Email: prefer top-level, fall back to profile claim
const email = payload.email ?? profileClaim?.email;
const result: CodexAccountIdentity = {};
if (typeof email === 'string' && email.length > 0) {
result.email = email;
}
if (typeof authClaim?.chatgpt_plan_type === 'string') {
result.plan_type = authClaim.chatgpt_plan_type;
}
if (typeof authClaim?.chatgpt_account_id === 'string') {
result.account_id = authClaim.chatgpt_account_id;
}
return result;
} catch {
return {};
}
}
export function hasStructurallyValidIdToken(idToken: string): boolean {
return decodeJwtPayload(idToken) !== null;
}
function decodeJwtPayload(idToken: string): JwtPayload | null {
try {
const parts = idToken.split('.');
if (parts.length !== 3) {
return null;
}
if (!parts.every((part) => isBase64UrlSegment(part))) {
return null;
}
const header = decodeJsonSegment(parts[0] ?? '');
if (!header || typeof header !== 'object' || Array.isArray(header)) {
return null;
}
const payload = decodeJsonSegment(parts[1] ?? '');
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return null;
}
return payload as JwtPayload;
} catch {
return null;
}
}
+13
View File
@@ -0,0 +1,13 @@
export { CodexProfileRegistry } from './codex-profile-registry';
export {
getCodexAuthRegistryPath,
getCodexInstancesDir,
resolveCodexProfileDir,
getSharedCodexConfigPath,
} from './codex-profile-paths';
export { ensureSharedConfigSymlink } from './codex-config-symlink';
export { ensureCodexProfileResources, SHARED_CODEX_RESOURCE_DIRS } from './codex-profile-resources';
export { decodeAccountIdentity } from './codex-account-identity';
export { decodeIdToken } from './decode-id-token';
export type { CodexProfileMetadata, CodexProfileData, CodexAccountIdentity } from './types';
export { CODEX_PROFILE_SCHEMA_VERSION } from './types';
+194
View File
@@ -0,0 +1,194 @@
/**
* Synchronous hot-path resolver for the active codex auth profile. <5ms typical.
* Precedence: CCS_CODEX_PROFILE env → registry.default → null (legacy ~/.codex).
* Legacy fallback is allowed only when no explicit CCS_CODEX_PROFILE was requested.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { getCodexAuthRegistryPath, resolveCodexProfileDir } from './codex-profile-paths';
import { getCcsDirSource } from '../utils/config-manager';
import { getCodexProfileNameError } from './types';
import { validateCodexProfileRegistryData } from './codex-profile-registry';
import type { CodexProfileData } from './types';
export interface ResolvedProfile {
name: string;
dir: string;
source: 'env' | 'default';
}
export class CodexAuthProfileResolutionError extends Error {
constructor(message: string) {
super(message);
this.name = 'CodexAuthProfileResolutionError';
}
}
function quoteDiagnosticValue(value: string): string {
const escaped = value
.replace(/[\x00-\x1f\x7f]/g, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
.replace(/'/g, "\\'");
return `'${escaped.length > 96 ? `${escaped.slice(0, 96)}...` : escaped}'`;
}
function registryDisplayPath(registryPath: string): string {
const [source] = getCcsDirSource();
if (source === 'default') {
return process.platform === 'win32'
? '%USERPROFILE%\\.ccs\\codex-profiles.yaml'
: '~/.ccs/codex-profiles.yaml';
}
if (source === 'CCS_HOME' || source === 'scoped:CCS_HOME') {
return '$CCS_HOME/.ccs/codex-profiles.yaml';
}
if (source === 'CCS_DIR' || source === 'scoped:CCS_DIR') {
return '$CCS_DIR/codex-profiles.yaml';
}
return registryPath;
}
function resolutionFailure(message: string, envName: string, displayEnvName: string): never {
const prefix = envName ? `CCS_CODEX_PROFILE=${displayEnvName} is set but ` : '';
throw new CodexAuthProfileResolutionError(
`${prefix}${message}. Refusing to fall back to ~/.codex.`
);
}
function assertValidProfileNameForResolution(
name: string,
envName: string,
displayEnvName: string
): void {
const nameError = getCodexProfileNameError(name);
if (nameError) {
resolutionFailure(
`profile name ${quoteDiagnosticValue(name)} is invalid: ${nameError}`,
envName,
displayEnvName
);
}
}
function assertValidProfileEntry(
name: string,
profiles: Record<string, unknown>,
envName: string,
displayEnvName: string,
displayRegistryPath: string
): void {
assertValidProfileNameForResolution(name, envName, displayEnvName);
const profile = profiles[name];
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
resolutionFailure(
`registry profile ${quoteDiagnosticValue(name)} at ${displayRegistryPath} is not a valid object`,
envName,
displayEnvName
);
}
const type = (profile as { type?: unknown }).type;
if (type !== 'codex') {
resolutionFailure(
`registry profile ${quoteDiagnosticValue(name)} at ${displayRegistryPath} is not a Codex profile`,
envName,
displayEnvName
);
}
}
/** @param env - Process env map; defaults to process.env. Injectable for tests. */
export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): ResolvedProfile | null {
const registryPath = getCodexAuthRegistryPath();
const envName = (env.CCS_CODEX_PROFILE ?? '').trim();
const displayEnvName = quoteDiagnosticValue(envName);
const displayRegistryPath = registryDisplayPath(registryPath);
// F4: silent fallback — no registry means no profiles, legacy mode
if (!fs.existsSync(registryPath)) {
if (envName) {
throw new CodexAuthProfileResolutionError(
`CCS_CODEX_PROFILE=${displayEnvName} is set but ${displayRegistryPath} does not exist. Refusing to fall back to ~/.codex.`
);
}
return null;
}
let parsed: unknown;
try {
const raw = fs.readFileSync(registryPath, 'utf8');
parsed = yaml.load(raw);
} catch (err) {
if (err instanceof CodexAuthProfileResolutionError) throw err;
const msg = `registry YAML could not be parsed at ${displayRegistryPath}`;
resolutionFailure(msg, envName, displayEnvName);
}
let registry: CodexProfileData;
try {
registry = validateCodexProfileRegistryData(parsed);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
resolutionFailure(
`registry at ${displayRegistryPath} is invalid: ${msg}`,
envName,
displayEnvName
);
}
const profiles = registry.profiles;
if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)) {
resolutionFailure(
`registry at ${displayRegistryPath} is missing a valid profiles map`,
envName,
displayEnvName
);
}
for (const profileName of Object.keys(profiles)) {
assertValidProfileEntry(profileName, profiles, envName, displayEnvName, displayRegistryPath);
}
// F2: explicit env override
if (envName) {
assertValidProfileNameForResolution(envName, envName, displayEnvName);
if (!Object.prototype.hasOwnProperty.call(profiles, envName)) {
throw new CodexAuthProfileResolutionError(
`CCS_CODEX_PROFILE=${displayEnvName} not found in registry. Refusing to fall back to ~/.codex.`
);
}
assertValidProfileEntry(envName, profiles, envName, displayEnvName, displayRegistryPath);
return {
name: envName,
dir: path.resolve(resolveCodexProfileDir(envName)),
source: 'env',
};
}
// F3: registry default
const defaultName = registry.default;
if (defaultName !== null) {
if (typeof defaultName !== 'string') {
resolutionFailure(
`registry default at ${displayRegistryPath} is not a valid profile name`,
envName,
displayEnvName
);
}
assertValidProfileNameForResolution(defaultName, envName, displayEnvName);
if (!Object.prototype.hasOwnProperty.call(profiles, defaultName)) {
resolutionFailure(
`registry default ${quoteDiagnosticValue(defaultName)} is missing from profiles map`,
envName,
displayEnvName
);
}
assertValidProfileEntry(defaultName, profiles, envName, displayEnvName, displayRegistryPath);
return {
name: defaultName,
dir: path.resolve(resolveCodexProfileDir(defaultName)),
source: 'default',
};
}
// F4: no profile configured
return null;
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Shell detection for codex-auth use command.
* Determines current shell to emit correct eval-safe export syntax.
*/
import * as childProcess from 'child_process';
export type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'cmd';
/**
* Detect current shell from environment.
* On Windows: inspect explicit shell executable hints, else default to cmd.
* On Unix: inspect $SHELL suffix.
*/
export function detectShell(
env: NodeJS.ProcessEnv = process.env,
platform: string = process.platform,
parentProcessName?: string
): Shell {
if (platform === 'win32') {
return (
shellFromExecutable(env.SHELL) ??
shellFromExecutable(parentProcessName ?? detectParentProcessName(platform)) ??
shellFromExecutable(env.ComSpec ?? env.COMSPEC) ??
'cmd'
);
}
const sh = (env.SHELL ?? '').toLowerCase();
if (sh.endsWith('/fish')) return 'fish';
if (sh.endsWith('/zsh')) return 'zsh';
return 'bash'; // default for bash, sh, dash, ksh
}
function detectParentProcessName(platform: string): string | undefined {
if (platform !== 'win32' || !Number.isInteger(process.ppid) || process.ppid <= 0) {
return undefined;
}
const result = childProcess.spawnSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-Process -Id ${process.ppid} -ErrorAction Stop).ProcessName`,
],
{ encoding: 'utf8', timeout: 1500, windowsHide: true }
);
if (result.status !== 0 || !result.stdout) return undefined;
return result.stdout.trim().split(/\r?\n/).pop()?.trim();
}
function shellFromExecutable(value: string | undefined): Shell | null {
if (!value) return null;
const base = value
.replace(/^["']|["']$/g, '')
.replace(/\\/g, '/')
.split('/')
.pop()
?.toLowerCase()
.replace(/\.(exe|cmd|ps1|bat)$/i, '');
switch (base) {
case 'fish':
case 'zsh':
case 'bash':
case 'cmd':
return base;
case 'pwsh':
case 'powershell':
return 'pwsh';
default:
return null;
}
}
/**
* Single-quote escape for POSIX shells (bash/zsh/fish).
* Closes the single-quote, inserts escaped quote, reopens.
*/
function posixSingleQuote(value: string): string {
return "'" + value.replace(/'/g, "'\\''") + "'";
}
/**
* Double-quote escape for PowerShell.
* Wraps in double quotes; escapes the PowerShell escape char first, doubles
* internal double quotes, and backtick-escapes $ to prevent interpolation.
*/
function pwshDoubleQuote(value: string): string {
return '"' + value.replace(/`/g, '``').replace(/"/g, '""').replace(/\$/g, '`$') + '"';
}
/**
* Quote a cmd.exe SET assignment. `set "KEY=value"` keeps command separators
* like &, |, <, and > inside the assignment instead of executing them.
*/
function cmdSetQuote(value: string): string {
return value.replace(/\^/g, '^^').replace(/%/g, '%%').replace(/"/g, '^"').replace(/!/g, '^^!');
}
/**
* Format a single env var export statement for the target shell.
* Used by use-command to emit eval-safe lines.
*/
export function formatExport(shell: Shell, key: string, value: string): string {
switch (shell) {
case 'fish':
return `set -gx ${key} ${posixSingleQuote(value)};`;
case 'pwsh':
return `$env:${key} = ${pwshDoubleQuote(value)}`;
case 'cmd':
return `set "${key}=${cmdSetQuote(value)}"`;
default:
// bash / zsh
return `export ${key}=${posixSingleQuote(value)}`;
}
}
+46
View File
@@ -0,0 +1,46 @@
export interface CodexProfileMetadata {
type: 'codex';
created: string;
last_used: string | null;
email?: string;
plan_type?: string | null;
account_id?: string;
}
export interface CodexProfileData {
version: string;
default: string | null;
profiles: Record<string, CodexProfileMetadata>;
}
export interface CodexAccountIdentity {
email?: string;
plan_type?: string;
account_id?: string;
}
export const CODEX_PROFILE_SCHEMA_VERSION = '1.0';
const RESERVED_CODEX_PROFILE_NAMES = new Set(['default', 'current']);
/**
* Profile name must match /^[a-z0-9][a-z0-9_-]{0,63}$/ and not be reserved.
* Rejects uppercase, path separators, leading dash/underscore, length >64.
*/
export function isValidCodexProfileName(name: string): boolean {
if (!name || name.length > 64) return false;
if (RESERVED_CODEX_PROFILE_NAMES.has(name)) return false;
if (name.includes('/') || name.includes('\\')) return false;
return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(name);
}
export function getCodexProfileNameError(name: string): string | null {
if (!name) return 'Profile name is required.';
if (RESERVED_CODEX_PROFILE_NAMES.has(name)) return `Profile name "${name}" is reserved.`;
if (name.includes('/') || name.includes('\\'))
return 'Profile name must not contain path separators.';
if (name.length > 64) return 'Profile name must be 64 characters or fewer.';
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name))
return 'Profile name must match [a-z0-9][a-z0-9_-]{0,63}.';
return null;
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'bun:test';
import { handleConfigCommand } from '../config-command';
describe('handleConfigCommand Docker supervisord lifecycle', () => {
it('does not spawn CLIProxy directly when supervisord owns the runtime', async () => {
let ensureCalled = false;
const logs: string[] = [];
const originalLog = console.log;
console.log = (message?: unknown) => {
logs.push(String(message ?? ''));
};
try {
await handleConfigCommand([], {
getPort: async () => 3000,
openBrowser: async () => ({}),
startServer: async () =>
({
server: { address: () => ({ address: '0.0.0.0' }) },
wss: {},
cleanup: () => {},
}) as never,
setupGracefulShutdown: () => {},
ensureCliproxyService: async () => {
ensureCalled = true;
return { started: false, alreadyRunning: false, port: 8317 };
},
isRunningUnderSupervisord: () => true,
getDashboardAuthConfig: () => ({ enabled: true }),
initUI: async () => {},
header: (text: string) => text,
ok: (text: string) => text,
info: (text: string) => text,
warn: (text: string) => text,
fail: (text: string) => text,
resolveNamedCommand: () => undefined,
configSubcommandRoutes: [],
});
} finally {
console.log = originalLog;
}
expect(ensureCalled).toBe(false);
expect(logs).toContain('CLIProxy is managed by supervisord on port 8317');
});
});
+1 -1
View File
@@ -130,7 +130,7 @@ function formatModelOption(model: ModelEntry): string {
return `${model.name}${tierBadge}`;
}
const CODEX_EFFORTS_IN_ORDER = ['medium', 'high', 'xhigh'] as const;
const CODEX_EFFORTS_IN_ORDER = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
type CodexSelectableEffort = (typeof CODEX_EFFORTS_IN_ORDER)[number];
function getCodexSelectableEfforts(model: ModelEntry): CodexSelectableEffort[] {
+10 -1
View File
@@ -281,7 +281,16 @@ export const CLIPROXY_SUBCOMMANDS = [
'resume',
] as const;
export const CONFIG_SUBCOMMANDS = ['auth', 'channels', 'image-analysis', 'thinking'] as const;
export const DOCKER_SUBCOMMANDS = ['up', 'down', 'status', 'update', 'logs', 'config'] as const;
export const DOCKER_SUBCOMMANDS = [
'up',
'down',
'status',
'update',
'logs',
'config',
'show-key',
'finalize-key-rotation',
] as const;
export const PROXY_SUBCOMMANDS = ['start', 'stop', 'status', 'activate'] as const;
export const TOKENS_FLAGS = [
'--show',
+2
View File
@@ -220,6 +220,8 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
return completeSubcommands([], ['--port', '--proxy-port', '--host', '--help', '-h']);
if (subcommand === 'logs')
return completeSubcommands([], ['--follow', '--service', '--host', '--help', '-h']);
if (subcommand === 'show-key')
return completeSubcommands([], ['--full', '--host', '--help', '-h']);
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker);
case 'browser':
if (!subcommand || subcommand.startsWith('-')) {
+30 -19
View File
@@ -12,6 +12,7 @@ import { startServer } from '../web-server';
import { setupGracefulShutdown } from '../web-server/shutdown';
import { ensureCliproxyService } from '../cliproxy/service-manager';
import { resolveLifecyclePort } from '../cliproxy/config/port-manager';
import { isRunningUnderSupervisord } from '../docker/supervisord-lifecycle';
import { initUI, header, ok, info, warn, fail } from '../utils/ui';
import { resolveNamedCommand, type NamedCommandRoute } from './named-command-router';
import {
@@ -63,6 +64,7 @@ interface ConfigCommandDependencies {
startServer: typeof startServer;
setupGracefulShutdown: typeof setupGracefulShutdown;
ensureCliproxyService: typeof ensureCliproxyService;
isRunningUnderSupervisord?: typeof isRunningUnderSupervisord;
getDashboardAuthConfig: typeof getDashboardAuthConfig;
initUI: typeof initUI;
header: typeof header;
@@ -80,6 +82,7 @@ const defaultConfigCommandDependencies: ConfigCommandDependencies = {
startServer,
setupGracefulShutdown,
ensureCliproxyService,
isRunningUnderSupervisord,
getDashboardAuthConfig,
initUI,
header,
@@ -135,29 +138,37 @@ export async function handleConfigCommand(
console.log(deps.header('CCS Config Dashboard'));
console.log('');
// Ensure CLIProxy service is running for dashboard features
console.log(deps.info('Starting CLIProxy service...'));
const cliproxyResult = await deps.ensureCliproxyService(resolveLifecyclePort(), verbose);
logger.info('cliproxy.ensure_result', 'Config command checked CLIProxy availability', {
started: cliproxyResult.started,
alreadyRunning: cliproxyResult.alreadyRunning,
configRegenerated: cliproxyResult.configRegenerated,
port: cliproxyResult.port || null,
error: cliproxyResult.error || null,
});
const lifecyclePort = resolveLifecyclePort();
if (deps.isRunningUnderSupervisord?.() ?? false) {
logger.info('cliproxy.supervisord_managed', 'Skipping direct CLIProxy startup in Docker', {
port: lifecyclePort,
});
console.log(deps.info(`CLIProxy is managed by supervisord on port ${lifecyclePort}`));
} else {
// Ensure CLIProxy service is running for dashboard features
console.log(deps.info('Starting CLIProxy service...'));
const cliproxyResult = await deps.ensureCliproxyService(lifecyclePort, verbose);
logger.info('cliproxy.ensure_result', 'Config command checked CLIProxy availability', {
started: cliproxyResult.started,
alreadyRunning: cliproxyResult.alreadyRunning,
configRegenerated: cliproxyResult.configRegenerated,
port: cliproxyResult.port || null,
error: cliproxyResult.error || null,
});
if (cliproxyResult.started) {
if (cliproxyResult.alreadyRunning) {
console.log(deps.ok(`CLIProxy already running on port ${cliproxyResult.port}`));
if (cliproxyResult.configRegenerated) {
console.log(deps.warn('Config updated - restart CLIProxy to apply changes'));
if (cliproxyResult.started) {
if (cliproxyResult.alreadyRunning) {
console.log(deps.ok(`CLIProxy already running on port ${cliproxyResult.port}`));
if (cliproxyResult.configRegenerated) {
console.log(deps.warn('Config updated - restart CLIProxy to apply changes'));
}
} else {
console.log(deps.ok(`CLIProxy started on port ${cliproxyResult.port}`));
}
} else {
console.log(deps.ok(`CLIProxy started on port ${cliproxyResult.port}`));
console.log(deps.warn(`CLIProxy not available: ${cliproxyResult.error}`));
console.log(deps.info('Dashboard will work but Control Panel/Stats may be limited'));
}
} else {
console.log(deps.warn(`CLIProxy not available: ${cliproxyResult.error}`));
console.log(deps.info('Dashboard will work but Control Panel/Stats may be limited'));
}
console.log('');
@@ -0,0 +1,60 @@
import * as fs from 'fs';
import { DockerExecutor } from '../../docker';
import { configExists, regenerateConfig } from '../../cliproxy/config/config-generator';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
import { finalizeDockerKeyRotation } from '../../docker/docker-key-rotation';
import { box, fail, info, initUI, ok } from '../../utils/ui';
import { collectUnexpectedDockerArgs, parseDockerTarget } from './options';
const KNOWN_FLAGS = ['--host', '--container-scope'] as const;
function shouldWriteContainerVolume(args: string[]): boolean {
return args.includes('--container-scope') || fs.existsSync('/.dockerenv');
}
export async function handleFinalizeKeyRotation(args: string[]): Promise<void> {
await initUI();
const parsed = parseDockerTarget(args, KNOWN_FLAGS);
const remainingArgs = parsed.remainingArgs.filter((arg) => arg !== '--container-scope');
const errors = [
...parsed.errors,
...collectUnexpectedDockerArgs(remainingArgs, {
knownFlags: [],
maxPositionals: 0,
}),
];
if (errors.length > 0) {
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
process.exitCode = 1;
return;
}
if (!shouldWriteContainerVolume(args)) {
try {
const output = new DockerExecutor().finalizeKeyRotation({ host: parsed.host });
if (output.trim()) {
console.log(output.trim());
}
} catch (error) {
console.error(
box(fail(error instanceof Error ? error.message : String(error)), {
title: 'Docker',
padding: 1,
})
);
process.exitCode = 1;
}
return;
}
const status = finalizeDockerKeyRotation();
if (configExists(CLIPROXY_DEFAULT_PORT)) {
regenerateConfig(CLIPROXY_DEFAULT_PORT);
}
console.log(ok('Docker CLIProxy legacy API key grace period finalized.'));
console.log(info(`Current API key: ${status.maskedApiKey ?? '(not configured)'}`));
console.log(info('Restart CLIProxy if it is already running to reload the regenerated config.'));
}
+6
View File
@@ -19,6 +19,8 @@ export async function showHelp(): Promise<void> {
['update', 'Update CCS and CLIProxy inside the running container'],
['logs', 'Show or follow container log output'],
['config', 'Show bundled asset paths and deployment defaults'],
['show-key', 'Show the Docker CLIProxy API key masked by default'],
['finalize-key-rotation', 'End the legacy Docker API key grace period'],
],
],
[
@@ -35,6 +37,7 @@ export async function showHelp(): Promise<void> {
['up --proxy-port <port>', 'Publish CLIProxy on a custom host port'],
['logs --follow', 'Stream logs continuously'],
['logs --service <name>', 'Filter logs to ccs or cliproxy'],
['show-key --full', 'Reveal the full Docker CLIProxy API key'],
],
],
[
@@ -45,6 +48,9 @@ export async function showHelp(): Promise<void> {
['ccs docker --host my-box status', 'Use the documented common-option ordering'],
['ccs docker up --host my-box', 'Stage assets to ~/.ccs/docker and deploy remotely'],
['ccs docker logs --follow --service ccs', 'Tail dashboard logs only'],
['ccs docker show-key', 'Print the masked CLIProxy API key from the container'],
['ccs docker show-key --full', 'Reveal the full CLIProxy API key'],
['ccs docker finalize-key-rotation', 'Remove the legacy key immediately'],
['ccs docker update --host my-box', 'Update the running remote stack in place'],
],
],
+4
View File
@@ -1,8 +1,10 @@
import { extractOption, hasAnyFlag } from '../arg-extractor';
import { handleConfig } from './config-subcommand';
import { handleDown } from './down-subcommand';
import { handleFinalizeKeyRotation } from './finalize-key-rotation-subcommand';
import { showHelp } from './help-subcommand';
import { handleLogs } from './logs-subcommand';
import { handleShowKey } from './show-key-subcommand';
import { handleStatus } from './status-subcommand';
import { handleUp } from './up-subcommand';
import { handleUpdate } from './update-subcommand';
@@ -44,6 +46,8 @@ export async function handleDockerCommand(args: string[]): Promise<void> {
update: handleUpdate,
logs: handleLogs,
config: handleConfig,
'show-key': handleShowKey,
'finalize-key-rotation': handleFinalizeKeyRotation,
help: async () => showHelp(),
};
@@ -0,0 +1,81 @@
import * as fs from 'fs';
import { DockerExecutor } from '../../docker';
import {
getDockerKeyRotationStatus,
renderDockerKeyRotationBanner,
} from '../../docker/docker-key-rotation';
import { box, fail, header, info, initUI } from '../../utils/ui';
import { collectUnexpectedDockerArgs, parseDockerTarget } from './options';
const KNOWN_FLAGS = ['--host', '--full', '--container-scope', '--banner-only'] as const;
function shouldReadContainerVolume(args: string[]): boolean {
return args.includes('--container-scope') || fs.existsSync('/.dockerenv');
}
export async function handleShowKey(args: string[]): Promise<void> {
const bannerOnly = args.includes('--banner-only');
if (!bannerOnly) {
await initUI();
}
const parsed = parseDockerTarget(args, KNOWN_FLAGS);
const remainingArgs = parsed.remainingArgs.filter(
(arg) => arg !== '--full' && arg !== '--container-scope' && arg !== '--banner-only'
);
const errors = [
...parsed.errors,
...collectUnexpectedDockerArgs(remainingArgs, {
knownFlags: [],
maxPositionals: 0,
}),
];
if (errors.length > 0) {
console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 }));
process.exitCode = 1;
return;
}
const full = args.includes('--full');
if (!shouldReadContainerVolume(args)) {
try {
const output = new DockerExecutor().showKey({ host: parsed.host }, full);
if (output.trim()) {
console.log(output.trim());
}
} catch (error) {
console.error(
box(fail(error instanceof Error ? error.message : String(error)), {
title: 'Docker',
padding: 1,
})
);
process.exitCode = 1;
}
return;
}
const status = getDockerKeyRotationStatus();
if (bannerOnly) {
const banner = renderDockerKeyRotationBanner(status);
if (banner) {
console.log(banner);
}
return;
}
const displayKey = full ? status.apiKey : status.maskedApiKey;
console.log(header('Docker CLIProxy API Key'));
console.log('');
console.log(info(`API key: ${displayKey ?? '(not configured)'}`));
if (status.legacyGraceActive && status.legacyGrace) {
console.log(info(`Legacy key: ${status.legacyGrace.legacyKey}`));
console.log(info(`Legacy key expires: ${status.legacyGrace.expiresAt}`));
} else {
console.log(info('Legacy key grace: inactive'));
}
if (status.stateCorrupted) {
console.log(info(`State marker was unreadable and will be recreated: ${status.statePath}`));
}
}
+4
View File
@@ -34,6 +34,10 @@ export async function handleUp(args: string[]): Promise<void> {
);
try {
await executor.up({ host: parsed.host, port, proxyPort });
const rotationBanner = executor.getKeyRotationBanner({ host: parsed.host });
if (rotationBanner) {
console.error(rotationBanner);
}
console.log(ok(`Docker stack is running${parsed.host ? ` on ${parsed.host}` : ' locally'}.`));
console.log(info(`Dashboard port: ${port}`));
console.log(info(`CLIProxy port: ${proxyPort}`));
+127 -5
View File
@@ -18,6 +18,11 @@ import ProfileDetector from '../auth/profile-detector';
import { getClaudeConfigDir, getClaudeSettingsPath } from '../utils/claude-config-path';
import { extractOption, hasAnyFlag } from './arg-extractor';
import { resolveClaudeExtensionSetup } from '../shared/claude-extension-setup';
import {
CODEX_TRANSLATOR_URL_MARKER,
findCodexTranslatorUrlPaths,
formatSettingsPathList,
} from '../shared/stale-codex-translator-settings';
interface PersistCommandArgs {
profile?: string;
@@ -37,6 +42,16 @@ interface ResolvedEnv {
notes?: string[];
}
interface PersistReceipt {
clearedKeys: string[];
clearedCodexTranslatorUrlKeys: string[];
writtenKeys: string[];
unchangedWrittenKeys: string[];
writtenSettings: string[];
unchangedSettings: string[];
codexTranslatorUrlPaths: string[];
}
const PERSIST_KNOWN_FLAGS = [
'--yes',
'-y',
@@ -54,6 +69,7 @@ const PERSIST_LOCK_STALE_MS = 10000;
const PERSIST_LOCK_RETRIES = 5;
const PERSIST_LOCK_RETRY_MIN_MS = 100;
const PERSIST_LOCK_RETRY_MAX_MS = 500;
const NATIVE_CODEX_TARGETS = ['ccsxp', 'ccs codex --target codex'];
type PermissionMode = (typeof VALID_PERMISSION_MODES)[number];
@@ -509,6 +525,96 @@ function isSensitiveEnvKey(key: string): boolean {
);
}
function buildPersistReceipt(
existingEnv: Record<string, string>,
existingSettings: Record<string, unknown>,
mergedSettings: Record<string, unknown>,
resolved: ResolvedEnv,
resolvedPermissionMode?: PermissionMode
): PersistReceipt {
const mergedEnv =
typeof mergedSettings.env === 'object' &&
mergedSettings.env !== null &&
!Array.isArray(mergedSettings.env)
? (mergedSettings.env as Record<string, string>)
: {};
const clearedKeys = resolved.clearEnvKeys.filter(
(key) => Object.prototype.hasOwnProperty.call(existingEnv, key) && mergedEnv[key] === undefined
);
const clearedCodexTranslatorUrlKeys = clearedKeys.filter(
(key) => findCodexTranslatorUrlPaths(existingEnv[key]).length > 0
);
const writtenKeys = Object.entries(resolved.env)
.filter(([key, value]) => existingEnv[key] !== value)
.map(([key]) => key)
.sort((left, right) => left.localeCompare(right));
const unchangedWrittenKeys = Object.entries(resolved.env)
.filter(([key, value]) => existingEnv[key] === value)
.map(([key]) => key)
.sort((left, right) => left.localeCompare(right));
const existingPermissions =
typeof existingSettings.permissions === 'object' &&
existingSettings.permissions !== null &&
!Array.isArray(existingSettings.permissions)
? (existingSettings.permissions as Record<string, unknown>)
: {};
const writtenSettings =
resolvedPermissionMode && existingPermissions.defaultMode !== resolvedPermissionMode
? ['permissions.defaultMode']
: [];
const unchangedSettings =
resolvedPermissionMode && existingPermissions.defaultMode === resolvedPermissionMode
? ['permissions.defaultMode']
: [];
return {
clearedKeys,
clearedCodexTranslatorUrlKeys,
writtenKeys,
unchangedWrittenKeys,
writtenSettings,
unchangedSettings,
codexTranslatorUrlPaths: findCodexTranslatorUrlPaths(mergedSettings),
};
}
function formatKeyList(keys: string[]): string {
return keys.length > 0 ? keys.join(', ') : 'none';
}
function printPersistReceipt(receipt: PersistReceipt): void {
console.log(subheader('Config Receipt'));
console.log(` Settings: ${getClaudeSettingsDisplayPath()}`);
console.log(` Cleared managed keys: ${formatKeyList(receipt.clearedKeys)}`);
console.log(` Written/rewritten managed keys: ${formatKeyList(receipt.writtenKeys)}`);
if (receipt.unchangedWrittenKeys.length > 0) {
console.log(` Already current keys: ${formatKeyList(receipt.unchangedWrittenKeys)}`);
}
if (receipt.writtenSettings.length > 0 || receipt.unchangedSettings.length > 0) {
console.log(` Written/rewritten managed settings: ${formatKeyList(receipt.writtenSettings)}`);
if (receipt.unchangedSettings.length > 0) {
console.log(` Already current settings: ${formatKeyList(receipt.unchangedSettings)}`);
}
}
const hadCodexTranslatorCleanup = receipt.clearedCodexTranslatorUrlKeys.length > 0;
if (receipt.codexTranslatorUrlPaths.length > 0) {
console.log(
warn(
` Codex translator URL: still found at ${formatSettingsPathList(
receipt.codexTranslatorUrlPaths
)} (${CODEX_TRANSLATOR_URL_MARKER})`
)
);
} else {
console.log(ok(' Codex translator URL: not found'));
}
if (hadCodexTranslatorCleanup || receipt.codexTranslatorUrlPaths.length > 0) {
console.log(` Native Codex target: ${NATIVE_CODEX_TARGETS.join(' or ')}`);
}
}
/** Resolve shared Claude settings payload for a profile */
async function resolveProfileEnvVars(profileName: string): Promise<ResolvedEnv> {
const setup = await resolveClaudeExtensionSetup(profileName);
@@ -680,7 +786,7 @@ async function showHelp(): Promise<void> {
console.log('');
console.log(subheader('Supported Profile Types'));
console.log(` ${color('API profiles', 'command')} glm, km, custom API profiles`);
console.log(` ${color('CLIProxy', 'command')} gemini, codex, agy, qwen, kiro, ghcp`);
console.log(` ${color('CLIProxy', 'command')} gemini, agy, qwen, kiro, ghcp`);
console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`);
console.log(
` ${color('Account profiles', 'command')} work, personal, client (persists CLAUDE_CONFIG_DIR)`
@@ -700,7 +806,7 @@ async function showHelp(): Promise<void> {
console.log(` ${color('ccs persist glm --permission-mode acceptEdits', 'command')}`);
console.log('');
console.log(` ${dim('# Persist with auto-approve enabled')}`);
console.log(` ${color('ccs persist codex --dangerously-skip-permissions', 'command')}`);
console.log(` ${color('ccs persist glm --dangerously-skip-permissions', 'command')}`);
console.log('');
console.log(` ${dim('# Persist an account profile for IDE/native Claude use')}`);
console.log(` ${color('ccs persist work --yes', 'command')}`);
@@ -719,6 +825,9 @@ async function showHelp(): Promise<void> {
console.log('');
console.log(subheader('Notes'));
console.log(' [i] CLIProxy profiles require the proxy to be running.');
console.log(
' [i] Codex CLIProxy profiles are native Codex-only: use ccsxp or ccs codex --target codex.'
);
console.log(' [i] Copilot profiles require copilot-api daemon.');
console.log(
' [i] Account/default flows remove stale ANTHROPIC_* overrides before applying new setup.'
@@ -924,6 +1033,22 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
}
await writeClaudeSettings(mergedSettings);
const persistedSettings = await readClaudeSettings();
const receipt = buildPersistReceipt(
existingEnv,
existingSettings,
persistedSettings,
resolved,
resolvedPermissionMode
);
console.log('');
console.log(
ok(`Profile '${parsedArgs.profile}' written to ${getClaudeSettingsDisplayPath()}`)
);
console.log('');
printPersistReceipt(receipt);
console.log('');
});
} catch (error) {
const message = (error as Error).message;
@@ -940,9 +1065,6 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
}
process.exit(1);
}
console.log('');
console.log(ok(`Profile '${parsedArgs.profile}' written to ${getClaudeSettingsDisplayPath()}`));
console.log('');
console.log(info('Claude Code will now use this profile by default.'));
console.log(dim(' To revert, restore the backup or edit settings.json manually.'));
console.log('');
@@ -9,7 +9,37 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:te
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { bootstrapAndParseEarlyCli } from '../cli-argument-parser';
import {
bootstrapAndParseEarlyCli,
CODEX_NATIVE_PASSTHROUGH_SUBCOMMANDS,
getNativeCodexPassthroughArgs,
} from '../cli-argument-parser';
import { setGlobalConfigDir } from '../../utils/config-manager';
const EXPECTED_NATIVE_CODEX_SUBCOMMANDS = [
'a',
'app',
'app-server',
'apply',
'cloud',
'completion',
'debug',
'e',
'exec',
'exec-server',
'features',
'fork',
'help',
'login',
'logout',
'mcp',
'mcp-server',
'plugin',
'remote-control',
'resume',
'review',
'sandbox',
];
// ========== Helpers ==========
@@ -53,6 +83,7 @@ describe('bootstrapAndParseEarlyCli', () => {
configurable: true,
});
delete process.env['CI'];
setGlobalConfigDir(undefined);
exitSpy.mockRestore();
});
@@ -150,3 +181,30 @@ describe('bootstrapAndParseEarlyCli', () => {
expect(result.browserLaunchOverride).toBeUndefined();
});
});
describe('native Codex passthrough parsing', () => {
it('keeps the native Codex passthrough subcommand list explicit', () => {
expect([...CODEX_NATIVE_PASSTHROUGH_SUBCOMMANDS].sort()).toEqual(
EXPECTED_NATIVE_CODEX_SUBCOMMANDS
);
for (const subcommand of EXPECTED_NATIVE_CODEX_SUBCOMMANDS) {
expect(getNativeCodexPassthroughArgs(['--target', 'codex', subcommand, '--help'])).toEqual([
subcommand,
'--help',
]);
expect(
getNativeCodexPassthroughArgs(['--target', 'codex', 'codex', subcommand, '--help'])
).toEqual([subcommand, '--help']);
}
});
it('keeps CCS-owned codex runtime commands out of native passthrough', () => {
for (const subcommand of ['auth', 'doctor', 'update']) {
expect(getNativeCodexPassthroughArgs(['--target', 'codex', subcommand, '--help'])).toBeNull();
expect(
getNativeCodexPassthroughArgs(['--target', 'codex', 'codex', subcommand, '--help'])
).toBeNull();
}
});
});
+35 -5
View File
@@ -43,7 +43,7 @@ export interface DispatcherBootstrap {
* - setGlobalConfigDir (--config-dir flag)
* - Cloud-sync warnings
* - Completion short-circuit via tryHandleRootCommand
* - Codex native passthrough via execNativeCodexFlagCommand
* - Codex native passthrough via execNativeCodexCommand
*
* Adapter registration (registerTarget calls) stays in main() because it is
* singleton wiring with no dependency on the parsed args.
@@ -141,9 +141,9 @@ export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise<Disp
return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true };
}
if (shouldPassthroughNativeCodexFlagCommand(args)) {
const { execNativeCodexFlagCommand } = await import('./target-executor');
execNativeCodexFlagCommand(args);
if (shouldPassthroughNativeCodexCommand(args)) {
const { execNativeCodexCommand } = await import('./target-executor');
execNativeCodexCommand(args);
return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true };
}
@@ -175,6 +175,30 @@ export const CODEX_RUNTIME_REASONING_LEVELS = new Set([
]);
export const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']);
export const CODEX_NATIVE_PASSTHROUGH_SUBCOMMANDS = new Set([
'a',
'app',
'app-server',
'apply',
'cloud',
'completion',
'debug',
'e',
'exec',
'exec-server',
'features',
'fork',
'help',
'login',
'logout',
'mcp',
'mcp-server',
'plugin',
'remote-control',
'resume',
'review',
'sandbox',
]);
export const NATIVE_CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
@@ -314,7 +338,7 @@ export function shouldNormalizeNativeClaudeEffort(
// ========== Native Codex Passthrough ==========
export function shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean {
export function shouldPassthroughNativeCodexCommand(args: string[]): boolean {
return getNativeCodexPassthroughArgs(args) !== null;
}
@@ -328,11 +352,17 @@ export function getNativeCodexPassthroughArgs(args: string[]): string[] | null {
if (CODEX_NATIVE_PASSTHROUGH_FLAGS.has(firstArg)) {
return targetArgs;
}
if (CODEX_NATIVE_PASSTHROUGH_SUBCOMMANDS.has(firstArg)) {
return targetArgs;
}
const secondArg = targetArgs[1] || '';
if (firstArg === 'codex' && CODEX_NATIVE_PASSTHROUGH_FLAGS.has(secondArg)) {
return targetArgs.slice(1);
}
if (firstArg === 'codex' && CODEX_NATIVE_PASSTHROUGH_SUBCOMMANDS.has(secondArg)) {
return targetArgs.slice(1);
}
return null;
}
+8
View File
@@ -59,6 +59,14 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
}
}
// CCS_NO_PRE_DISPATCH guard — set by `ccsx auth use` to keep stdout clean
// for shell eval. Must be checked BEFORE autoMigrate/recovery, both of which
// write to stdout and would otherwise contaminate `eval "$(ccsx auth use <name>)"`.
// See: src/codex-auth/commands/use-command.ts (C2 in plan.md §Validation findings)
if (process.env.CCS_NO_PRE_DISPATCH === '1') {
return false;
}
// Auto-migrate to unified config format (silent if already migrated)
// Skip if user is explicitly running migrate command
if (firstArg !== 'migrate') {
+3 -3
View File
@@ -3,7 +3,7 @@
* and top-level profile dispatcher (Phase E switch).
*
* Extracted from src/ccs.ts (lines 291-295, 394-426).
* Handles direct execution of native Codex flag passthrough (--help, --version, etc.)
* Handles direct execution of native Codex passthrough commands (--help, resume, etc.)
* before the main profile dispatch loop runs.
*
* dispatchProfile() collapses the 6-branch switch in main() to a single call.
@@ -29,9 +29,9 @@ export interface ProfileError extends Error {
suggestions?: string[];
}
// ========== Native Codex Flag Command Executor ==========
// ========== Native Codex Command Executor ==========
export function execNativeCodexFlagCommand(args: string[]): void {
export function execNativeCodexCommand(args: string[]): void {
const adapter = getTarget('codex');
if (!adapter) {
console.error(fail('Target adapter not found for "codex"'));
+107 -3
View File
@@ -1,8 +1,12 @@
import { randomBytes } from 'crypto';
import { spawn } from 'child_process';
import { ensureCLIProxyBinary } from '../cliproxy/binary-manager';
import * as fs from 'fs';
import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from '../cliproxy/binary-manager';
import {
configExists,
configNeedsRegeneration,
CCS_CONTROL_PANEL_SECRET,
CCS_INTERNAL_API_KEY,
generateConfig,
getCliproxyWritablePath,
regenerateConfig,
@@ -10,13 +14,113 @@ import {
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { getCliproxyConfigPath } from '../cliproxy/config/path-resolver';
import { registerSession, unregisterSession } from '../cliproxy/session-tracker';
import { getInstalledCliproxyVersion } from '../cliproxy/binary-manager';
import {
getConfigYamlPath,
loadOrCreateUnifiedConfig,
mutateConfig,
} from '../config/config-loader-facade';
import {
addLegacyKeyGrace,
createDockerBootstrapState,
DOCKER_LEGACY_API_KEY,
isDockerLegacyKeyGraceActive,
isLikelyDockerGeneratedApiKey,
readDockerBootstrapState,
shouldRestoreDockerLegacyApiKey,
writeDockerBootstrapState,
} from './docker-key-rotation';
function generateDockerSecret(): string {
return randomBytes(32).toString('base64url');
}
export function ensureDockerCliproxyAuth(): boolean {
const now = new Date();
const hadUnifiedConfig = fs.existsSync(getConfigYamlPath());
const hadCliproxyConfig = configExists(CLIPROXY_DEFAULT_PORT);
const cliproxyConfigPath = getCliproxyConfigPath();
const existingCliproxyConfig = hadCliproxyConfig
? fs.readFileSync(cliproxyConfigPath, 'utf8')
: '';
const config = loadOrCreateUnifiedConfig();
const auth = config.cliproxy.auth;
const needsApiKey = !auth?.api_key || auth.api_key === CCS_INTERNAL_API_KEY;
const needsManagementSecret =
!auth?.management_secret || auth.management_secret === CCS_CONTROL_PANEL_SECRET;
const stateRead = readDockerBootstrapState();
const existingState = stateRead.state;
const existingApiKey = auth?.api_key;
let replacementApiKey = existingApiKey;
let configChanged = false;
if (needsApiKey || needsManagementSecret) {
mutateConfig((nextConfig) => {
nextConfig.cliproxy.auth ??= {};
if (needsApiKey) {
replacementApiKey = generateDockerSecret();
nextConfig.cliproxy.auth.api_key = replacementApiKey;
}
if (needsManagementSecret) {
nextConfig.cliproxy.auth.management_secret = generateDockerSecret();
}
});
configChanged = true;
}
if (!replacementApiKey) {
replacementApiKey = loadOrCreateUnifiedConfig().cliproxy.auth?.api_key;
}
const existingLegacyKeyInCliproxyConfig = existingCliproxyConfig.includes(
`"${DOCKER_LEGACY_API_KEY}"`
);
const freshInstall = !hadUnifiedConfig && !hadCliproxyConfig;
const oldDefaultUpgrade = needsApiKey && !freshInstall;
const explicitLegacyRestore = shouldRestoreDockerLegacyApiKey();
const legacyRestoreEligible = !existingState?.legacyKeyGrace;
const alreadyBrokenUpgrade =
explicitLegacyRestore &&
legacyRestoreEligible &&
hadCliproxyConfig &&
isLikelyDockerGeneratedApiKey(replacementApiKey) &&
!existingLegacyKeyInCliproxyConfig;
const corruptedRecoverableUpgrade =
explicitLegacyRestore &&
stateRead.corrupted &&
hadCliproxyConfig &&
isLikelyDockerGeneratedApiKey(replacementApiKey);
let nextState = existingState ?? createDockerBootstrapState(replacementApiKey, now);
if (replacementApiKey && !nextState.apiKey) {
nextState = { ...nextState, apiKey: replacementApiKey };
}
if (
replacementApiKey &&
!isDockerLegacyKeyGraceActive(existingState, now) &&
(oldDefaultUpgrade || alreadyBrokenUpgrade || corruptedRecoverableUpgrade)
) {
nextState = addLegacyKeyGrace(nextState, replacementApiKey, now);
}
if (!existingState || stateRead.corrupted || nextState !== existingState) {
writeDockerBootstrapState(nextState);
}
const legacyShouldBePresent = isDockerLegacyKeyGraceActive(nextState, now);
const legacyPresenceChanged = existingLegacyKeyInCliproxyConfig !== legacyShouldBePresent;
return configChanged || legacyPresenceChanged;
}
async function prepareIntegratedRuntime(): Promise<{ binaryPath: string; configPath: string }> {
const binaryPath = await ensureCLIProxyBinary(false);
const authWasGenerated = ensureDockerCliproxyAuth();
const configPath = !configExists(CLIPROXY_DEFAULT_PORT)
? generateConfig('gemini', CLIPROXY_DEFAULT_PORT)
: configNeedsRegeneration()
: authWasGenerated || configNeedsRegeneration()
? regenerateConfig(CLIPROXY_DEFAULT_PORT)
: getCliproxyConfigPath();
+60
View File
@@ -124,6 +124,11 @@ function runStreaming(command: string, args: string[]): Promise<void> {
});
}
function sleepSync(ms: number): void {
const buffer = new SharedArrayBuffer(4);
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
}
let cachedLocalComposePrefix: string[] | undefined;
function resolveLocalComposePrefix(): string[] {
@@ -202,6 +207,9 @@ export class DockerExecutor {
CCS_NPM_VERSION: this.getInstalledCcsVersion(),
CCS_DASHBOARD_PORT: String(options.port),
CCS_CLIPROXY_PORT: String(options.proxyPort),
CCS_DOCKER_BIND_HOST: process.env.CCS_DOCKER_BIND_HOST || '127.0.0.1',
CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS || '',
CCS_DOCKER_RESTORE_LEGACY_API_KEY: process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY || '',
},
REMOTE_DOCKER_BUILD_TIMEOUT_MS
),
@@ -264,6 +272,58 @@ export class DockerExecutor {
return result.stdout;
}
showKey(options: DockerCommandTarget, full: boolean): string {
const args = [
'exec',
DOCKER_CONTAINER_NAME,
'ccs',
'docker',
'show-key',
'--container-scope',
...(full ? ['--full'] : []),
];
const result = this.runDocker(args, options);
this.ensureSuccess(result, 'Docker key display', options);
return result.stdout;
}
finalizeKeyRotation(options: DockerCommandTarget): string {
const result = this.runDocker(
[
'exec',
DOCKER_CONTAINER_NAME,
'ccs',
'docker',
'finalize-key-rotation',
'--container-scope',
],
options
);
this.ensureSuccess(result, 'Docker key rotation finalization', options);
return result.stdout;
}
getKeyRotationBanner(options: DockerCommandTarget): string {
const args = [
'exec',
DOCKER_CONTAINER_NAME,
'ccs',
'docker',
'show-key',
'--container-scope',
'--full',
'--banner-only',
];
for (let attempt = 0; attempt < 10; attempt += 1) {
const result = this.runDocker(args, options, LOCAL_DOCKER_SYNC_TIMEOUT_MS);
if (result.exitCode === 0) {
return result.stdout.trim();
}
sleepSync(500);
}
return '';
}
private stageRemoteAssets(host: string): void {
this.ensureSuccess(
this.runSync('ssh', [host, `mkdir -p ${DOCKER_REMOTE_DIR}`], { remote: true }),
+206
View File
@@ -0,0 +1,206 @@
import * as fs from 'fs';
import * as path from 'path';
import { loadOrCreateUnifiedConfig, getCcsDir } from '../config/config-loader-facade';
export const DOCKER_BOOTSTRAP_STATE_FILENAME = '.docker-bootstrap-state.json';
export const DOCKER_LEGACY_API_KEY = 'ccs-internal-managed';
export const DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS = 14;
export const DOCKER_LEGACY_KEY_GRACE_ENV = 'CCS_DOCKER_LEGACY_KEY_GRACE_DAYS';
export const DOCKER_RESTORE_LEGACY_KEY_ENV = 'CCS_DOCKER_RESTORE_LEGACY_API_KEY';
const DAY_MS = 24 * 60 * 60 * 1000;
const STATE_VERSION = 1;
export interface DockerLegacyKeyGrace {
legacyKey: string;
replacementKey: string;
startedAt: string;
expiresAt: string;
finalizedAt?: string;
}
export interface DockerBootstrapState {
version: number;
apiKey?: string;
bootstrappedAt: string;
legacyKeyGrace?: DockerLegacyKeyGrace;
}
export interface DockerBootstrapStateReadResult {
state: DockerBootstrapState | null;
corrupted: boolean;
path: string;
}
export interface DockerKeyRotationStatus {
apiKey?: string;
maskedApiKey?: string;
statePath: string;
stateCorrupted: boolean;
legacyGraceActive: boolean;
legacyGrace?: DockerLegacyKeyGrace;
}
export function getDockerBootstrapStatePath(): string {
return path.join(getCcsDir(), 'cliproxy', DOCKER_BOOTSTRAP_STATE_FILENAME);
}
export function parseDockerLegacyKeyGraceDays(env = process.env): number {
const rawValue = env[DOCKER_LEGACY_KEY_GRACE_ENV];
if (rawValue === undefined || rawValue.trim() === '') {
return DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS;
}
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed < 0) {
return DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS;
}
return Math.floor(parsed);
}
export function isLikelyDockerGeneratedApiKey(value: string | undefined): boolean {
return Boolean(value && /^[A-Za-z0-9_-]{43}$/.test(value));
}
export function shouldRestoreDockerLegacyApiKey(env = process.env): boolean {
return env[DOCKER_RESTORE_LEGACY_KEY_ENV] === '1';
}
export function readDockerBootstrapState(): DockerBootstrapStateReadResult {
const statePath = getDockerBootstrapStatePath();
if (!fs.existsSync(statePath)) {
return { state: null, corrupted: false, path: statePath };
}
try {
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')) as DockerBootstrapState;
if (!parsed || parsed.version !== STATE_VERSION || typeof parsed.bootstrappedAt !== 'string') {
return { state: null, corrupted: true, path: statePath };
}
return { state: parsed, corrupted: false, path: statePath };
} catch {
return { state: null, corrupted: true, path: statePath };
}
}
export function writeDockerBootstrapState(state: DockerBootstrapState): void {
const statePath = getDockerBootstrapStatePath();
fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tempPath, statePath);
}
export function createDockerBootstrapState(
apiKey: string | undefined,
now = new Date()
): DockerBootstrapState {
return {
version: STATE_VERSION,
apiKey,
bootstrappedAt: now.toISOString(),
};
}
export function addLegacyKeyGrace(
state: DockerBootstrapState,
replacementKey: string,
now = new Date(),
graceDays = parseDockerLegacyKeyGraceDays()
): DockerBootstrapState {
return {
...state,
apiKey: replacementKey,
legacyKeyGrace: {
legacyKey: DOCKER_LEGACY_API_KEY,
replacementKey,
startedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + graceDays * DAY_MS).toISOString(),
},
};
}
export function isDockerLegacyKeyGraceActive(
state: DockerBootstrapState | null,
now = new Date()
): boolean {
const grace = state?.legacyKeyGrace;
if (!grace || grace.finalizedAt) {
return false;
}
const expiresAt = Date.parse(grace.expiresAt);
return Number.isFinite(expiresAt) && expiresAt > now.getTime();
}
export function getActiveDockerLegacyApiKeys(now = new Date()): string[] {
const { state } = readDockerBootstrapState();
if (!isDockerLegacyKeyGraceActive(state, now)) {
return [];
}
return state?.legacyKeyGrace?.legacyKey ? [state.legacyKeyGrace.legacyKey] : [];
}
export function maskDockerApiKey(apiKey: string | undefined): string | undefined {
if (!apiKey) {
return undefined;
}
if (apiKey.length <= 8) {
return '****';
}
return `${apiKey.slice(0, 4)}...${apiKey.slice(-4)}`;
}
export function getDockerKeyRotationStatus(now = new Date()): DockerKeyRotationStatus {
const config = loadOrCreateUnifiedConfig();
const readResult = readDockerBootstrapState();
const apiKey = config.cliproxy.auth?.api_key;
return {
apiKey,
maskedApiKey: maskDockerApiKey(apiKey),
statePath: readResult.path,
stateCorrupted: readResult.corrupted,
legacyGraceActive: isDockerLegacyKeyGraceActive(readResult.state, now),
legacyGrace: readResult.state?.legacyKeyGrace,
};
}
export function renderDockerKeyRotationBanner(status = getDockerKeyRotationStatus()): string {
if (!status.legacyGraceActive || !status.legacyGrace) {
return '';
}
const maskedReplacementKey =
maskDockerApiKey(status.legacyGrace.replacementKey) ?? '(not configured)';
return [
'[!] Docker CLIProxy API key rotation grace period is active.',
`[i] New CLIProxy API key: ${maskedReplacementKey}`,
`[i] Legacy key ${status.legacyGrace.legacyKey} remains valid until ${status.legacyGrace.expiresAt}.`,
'[i] Reveal the full key with `ccs docker show-key --full`.',
'[i] Update existing clients, then run `ccs docker finalize-key-rotation`.',
].join('\n');
}
export function finalizeDockerKeyRotation(now = new Date()): DockerKeyRotationStatus {
const readResult = readDockerBootstrapState();
const state =
readResult.state ??
createDockerBootstrapState(loadOrCreateUnifiedConfig().cliproxy.auth?.api_key, now);
writeDockerBootstrapState({
...state,
legacyKeyGrace: state.legacyKeyGrace
? {
...state.legacyKeyGrace,
finalizedAt: now.toISOString(),
expiresAt: now.toISOString(),
}
: undefined,
});
return getDockerKeyRotationStatus(now);
}
+21 -1
View File
@@ -9,6 +9,11 @@ import { HealthCheck, IHealthChecker, createSpinner } from './types';
import { getClaudeConfigDir } from '../../utils/claude-config-path';
import { getCcsDir } from '../../config/config-loader-facade';
import {
CODEX_TRANSLATOR_URL_MARKER,
findCodexTranslatorUrlPaths,
formatSettingsPathList,
} from '../../shared/stale-codex-translator-settings';
const ora = createSpinner();
@@ -219,7 +224,22 @@ export class ClaudeSettingsChecker implements IHealthChecker {
// Validate JSON
try {
const content = fs.readFileSync(settingsPath, 'utf8');
JSON.parse(content);
const settings = JSON.parse(content) as unknown;
const codexTranslatorUrlPaths = findCodexTranslatorUrlPaths(settings);
if (codexTranslatorUrlPaths.length > 0) {
const formattedPaths = formatSettingsPathList(codexTranslatorUrlPaths);
spinner.warn();
console.log(
` ${warn(settingsName.padEnd(22))} Codex CLIProxy bridge persisted at ${formattedPaths}`
);
results.addCheck(
'Claude Settings',
'warning',
`Claude settings route Claude Code through the Codex CLIProxy translator at ${formattedPaths} (${CODEX_TRANSLATOR_URL_MARKER})`,
'Run: ccs persist default --yes; use ccsxp or ccs codex --target codex for Codex'
);
return;
}
spinner.succeed();
console.log(` ${ok(settingsName.padEnd(22))} Valid`);
results.addCheck('Claude Settings', 'success');
+44
View File
@@ -16,6 +16,7 @@ import {
runImageAnalysisCheck,
} from './checks';
import { runAutoRepair } from './repair';
import { getDockerKeyRotationStatus } from '../docker/docker-key-rotation';
/**
* Doctor Class - Orchestrates health checks
@@ -55,6 +56,7 @@ class Doctor {
// Group 3: Configuration
console.log(header('CONFIGURATION'));
runConfigChecks(this.results);
this.runDockerKeyRotationCheck();
console.log('');
// Group 4: Profiles & Delegation
@@ -158,6 +160,48 @@ class Doctor {
console.log('');
}
private runDockerKeyRotationCheck(): void {
const status = getDockerKeyRotationStatus();
if (status.legacyGraceActive && status.legacyGrace) {
this.results.addCheck(
'Docker Key Rotation',
'warning',
`Legacy API key remains valid until ${status.legacyGrace.expiresAt}`,
'Run `ccs docker show-key --full`, update clients, then run `ccs docker finalize-key-rotation`.',
{
status: 'WARN',
info: `legacy key grace active until ${status.legacyGrace.expiresAt}`,
}
);
return;
}
if (status.stateCorrupted) {
this.results.addCheck(
'Docker Key Rotation',
'warning',
'Docker bootstrap state marker is unreadable',
'Run `ccs docker up` to recreate the marker.',
{
status: 'WARN',
info: 'state marker unreadable',
}
);
return;
}
this.results.addCheck(
'Docker Key Rotation',
'success',
'No active legacy API key grace',
undefined,
{
status: 'OK',
info: 'no active grace',
}
);
}
/**
* Generate JSON report
*/
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff. Show more