mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-17 04:14:32 +00:00
Merge pull request #1343 from kaitranntt/dev
feat(release): promote dev to main
This commit is contained in:
200 files changed
+18564
-997
No files matched your search
@@ -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"
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:-}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 -->
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Executable
+171
@@ -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,
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.'));
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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>)"`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('-')) {
|
||||
|
||||
@@ -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.'));
|
||||
}
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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}`));
|
||||
}
|
||||
}
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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"'));
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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
Reference in new issue
Block a user